From df517d4a68fe5e61c68d0b879a4cf09136ad7dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=85=BE=E8=AE=AFWorkBuddy?= Date: Tue, 18 Aug 2026 21:06:08 +0800 Subject: [PATCH] =?UTF-8?q?B-43:=20=E5=8F=8C=E8=BE=B9=E6=B5=81=E6=B0=B4?= =?UTF-8?q?=E5=BD=92=E5=B9=B6=E4=B8=8E=E8=A7=84=E8=8C=83=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E5=B1=82=E2=80=94=E2=80=94=E8=BF=81=E7=A7=BB5=E3=80=81?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E5=BC=95=E6=93=8E=E3=80=81=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E8=BF=87=E8=B4=A6=E6=98=A0=E5=B0=84=E4=B8=8E=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../005-canonical-transfer-matching.md | 62 + server.py | 757 +++++++- src/bank_importer/db.py | 214 +++ src/bank_importer/importing.py | 108 +- src/bank_importer/matching.py | 1585 +++++++++++++++++ src/bank_importer/personal_transit.py | 281 +++ tests/test_import_api.py | 148 ++ tests/test_matching.py | 865 +++++++++ tests/test_matching_api.py | 463 +++++ tests/test_persistence.py | 131 +- 10 files changed, 4565 insertions(+), 49 deletions(-) create mode 100644 docs/decisions/005-canonical-transfer-matching.md create mode 100644 src/bank_importer/matching.py create mode 100644 src/bank_importer/personal_transit.py create mode 100644 tests/test_matching.py create mode 100644 tests/test_matching_api.py diff --git a/docs/decisions/005-canonical-transfer-matching.md b/docs/decisions/005-canonical-transfer-matching.md new file mode 100644 index 0000000..568356b --- /dev/null +++ b/docs/decisions/005-canonical-transfer-matching.md @@ -0,0 +1,62 @@ +# 005 规范转账事件与双边归并技术决策 + +对应 Issue:B-43/B-83/B-114(`docs/issues/006-p1-canonical-transfer-matching.md`)。 + +## 分层模型 + +- 银行源行是不可变观察;匹配层从不修改 `source_rows`。 +- 新增“不可变决定日志 + 可重建当前投影”的规范事件层: + - `transfer_match_decisions` 等证据表只增不改(UPDATE/DELETE 被触发器拒绝)。 + - `current_transfer_decisions` 与 `transfer_observation_claims` 是可更新、可重建的 + 当前投影,`source_row_id` 主键保证一条观察不可能同时属于两个当前事件。 +- B-44 余额计算只读 `eligible_intercompany_events` 视图,只含 + `active + current + classification='intercompany'`;单边内部、待审、同公司调拨、 + 外部事件全部排除。 + +## 已确认业务口径(B-114) + +1. 单边流水即使双方公司都能由批准账号唯一确认,也先进入未决金额,不进入 B-44 + 已确认往来余额;只有完成双边归并(`intercompany` + `paired`)或管理员按证据确认 + (`intercompany` + locked 单边)后才可计算。 +2. 跨日事件经济日期取付款方 outgoing 银行入账时间,与导入顺序无关。 +3. 自动窗口 v1:非空参考号相同且账号镜像 <= 3 自然日(M1);无参考号精确账号镜像 + <= 1 自然日(M2);别名/个人映射镜像 <= 1 自然日且参考号或摘要相等(M3)。 + 超窗或同层多候选全部进入人工审核,禁止按行号、导入顺序或名字破平局。 +4. 个人过账映射绑定具体账号、生效区间、允许方向和代表公司,必须管理员批准; + 姓名只作辅助证据,不能单独定案。 + +## 参与方解析 + +- 本方:源行 `own_account` 精确命中生效期内已批准账户(`own_exact`);缺失时才用 + 上传时持久化的批准上传账户(`upload_account`),且必须与批次公司一致。冲突进入 + 审核,不以 `company_id` 或户名反推。 +- 对方:严格按账号层解析——精确账号 -> 已审核账号别名 -> 已批准且在窗口内、方向 + 允许的个人过账映射;每层唯一最佳结果才继续。户名/户名别名只用于一致性/冲突证据, + 冲突降级到审核。 +- 方向:`expense > 0 且 income = 0` 为 outgoing;`income > 0 且 expense = 0` 为 + incoming;双正/双零/负数/缺币种进入 `unresolved`。金额用 `Decimal` 精确相等, + 不设手续费容差。 + +## 匹配与状态机 + +- 候选硬门槛:双方工作表 confirmed、方向相反、金额与币种相等、公司端点互为反向。 +- M1/M2/M3 唯一候选自动 paired;R1(端点可确认但镜像/参考号证据不足)与 R2(同层 + 多候选)进入 `needs_review`;待审行不能被其他自动匹配抢占。 +- 状态:`unresolved -> internal_single -> matched`、`needs_review -> matched | + same_company_transfer | external`;单边确认两端同公司立即为同公司调拨。 +- 幂等:rule_version + 当前观察集合 + 证据未变时零写入;locked 人工决定自动重跑 + 跳过;纠错只能通过 `reverse`(`mode='reversal'` 新记录)+ 原因,之后允许重跑。 + +## 事务、并发与回滚 + +- “确认工作表 + 为新增确认行建立/更新事件 + audit”在同一个服务事务,任一失败整体 + 回滚,不存在 confirmed 但未匹配的半成品。 +- 独立重跑使用 `BEGIN IMMEDIATE`,按 `source_row_id` 升序写,SQLite 30 秒 busy + timeout 保留;投影可从只增日志重建(`rebuild_current_projection`)。 +- migration down 只用于测试/上线前回退;生产产生决定后默认保留 migration 5 与审计 + 数据,不执行破坏性 down。 + +## Decimal + +金额全程以规范十进制字符串存储和比较,任何路径都不转 `float`;`100.0` 与 `100.00` +视为相等,`100.00` 与 `100.01` 不匹配。 diff --git a/server.py b/server.py index 5aaae25..bf89568 100644 --- a/server.py +++ b/server.py @@ -10,7 +10,7 @@ from http.cookies import SimpleCookie from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse -from bank_importer import auth, importing, master_data, multipart +from bank_importer import auth, importing, master_data, matching, multipart, personal_transit from bank_importer.db import connect, migrate, utc_now @@ -77,6 +77,29 @@ class AppHandler(SimpleHTTPRequestHandler): if path == "/api/admin/audit-log": self._handle_admin_audit_log(query) return + if path == "/api/admin/transfer-events": + self._handle_admin_transfer_events(query) + return + if path == "/api/admin/match-exceptions": + self._handle_admin_match_exceptions(query) + return + if path == "/api/admin/personal-transit-mappings": + self._handle_admin_personal_mappings(query) + return + event_match = re.fullmatch(r"/api/admin/transfer-events/(\d+)", path) + if event_match: + self._handle_admin_transfer_event_detail(int(event_match.group(1))) + return + if path == "/api/company/transfer-events": + self._handle_company_transfer_events(query) + return + if path == "/api/company/match-exceptions": + self._handle_company_match_exceptions(query) + return + company_event_match = re.fullmatch(r"/api/company/transfer-events/(\d+)", path) + if company_event_match: + self._handle_company_transfer_event_detail(int(company_event_match.group(1))) + return if path == "/admin.html" and not self._guard_page("admin"): return @@ -130,6 +153,20 @@ class AppHandler(SimpleHTTPRequestHandler): if ignore_match: self._handle_batch_review(int(ignore_match.group(1)), "ignore") return + if path == "/api/admin/transfer-events/reconcile": + self._handle_admin_reconcile() + return + if path == "/api/admin/personal-transit-mappings": + self._handle_admin_create_personal_mapping() + return + decision_match = re.fullmatch(r"/api/admin/transfer-events/(\d+)/decisions", path) + if decision_match: + self._handle_admin_transfer_decision(int(decision_match.group(1))) + return + mapping_review = re.fullmatch(r"/api/admin/personal-transit-mappings/(\d+)/review", path) + if mapping_review: + self._handle_admin_review_personal_mapping(int(mapping_review.group(1))) + return self._send_json(404, {"status": "error", "message": "接口不存在。"}) # ------------------------------------------------------------------ @@ -339,7 +376,10 @@ class AppHandler(SimpleHTTPRequestHandler): # Tenant binding comes from the session only; any # company_id in the multipart body is ignored. company_id = user["company_id"] - if not self._validate_upload_account(connection, user, fields): + upload_account_id, invalid = self._resolve_upload_account( + connection, user, fields, company_id + ) + if invalid: return else: company_id = self._parse_company_field( @@ -347,10 +387,16 @@ class AppHandler(SimpleHTTPRequestHandler): ) if company_id is None: return + upload_account_id, invalid = self._resolve_upload_account( + connection, user, fields, company_id + ) + if invalid: + return result = importing.import_statement_path( connection, STORAGE_DIR, filename, upload.path, company_id=company_id, + upload_bank_account_id=upload_account_id, ) auth.audit( connection, @@ -410,32 +456,33 @@ class AppHandler(SimpleHTTPRequestHandler): raise ValueError(str(exc)) from exc return fields, upload - def _validate_upload_account(self, connection, user, fields) -> bool: - """Validate the optional bank_account_id on a company upload. + def _resolve_upload_account(self, connection, user, fields, company_id) -> tuple[int | None, bool]: + """Validate and return the approved upload account for a batch. - When supplied, the account must belong to the session company and be - enabled inside its effective interval — pending, returned or disabled - accounts never accept uploads. + Returns ``(account_id, False)`` when the account is accepted or when + none was supplied, and ``(None, True)`` after sending the rejection. + The approved account is persisted on the batch so ownership can be + resolved later even when source rows lack an own account. """ raw = fields.get("bank_account_id") if not raw: - return True + return None, False try: account_id = int(raw) except ValueError: self._send_json(400, {"status": "error", "message": "bank_account_id 参数无效。"}) - return False + return None, True account = master_data.get_account(connection, account_id) - if account is None or account["company_id"] != user["company_id"]: + if account is None or account["company_id"] != company_id: self._send_json(404, {"status": "error", "message": "账户不存在。"}) - return False + return None, True if not master_data.is_usable(account, master_data.utc_today()): self._send_json( 409, {"status": "error", "message": "该账户未启用或已超出有效期,不能上传流水。"}, ) - return False - return True + return None, True + return account_id, False def _parse_company_field(self, connection, raw) -> int | None: try: @@ -600,16 +647,24 @@ class AppHandler(SimpleHTTPRequestHandler): except ValueError as exc: self._send_json(400, {"status": "error", "message": str(exc)}) return + except Exception: + # Confirmation and matching share one transaction; any failure + # rolls both back (the sheet stays pending). + self._send_json( + 500, + {"status": "error", "message": "确认失败,本次确认与匹配已整体回滚。"}, + ) + return rows = importing.sheet_review_rows(connection, batch_id) - self._send_json( - 200, - { - "status": "ok", - "updated": outcome["updated"], - "already": outcome["already"], - "sheets": [self._sheet_payload(row) for row in rows], - }, - ) + payload: dict[str, object] = { + "status": "ok", + "updated": outcome["updated"], + "already": outcome["already"], + "sheets": [self._sheet_payload(row) for row in rows], + } + if outcome.get("matching") is not None: + payload["matching"] = outcome["matching"] + self._send_json(200, payload) finally: connection.close() @@ -1261,6 +1316,664 @@ class AppHandler(SimpleHTTPRequestHandler): finally: connection.close() + # ------------------------------------------------------------------ + # Canonical transfer events (admin) + # ------------------------------------------------------------------ + + @staticmethod + def _event_base_sql(company_scope: bool) -> str: + """Shared list SELECT; ``company_scope`` filters to one company.""" + where = ( + "WHERE payer.company_id = ? OR payee.company_id = ?" + if company_scope + else "" + ) + return f""" + SELECT c.event_id, d.id AS decision_id, d.revision, d.classification, + d.pairing, d.amount, d.currency, d.effective_at, d.mode, + d.locked, d.rule_version, d.created_at, + payer.company_id AS payer_company_id, + payee.company_id AS payee_company_id, + payer.bank_account_id AS payer_account_id, + payee.bank_account_id AS payee_account_id, + cpayer.name AS payer_company_name, + cpayee.name AS payee_company_name, + (SELECT COUNT(*) FROM transfer_decision_observations o + WHERE o.decision_id = d.id) AS evidence_count + FROM current_transfer_decisions c + JOIN transfer_match_decisions d ON d.id = c.decision_id + LEFT JOIN transfer_decision_participants payer + ON payer.decision_id = d.id AND payer.role = 'payer' + LEFT JOIN transfer_decision_participants payee + ON payee.decision_id = d.id AND payee.role = 'payee' + LEFT JOIN companies cpayer ON cpayer.id = payer.company_id + LEFT JOIN companies cpayee ON cpayee.id = payee.company_id + {where} + """ + + def _handle_admin_transfer_events(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + conditions: list[str] = [] + params: list[object] = [] + raw_company = (query.get("company_id") or [None])[0] + if raw_company: + try: + params.append(int(raw_company)) + params.append(int(raw_company)) + except ValueError: + self._send_json(400, {"status": "error", "message": "company_id 参数无效。"}) + return + conditions.append("(payer.company_id = ? OR payee.company_id = ?)") + raw_from = (query.get("from") or [None])[0] + if raw_from: + conditions.append("d.effective_at >= ?") + params.append(raw_from) + raw_to = (query.get("to") or [None])[0] + if raw_to: + conditions.append("d.effective_at <= ?") + params.append(raw_to) + raw_limit = (query.get("limit") or ["200"])[0] + try: + limit = max(1, min(int(raw_limit), 500)) + except ValueError: + limit = 200 + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + rows = connection.execute( + self._event_base_sql(False) + where + " ORDER BY d.id DESC LIMIT ?", + (*params, limit), + ).fetchall() + items = [self._admin_event_list_item(row) for row in rows] + status = (query.get("status") or [None])[0] + if status: + items = [item for item in items if item["status"] == status] + self._send_json(200, {"status": "ok", "events": items}) + finally: + connection.close() + + @staticmethod + def _admin_event_list_item(row) -> dict[str, object]: + return { + "event_id": row["event_id"], + "decision_id": row["decision_id"], + "revision": row["revision"], + "classification": row["classification"], + "pairing": row["pairing"], + "status": matching.exposed_status(row), + "amount": row["amount"], + "currency": row["currency"], + "effective_at": row["effective_at"], + "mode": row["mode"], + "locked": bool(row["locked"]), + "rule_version": row["rule_version"], + "payer_company_id": row["payer_company_id"], + "payer_company_name": row["payer_company_name"], + "payee_company_id": row["payee_company_id"], + "payee_company_name": row["payee_company_name"], + "evidence_count": row["evidence_count"], + "created_at": row["created_at"], + } + + def _handle_admin_transfer_event_detail(self, event_id: int) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + payload = self._admin_event_detail(connection, event_id) + if payload is None: + self._send_json(404, {"status": "error", "message": "事件不存在。"}) + return + self._send_json(200, {"status": "ok", "event": payload}) + finally: + connection.close() + + def _admin_event_detail(self, connection, event_id: int) -> dict[str, object] | None: + current = matching._current_decision_for_event(connection, event_id) + if current is None: + return None + decisions = connection.execute( + "SELECT * FROM transfer_match_decisions WHERE event_id = ? ORDER BY revision", + (event_id,), + ).fetchall() + participants = matching._decision_participants(connection, current["id"]) + observations = matching._decision_observations(connection, current["id"]) + candidates = connection.execute( + "SELECT * FROM transfer_match_candidates WHERE decision_id = ? ORDER BY id", + (current["id"],), + ).fetchall() + observation_rows = [] + for observation in observations: + row = connection.execute( + """ + SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense, + r.own_account, r.own_name, r.counterparty_account, + r.counterparty_name, r.summary, r.reference, r.currency, + s.sheet_name, f.original_filename, + b.company_id AS batch_company_id + FROM source_rows r + JOIN sheet_batches s ON s.id = r.sheet_batch_id + JOIN import_batches b ON b.id = s.import_batch_id + JOIN source_files f ON f.id = b.source_file_id + WHERE r.id = ? + """, + (observation["source_row_id"],), + ).fetchone() + observation_rows.append( + { + "source_row_id": observation["source_row_id"], + "role": observation["role"], + "source_row": row["source_row"] if row else None, + "sheet_name": row["sheet_name"] if row else None, + "original_filename": row["original_filename"] if row else None, + "company_id": row["batch_company_id"] if row else None, + "transaction_at": row["transaction_at"] if row else None, + "income": row["income"] if row else None, + "expense": row["expense"] if row else None, + "counterparty_name": row["counterparty_name"] if row else None, + } + ) + history = [ + { + "decision_id": decision["id"], + "revision": decision["revision"], + "classification": decision["classification"], + "pairing": decision["pairing"], + "status": matching.exposed_status(decision), + "amount": decision["amount"], + "currency": decision["currency"], + "effective_at": decision["effective_at"], + "mode": decision["mode"], + "locked": bool(decision["locked"]), + "rule_version": decision["rule_version"], + "reason": decision["reason"], + "actor_username": decision["actor_username"], + "supersedes_decision_id": decision["supersedes_decision_id"], + "created_at": decision["created_at"], + } + for decision in decisions + ] + return { + "event_id": event_id, + "decision_id": current["id"], + "revision": current["revision"], + "classification": current["classification"], + "pairing": current["pairing"], + "status": matching.exposed_status(current), + "amount": current["amount"], + "currency": current["currency"], + "effective_at": current["effective_at"], + "mode": current["mode"], + "locked": bool(current["locked"]), + "rule_version": current["rule_version"], + "participants": [ + { + "role": participant["role"], + "company_id": participant["company_id"], + "bank_account_id": participant["bank_account_id"], + "resolve_method": participant["resolve_method"], + "evidence": json.loads(participant["evidence"]) + if participant["evidence"] + else None, + } + for participant in participants + ], + "observations": observation_rows, + "candidates": [ + { + "source_row_id": candidate["source_row_id"], + "rule_tier": candidate["rule_tier"], + "date_diff_days": candidate["date_diff_days"], + "account_mirror": bool(candidate["account_mirror"]), + "reference_match": candidate["reference_match"], + "summary_match": bool(candidate["summary_match"]), + "accepted": candidate["accepted"], + "reason": candidate["reason"], + } + for candidate in candidates + ], + "history": history, + } + + def _handle_admin_match_exceptions(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + conditions = ["d.classification IN ('unresolved', 'needs_review')"] + params: list[object] = [] + raw_company = (query.get("company_id") or [None])[0] + if raw_company: + try: + company_id = int(raw_company) + except ValueError: + self._send_json(400, {"status": "error", "message": "company_id 参数无效。"}) + return + conditions.append("(payer.company_id = ? OR payee.company_id = ?)") + params.extend([company_id, company_id]) + rows = connection.execute( + self._event_base_sql(False) + + f"WHERE {' AND '.join(conditions)} ORDER BY d.id DESC LIMIT 500", + params, + ).fetchall() + items = [self._admin_event_list_item(row) for row in rows] + self._send_json(200, {"status": "ok", "exceptions": items}) + finally: + connection.close() + + def _handle_admin_reconcile(self) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + data = self._read_json_body() + if data is None: + return + raw_rows = data.get("source_row_ids") + row_ids: list[int] = [] + if raw_rows is not None: + if not isinstance(raw_rows, list): + self._send_json(400, {"status": "error", "message": "source_row_ids 必须是数组。"}) + return + try: + row_ids = [int(item) for item in raw_rows] + except (TypeError, ValueError): + self._send_json(400, {"status": "error", "message": "source_row_ids 必须是整数数组。"}) + return + raw_batch = data.get("batch_id") + if raw_batch is not None and not row_ids: + try: + batch_id = int(raw_batch) + except (TypeError, ValueError): + self._send_json(400, {"status": "error", "message": "batch_id 参数无效。"}) + return + rows = connection.execute( + """ + SELECT r.id FROM source_rows r + JOIN sheet_batches s ON s.id = r.sheet_batch_id + JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id + WHERE s.import_batch_id = ? AND rv.review_status = 'confirmed' + ORDER BY r.id + """, + (batch_id,), + ).fetchall() + row_ids = [item["id"] for item in rows] + if not row_ids: + self._send_json(400, {"status": "error", "message": "必须指定 source_row_ids 或 batch_id。"}) + return + try: + result = matching.reconcile_rows(connection, row_ids, actor=user) + except Exception as exc: + self._send_json(500, {"status": "error", "message": f"重跑匹配失败:{exc}"}) + return + auth.audit( + connection, "transfer_reconcile", actor=user, + target=f"rows:{len(row_ids)}", + detail=f"created:{result['created_events']};updated:{result['updated_events']}", + ip=self._client_ip, + ) + self._send_json(200, {"status": "ok", "matching": result}) + finally: + connection.close() + + def _handle_admin_transfer_decision(self, event_id: int) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + data = self._read_json_body() + if data is None: + return + action = str(data.get("action") or "") + try: + expected_revision = ( + int(data["expected_revision"]) + if data.get("expected_revision") is not None + else None + ) + except (TypeError, ValueError): + self._send_json(400, {"status": "error", "message": "expected_revision 参数无效。"}) + return + source_row_ids = data.get("source_row_ids") + if source_row_ids is not None and not isinstance(source_row_ids, list): + self._send_json(400, {"status": "error", "message": "source_row_ids 必须是数组。"}) + return + try: + payload = matching.apply_manual_decision( + connection, + event_id, + action, + reason=str(data.get("reason") or ""), + expected_revision=expected_revision, + request_key=str(data.get("request_key") or "") or None, + actor=user, + source_row_ids=[int(item) for item in source_row_ids] + if source_row_ids + else None, + participant=data.get("participant"), + ) + except matching.MatchConflictError as exc: + self._send_json(409, {"status": "error", "message": str(exc)}) + return + except matching.MatchInputError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json(200, {"status": "ok", "decision": payload}) + finally: + connection.close() + + # ------------------------------------------------------------------ + # Personal transit mappings (admin) + # ------------------------------------------------------------------ + + def _handle_admin_personal_mappings(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + raw_company = (query.get("company_id") or [None])[0] + raw_status = (query.get("status") or [None])[0] + try: + company_id = int(raw_company) if raw_company else None + except ValueError: + self._send_json(400, {"status": "error", "message": "company_id 参数无效。"}) + return + try: + rows = personal_transit.list_mappings( + connection, company_id=company_id, status=raw_status or None + ) + except ValueError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + self._send_json( + 200, + {"status": "ok", + "mappings": [personal_transit.mapping_payload(row, full=True) for row in rows]}, + ) + finally: + connection.close() + + def _handle_admin_create_personal_mapping(self) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + data = self._read_json_body() + if data is None: + return + try: + company_id = int(str(data.get("represented_company_id"))) + except (TypeError, ValueError): + self._send_json(400, {"status": "error", "message": "represented_company_id 参数无效。"}) + return + try: + mapping = personal_transit.submit_mapping( + connection, + account_number=data.get("account_number"), + account_name=data.get("account_name"), + represented_company_id=company_id, + allowed_direction=str(data.get("allowed_direction") or ""), + effective_from=data.get("effective_from"), + actor=user, + ) + except personal_transit.ConflictError as exc: + self._send_json(409, {"status": "error", "message": str(exc)}) + return + except ValueError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + auth.audit( + connection, "personal_transit_submit", actor=user, + target=f"personal_transit:{mapping['id']}", + detail=f"company:{company_id}", + ip=self._client_ip, + ) + self._send_json( + 200, + {"status": "ok", + "mapping": personal_transit.mapping_payload(mapping, full=True)}, + ) + finally: + connection.close() + + def _handle_admin_review_personal_mapping(self, mapping_id: int) -> None: + connection = connect(DB_PATH) + try: + user = self._require_admin(connection) + if user is None: + return + data = self._read_json_body() + if data is None: + return + try: + mapping = personal_transit.review_mapping( + connection, + mapping_id, + str(data.get("decision") or ""), + str(data.get("reason") or ""), + user, + effective_from=data.get("effective_from"), + effective_to=data.get("effective_to"), + ) + except LookupError as exc: + self._send_json(404, {"status": "error", "message": str(exc)}) + return + except personal_transit.ConflictError as exc: + self._send_json(409, {"status": "error", "message": str(exc)}) + return + except ValueError as exc: + self._send_json(400, {"status": "error", "message": str(exc)}) + return + auth.audit( + connection, "personal_transit_review", actor=user, + target=f"personal_transit:{mapping_id}", + detail=str(data.get("decision")), + ip=self._client_ip, + ) + self._send_json( + 200, + {"status": "ok", + "mapping": personal_transit.mapping_payload(mapping, full=True)}, + ) + finally: + connection.close() + + # ------------------------------------------------------------------ + # Canonical transfer events (company, own-company scope only) + # ------------------------------------------------------------------ + + def _handle_company_transfer_events(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_user(connection) + if user is None: + return + if user["role"] != "company": + self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"}) + return + conditions = [] + params: list[object] = [user["company_id"], user["company_id"]] + status = (query.get("status") or [None])[0] + raw_limit = (query.get("limit") or ["200"])[0] + try: + limit = max(1, min(int(raw_limit), 500)) + except ValueError: + limit = 200 + rows = connection.execute( + self._event_base_sql(True) + " ORDER BY d.id DESC LIMIT ?", + (*params, limit), + ).fetchall() + items = [ + self._company_event_list_item(row, user["company_id"]) for row in rows + ] + if status: + items = [item for item in items if item["status"] == status] + self._send_json(200, {"status": "ok", "events": items}) + finally: + connection.close() + + @staticmethod + def _company_event_list_item(row, own_company_id: int) -> dict[str, object]: + counterparty_company_id = ( + row["payee_company_id"] + if row["payer_company_id"] == own_company_id + else row["payer_company_id"] + ) + counterparty_company_name = ( + row["payee_company_name"] + if row["payer_company_id"] == own_company_id + else row["payer_company_name"] + ) + return { + "event_id": row["event_id"], + "classification": row["classification"], + "pairing": row["pairing"], + "status": matching.exposed_status(row), + "amount": row["amount"], + "currency": row["currency"], + "effective_at": row["effective_at"], + "mode": row["mode"], + "rule_version": row["rule_version"], + "own_company_id": own_company_id, + "counterparty_company_id": counterparty_company_id, + "counterparty_company_name": counterparty_company_name, + "evidence_count": row["evidence_count"], + } + + def _handle_company_match_exceptions(self, query: dict[str, list[str]]) -> None: + connection = connect(DB_PATH) + try: + user = self._require_user(connection) + if user is None: + return + if user["role"] != "company": + self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"}) + return + params: list[object] = [user["company_id"], user["company_id"]] + rows = connection.execute( + self._event_base_sql(True) + + "AND d.classification IN ('unresolved', 'needs_review') " + "ORDER BY d.id DESC LIMIT 500", + params, + ).fetchall() + items = [ + self._company_event_list_item(row, user["company_id"]) for row in rows + ] + self._send_json(200, {"status": "ok", "exceptions": items}) + finally: + connection.close() + + def _handle_company_transfer_event_detail(self, event_id: int) -> None: + connection = connect(DB_PATH) + try: + user = self._require_user(connection) + if user is None: + return + if user["role"] != "company": + self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"}) + return + current = matching._current_decision_for_event(connection, event_id) + if current is None: + self._send_json(404, {"status": "error", "message": "事件不存在。"}) + return + participants = matching._decision_participants(connection, current["id"]) + participant_company_ids = {p["company_id"] for p in participants} + if user["company_id"] not in participant_company_ids: + self._send_json(404, {"status": "error", "message": "事件不存在。"}) + return + observations = matching._decision_observations(connection, current["id"]) + own_rows = self._company_observation_rows(connection, observations, user["company_id"]) + other_side = next( + ( + p for p in participants + if p["company_id"] != user["company_id"] + ), + None, + ) + counterparty = None + if other_side is not None: + company = connection.execute( + "SELECT name FROM companies WHERE id = ?", (other_side["company_id"],) + ).fetchone() + counterparty = { + "company_id": other_side["company_id"], + "company_name": company["name"] if company else None, + } + if other_side["bank_account_id"] is not None: + account = master_data.get_account(connection, other_side["bank_account_id"]) + if account is not None: + counterparty["account_number_masked"] = master_data.mask_account_number( + account["account_number"] + ) + self._send_json( + 200, + { + "status": "ok", + "event": { + "event_id": event_id, + "classification": current["classification"], + "pairing": current["pairing"], + "status": matching.exposed_status(current), + "amount": current["amount"], + "currency": current["currency"], + "effective_at": current["effective_at"], + "mode": current["mode"], + "reason": current["reason"], + "counterparty": counterparty, + "observations": own_rows, + }, + }, + ) + finally: + connection.close() + + def _company_observation_rows(self, connection, observations, own_company_id: int) -> list[dict]: + rows = [] + for observation in observations: + row = connection.execute( + """ + SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense, + r.own_account, r.own_name, r.counterparty_account, + r.counterparty_name, r.summary, r.reference, + s.sheet_name, b.company_id AS batch_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 = ? + """, + (observation["source_row_id"],), + ).fetchone() + if row is None or row["batch_company_id"] != own_company_id: + continue + rows.append( + { + "source_row_id": row["id"], + "role": observation["role"], + "source_row": row["source_row"], + "sheet_name": row["sheet_name"], + "transaction_at": row["transaction_at"], + "income": row["income"], + "expense": row["expense"], + "batch_company_id": row["batch_company_id"], + "own_account_masked": master_data.mask_account_number(row["own_account"]) + if row["own_account"] + else None, + "counterparty_name": row["counterparty_name"], + "summary": row["summary"], + "reference": row["reference"], + } + ) + return rows + + # ------------------------------------------------------------------ # Request/response plumbing # ------------------------------------------------------------------ diff --git a/src/bank_importer/db.py b/src/bank_importer/db.py index bc6e60f..2f8fc48 100644 --- a/src/bank_importer/db.py +++ b/src/bank_importer/db.py @@ -354,6 +354,220 @@ MIGRATIONS: tuple[Migration, ...] = ( DROP TABLE IF EXISTS sheet_reviews; """, ), + Migration( + version=5, + name="0005_canonical_transfer_matching", + # Canonical transfer event layer (B-43). Immutable source rows stay + # untouched; every matching decision, its participant/observation + # bindings and its candidate evidence are append-only history, and the + # current decision + row claims are a rebuildable projection that the + # B-44 balance layer reads through the eligible view. + up=""" + ALTER TABLE import_batches ADD COLUMN upload_bank_account_id INTEGER REFERENCES bank_accounts (id); + + CREATE TABLE master_data_changes_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL + CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias', 'personal_transit_mapping')), + entity_id INTEGER NOT NULL, + action TEXT NOT NULL, + before_json TEXT, + after_json TEXT, + reason TEXT, + actor_user_id INTEGER REFERENCES users (id), + actor_username TEXT, + created_at TEXT NOT NULL + ); + INSERT INTO master_data_changes_new SELECT * FROM master_data_changes; + DROP TABLE master_data_changes; + ALTER TABLE master_data_changes_new RENAME TO master_data_changes; + + CREATE TABLE personal_transit_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_number TEXT NOT NULL UNIQUE, + account_name TEXT, + represented_company_id INTEGER NOT NULL REFERENCES companies (id), + allowed_direction TEXT NOT NULL + CHECK (allowed_direction IN ('outgoing', 'incoming', 'both')), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'active', 'returned', 'disabled')), + effective_from TEXT, + effective_to TEXT, + submitted_by INTEGER REFERENCES users (id), + reviewed_by INTEGER REFERENCES users (id), + reviewed_at TEXT, + review_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE canonical_transfer_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lifecycle TEXT NOT NULL DEFAULT 'active' + CHECK (lifecycle IN ('active', 'superseded')), + created_at TEXT NOT NULL + ); + + CREATE TABLE transfer_match_decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL REFERENCES canonical_transfer_events (id), + revision INTEGER NOT NULL, + classification TEXT NOT NULL CHECK (classification IN ( + 'unresolved', 'needs_review', 'internal_single', + 'intercompany', 'same_company', 'external' + )), + pairing TEXT NOT NULL + CHECK (pairing IN ('single', 'paired', 'not_applicable')), + amount TEXT, + currency TEXT, + effective_at TEXT, + mode TEXT NOT NULL CHECK (mode IN ('auto', 'manual', 'reversal')), + rule_version TEXT, + locked INTEGER NOT NULL DEFAULT 0 CHECK (locked IN (0, 1)), + reason TEXT, + idempotency_key TEXT, + actor_user_id INTEGER REFERENCES users (id), + actor_username TEXT, + supersedes_decision_id INTEGER REFERENCES transfer_match_decisions (id), + created_at TEXT NOT NULL, + UNIQUE (event_id, revision) + ); + + CREATE TABLE transfer_decision_observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id), + source_row_id INTEGER NOT NULL REFERENCES source_rows (id), + role TEXT NOT NULL CHECK (role IN ('outgoing', 'incoming')), + created_at TEXT NOT NULL, + UNIQUE (decision_id, source_row_id) + ); + + CREATE TABLE transfer_decision_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id), + role TEXT NOT NULL CHECK (role IN ('payer', 'payee')), + company_id INTEGER REFERENCES companies (id), + bank_account_id INTEGER REFERENCES bank_accounts (id), + resolve_method TEXT NOT NULL CHECK (resolve_method IN ( + 'own_exact', 'upload_account', 'counterparty_exact', + 'account_alias', 'personal_mapping', 'manual' + )), + alias_id INTEGER REFERENCES account_aliases (id), + mapping_id INTEGER REFERENCES personal_transit_mappings (id), + evidence TEXT, + created_at TEXT NOT NULL, + UNIQUE (decision_id, role) + ); + + CREATE TABLE transfer_match_candidates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id), + source_row_id INTEGER NOT NULL REFERENCES source_rows (id), + rule_tier TEXT NOT NULL, + date_diff_days INTEGER, + account_mirror INTEGER NOT NULL DEFAULT 0, + reference_match TEXT, + summary_match INTEGER NOT NULL DEFAULT 0, + accepted INTEGER, + reason TEXT, + rule_version TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE current_transfer_decisions ( + event_id INTEGER PRIMARY KEY REFERENCES canonical_transfer_events (id), + decision_id INTEGER NOT NULL UNIQUE REFERENCES transfer_match_decisions (id) + ); + + CREATE TABLE transfer_observation_claims ( + source_row_id INTEGER PRIMARY KEY REFERENCES source_rows (id), + event_id INTEGER NOT NULL REFERENCES canonical_transfer_events (id), + decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id) + ); + + CREATE INDEX idx_obs_decision ON transfer_decision_observations (decision_id); + CREATE INDEX idx_participants_decision ON transfer_decision_participants (decision_id); + CREATE INDEX idx_candidates_decision ON transfer_match_candidates (decision_id); + CREATE INDEX idx_claims_event ON transfer_observation_claims (event_id); + + CREATE VIEW eligible_intercompany_events AS + SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision, + d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency, + payer.company_id AS payer_company_id, + payer.bank_account_id AS payer_account_id, + payee.company_id AS payee_company_id, + payee.bank_account_id AS payee_account_id, + d.pairing AS pairing, d.rule_version AS rule_version, + (SELECT COUNT(*) FROM transfer_decision_observations o + WHERE o.decision_id = d.id) AS evidence_count + FROM current_transfer_decisions c + JOIN canonical_transfer_events e ON e.id = c.event_id + JOIN transfer_match_decisions d ON d.id = c.decision_id + JOIN transfer_decision_participants payer + ON payer.decision_id = d.id AND payer.role = 'payer' + JOIN transfer_decision_participants payee + ON payee.decision_id = d.id AND payee.role = 'payee' + WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'; + + CREATE TRIGGER canonical_transfer_events_no_delete BEFORE DELETE ON canonical_transfer_events + BEGIN SELECT RAISE (ABORT, 'canonical_transfer_events rows are immutable'); END; + CREATE TRIGGER canonical_transfer_events_no_update BEFORE UPDATE ON canonical_transfer_events + BEGIN + SELECT RAISE (ABORT, 'canonical_transfer_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 transfer_match_decisions_no_update BEFORE UPDATE ON transfer_match_decisions + BEGIN SELECT RAISE (ABORT, 'transfer_match_decisions rows are immutable'); END; + CREATE TRIGGER transfer_match_decisions_no_delete BEFORE DELETE ON transfer_match_decisions + BEGIN SELECT RAISE (ABORT, 'transfer_match_decisions rows are immutable'); END; + + CREATE TRIGGER transfer_decision_observations_no_update BEFORE UPDATE ON transfer_decision_observations + BEGIN SELECT RAISE (ABORT, 'transfer_decision_observations rows are immutable'); END; + CREATE TRIGGER transfer_decision_observations_no_delete BEFORE DELETE ON transfer_decision_observations + BEGIN SELECT RAISE (ABORT, 'transfer_decision_observations rows are immutable'); END; + + CREATE TRIGGER transfer_decision_participants_no_update BEFORE UPDATE ON transfer_decision_participants + BEGIN SELECT RAISE (ABORT, 'transfer_decision_participants rows are immutable'); END; + CREATE TRIGGER transfer_decision_participants_no_delete BEFORE DELETE ON transfer_decision_participants + BEGIN SELECT RAISE (ABORT, 'transfer_decision_participants rows are immutable'); END; + + CREATE TRIGGER transfer_match_candidates_no_update BEFORE UPDATE ON transfer_match_candidates + BEGIN SELECT RAISE (ABORT, 'transfer_match_candidates rows are immutable'); END; + CREATE TRIGGER transfer_match_candidates_no_delete BEFORE DELETE ON transfer_match_candidates + BEGIN SELECT RAISE (ABORT, 'transfer_match_candidates rows are immutable'); END; + """, + down=""" + DROP VIEW IF EXISTS eligible_intercompany_events; + DROP TABLE IF EXISTS transfer_observation_claims; + DROP TABLE IF EXISTS current_transfer_decisions; + DROP TABLE IF EXISTS transfer_match_candidates; + DROP TABLE IF EXISTS transfer_decision_participants; + DROP TABLE IF EXISTS transfer_decision_observations; + DROP TABLE IF EXISTS transfer_match_decisions; + DROP TABLE IF EXISTS canonical_transfer_events; + DROP TABLE IF EXISTS personal_transit_mappings; + CREATE TABLE master_data_changes_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL + CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias')), + entity_id INTEGER NOT NULL, + action TEXT NOT NULL, + before_json TEXT, + after_json TEXT, + reason TEXT, + actor_user_id INTEGER REFERENCES users (id), + actor_username TEXT, + created_at TEXT NOT NULL + ); + INSERT INTO master_data_changes_new SELECT * FROM master_data_changes; + DROP TABLE master_data_changes; + ALTER TABLE master_data_changes_new RENAME TO master_data_changes; + ALTER TABLE import_batches DROP COLUMN upload_bank_account_id; + """, + ), ) diff --git a/src/bank_importer/importing.py b/src/bank_importer/importing.py index 8985a9d..6c5fc0b 100644 --- a/src/bank_importer/importing.py +++ b/src/bank_importer/importing.py @@ -22,8 +22,10 @@ import os from pathlib import Path import shutil import sqlite3 +import tempfile from . import auth +from . import matching from .db import utc_now from .models import SheetResult, StatementBatch from .parser import StatementParseError, analyze_workbook @@ -52,6 +54,7 @@ def import_statement( original_filename: str, content: bytes, company_id: int | None = None, + upload_bank_account_id: int | None = None, ) -> ImportResult: sha256 = hashlib.sha256(content).hexdigest() existing_file = connection.execute( @@ -64,7 +67,8 @@ def import_statement( stored_path = _store_immutable(Path(storage_dir), original_filename, content, sha256) try: source_file_id, batch_id = _create_batch_records( - connection, sha256, original_filename, len(content), stored_path, company_id + connection, sha256, original_filename, len(content), stored_path, + company_id, upload_bank_account_id, ) except sqlite3.IntegrityError: # Lost a concurrent-insert race on the sha256 UNIQUE constraint: the @@ -87,13 +91,16 @@ def import_statement_path( original_filename: str, upload_path: str | Path, company_id: int | None = None, + upload_bank_account_id: int | None = None, ) -> ImportResult: """Import from an already-downloaded upload file (streaming-friendly). ``upload_path`` names the temp file the multipart handler streamed to disk; it is hashed incrementally and either discarded (duplicate) or published into the content-addressed store. The upload file itself is - never modified. + never modified. The approved ``upload_bank_account_id`` used at upload time + is persisted on the batch so ownership can be resolved later even when the + source rows carry no own account. """ source = Path(upload_path) sha256 = _file_sha256(source) @@ -109,7 +116,8 @@ def import_statement_path( size = stored_path.stat().st_size try: source_file_id, batch_id = _create_batch_records( - connection, sha256, original_filename, size, stored_path, company_id + connection, sha256, original_filename, size, stored_path, + company_id, upload_bank_account_id, ) except sqlite3.IntegrityError: # Lost a concurrent-insert race on the sha256 UNIQUE constraint: the @@ -133,6 +141,7 @@ def _create_batch_records( size_bytes: int, stored_path: Path, company_id: int | None, + upload_bank_account_id: int | None, ) -> tuple[int, int]: now = utc_now() with connection: @@ -146,10 +155,10 @@ def _create_batch_records( source_file_id = cursor.lastrowid cursor = connection.execute( """ - INSERT INTO import_batches (source_file_id, status, company_id, created_at, updated_at) - VALUES (?, 'parsing', ?, ?, ?) + INSERT INTO import_batches (source_file_id, status, company_id, upload_bank_account_id, created_at, updated_at) + VALUES (?, 'parsing', ?, ?, ?, ?) """, - (source_file_id, company_id, now, now), + (source_file_id, company_id, upload_bank_account_id, now, now), ) batch_id = cursor.lastrowid return source_file_id, batch_id @@ -278,17 +287,24 @@ def _store_immutable( target_dir = storage_dir / sha256[:2] target_dir.mkdir(parents=True, exist_ok=True) target = target_dir / f"{sha256}{suffix}" - # Publish via a temporary file + hard link: the content-addressed target - # either appears complete or not at all, and is never overwritten. - temp = target_dir / f".{sha256}.tmp" - temp.write_bytes(content) + # Publish via a unique temporary file + hard link: the content-addressed + # target either appears complete or not at all, and is never overwritten. + # The temp name is unique per writer, so concurrent uploads of the same + # bytes cannot corrupt each other's staging file (B-43 baseline fix). + fd, temp_path = tempfile.mkstemp(prefix=f".{sha256}.", suffix=".tmp", dir=target_dir) try: - os.link(temp, target) - except FileExistsError: - # Content-addressed name means identical bytes; never overwrite. - pass + with os.fdopen(fd, "wb") as handle: + handle.write(content) + try: + os.link(temp_path, target) + except FileExistsError: + # Content-addressed name means identical bytes; never overwrite. + pass finally: - temp.unlink(missing_ok=True) + try: + os.unlink(temp_path) + except FileNotFoundError: + pass return target @@ -299,17 +315,24 @@ def _publish_immutable( target_dir = storage_dir / sha256[:2] target_dir.mkdir(parents=True, exist_ok=True) target = target_dir / f"{sha256}{suffix}" - temp = target_dir / f".{sha256}.tmp" + fd, temp_path = tempfile.mkstemp(prefix=f".{sha256}.", suffix=".tmp", dir=target_dir) try: - # Copy keeps the same-filesystem guarantee even if the upload temp - # lives elsewhere; the hard link then publishes atomically. - shutil.copyfile(upload_path, temp) + with os.fdopen(fd, "wb") as handle: + # Copy keeps the same-filesystem guarantee even if the upload temp + # lives elsewhere; the hard link then publishes atomically. The + # staging file is unique per writer, so two concurrent uploads of + # the same bytes never corrupt each other's copy. + with upload_path.open("rb") as source: + shutil.copyfileobj(source, handle, length=1024 * 1024) try: - os.link(temp, target) + os.link(temp_path, target) except FileExistsError: pass finally: - temp.unlink(missing_ok=True) + try: + os.unlink(temp_path) + except FileNotFoundError: + pass upload_path.unlink(missing_ok=True) return target @@ -572,7 +595,15 @@ def review_sheets( now = utc_now() updated: list[str] = [] already: list[str] = [] - with connection: + matching_result: dict[str, object] | None = None + # BEGIN IMMEDIATE serializes concurrent confirmations: the write lock is + # taken before the read, so two cashiers confirming matching worksheets can + # never derive their match decisions from stale snapshots. + began = False + if not connection.in_transaction: + connection.execute("BEGIN IMMEDIATE") + began = True + try: for row in rows: if row["review_status"] == target: already.append(row["sheet_name"]) @@ -596,6 +627,34 @@ def review_sheets( if cursor.rowcount: updated.append(row["sheet_name"]) + # Confirming a worksheet and reconciling its rows must succeed or fail + # together: any matching failure rolls the confirmation back, so there + # is never a "confirmed but unmatched" half state. + if updated and decision == "confirm": + placeholders = ",".join("?" for _ in updated) + confirmed_rows = connection.execute( + f""" + SELECT r.id FROM source_rows r + JOIN sheet_batches s ON s.id = r.sheet_batch_id + JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id + WHERE rv.sheet_name IN ({placeholders}) + AND rv.import_batch_id = ? + ORDER BY r.id + """, + (*updated, batch_id), + ).fetchall() + matching_result = matching.reconcile_rows( + connection, + [item["id"] for item in confirmed_rows], + actor=actor, + ) + if began: + connection.commit() + except Exception: + if began: + connection.rollback() + raise + if updated: auth.audit( connection, @@ -604,4 +663,7 @@ def review_sheets( target=f"batch:{batch_id}", detail=f"sheets:{','.join(updated)}" + (f";reason:{reason}" if reason else ""), ) - return {"updated": updated, "already": already} + payload: dict[str, object] = {"updated": updated, "already": already} + if matching_result is not None: + payload["matching"] = matching_result + return payload diff --git a/src/bank_importer/matching.py b/src/bank_importer/matching.py new file mode 100644 index 0000000..c642872 --- /dev/null +++ b/src/bank_importer/matching.py @@ -0,0 +1,1585 @@ +"""Canonical transfer matching: deterministic bilateral pairing and event layer. + +Immutable bank source rows are observations. This module turns one or two +confirmed observations into one canonical transfer event by appending +decisions to an append-only log and maintaining a rebuildable current +projection. It never modifies ``source_rows``. + +Confirmed business rules (B-43/B-114): + +- A single observation stays in the unresolved amount bucket even when both + participants are uniquely identified by approved accounts; only a bilateral + merge (``intercompany`` + ``paired``) or an administrator confirmation based + on evidence (``intercompany`` + locked manual single) enters the confirmed + B-44 balance. +- The economic date of a cross-day event is the payer's outgoing bank posting + time, never the first-import time. +- Automatic window v1: same non-empty reference + mirrored accounts <= 3 + calendar days (M1); exact account mirror without reference <= 1 day (M2); + alias/personal-mapping mirror <= 1 day with reference or summary equality + (M3). Anything over-window or with 2+ same-tier candidates goes to review. +- Personal transit mappings bind a specific account, an effective interval, an + allowed direction and a represented company, and require administrator + approval; names are supporting evidence only. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from decimal import Decimal, InvalidOperation +import json +import re +import sqlite3 + +from .auth import audit +from .db import utc_now +from .master_data import ( + is_identifiable, + normalize_account_number, +) +from . import personal_transit + +RULE_VERSION = "transfer-match-v1" + +CLASSIFICATION_UNRESOLVED = "unresolved" +CLASSIFICATION_NEEDS_REVIEW = "needs_review" +CLASSIFICATION_INTERNAL_SINGLE = "internal_single" +CLASSIFICATION_INTERCOMPANY = "intercompany" +CLASSIFICATION_SAME_COMPANY = "same_company" +CLASSIFICATION_EXTERNAL = "external" + +PAIRING_SINGLE = "single" +PAIRING_PAIRED = "paired" +PAIRING_NA = "not_applicable" + +MODE_AUTO = "auto" +MODE_MANUAL = "manual" +MODE_REVERSAL = "reversal" + +MAX_DATE_DIFF_DAYS = 3 +AUTO_TIERS = ("M1", "M2", "M3") +REVIEW_TIERS = ("R1", "R2") + +_FULL_WIDTH = str.maketrans( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz0123456789", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz0123456789", +) +# Bank noise words stripped from summaries before comparison (M3). Only exact +# equality after normalization decides; no fuzzy matching or AI guesses. +_SUMMARY_NOISE = ( + "转账", "汇款", "网上银行", "手机银行", "跨行", "行内", "对公", + "跨行转账", "行内转账", "网银转账", "速汇", "实时", "普通", "加急", +) + + +class MatchConflictError(ValueError): + """A decision/claim/lock conflict (mapped to HTTP 409).""" + + +class MatchInputError(ValueError): + """Invalid input for matching (mapped to HTTP 400/422).""" + + +# --------------------------------------------------------------------------- +# Small value objects +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Resolved: + """One participant endpoint resolved from approved identifiers.""" + + role: str + company_id: int + bank_account_id: int | None + resolve_method: str + alias_id: int | None = None + mapping_id: int | None = None + evidence: dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class RowResolution: + direction: str | None + own: Resolved | None + own_reason: str | None + counterparty: Resolved | None + counterparty_reason: str | None + + +@dataclass(frozen=True) +class CandidateHit: + row_id: int + tier: str + date_diff_days: int + account_mirror: str # exact | alias | none + reference_match: str | None # same | conflict | None + summary_match: bool + + +# --------------------------------------------------------------------------- +# Parsing and normalization helpers +# --------------------------------------------------------------------------- + + +def parse_direction(income: object, expense: object) -> str | None: + """Exactly one positive amount decides the direction; anything else is None.""" + income = Decimal(str(income or "0")) + expense = Decimal(str(expense or "0")) + if expense > 0 and income == 0: + return "outgoing" + if income > 0 and expense == 0: + return "incoming" + return None + + +def amount_of_row(row: sqlite3.Row) -> Decimal | None: + income = Decimal(str(row["income"] or "0")) + expense = Decimal(str(row["expense"] or "0")) + if income > 0 and expense == 0: + return income + if expense > 0 and income == 0: + return expense + return None + + +def normalize_reference(value: object) -> str | None: + text = str(value or "").translate(_FULL_WIDTH) + text = re.sub(r"\s+", "", text) + return text or None + + +def normalize_summary(row: sqlite3.Row) -> str: + parts = [] + for key in ("summary", "purpose"): + text = str(row[key] or "").translate(_FULL_WIDTH) + text = re.sub(r"[\s\ufeff]+", "", text) + for noise in _SUMMARY_NOISE: + text = text.replace(noise, "") + if text: + parts.append(text) + return "|".join(parts) + + +# --------------------------------------------------------------------------- +# Transaction helpers +# --------------------------------------------------------------------------- + + +def _ensure_transaction(connection: sqlite3.Connection): + """Begin an immediate transaction unless one is already open. + + ``reconcile_rows`` / ``apply_manual_decision`` may run standalone (they own + the transaction) or nested inside the caller's ``with connection:`` block + (the worksheet-confirm flow); nested calls never start their own commit. + """ + began = False + if not connection.in_transaction: + connection.execute("BEGIN IMMEDIATE") + began = True + return began + + +# --------------------------------------------------------------------------- +# Participant resolution +# --------------------------------------------------------------------------- + + +def _normalized_account(value: object) -> str | None: + text = str(value or "").strip() + if not text: + return None + try: + return normalize_account_number(text) + except ValueError: + return None + + +def _resolve_own( + connection: sqlite3.Connection, + row: sqlite3.Row, + on_date: str, + upload_account_id: int | None, +) -> tuple[Resolved | None, str | None]: + """Resolve the owner endpoint: source-row own account first, the approved + upload account as fallback. A company conflict between the two is a review + signal, never a guess. + """ + own_number = _normalized_account(row["own_account"]) + + own: Resolved | None = None + own_reason: str | None = None + if own_number is not None: + account = connection.execute( + "SELECT * FROM bank_accounts WHERE account_number = ?", (own_number,) + ).fetchone() + if account is not None and is_identifiable(account, on_date): + own = Resolved( + role="", company_id=account["company_id"], + bank_account_id=account["id"], + resolve_method="own_exact", + evidence={"account_number": own_number, "via": "own_account"}, + ) + else: + own_reason = "本方账号未能匹配到生效期内已批准的账户" + + if upload_account_id is not None: + upload = connection.execute( + "SELECT * FROM bank_accounts WHERE id = ?", (upload_account_id,) + ).fetchone() + if upload is not None and is_identifiable(upload, on_date): + if own is not None and own.company_id != upload["company_id"]: + return None, "本方账号与上传账户归属公司冲突" + if own is None: + own = Resolved( + role="", company_id=upload["company_id"], + bank_account_id=upload["id"], + resolve_method="upload_account", + evidence={"account_number": upload["account_number"], + "via": "upload_bank_account"}, + ) + own_reason = None + + return own, own_reason + + +def _resolve_counterparty( + connection: sqlite3.Connection, + row: sqlite3.Row, + on_date: str, + direction: str | None, +) -> tuple[Resolved | None, str | None]: + """Resolve the counterparty strictly by account identifier tiers. + + Tier order: exact ``bank_accounts`` number -> approved ``account`` alias -> + approved in-window personal transit mapping (with direction allowed). Names + never resolve a counterparty; they only supply conflict evidence. + """ + number = _normalized_account(row["counterparty_account"]) + if number is None: + return None, "无对方账号" + + exact = connection.execute( + "SELECT * FROM bank_accounts WHERE account_number = ?", (number,) + ).fetchone() + if exact is not None and is_identifiable(exact, on_date): + return _finish_counterparty(connection, row, exact, "counterparty_exact", None, None) + + alias_rows = connection.execute( + """ + SELECT a.*, al.id AS alias_row_id, al.effective_from AS alias_from, + al.effective_to AS alias_to + FROM account_aliases al + JOIN bank_accounts a ON a.id = al.bank_account_id + WHERE al.alias_kind = 'account' AND al.alias_value = ? + """, + (number,), + ).fetchall() + in_window = [ + alias for alias in alias_rows + if _alias_in_window(alias, on_date) and is_identifiable(alias, on_date) + ] + if len(in_window) == 1: + alias = in_window[0] + return _finish_counterparty( + connection, row, alias, "account_alias", alias["alias_row_id"], None, + ) + if len(in_window) > 1: + return None, "对方账号命中多个账号别名,落入人工审核" + + mapping = connection.execute( + "SELECT * FROM personal_transit_mappings WHERE account_number = ?", (number,) + ).fetchone() + if mapping is not None and personal_transit.is_mapping_active(mapping, on_date): + if direction is not None and not personal_transit.direction_allowed(mapping, direction): + return None, "个人过账映射方向与该笔交易不符" + return _finish_counterparty( + connection, row, mapping, "personal_mapping", None, mapping["id"], + ) + + return None, "对方账号未能匹配到已批准账户、账号别名或个人过账映射" + + +def _finish_counterparty( + connection: sqlite3.Connection, + row: sqlite3.Row, + target: sqlite3.Row, + method: str, + alias_id: int | None, + mapping_id: int | None, +) -> tuple[Resolved | None, str | None]: + """Attach a resolved counterparty; a conflicting name alias downgrades it. + + A name alias belonging to a different company than the account evidence is + a conflict that must go to review. A name alias matching the same company + is recorded as supporting evidence only. + """ + company_id = target["company_id"] if "company_id" in target.keys() else target["represented_company_id"] + bank_account_id = target["id"] if "company_id" in target.keys() else None + evidence: dict[str, object] = { + "account_number": _normalized_account(row["counterparty_account"]), + "via": method, + } + if mapping_id is not None: + evidence["mapping_id"] = mapping_id + if alias_id is not None: + evidence["alias_id"] = alias_id + + name = str(row["counterparty_name"] or "").strip() + if name: + name_rows = connection.execute( + """ + SELECT a.company_id, al.bank_account_id + FROM account_aliases al + JOIN bank_accounts a ON a.id = al.bank_account_id + WHERE al.alias_kind = 'name' AND al.alias_value = ? + """, + (re.sub(r"\s+", "", name),), + ).fetchall() + conflicts = [hit for hit in name_rows if hit["company_id"] != company_id] + if conflicts: + return None, "对方户名别名归属与账号证据冲突,落入人工审核" + if name_rows: + evidence["name_alias"] = name + + resolved = Resolved( + role="", company_id=company_id, bank_account_id=bank_account_id, + resolve_method=method, alias_id=alias_id, mapping_id=mapping_id, + evidence=evidence, + ) + return resolved, None + + +def _alias_in_window(alias: sqlite3.Row, on_date: str) -> bool: + if alias["alias_from"] and on_date < alias["alias_from"]: + return False + if alias["alias_to"] and on_date > alias["alias_to"]: + return False + return True + + +def _load_rows( + connection: sqlite3.Connection, row_ids: list[int] +) -> list[sqlite3.Row]: + """Load source rows joined with their batch ownership/upload account.""" + placeholders = ",".join("?" for _ in row_ids) + return connection.execute( + f""" + SELECT r.*, s.sheet_name, rv.review_status, + b.company_id AS batch_company_id, + b.upload_bank_account_id AS upload_bank_account_id + FROM source_rows r + JOIN sheet_batches s ON s.id = r.sheet_batch_id + JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id + JOIN import_batches b ON b.id = s.import_batch_id + WHERE r.id IN ({placeholders}) + ORDER BY r.id + """, + row_ids, + ).fetchall() + + +def resolve_row( + connection: sqlite3.Connection, row: sqlite3.Row +) -> RowResolution: + """Resolve one source row's direction, own and counterparty endpoints.""" + on_date = _date_only(row["transaction_at"]) + direction = parse_direction(row["income"], row["expense"]) + own, own_reason = _resolve_own( + connection, row, on_date, + row["upload_bank_account_id"] if "upload_bank_account_id" in row.keys() else None, + ) + + if ( + own is not None + and row["batch_company_id"] is not None + and own.resolve_method == "upload_account" + and own.company_id != row["batch_company_id"] + ): + own, own_reason = None, "上传账户与批次公司不一致" + + counterparty, cp_reason = None, None + if direction is not None and own is not None: + counterparty, cp_reason = _resolve_counterparty( + connection, row, on_date, direction + ) + elif direction is not None: + cp_reason = "本方未解析,无法解析对方" + + return RowResolution( + direction, own, own_reason, counterparty, cp_reason, + ) + + +def _date_only(value: object) -> str: + text = str(value or "")[:10] + return text + + +# --------------------------------------------------------------------------- +# Candidate search +# --------------------------------------------------------------------------- + + +def _confirmed_rows(connection: sqlite3.Connection) -> list[sqlite3.Row]: + rows = connection.execute( + """ + SELECT r.*, s.sheet_name, rv.review_status, + b.company_id AS batch_company_id, + b.upload_bank_account_id AS upload_bank_account_id + FROM source_rows r + JOIN sheet_batches s ON s.id = r.sheet_batch_id + JOIN sheet_reviews rv ON rv.sheet_batch_id = s.id + JOIN import_batches b ON b.id = s.import_batch_id + WHERE rv.review_status = 'confirmed' + ORDER BY r.id + """ + ).fetchall() + locked = connection.execute( + """ + SELECT c.source_row_id FROM transfer_observation_claims c + JOIN transfer_match_decisions d ON d.id = c.decision_id + WHERE d.locked = 1 + """ + ).fetchall() + locked_ids = {item["source_row_id"] for item in locked} + return [row for row in rows if row["id"] not in locked_ids] + + +def _find_candidates( + connection: sqlite3.Connection, + row: sqlite3.Row, + res: RowResolution, + pool: list[sqlite3.Row], + resolutions: dict[int, RowResolution], +) -> tuple[list[CandidateHit], str | None]: + """Deterministic candidate search over confirmed rows. + + Returns ``(hits, None)`` when a unique auto-tier candidate decides the + match, ``(hits, reason)`` with reason ``r1_review``/``r2_ambiguous``/None + when the outcome needs review. Only hits passing the hard gates are kept. + """ + row_amount = amount_of_row(row) + row_currency = str(row["currency"] or "").strip() + if res.own is None or res.counterparty is None: + return [], None + if row_amount is None or not row_currency: + return [], None + + row_date = _parse_datetime(row["transaction_at"]).date() + + # Rows already awaiting review may not be grabbed by another automatic + # match: pairing with one of several same-tier candidates would be a + # processing-order tie-break, which is never allowed. + blocked = { + item["source_row_id"] + for item in connection.execute( + """ + SELECT c.source_row_id FROM transfer_observation_claims c + JOIN transfer_match_decisions d ON d.id = c.decision_id + WHERE d.classification = ? + """, + (CLASSIFICATION_NEEDS_REVIEW,), + ) + } + + hits: list[CandidateHit] = [] + for other in pool: + if other["id"] == row["id"] or other["id"] in blocked: + continue + other_res = resolutions[other["id"]] + if other_res.direction is None or other_res.own is None or other_res.counterparty is None: + continue + if other_res.direction == res.direction: + continue + other_amount = amount_of_row(other) + other_currency = str(other["currency"] or "").strip() + if other_amount != row_amount or not other_currency or other_currency != row_currency: + continue + other_date = _parse_datetime(other["transaction_at"]).date() + date_diff = abs((row_date - other_date).days) + # Over-window pairs stay as R1 review candidates: the hard gate only + # filters money/direction/company reciprocity, never the date window. + if not _reciprocal(row, other, res, other_res): + continue + tier = _classify_tier(row, other, res, other_res) + if tier is None: + continue + mirror = _mirror_level(row, other, res, other_res) or "none" + ref_match, summary_match = _tier_evidence(row, other) + hits.append( + CandidateHit( + row_id=other["id"], tier=tier, date_diff_days=date_diff, + account_mirror=mirror, reference_match=ref_match, + summary_match=summary_match, + ) + ) + + if not hits: + return [], None + + best_tier = min(hits, key=lambda hit: AUTO_TIERS.index(hit.tier) if hit.tier in AUTO_TIERS else len(AUTO_TIERS)).tier + if best_tier in AUTO_TIERS: + same_tier = [hit for hit in hits if hit.tier == best_tier] + if len(same_tier) == 1: + return same_tier, None + return hits, "r2_ambiguous" + return hits, "r1_review" + + +def _reciprocal( + row: sqlite3.Row, + other: sqlite3.Row, + res: RowResolution, + other_res: RowResolution, +) -> bool: + return ( + res.own.company_id == other_res.counterparty.company_id + and res.counterparty.company_id == other_res.own.company_id + ) + + +def _mirror_level( + row: sqlite3.Row, + other: sqlite3.Row, + res: RowResolution, + other_res: RowResolution, +) -> str | None: + row_own = _normalized_account(row["own_account"]) + row_cp = _normalized_account(row["counterparty_account"]) + other_own = _normalized_account(other["own_account"]) + other_cp = _normalized_account(other["counterparty_account"]) + + exact_pairs = 0 + if row_own and row_own == other_cp: + exact_pairs += 1 + if row_cp and row_cp == other_own: + exact_pairs += 1 + if exact_pairs == 2: + return "exact" + if exact_pairs == 1: + methods = { + res.counterparty.resolve_method, + other_res.counterparty.resolve_method, + } + if methods & {"account_alias", "personal_mapping"}: + return "alias" + return None + + +def _classify_tier( + row: sqlite3.Row, + other: sqlite3.Row, + res: RowResolution, + other_res: RowResolution, +) -> str | None: + mirror = _mirror_level(row, other, res, other_res) + if mirror is None: + return "R1" + row_date = _parse_datetime(row["transaction_at"]).date() + other_date = _parse_datetime(other["transaction_at"]).date() + date_diff = abs((row_date - other_date).days) + ref_row = normalize_reference(row["reference"]) + ref_other = normalize_reference(other["reference"]) + ref_conflict = bool(ref_row and ref_other and ref_row != ref_other) + summary_match = bool(ref_row and ref_row == ref_other) or _summary_equal(row, other) + + if mirror == "exact": + if ref_row and ref_other and ref_row == ref_other and date_diff <= 3: + return "M1" + if date_diff <= 1 and not ref_conflict: + return "M2" + return "R1" + # alias / personal-mapping mirror + if date_diff <= 1 and not ref_conflict and (ref_row == ref_other or _summary_equal(row, other)): + return "M3" + return "R1" + + +def _tier_evidence( + row: sqlite3.Row, other: sqlite3.Row +) -> tuple[str | None, bool]: + ref_row = normalize_reference(row["reference"]) + ref_other = normalize_reference(other["reference"]) + if ref_row and ref_other and ref_row == ref_other: + ref_match = "same" + elif ref_row and ref_other: + ref_match = "conflict" + else: + ref_match = None + return ref_match, _summary_equal(row, other) + + +def _summary_equal(row: sqlite3.Row, other: sqlite3.Row) -> bool: + left = normalize_summary(row) + right = normalize_summary(other) + return bool(left) and left == right + + +def _parse_datetime(value: object) -> datetime: + text = str(value) + try: + return datetime.fromisoformat(text) + except ValueError: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + + +# --------------------------------------------------------------------------- +# Derivation and application +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class DerivedDecision: + classification: str + pairing: str + amount: str + currency: str | None + effective_at: str + observations: tuple[tuple[int, str], ...] # (row_id, role) + participants: tuple[dict[str, object], ...] + reason: str | None + candidates: tuple[dict[str, object], ...] = () + + +def _derive( + connection: sqlite3.Connection, + row: sqlite3.Row, + res: RowResolution, + pool: list[sqlite3.Row], + resolutions: dict[int, RowResolution], +) -> DerivedDecision: + amount = amount_of_row(row) + amount_text = str(amount) if amount is not None else None + currency = str(row["currency"] or "").strip() or None + row_time = _parse_datetime(row["transaction_at"]).isoformat() + + if res.direction is None: + return DerivedDecision( + CLASSIFICATION_UNRESOLVED, PAIRING_NA, amount_text, currency, + row_time, ((row["id"], "outgoing"),), (), "金额方向不满足一出一入", + ) + if res.own is None: + classification = ( + CLASSIFICATION_NEEDS_REVIEW if _own_is_conflict(res) + else CLASSIFICATION_UNRESOLVED + ) + role = "outgoing" if res.direction == "outgoing" else "incoming" + return DerivedDecision( + classification, PAIRING_NA, amount_text, currency, row_time, + ((row["id"], role),), (), res.own_reason, + ) + role = "outgoing" if res.direction == "outgoing" else "incoming" + counter_role = "payee" if role == "outgoing" else "payer" + + if res.counterparty is None: + classification = ( + CLASSIFICATION_NEEDS_REVIEW + if _cp_is_ambiguous(res) + else CLASSIFICATION_UNRESOLVED + ) + return DerivedDecision( + classification, PAIRING_NA, amount_text, currency, + row_time, ((row["id"], role),), + (_participant(res.own, "payer" if role == "outgoing" else "payee"),), + res.counterparty_reason, + ) + + own_participant = _participant(res.own, "payer" if role == "outgoing" else "payee") + cp_participant = _participant(res.counterparty, counter_role) + if res.own.company_id == res.counterparty.company_id: + # Same-company transfers pair when a mirror candidate exists; cash + # trail is kept but they never enter the intercompany balance. + hits, decision = _find_candidates(connection, row, res, pool, resolutions) + if decision == "r2_ambiguous": + return DerivedDecision( + CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency, + row_time, ((row["id"], role),), (own_participant, cp_participant), + "同层多候选,落入人工审核", tuple(_candidate_rows(hits)), + ) + if decision == "r1_review": + return DerivedDecision( + CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency, + row_time, ((row["id"], role),), (own_participant, cp_participant), + "端点可确认但账号镜像或参考号证据不足,落入人工审核", + tuple(_candidate_rows(hits)), + ) + if hits: + other = next(item for item in pool if item["id"] == hits[0].row_id) + return _derive_paired(row, res, other, resolutions[other["id"]], hits[0]) + return DerivedDecision( + CLASSIFICATION_SAME_COMPANY, PAIRING_SINGLE, amount_text, currency, + row_time, ((row["id"], role),), + (own_participant, cp_participant), "本方与对方同属一家公司", + ) + + hits, decision = _find_candidates(connection, row, res, pool, resolutions) + if decision == "r2_ambiguous": + candidates = _candidate_rows(hits) + return DerivedDecision( + CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency, + row_time, ((row["id"], role),), (own_participant, cp_participant), + "同层多候选,落入人工审核", tuple(candidates), + ) + if decision == "r1_review": + candidates = _candidate_rows(hits) + return DerivedDecision( + CLASSIFICATION_NEEDS_REVIEW, PAIRING_NA, amount_text, currency, + row_time, ((row["id"], role),), (own_participant, cp_participant), + "端点可确认但账号镜像或参考号证据不足,落入人工审核", + tuple(candidates), + ) + if hits: + other = next(item for item in pool if item["id"] == hits[0].row_id) + other_res = resolutions[other["id"]] + return _derive_paired(row, res, other, other_res, hits[0]) + return DerivedDecision( + CLASSIFICATION_INTERNAL_SINGLE, PAIRING_SINGLE, amount_text, currency, + row_time, ((row["id"], role),), (own_participant, cp_participant), + "单边观察,等待另一方到账或人工确认", + ) + + +def _own_is_conflict(res: RowResolution) -> bool: + return res.own_reason in ( + "本方账号与上传账户归属公司冲突", + "上传账户与批次公司不一致", + ) + + +def _cp_is_ambiguous(res: RowResolution) -> bool: + return res.counterparty_reason in ( + "对方账号命中多个账号别名,落入人工审核", + "对方户名别名归属与账号证据冲突,落入人工审核", + ) + + +def _participant(resolved: Resolved, role: str) -> dict[str, object]: + return { + "role": role, + "company_id": resolved.company_id, + "bank_account_id": resolved.bank_account_id, + "resolve_method": resolved.resolve_method, + "alias_id": resolved.alias_id, + "mapping_id": resolved.mapping_id, + "evidence": json.dumps(resolved.evidence, ensure_ascii=False), + } + + +def _candidate_rows(hits: list[CandidateHit]) -> list[dict[str, object]]: + return [ + { + "source_row_id": hit.row_id, + "rule_tier": hit.tier, + "date_diff_days": hit.date_diff_days, + "account_mirror": 1 if hit.account_mirror != "none" else 0, + "reference_match": hit.reference_match, + "summary_match": 1 if hit.summary_match else 0, + "accepted": None, + "reason": None, + } + for hit in hits + ] + + +def _derive_paired( + row: sqlite3.Row, + res: RowResolution, + other: sqlite3.Row, + other_res: RowResolution, + hit: CandidateHit, +) -> DerivedDecision: + row_role = "outgoing" if res.direction == "outgoing" else "incoming" + other_role = "incoming" if row_role == "outgoing" else "outgoing" + if row_role == "outgoing": + payer = _participant(res.own, "payer") + payee = _participant(other_res.own, "payee") + effective_at = _parse_datetime(row["transaction_at"]).isoformat() + else: + payer = _participant(other_res.own, "payer") + payee = _participant(res.own, "payee") + effective_at = _parse_datetime(other["transaction_at"]).isoformat() + + classification = ( + CLASSIFICATION_SAME_COMPANY + if payer["company_id"] == payee["company_id"] + else CLASSIFICATION_INTERCOMPANY + ) + observations = ( + (row["id"], row_role), + (other["id"], other_role), + ) + return DerivedDecision( + classification, PAIRING_PAIRED, + str(amount_of_row(row)), str(row["currency"] or "").strip() or None, + effective_at, observations, (payer, payee), "双边镜像归并", + (_candidate_row(hit, accepted=1),), + ) + + +def _candidate_row(hit: CandidateHit, *, accepted: int | None) -> dict[str, object]: + return { + "source_row_id": hit.row_id, + "rule_tier": hit.tier, + "date_diff_days": hit.date_diff_days, + "account_mirror": 1 if hit.account_mirror != "none" else 0, + "reference_match": hit.reference_match, + "summary_match": 1 if hit.summary_match else 0, + "accepted": accepted, + "reason": None, + } + + +def _derive_for_row( + connection: sqlite3.Connection, + row: sqlite3.Row, + res: RowResolution, + pool: list[sqlite3.Row], + resolutions: dict[int, RowResolution], +) -> DerivedDecision: + return _derive(connection, row, res, pool, resolutions) + + +# --------------------------------------------------------------------------- +# Current claim helpers +# --------------------------------------------------------------------------- + + +def _current_claim(connection: sqlite3.Connection, row_id: int) -> sqlite3.Row | None: + return connection.execute( + """ + SELECT c.event_id, c.decision_id, d.locked, d.mode, d.classification, + d.pairing, d.revision, d.rule_version + FROM transfer_observation_claims c + JOIN transfer_match_decisions d ON d.id = c.decision_id + WHERE c.source_row_id = ? + """, + (row_id,), + ).fetchone() + + +def _current_decision_for_event( + connection: sqlite3.Connection, event_id: int +) -> sqlite3.Row | None: + return 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() + + +def _decision_observations( + connection: sqlite3.Connection, decision_id: int +) -> list[sqlite3.Row]: + return connection.execute( + "SELECT * FROM transfer_decision_observations WHERE decision_id = ? ORDER BY id", + (decision_id,), + ).fetchall() + + +def _decision_participants( + connection: sqlite3.Connection, decision_id: int +) -> list[sqlite3.Row]: + return connection.execute( + "SELECT * FROM transfer_decision_participants WHERE decision_id = ? ORDER BY role", + (decision_id,), + ).fetchall() + + +def _same_decision( + connection: sqlite3.Connection, + decision_id: int, + derived: DerivedDecision, +) -> bool: + """Idempotency check: derived outcome equals the current decision.""" + current = connection.execute( + "SELECT * FROM transfer_match_decisions WHERE id = ?", (decision_id,) + ).fetchone() + if current is None: + return False + if current["classification"] != derived.classification: + return False + if current["pairing"] != derived.pairing: + return False + if (current["amount"] or None) != derived.amount: + return False + if (current["currency"] or None) != derived.currency: + return False + if (current["effective_at"] or None) != derived.effective_at: + return False + + observed = { + (row["source_row_id"], row["role"]) + for row in _decision_observations(connection, decision_id) + } + if observed != set(derived.observations): + return False + current_participants = { + ( + row["role"], row["company_id"], row["bank_account_id"], + row["resolve_method"], row["alias_id"], row["mapping_id"], + ) + for row in _decision_participants(connection, decision_id) + } + derived_participants = { + ( + str(p["role"]), p["company_id"], p["bank_account_id"], + str(p["resolve_method"]), p["alias_id"], p["mapping_id"], + ) + for p in derived.participants + } + return current_participants == derived_participants + + +# --------------------------------------------------------------------------- +# Applying decisions +# --------------------------------------------------------------------------- + + +def _apply( + connection: sqlite3.Connection, + derived: DerivedDecision, + *, + mode: str, + locked: int, + reason: str | None, + actor: sqlite3.Row | None, + rule_version: str, + supersede_decision_id: int | None = None, + idempotency_key: str | None = None, +) -> dict[str, object]: + """Write one decision + projection update inside the open transaction.""" + now = utc_now() + superseded_events: set[int] = set() + + claim_events: dict[int, sqlite3.Row] = {} + for row_id, _role in derived.observations: + claim = _current_claim(connection, row_id) + if claim is not None: + claim_events[row_id] = claim + + if claim_events: + event_id = min(item["event_id"] for item in claim_events.values()) + else: + cursor = connection.execute( + "INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)", + (now,), + ) + event_id = int(cursor.lastrowid) + + previous = _current_decision_for_event(connection, event_id) + if supersede_decision_id is None and previous is not None: + supersede_decision_id = previous["id"] + + revision_row = connection.execute( + "SELECT COALESCE(MAX(revision), 0) AS m FROM transfer_match_decisions WHERE event_id = ?", + (event_id,), + ).fetchone() + revision = int(revision_row["m"]) + 1 + + cursor = connection.execute( + """ + INSERT INTO transfer_match_decisions ( + event_id, revision, classification, pairing, amount, currency, + effective_at, mode, rule_version, locked, reason, idempotency_key, + actor_user_id, actor_username, supersedes_decision_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, revision, derived.classification, derived.pairing, + derived.amount, derived.currency, derived.effective_at, + mode, rule_version, locked, reason, idempotency_key, + actor["id"] if actor is not None else None, + actor["username"] if actor is not None else None, + supersede_decision_id, + now, + ), + ) + decision_id = int(cursor.lastrowid) + + for row_id, role in derived.observations: + connection.execute( + """ + INSERT INTO transfer_decision_observations (decision_id, source_row_id, role, created_at) + VALUES (?, ?, ?, ?) + """, + (decision_id, row_id, role, now), + ) + for participant in derived.participants: + connection.execute( + """ + INSERT INTO transfer_decision_participants ( + decision_id, role, company_id, bank_account_id, resolve_method, + alias_id, mapping_id, evidence, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + decision_id, participant["role"], participant["company_id"], + participant["bank_account_id"], participant["resolve_method"], + participant["alias_id"], participant["mapping_id"], + participant["evidence"], now, + ), + ) + for candidate in derived.candidates: + connection.execute( + """ + INSERT INTO transfer_match_candidates ( + decision_id, source_row_id, rule_tier, date_diff_days, + account_mirror, reference_match, summary_match, accepted, + reason, rule_version, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + decision_id, candidate["source_row_id"], candidate["rule_tier"], + candidate["date_diff_days"], candidate["account_mirror"], + candidate["reference_match"], candidate["summary_match"], + candidate["accepted"], candidate["reason"], rule_version, now, + ), + ) + + # Projection: point every involved observation's claim at the new decision. + for row_id, _role in derived.observations: + connection.execute( + """ + INSERT OR REPLACE INTO transfer_observation_claims (source_row_id, event_id, decision_id) + VALUES (?, ?, ?) + """, + (row_id, event_id, decision_id), + ) + for row_id in claim_events: + if row_id not in {rid for rid, _ in derived.observations}: + connection.execute( + "DELETE FROM transfer_observation_claims WHERE source_row_id = ?", + (row_id,), + ) + + # Other involved events lose their current pointer and become superseded. + for other_event in {item["event_id"] for item in claim_events.values()}: + if other_event != event_id: + connection.execute( + "DELETE FROM current_transfer_decisions WHERE event_id = ?", + (other_event,), + ) + connection.execute( + "UPDATE canonical_transfer_events SET lifecycle = 'superseded' WHERE id = ?", + (other_event,), + ) + superseded_events.add(other_event) + connection.execute( + """ + INSERT OR REPLACE INTO current_transfer_decisions (event_id, decision_id) + VALUES (?, ?) + """, + (event_id, decision_id), + ) + + return { + "event_id": event_id, + "decision_id": decision_id, + "revision": revision, + "superseded_events": sorted(superseded_events), + } + + +# --------------------------------------------------------------------------- +# Public reconcile API +# --------------------------------------------------------------------------- + + +def reconcile_rows( + connection: sqlite3.Connection, + source_row_ids: list[int], + *, + rule_version: str = RULE_VERSION, + actor: sqlite3.Row | None = None, +) -> dict[str, object]: + """Reconcile confirmed source rows into canonical events, idempotently. + + Rows claimed by a locked (manual) decision are never touched. Rows whose + derived outcome already equals their current decision produce zero writes. + Runs inside the caller's transaction when one is open, otherwise in its + own ``BEGIN IMMEDIATE`` transaction. + """ + began = _ensure_transaction(connection) + try: + ids = sorted({int(row_id) for row_id in source_row_ids}) + if not ids: + return { + "created_events": 0, "updated_events": 0, "unchanged": 0, + "skipped_locked": 0, "rows": 0, + } + stats = { + "created_events": 0, "updated_events": 0, "unchanged": 0, + "skipped_locked": 0, "rows": 0, + } + pool = _confirmed_rows(connection) + pool_by_id = {row["id"]: row for row in pool} + resolutions = { + row["id"]: resolve_row(connection, row) + for row in pool + } + input_rows = _load_rows(connection, ids) + + for row in input_rows: + stats["rows"] += 1 + claim = _current_claim(connection, row["id"]) + if claim is not None and claim["locked"]: + stats["skipped_locked"] += 1 + continue + if row["id"] not in pool_by_id: + stats["skipped_unconfirmed"] += 1 + continue + derived = _derive_for_row(connection, row, resolutions[row["id"]], pool, resolutions) + if claim is not None and _same_decision(connection, claim["decision_id"], derived): + stats["unchanged"] += 1 + continue + outcome = _apply( + connection, derived, mode=MODE_AUTO, locked=0, + reason=derived.reason, actor=actor, rule_version=rule_version, + ) + if outcome["revision"] == 1: + stats["created_events"] += 1 + else: + stats["updated_events"] += 1 + except Exception: + if began: + connection.rollback() + raise + else: + if began: + connection.commit() + return stats + + +# --------------------------------------------------------------------------- +# Manual decisions +# --------------------------------------------------------------------------- + + +def apply_manual_decision( + connection: sqlite3.Connection, + event_id: int, + action: str, + *, + reason: str, + expected_revision: int | None, + request_key: str | None, + actor: sqlite3.Row, + source_row_ids: list[int] | None = None, + participant: dict[str, object] | None = None, +) -> dict[str, object]: + """Apply an administrator decision on an event (link_rows/assign_participant + /mark_external/reverse). Replaces the current decision with a locked manual + one; corrections must go through ``reverse``, never in-place editing. + """ + reason = (reason or "").strip() + if not reason: + raise MatchInputError("必须填写操作原因。") + began = _ensure_transaction(connection) + try: + # Idempotency: a replayed request key returns the earlier decision + # without re-validating the (now changed) revision. + if request_key: + existing = connection.execute( + "SELECT id FROM transfer_match_decisions WHERE idempotency_key = ? AND event_id = ?", + (request_key, event_id), + ).fetchone() + if existing is not None: + if began: + connection.commit() + return _decision_payload(connection, event_id, existing["id"]) + + current = _current_decision_for_event(connection, event_id) + if current is None: + raise MatchConflictError("该事件当前没有有效决定。") + if expected_revision is not None and int(expected_revision) != current["revision"]: + raise MatchConflictError("事件已发生变更,请刷新后重试。") + + if action == "link_rows": + derived = _manual_link( + connection, event_id, current, source_row_ids, reason, actor + ) + elif action == "assign_participant": + derived = _manual_assign_participant( + connection, event_id, current, participant, reason, actor + ) + elif action == "mark_external": + derived = _manual_external(connection, event_id, current, reason) + elif action == "reverse": + derived = None + else: + raise MatchInputError("未知的人工决定类型。") + + if action == "reverse": + outcome = _apply_reversal( + connection, event_id, current, reason, actor, request_key + ) + else: + outcome = _apply( + connection, derived, mode=MODE_MANUAL, locked=1, + reason=reason, actor=actor, + rule_version=current["rule_version"] or RULE_VERSION, + idempotency_key=request_key, + ) + audit( + connection, + f"transfer_{action}", + actor=actor, + target=f"transfer_event:{event_id}", + detail=f"decision:{outcome['decision_id']};reason:{reason}", + ) + except Exception: + if began: + connection.rollback() + raise + else: + if began: + connection.commit() + return _decision_payload(connection, event_id, outcome["decision_id"]) + + +def _manual_link( + connection: sqlite3.Connection, + event_id: int, + current: sqlite3.Row, + source_row_ids: list[int] | None, + reason: str, + actor: sqlite3.Row, +) -> DerivedDecision: + if not source_row_ids or len(source_row_ids) != 2: + raise MatchInputError("人工关联必须且只能提供两条源行。") + rows = _load_rows(connection, [int(row_id) for row_id in source_row_ids]) + if len(rows) != 2: + raise MatchInputError("存在无效的源行。") + for row in rows: + claim = _current_claim(connection, row["id"]) + if claim is not None and claim["decision_id"] != current["id"] and claim["locked"]: + raise MatchConflictError(f"源行 {row['id']} 已被锁定的人工决定占用。") + resolutions = {row["id"]: resolve_row(connection, row) for row in rows} + directions = {row["id"]: resolutions[row["id"]].direction for row in rows} + if any(value is None for value in directions.values()): + raise MatchInputError("人工关联的源行金额方向必须一出一入。") + if directions[rows[0]["id"]] == directions[rows[1]["id"]]: + raise MatchInputError("人工关联的源行金额方向必须相反。") + outgoing_row = rows[0] if directions[rows[0]["id"]] == "outgoing" else rows[1] + incoming_row = rows[1] if outgoing_row is rows[0] else rows[0] + out_res = resolutions[outgoing_row["id"]] + in_res = resolutions[incoming_row["id"]] + if out_res.own is None or in_res.own is None: + raise MatchInputError("人工关联的源行未能唯一确认本方归属,不能定案。") + payer = _participant(out_res.own, "payer") + payee = _participant(in_res.own, "payee") + classification = ( + CLASSIFICATION_SAME_COMPANY + if payer["company_id"] == payee["company_id"] + else CLASSIFICATION_INTERCOMPANY + ) + amount = amount_of_row(outgoing_row) + return DerivedDecision( + classification, PAIRING_PAIRED, str(amount), + str(outgoing_row["currency"] or "").strip() or None, + _parse_datetime(outgoing_row["transaction_at"]).isoformat(), + ( + (outgoing_row["id"], "outgoing"), + (incoming_row["id"], "incoming"), + ), + (payer, payee), "管理员人工关联", + ) + + +def _manual_assign_participant( + connection: sqlite3.Connection, + event_id: int, + current: sqlite3.Row, + participant: dict[str, object] | None, + reason: str, + actor: sqlite3.Row, +) -> DerivedDecision: + if not participant or not isinstance(participant, dict): + raise MatchInputError("assign_participant 必须提供 participant。") + role = str(participant.get("role") or "") + if role not in ("payer", "payee"): + raise MatchInputError("participant.role 必须是 payer 或 payee。") + try: + company_id = int(participant["company_id"]) + except (KeyError, TypeError, ValueError): + raise MatchInputError("participant.company_id 必须是有效的公司 id。") from None + company = connection.execute( + "SELECT id FROM companies WHERE id = ?", (company_id,) + ).fetchone() + if company is None: + raise MatchInputError("participant.company_id 指向的公司不存在。") + + observations = _decision_observations(connection, current["id"]) + if len(observations) != 1: + raise MatchInputError("assign_participant 只适用于单边事件。") + observation = observations[0] + row = _load_rows(connection, [observation["source_row_id"]])[0] + resolved = resolve_row(connection, row) + if resolved.own is None or resolved.direction is None: + raise MatchInputError("assign_participant 需要本方归属已确认。") + own_role = "payer" if resolved.direction == "outgoing" else "payee" + if role == own_role: + raise MatchInputError("assign_participant 只能指定对方参与方角色。") + own_participant = _participant(resolved.own, own_role) + assigned = { + "role": role, + "company_id": company_id, + "bank_account_id": ( + int(participant["bank_account_id"]) + if participant.get("bank_account_id") + else None + ), + "resolve_method": "manual", + "alias_id": None, + "mapping_id": None, + "evidence": json.dumps( + {"via": "manual", "company_id": company_id}, ensure_ascii=False + ), + } + participants = (own_participant, assigned) + classification = ( + CLASSIFICATION_SAME_COMPANY + if own_participant["company_id"] == company_id + else CLASSIFICATION_INTERCOMPANY + ) + amount = amount_of_row(row) + return DerivedDecision( + classification, PAIRING_SINGLE, str(amount), + str(row["currency"] or "").strip() or None, + _parse_datetime(row["transaction_at"]).isoformat(), + ((row["id"], observation["role"]),), + participants, "管理员按证据确认参与方", + ) + + +def _manual_external( + connection: sqlite3.Connection, event_id: int, current: sqlite3.Row, reason: str +) -> DerivedDecision: + observations = _decision_observations(connection, current["id"]) + participants = _decision_participants(connection, current["id"]) + if len(observations) != 1: + raise MatchInputError("mark_external 只适用于单边事件。") + observation = observations[0] + row = _load_rows(connection, [observation["source_row_id"]])[0] + amount = amount_of_row(row) + participant_rows = [ + { + "role": p["role"], "company_id": p["company_id"], + "bank_account_id": p["bank_account_id"], + "resolve_method": p["resolve_method"], "alias_id": p["alias_id"], + "mapping_id": p["mapping_id"], "evidence": p["evidence"], + } + for p in participants + ] + return DerivedDecision( + CLASSIFICATION_EXTERNAL, PAIRING_NA, str(amount), + str(row["currency"] or "").strip() or None, + _parse_datetime(row["transaction_at"]).isoformat(), + ((row["id"], observation["role"]),), + tuple(participant_rows), "管理员确认外部交易", + ) + + +def _apply_reversal( + connection: sqlite3.Connection, + event_id: int, + current: sqlite3.Row, + reason: str, + actor: sqlite3.Row | None, + idempotency_key: str | None, +) -> dict[str, object]: + now = utc_now() + revision_row = connection.execute( + "SELECT COALESCE(MAX(revision), 0) AS m FROM transfer_match_decisions WHERE event_id = ?", + (event_id,), + ).fetchone() + revision = int(revision_row["m"]) + 1 + cursor = connection.execute( + """ + INSERT INTO transfer_match_decisions ( + event_id, revision, classification, pairing, amount, currency, + effective_at, mode, rule_version, locked, reason, idempotency_key, + actor_user_id, actor_username, supersedes_decision_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) + """, + ( + event_id, revision, current["classification"], current["pairing"], + current["amount"], current["currency"], current["effective_at"], + MODE_REVERSAL, current["rule_version"], reason, idempotency_key, + actor["id"] if actor is not None else None, + actor["username"] if actor is not None else None, + current["id"], now, + ), + ) + decision_id = int(cursor.lastrowid) + connection.execute( + "DELETE FROM transfer_observation_claims WHERE event_id = ?", (event_id,) + ) + connection.execute( + "DELETE FROM current_transfer_decisions WHERE event_id = ?", (event_id,) + ) + return {"decision_id": decision_id, "event_id": event_id} + + +# --------------------------------------------------------------------------- +# Projection rebuild and B-44 read contract +# --------------------------------------------------------------------------- + + +def rebuild_current_projection(connection: sqlite3.Connection) -> int: + """Rebuild current decisions and row claims from the append-only log. + + For every active event, the current decision is its highest-revision + decision — unless that decision is a reversal, in which case the event has + no current pointer and no claims. Returns the number of current decisions + rebuilt. Intended as a recovery/consistency entry point. + """ + connection.execute("DELETE FROM transfer_observation_claims") + connection.execute("DELETE FROM current_transfer_decisions") + events = connection.execute( + """ + SELECT e.id AS event_id, + (SELECT d2.id FROM transfer_match_decisions d2 + WHERE d2.event_id = e.id + ORDER BY d2.revision DESC LIMIT 1) AS latest_id + FROM canonical_transfer_events e + WHERE e.lifecycle = 'active' + """ + ).fetchall() + rebuilt = 0 + for event in events: + if event["latest_id"] is None: + continue + latest = connection.execute( + "SELECT mode FROM transfer_match_decisions WHERE id = ?", + (event["latest_id"],), + ).fetchone() + if latest is None or latest["mode"] == MODE_REVERSAL: + continue + observations = connection.execute( + """ + SELECT source_row_id FROM transfer_decision_observations + WHERE decision_id = ? ORDER BY id + """, + (event["latest_id"],), + ).fetchall() + with connection: + connection.execute( + """ + INSERT OR REPLACE INTO current_transfer_decisions (event_id, decision_id) + VALUES (?, ?) + """, + (event["event_id"], event["latest_id"]), + ) + for observation in observations: + connection.execute( + """ + INSERT OR REPLACE INTO transfer_observation_claims (source_row_id, event_id, decision_id) + VALUES (?, ?, ?) + """, + (observation["source_row_id"], event["event_id"], event["latest_id"]), + ) + rebuilt += 1 + return rebuilt + + +def eligible_intercompany_events(connection: sqlite3.Connection) -> list[sqlite3.Row]: + return connection.execute( + "SELECT * FROM eligible_intercompany_events ORDER BY event_id" + ).fetchall() + + +def unresolved_amounts( + connection: sqlite3.Connection, + company_id: int, + cutoff: str | None = None, +) -> list[sqlite3.Row]: + """Unresolved amounts a company is exposed to as of ``cutoff``. + + Only current decisions in ``unresolved``/``needs_review``/``internal_single`` + count as unresolved; paired intercompany, same-company and external events + are resolved classifications and never appear here. + """ + cutoff_where = "AND d.effective_at <= ?" if cutoff else "" + params: list[object] = [] + if cutoff: + params.append(cutoff) + rows = connection.execute( + f""" + SELECT d.id AS decision_id, d.event_id, d.classification, d.amount, + d.currency, d.effective_at, o.role AS direction + 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 + JOIN transfer_decision_observations o ON o.decision_id = d.id + WHERE d.classification IN ('unresolved', 'needs_review', 'internal_single') + AND p.company_id = ? + {cutoff_where} + ORDER BY d.id + """, + (company_id, *params), + ).fetchall() + return rows + + +def exposed_status(decision: sqlite3.Row | dict) -> str: + classification = decision["classification"] + pairing = decision["pairing"] + if classification == CLASSIFICATION_INTERCOMPANY and pairing == PAIRING_PAIRED: + return "matched" + if classification == CLASSIFICATION_INTERCOMPANY: + return "confirmed_single" + if classification == CLASSIFICATION_SAME_COMPANY: + return "same_company_transfer" + if classification == CLASSIFICATION_EXTERNAL: + return "external" + if classification == CLASSIFICATION_NEEDS_REVIEW: + return "needs_review" + if classification == CLASSIFICATION_INTERNAL_SINGLE: + return "internal_single" + return "unresolved" + + +# --------------------------------------------------------------------------- +# API payload helpers +# --------------------------------------------------------------------------- + + +def _decision_payload( + connection: sqlite3.Connection, event_id: int, decision_id: int +) -> dict[str, object]: + decision = connection.execute( + "SELECT * FROM transfer_match_decisions WHERE id = ?", (decision_id,) + ).fetchone() + observations = _decision_observations(connection, decision_id) + participants = _decision_participants(connection, decision_id) + return { + "event_id": event_id, + "decision_id": decision_id, + "revision": decision["revision"], + "classification": decision["classification"], + "pairing": decision["pairing"], + "status": exposed_status(decision), + "amount": decision["amount"], + "currency": decision["currency"], + "effective_at": decision["effective_at"], + "mode": decision["mode"], + "locked": bool(decision["locked"]), + "rule_version": decision["rule_version"], + "source_row_ids": [o["source_row_id"] for o in observations], + "participants": [ + { + "role": p["role"], "company_id": p["company_id"], + "bank_account_id": p["bank_account_id"], + "resolve_method": p["resolve_method"], + } + for p in participants + ], + } diff --git a/src/bank_importer/personal_transit.py b/src/bank_importer/personal_transit.py new file mode 100644 index 0000000..8559b7f --- /dev/null +++ b/src/bank_importer/personal_transit.py @@ -0,0 +1,281 @@ +"""Personal transit account mappings and their administrator approval. + +A personal transit mapping states that a personal bank account represents a +company for a specific flow direction inside an effective interval. The key is +the normalized account number; the account holder name is display-only and +never resolves a counterparty on its own (``AGENTS.md``: never infer a +financial fact from a name alone). Only administrator-approved (``active``) +mappings inside their effective window participate in counterparty resolution. +""" + +from __future__ import annotations + +import sqlite3 + +from .db import utc_now +from .master_data import ( + ConflictError, + mask_account_number, + normalize_account_number, + record_change, + validate_date, +) + +MAPPING_STATUSES = ("pending", "active", "returned", "disabled") +ALLOWED_DIRECTIONS = ("outgoing", "incoming", "both") + + +def get_mapping(connection: sqlite3.Connection, mapping_id: int) -> sqlite3.Row | None: + return connection.execute( + "SELECT * FROM personal_transit_mappings WHERE id = ?", (mapping_id,) + ).fetchone() + + +def submit_mapping( + connection: sqlite3.Connection, + *, + account_number: object, + account_name: object, + represented_company_id: int, + allowed_direction: str, + effective_from: object = None, + actor: sqlite3.Row | None, +) -> sqlite3.Row: + """Create a pending personal transit mapping; returns the new row. + + The account number is normalized (digits only) and unique: resubmitting a + returned request from the same company reopens the same row as pending; + any other existing row is a conflict guarded by the UNIQUE constraint. + """ + number = normalize_account_number(account_number) + holder = str(account_name or "").strip() or None + direction = str(allowed_direction or "").strip() + if direction not in ALLOWED_DIRECTIONS: + raise ValueError(f"允许方向必须是:{'、'.join(ALLOWED_DIRECTIONS)}。") + if not holder: + raise ValueError("账户户名不能为空(仅作展示,不作匹配键)。") + company = connection.execute( + "SELECT id FROM companies WHERE id = ?", (represented_company_id,) + ).fetchone() + if company is None: + raise ValueError("代表的公司不存在。") + start = validate_date(effective_from, "生效日期") + now = utc_now() + + existing = connection.execute( + "SELECT * FROM personal_transit_mappings WHERE account_number = ?", (number,) + ).fetchone() + if existing is None: + try: + with connection: + cursor = connection.execute( + """ + INSERT INTO personal_transit_mappings ( + account_number, account_name, represented_company_id, + allowed_direction, status, effective_from, submitted_by, + created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?) + """, + ( + number, holder, represented_company_id, direction, + start, actor["id"] if actor else None, now, now, + ), + ) + except sqlite3.IntegrityError as exc: + raise ConflictError("该个人过账账号已登记,请等待现有申请处理。") from exc + mapping_id = int(cursor.lastrowid) + with connection: + record_change( + connection, "personal_transit_mapping", mapping_id, "submit", None, + {"account_number": number, "account_name": holder, + "represented_company_id": represented_company_id, + "allowed_direction": direction, "status": "pending", + "effective_from": start}, + None, actor, + ) + return get_mapping(connection, mapping_id) + + if existing["status"] == "returned": + with connection: + connection.execute( + """ + UPDATE personal_transit_mappings + SET account_name = ?, represented_company_id = ?, + allowed_direction = ?, status = 'pending', effective_from = ?, + submitted_by = ?, reviewed_by = NULL, reviewed_at = NULL, + review_reason = NULL, updated_at = ? + WHERE id = ? AND status = 'returned' + """, + ( + holder, represented_company_id, direction, start, + actor["id"] if actor else None, now, existing["id"], + ), + ) + record_change( + connection, "personal_transit_mapping", existing["id"], "resubmit", + None, {"account_number": number, "status": "pending", + "allowed_direction": direction, "effective_from": start}, + "退回后重新提交", actor, + ) + return get_mapping(connection, existing["id"]) + + raise ConflictError("该个人过账账号已登记,请等待现有申请处理。") + + +def review_mapping( + connection: sqlite3.Connection, + mapping_id: int, + decision: str, + reason: str | None, + actor: sqlite3.Row, + *, + effective_from: object = None, + effective_to: object = None, +) -> sqlite3.Row: + """Approve, return or disable a mapping; returns the updated row.""" + mapping = get_mapping(connection, mapping_id) + if mapping is None: + raise LookupError("个人过账映射不存在。") + reason = (reason or "").strip() or None + today = utc_now()[:10] + + if decision == "approve": + if mapping["status"] != "pending": + raise ConflictError("只有待复核的映射可以审核通过。") + start = validate_date(effective_from, "生效日期") or mapping["effective_from"] or today + with connection: + connection.execute( + """ + UPDATE personal_transit_mappings + SET status = 'active', effective_from = ?, effective_to = NULL, + reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ? + WHERE id = ? + """, + (start, actor["id"], utc_now(), reason, utc_now(), mapping_id), + ) + record_change( + connection, "personal_transit_mapping", mapping_id, "approve", None, + {"status": "active", "effective_from": start}, reason, actor, + ) + elif decision == "return": + if mapping["status"] != "pending": + raise ConflictError("只有待复核的映射可以退回。") + if reason is None: + raise ValueError("退回必须填写原因。") + with connection: + connection.execute( + """ + UPDATE personal_transit_mappings + SET status = 'returned', reviewed_by = ?, reviewed_at = ?, + review_reason = ?, updated_at = ? + WHERE id = ? + """, + (actor["id"], utc_now(), reason, utc_now(), mapping_id), + ) + record_change( + connection, "personal_transit_mapping", mapping_id, "return", None, + {"status": "returned"}, reason, actor, + ) + elif decision == "disable": + if mapping["status"] != "active": + raise ConflictError("只有已启用的映射可以停用。") + if reason is None: + raise ValueError("停用必须填写原因。") + end = validate_date(effective_to, "停用日期") or today + with connection: + connection.execute( + """ + UPDATE personal_transit_mappings + SET status = 'disabled', effective_to = ?, + reviewed_by = ?, reviewed_at = ?, review_reason = ?, updated_at = ? + WHERE id = ? + """, + (end, actor["id"], utc_now(), reason, utc_now(), mapping_id), + ) + record_change( + connection, "personal_transit_mapping", mapping_id, "disable", None, + {"status": "disabled", "effective_to": end}, reason, actor, + ) + else: + raise ValueError("审核决定必须是 approve、return 或 disable。") + return get_mapping(connection, mapping_id) + + +def list_mappings( + connection: sqlite3.Connection, + company_id: int | None = None, + status: str | None = None, +) -> list[sqlite3.Row]: + conditions: list[str] = [] + params: list[object] = [] + if company_id is not None: + conditions.append("m.represented_company_id = ?") + params.append(company_id) + if status is not None: + if status not in MAPPING_STATUSES: + raise ValueError("无效的映射状态。") + conditions.append("m.status = ?") + params.append(status) + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + return connection.execute( + f""" + SELECT m.*, c.name AS company_name + FROM personal_transit_mappings m + JOIN companies c ON c.id = m.represented_company_id + {where} + ORDER BY m.id + """, + params, + ).fetchall() + + +def is_mapping_active(mapping: sqlite3.Row, on_date: str) -> bool: + """True when the mapping may resolve a counterparty on ``on_date``. + + ``effective_to`` is the last participating day (inclusive). Disabled + mappings stop resolving new rows but keep identifying historical rows + inside their effective window, matching ``bank_accounts`` semantics. + """ + if mapping["status"] not in ("active", "disabled"): + return False + if mapping["effective_from"] and on_date < mapping["effective_from"]: + return False + if mapping["effective_to"] and on_date > mapping["effective_to"]: + return False + return True + + +def direction_allowed(mapping: sqlite3.Row, row_direction: str) -> bool: + """True when the personal account may represent the company for this flow. + + ``allowed_direction`` describes the transfer from the represented company's + perspective: when the personal account appears as the counterparty of an + outgoing row, the represented company is receiving (incoming); for an + incoming row the represented company is paying out (outgoing). + """ + counterparty_flow = "incoming" if row_direction == "outgoing" else "outgoing" + allowed = mapping["allowed_direction"] + return allowed == "both" or allowed == counterparty_flow + + +def mapping_payload(mapping: sqlite3.Row, *, full: bool) -> dict[str, object]: + """Serialize a mapping; company-facing views only ever see masked numbers.""" + payload: dict[str, object] = { + "id": mapping["id"], + "account_name": mapping["account_name"], + "represented_company_id": mapping["represented_company_id"], + "allowed_direction": mapping["allowed_direction"], + "status": mapping["status"], + "effective_from": mapping["effective_from"], + "effective_to": mapping["effective_to"], + "reviewed_at": mapping["reviewed_at"], + "review_reason": mapping["review_reason"], + "created_at": mapping["created_at"], + } + if "company_name" in mapping.keys(): + payload["company_name"] = mapping["company_name"] + if full: + payload["account_number"] = mapping["account_number"] + else: + payload["account_number_masked"] = mask_account_number(mapping["account_number"]) + return payload diff --git a/tests/test_import_api.py b/tests/test_import_api.py index 9855df0..3abf507 100644 --- a/tests/test_import_api.py +++ b/tests/test_import_api.py @@ -17,6 +17,7 @@ from pathlib import Path import tempfile import threading import unittest +from unittest import mock from openpyxl import Workbook @@ -49,6 +50,14 @@ CCB_DATA = [ ] +def ccb_row(own, cp, *, expense="", income="", at="2026-01-05 10:00:00"): + return [own, "测试公司", at, expense or "", income or "", "99900.00", "RMB", "对方", cp, "某银行", "货款", ""] + + +def ccb_bytes(rows): + return workbook_bytes([("正常流水", [CCB_HEADER, *rows])]) + + def workbook_bytes(sheets): """Build an xlsx in memory. @@ -582,6 +591,145 @@ class ImportApiServerTests(unittest.TestCase): finally: connection.close() + # ------------------------------------------------------------------ + # Worksheet confirmation drives canonical matching in the same transaction + # ------------------------------------------------------------------ + + def _approve_test_account(self, company_client, number: str) -> int: + status, _, data = company_client.post_json( + "/api/company/accounts", + {"bank_name": "中信银行", "account_type": "基本户", + "account_number": number, "start_date": "2026-01-01"}, + ) + self.assertEqual(200, status, 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"}, + ) + self.assertEqual(200, status, data) + return account_id + + def _company_cashier(self, username: str, company_id: int) -> Client: + """A company cashier bound to the given company (the class fixture binds + both cashiers to company A, so tests needing a real tenant B create one).""" + status, _, data = self.admin.post_json( + "/api/admin/users", {"username": username, "company_id": company_id} + ) + self.assertEqual(200, status, data) + initial = as_json(data)["initial_password"] + client = self.fresh_client() + status, _, data = client.post_json( + "/api/login", + {"username": username, "password": initial, "portal": "company"}, + ) + self.assertEqual(200, status, data) + status, _, data = client.post_json( + "/api/password/change", + {"old_password": initial, "new_password": "Changed456"}, + ) + self.assertEqual(200, status, data) + return client + + def test_confirm_drives_bilateral_matching_atomically(self) -> None: + account_a = self._approve_test_account(self.cashier_a, "6222000000000001") + cashier_b = self._company_cashier("cashier-b2", self.company_b) + account_b = self._approve_test_account(cashier_b, "6222000000000002") + + # A's side first: confirming the worksheet reconciles its row into an + # internal single (not yet eligible for the intercompany balance). + status, _, data = self.upload_for_a( + ccb_bytes([ccb_row("6222000000000001", "6222000000000002", expense="100.00")]), + filename="A方.xlsx", + ) + self.assertEqual(200, status, data) + batch_a = as_json(data)["batch_id"] + status, _, data = self.confirm(self.cashier_a, batch_a, ["正常流水"]) + self.assertEqual(200, status, data) + self.assertEqual(1, as_json(data)["matching"]["created_events"]) + connection = connect(self.db_path) + try: + row_a = connection.execute( + """ + SELECT r.id FROM source_rows r + JOIN sheet_batches s ON s.id = r.sheet_batch_id + WHERE s.import_batch_id = ? + """, + (batch_a,), + ).fetchone() + status_a = connection.execute( + """ + SELECT d.classification FROM transfer_observation_claims c + JOIN transfer_match_decisions d ON d.id = c.decision_id + WHERE c.source_row_id = ? + """, + (row_a["id"],), + ).fetchone() + self.assertEqual("internal_single", status_a["classification"]) + finally: + connection.close() + + # B's side arrives later: the second confirmation merges both into one + # paired intercompany event without duplicating the first. + status, _, data = cashier_b.post_multipart( + "/api/parse", {}, "B方.xlsx", + ccb_bytes([ccb_row("6222000000000002", "6222000000000001", income="100.00")]), + ) + self.assertEqual(200, status, data) + batch_b = as_json(data)["batch_id"] + status, _, data = self.confirm(cashier_b, batch_b, ["正常流水"]) + self.assertEqual(200, status, data) + connection = connect(self.db_path) + try: + eligible = connection.execute( + "SELECT * FROM eligible_intercompany_events" + ).fetchall() + self.assertEqual(1, len(eligible)) + self.assertEqual("100.00", eligible[0]["amount"]) + self.assertEqual("paired", eligible[0]["pairing"]) + # Exactly one current intercompany decision for the pair. + self.assertEqual(1, connection.execute( + """ + SELECT COUNT(*) AS n FROM current_transfer_decisions c + JOIN transfer_match_decisions d ON d.id = c.decision_id + WHERE d.classification = 'intercompany' + """ + ).fetchone()["n"]) + finally: + connection.close() + + def test_confirm_rolls_back_when_matching_fails(self) -> None: + account_a = self._approve_test_account(self.cashier_a, "6222000000000003") + status, _, data = self.upload_for_a( + ccb_bytes([ccb_row("6222000000000003", "6222000000000004", expense="50.00")]), + filename="回滚.xlsx", + ) + batch_id = as_json(data)["batch_id"] + with mock.patch( + "bank_importer.importing.matching.reconcile_rows", + side_effect=RuntimeError("simulated matching failure"), + ): + status, _, data = self.confirm(self.cashier_a, batch_id, ["正常流水"]) + self.assertEqual(500, status, data) + # The worksheet confirmation was rolled back with the failed matching. + sheet = self.batch_sheets(self.cashier_a, batch_id)[0] + self.assertEqual("pending", sheet["review_status"]) + connection = connect(self.db_path) + try: + # No decision or claim was left behind for this batch's rows. + claims = connection.execute( + """ + SELECT COUNT(*) AS n FROM transfer_observation_claims c + JOIN source_rows r ON r.id = c.source_row_id + JOIN sheet_batches s ON s.id = r.sheet_batch_id + WHERE s.import_batch_id = ? + """, + (batch_id,), + ).fetchone()["n"] + self.assertEqual(0, claims) + finally: + connection.close() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_matching.py b/tests/test_matching.py new file mode 100644 index 0000000..ddc9069 --- /dev/null +++ b/tests/test_matching.py @@ -0,0 +1,865 @@ +"""Matching engine tests: bilateral ordering, cross-day windows, ambiguity, +same-company transfers, personal transit mappings, manual decisions, locking, +idempotency, concurrency, Decimal precision and B-44 eligibility.""" + +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path +import sqlite3 +import tempfile +import threading +import unittest + +from bank_importer import auth, matching, master_data, personal_transit +from bank_importer.db import connect, migrate, utc_now + + +class MatchingBase(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") + self.account_a2 = self._approved_account(self.company_a, "6222000000000003") + self.account_b2 = self._approved_account(self.company_b, "6222000000000004") + + 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, + own_name: str = "测试公司", + cp_account: str | None = None, + cp_name: 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, + sheet: str = "流水", + ) -> 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, sheet, utc_now()), + ) + sheet_batch_id = int(cursor.lastrowid) + self.connection.execute( + """ + INSERT INTO sheet_reviews ( + import_batch_id, sheet_name, outcome, sheet_batch_id, + review_status, created_at + ) VALUES (?, ?, 'parsed', ?, 'confirmed', ?) + """, + (batch_id, sheet, sheet_batch_id, utc_now()), + ) + cursor = self.connection.execute( + """ + INSERT INTO source_rows ( + sheet_batch_id, source_row, transaction_at, income, expense, balance, + own_account, own_name, counterparty_account, counterparty_name, + counterparty_bank, summary, purpose, reference, currency, created_at + ) VALUES (?, 1, ?, ?, ?, NULL, ?, ?, ?, ?, NULL, ?, NULL, ?, ?, ?) + """, + ( + sheet_batch_id, at, income, expense, own_account, own_name, + cp_account, cp_name, summary, reference, currency, utc_now(), + ), + ) + return int(cursor.lastrowid) + + def current(self, row_id: int): + return self.connection.execute( + """ + SELECT d.classification, d.pairing, d.amount, d.currency, d.effective_at, + d.mode, d.locked, d.revision + FROM transfer_observation_claims c + JOIN transfer_match_decisions d ON d.id = c.decision_id + WHERE c.source_row_id = ? + """, + (row_id,), + ).fetchone() + + def _event_of(self, row_id: int) -> int: + return self.connection.execute( + "SELECT event_id FROM transfer_observation_claims WHERE source_row_id = ?", + (row_id,), + ).fetchone()["event_id"] + + def eligible(self) -> list[sqlite3.Row]: + return matching.eligible_intercompany_events(self.connection) + + def event_count(self) -> int: + return self.connection.execute( + "SELECT COUNT(*) AS n FROM canonical_transfer_events" + ).fetchone()["n"] + + def decision_count(self) -> int: + return self.connection.execute( + "SELECT COUNT(*) AS n FROM transfer_match_decisions" + ).fetchone()["n"] + + +class BilateralOrderTests(MatchingBase): + def test_a_outgoing_then_b_incoming_pairs_once(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", at="2026-01-05T10:00:00", + ) + matching.reconcile_rows(self.connection, [row_a]) + self.assertEqual("internal_single", self.current(row_a)["classification"]) + # A single internal observation must not enter the B-44 balance yet. + self.assertEqual([], self.eligible()) + + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", at="2026-01-05T11:00:00", + ) + matching.reconcile_rows(self.connection, [row_b]) + for row_id in (row_a, row_b): + self.assertEqual("intercompany", self.current(row_id)["classification"]) + self.assertEqual("paired", self.current(row_id)["pairing"]) + eligible = self.eligible() + self.assertEqual(1, len(eligible)) + self.assertEqual(Decimal("100.00"), Decimal(eligible[0]["amount"])) + self.assertEqual("CNY", eligible[0]["currency"]) + self.assertEqual(self.company_a, eligible[0]["payer_company_id"]) + self.assertEqual(self.company_b, eligible[0]["payee_company_id"]) + self.assertEqual("paired", eligible[0]["pairing"]) + # One event, one paired decision for both rows. + self.assertEqual(1, self.event_count()) + + def test_b_incoming_then_a_outgoing_same_result(self) -> None: + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", at="2026-01-05T11:00:00", + ) + matching.reconcile_rows(self.connection, [row_b]) + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", at="2026-01-05T10:00:00", + ) + matching.reconcile_rows(self.connection, [row_a]) + eligible = self.eligible() + self.assertEqual(1, len(eligible)) + self.assertEqual("100.00", eligible[0]["amount"]) + # Economic date is the payer's outgoing posting time, import-order free. + self.assertEqual("2026-01-05T10:00:00", eligible[0]["effective_at"]) + self.assertEqual(self.company_a, eligible[0]["payer_company_id"]) + + def test_same_batch_both_sides_pairs_once(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + ) + matching.reconcile_rows(self.connection, [row_b, row_a]) + eligible = self.eligible() + self.assertEqual(1, len(eligible)) + self.assertEqual("paired", self.current(row_a)["pairing"]) + # One event, one auto decision carrying both observations. + self.assertEqual(1, self.event_count()) + self.assertEqual(1, self.decision_count()) + + def test_repeated_reconcile_is_idempotent(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + before_decisions = self.decision_count() + stats = matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual(self.decision_count(), before_decisions) + self.assertEqual(2, stats["unchanged"]) + self.assertEqual(0, stats["created_events"]) + self.assertEqual(0, stats["updated_events"]) + + +class DateWindowTests(MatchingBase): + def test_m1_same_reference_within_3_days(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + at="2026-01-01T09:00:00", reference="R1001", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + at="2026-01-04T15:00:00", reference="R1001", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual("matched", matching.exposed_status(self.current(row_a))) + candidate = self.connection.execute( + "SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1" + ).fetchone() + self.assertEqual("M1", candidate["rule_tier"]) + + def test_m2_exact_mirror_same_day_without_reference(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual("matched", matching.exposed_status(self.current(row_a))) + candidate = self.connection.execute( + "SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1" + ).fetchone() + self.assertEqual("M2", candidate["rule_tier"]) + + def test_over_window_goes_to_review(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + at="2026-01-01T09:00:00", reference="R1001", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + at="2026-01-05T15:00:00", reference="R1001", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual("needs_review", matching.exposed_status(self.current(row_a))) + self.assertEqual([], self.eligible()) + + def test_reference_conflict_goes_to_review(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + reference="R-A", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + reference="R-B", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual("needs_review", matching.exposed_status(self.current(row_a))) + + +class AmbiguityTests(MatchingBase): + def test_alias_across_companies_goes_to_review(self) -> None: + # The same observed number is an account alias of TWO companies; the + # counterparty cannot be uniquely decided, so it must go to review. + company_c_account = self._approved_account(self.company_c, "6222000000000005") + master_data.add_alias( + self.connection, self.account_b["id"], "account", "770077007700", + actor=self.admin, + ) + master_data.add_alias( + self.connection, company_c_account["id"], "account", "770077007700", + actor=self.admin, + ) + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="770077007700", expense="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + self.assertEqual("needs_review", matching.exposed_status(self.current(row_a))) + self.assertEqual([], self.eligible()) + + def test_two_same_tier_candidates_go_to_review(self) -> None: + # Two identical incoming observations mirror the outgoing row exactly; + # no deterministic rule may pick one, so the pair goes to review. + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + row_b1 = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + ) + row_b2 = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + ) + matching.reconcile_rows(self.connection, [row_a, row_b1, row_b2]) + # The ambiguous side stays in review; the two identical observations + # may not be auto-grabbed by a processing-order tie-break. + self.assertEqual("needs_review", matching.exposed_status(self.current(row_a))) + self.assertEqual("internal_single", matching.exposed_status(self.current(row_b1))) + self.assertEqual("internal_single", matching.exposed_status(self.current(row_b2))) + self.assertEqual([], self.eligible()) + + def test_different_amounts_never_match(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.01", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual("internal_single", matching.exposed_status(self.current(row_a))) + self.assertEqual("internal_single", matching.exposed_status(self.current(row_b))) + self.assertEqual([], self.eligible()) + + +class SameCompanyTests(MatchingBase): + def test_same_company_transfer_excluded_from_intercompany(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000003", expense="50.00", + ) + row_b = self.add_row( + self.company_a, own_account="6222000000000003", + cp_account="6222000000000001", income="50.00", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual("same_company_transfer", matching.exposed_status(self.current(row_a))) + self.assertEqual("same_company_transfer", matching.exposed_status(self.current(row_b))) + self.assertEqual([], self.eligible()) + # The cash trail is kept: both rows remain claimed by one event. + self.assertEqual(1, self.event_count()) + + def test_same_company_single_observation_classified_immediately(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000003", expense="50.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + self.assertEqual("same_company_transfer", matching.exposed_status(self.current(row_a))) + + +class UnresolvedAndExternalTests(MatchingBase): + def test_unknown_counterparty_stays_unresolved(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="9999999999999999", expense="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + self.assertEqual("unresolved", matching.exposed_status(self.current(row_a))) + self.assertEqual([], self.eligible()) + unresolved = matching.unresolved_amounts(self.connection, self.company_a) + self.assertEqual(1, len(unresolved)) + self.assertEqual("100.00", unresolved[0]["amount"]) + + def test_internal_single_counts_as_unresolved_amount(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + self.assertEqual("internal_single", matching.exposed_status(self.current(row_a))) + self.assertEqual([], self.eligible()) + unresolved = matching.unresolved_amounts(self.connection, self.company_a) + self.assertEqual(1, len(unresolved)) + self.assertEqual("100.00", unresolved[0]["amount"]) + + def test_admin_confirm_single_becomes_eligible(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + current = self.current(row_a) + result = matching.apply_manual_decision( + self.connection, self._event_of(row_a), + "assign_participant", + reason="对方公司函证确认", + expected_revision=current["revision"], + request_key="assign-1", + actor=self.admin, + participant={"role": "payee", "company_id": self.company_b}, + ) + self.assertEqual("intercompany", result["classification"]) + self.assertTrue(result["locked"]) + eligible = self.eligible() + self.assertEqual(1, len(eligible)) + self.assertEqual("single", eligible[0]["pairing"]) + # The manual confirmation is skipped by auto reconcile. + decisions_before = self.decision_count() + stats = matching.reconcile_rows(self.connection, [row_a]) + self.assertEqual(1, stats["skipped_locked"]) + self.assertEqual(decisions_before, self.decision_count()) + + def test_mark_external_requires_admin_and_excludes(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="9999999999999999", expense="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + event_id = self._event_of(row_a) + result = matching.apply_manual_decision( + self.connection, event_id, "mark_external", + reason="经核实为外部供应商付款", + expected_revision=self.current(row_a)["revision"], + request_key=None, actor=self.admin, + ) + self.assertEqual("external", result["classification"]) + self.assertEqual([], self.eligible()) + + def test_reverse_frees_claims_for_reconcile(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + event_id = self._event_of(row_a) + decisions_before = self.decision_count() + result = matching.apply_manual_decision( + self.connection, event_id, "reverse", + reason="配对依据有误,需要重新匹配", + expected_revision=self.current(row_a)["revision"], + request_key="rev-1", actor=self.admin, + ) + self.assertEqual(decisions_before + 1, self.decision_count()) + # Claims and current pointer are gone; B-44 sees nothing. + self.assertIsNone(self.connection.execute( + "SELECT 1 FROM transfer_observation_claims WHERE source_row_id = ?", + (row_a,), + ).fetchone()) + self.assertEqual([], self.eligible()) + # A re-run is allowed and re-derives deterministically. + matching.reconcile_rows(self.connection, [row_a]) + self.assertEqual("matched", matching.exposed_status(self.current(row_a))) + + +class PersonalTransitTests(MatchingBase): + def _submit_mapping(self, company_id: int, number: str, direction: str) -> int: + mapping = personal_transit.submit_mapping( + self.connection, + account_number=number, + account_name="张个人", + represented_company_id=company_id, + allowed_direction=direction, + effective_from="2026-01-01", + actor=self.admin, + ) + approved = personal_transit.review_mapping( + self.connection, mapping["id"], "approve", None, self.admin, + effective_from="2026-01-01", + ) + return approved["id"] + + def test_personal_mapping_resolves_counterparty(self) -> None: + self._submit_mapping(self.company_b, "880088008800", "incoming") + # A pays to the personal account -> the counterparty represents B. + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="880088008800", expense="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + self.assertEqual("internal_single", matching.exposed_status(self.current(row_a))) + unresolved = matching.unresolved_amounts(self.connection, self.company_a) + self.assertEqual(1, len(unresolved)) + + def test_personal_mapping_direction_mismatch_unresolved(self) -> None: + self._submit_mapping(self.company_b, "880088008800", "incoming") + # An outgoing row to the personal account is NOT covered by an + # incoming-only mapping. + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="880088008800", expense="100.00", + ) + # reverse direction check: make it incoming via income + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="880088008800", income="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + matching.reconcile_rows(self.connection, [row_b]) + self.assertEqual("unresolved", matching.exposed_status(self.current(row_b))) + + def test_unapproved_mapping_never_resolves(self) -> None: + personal_transit.submit_mapping( + self.connection, + account_number="880088008801", + account_name="李个人", + represented_company_id=self.company_b, + allowed_direction="both", + actor=self.admin, + ) + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="880088008801", expense="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + self.assertEqual("unresolved", matching.exposed_status(self.current(row_a))) + + def test_mapping_effective_window_and_resubmit_reuse(self) -> None: + mapping = personal_transit.submit_mapping( + self.connection, + account_number="880088008802", + account_name="王个人", + represented_company_id=self.company_b, + allowed_direction="both", + effective_from="2026-03-01", + actor=self.admin, + ) + personal_transit.review_mapping( + self.connection, mapping["id"], "approve", None, self.admin, + effective_from="2026-03-01", + ) + # Outside the effective window the mapping does not resolve. + row_before = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="880088008802", expense="100.00", at="2026-02-20T10:00:00", + ) + matching.reconcile_rows(self.connection, [row_before]) + self.assertEqual("unresolved", matching.exposed_status(self.current(row_before))) + # Inside the window it does. + row_inside = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="880088008802", expense="100.00", at="2026-03-05T10:00:00", + ) + matching.reconcile_rows(self.connection, [row_inside]) + self.assertEqual("internal_single", matching.exposed_status(self.current(row_inside))) + + # Returned mappings reopen on resubmission reusing the same row. + returned = personal_transit.submit_mapping( + self.connection, + account_number="880088008803", + account_name="赵个人", + represented_company_id=self.company_b, + allowed_direction="both", + actor=self.admin, + ) + personal_transit.review_mapping( + self.connection, returned["id"], "return", "资料待补充", self.admin, + ) + resubmitted = personal_transit.submit_mapping( + self.connection, + account_number="8800 8800 8803", + account_name="赵个人", + represented_company_id=self.company_b, + allowed_direction="incoming", + actor=self.admin, + ) + self.assertEqual(returned["id"], resubmitted["id"]) + self.assertEqual("pending", resubmitted["status"]) + + +class ResolutionTests(MatchingBase): + def test_name_alias_never_resolves_alone(self) -> None: + master_data.add_alias( + self.connection, self.account_b["id"], "name", "乙公司贸易部", + actor=self.admin, + ) + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account=None, cp_name="乙公司贸易部", expense="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + self.assertEqual("unresolved", matching.exposed_status(self.current(row_a))) + + def test_own_account_conflict_with_upload_account_goes_to_review(self) -> None: + # own_account belongs to company B while the batch is uploaded for A. + row = self.add_row( + self.company_a, own_account="6222000000000002", + cp_account="6222000000000001", expense="100.00", + ) + # Simulate an upload account pointing at A. + self.connection.execute( + """ + UPDATE import_batches SET upload_bank_account_id = ? + WHERE id = (SELECT b.id FROM import_batches b + JOIN sheet_batches s ON s.import_batch_id = b.id + JOIN source_rows r ON r.sheet_batch_id = s.id + WHERE r.id = ?) + """, + (self.account_a["id"], row), + ) + matching.reconcile_rows(self.connection, [row]) + self.assertEqual("needs_review", matching.exposed_status(self.current(row))) + + def test_upload_account_fallback_resolves_own(self) -> None: + row = self.add_row( + self.company_a, own_account=None, + cp_account="6222000000000002", expense="100.00", + ) + self.connection.execute( + """ + UPDATE import_batches SET upload_bank_account_id = ? + WHERE id = (SELECT b.id FROM import_batches b + JOIN sheet_batches s ON s.import_batch_id = b.id + JOIN source_rows r ON r.sheet_batch_id = s.id + WHERE r.id = ?) + """, + (self.account_a["id"], row), + ) + matching.reconcile_rows(self.connection, [row]) + self.assertEqual("internal_single", matching.exposed_status(self.current(row))) + + def test_alias_mirror_m3_pairs(self) -> None: + # A's own account has an account alias; B's statement references the + # alias, so the mirror is proven through the approved alias (M3). + master_data.add_alias( + self.connection, self.account_a["id"], "account", "770077007700", + actor=self.admin, + ) + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", summary="货款", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="770077007700", income="100.00", summary="货款", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual("matched", matching.exposed_status(self.current(row_a))) + candidate = self.connection.execute( + "SELECT rule_tier FROM transfer_match_candidates WHERE accepted = 1" + ).fetchone() + self.assertEqual("M3", candidate["rule_tier"]) + + +class ManualLinkTests(MatchingBase): + def test_manual_link_locks_pair(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + matching.reconcile_rows(self.connection, [row_b]) + event_id = self._event_of(row_a) + current_revision = self.current(row_a)["revision"] + result = matching.apply_manual_decision( + self.connection, event_id, "link_rows", + reason="人工核对后确认是同一笔", + expected_revision=current_revision, + request_key="link-1", actor=self.admin, + source_row_ids=[row_a, row_b], + ) + self.assertTrue(result["locked"]) + self.assertEqual("paired", result["pairing"]) + # Auto reconcile never touches locked decisions. + stats = matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual(2, stats["skipped_locked"]) + + def test_manual_decision_requires_reason(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + with self.assertRaises(matching.MatchInputError): + matching.apply_manual_decision( + self.connection, self._event_of(row_a), "reverse", + reason=" ", expected_revision=None, request_key=None, + actor=self.admin, + ) + + def test_stale_expected_revision_conflicts(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + matching.reconcile_rows(self.connection, [row_a]) + event_id = self._event_of(row_a) + with self.assertRaises(matching.MatchConflictError): + matching.apply_manual_decision( + self.connection, event_id, "reverse", + reason="测试", expected_revision=999, request_key=None, + actor=self.admin, + ) + + +class DecimalPrecisionTests(MatchingBase): + def test_equivalent_decimal_strings_match(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.0", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual("matched", matching.exposed_status(self.current(row_a))) + + def test_cent_difference_never_matches(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.01", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual("internal_single", matching.exposed_status(self.current(row_a))) + + def test_large_amount_precision(self) -> None: + amount = "99999999999999999999.99" + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense=amount, + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income=amount, + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + eligible = self.eligible() + self.assertEqual(1, len(eligible)) + self.assertEqual(Decimal(amount), Decimal(eligible[0]["amount"])) + + def test_currency_mismatch_never_matches(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", currency="CNY", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", currency="USD", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + self.assertEqual("internal_single", matching.exposed_status(self.current(row_a))) + + +class ProjectionRebuildTests(MatchingBase): + def test_rebuild_matches_current_projection(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + ) + matching.reconcile_rows(self.connection, [row_a, row_b]) + before = [ + (item["event_id"], item["decision_id"]) + for item in self.connection.execute( + "SELECT * FROM current_transfer_decisions" + ).fetchall() + ] + claims_before = [ + (item["source_row_id"], item["event_id"], item["decision_id"]) + for item in self.connection.execute( + "SELECT * FROM transfer_observation_claims" + ).fetchall() + ] + matching.rebuild_current_projection(self.connection) + after = [ + (item["event_id"], item["decision_id"]) + for item in self.connection.execute( + "SELECT * FROM current_transfer_decisions" + ).fetchall() + ] + claims_after = [ + (item["source_row_id"], item["event_id"], item["decision_id"]) + for item in self.connection.execute( + "SELECT * FROM transfer_observation_claims" + ).fetchall() + ] + self.assertEqual(sorted(before), sorted(after)) + self.assertEqual(sorted(claims_before), sorted(claims_after)) + + +class ConcurrentReconcileTests(MatchingBase): + def test_concurrent_reconcile_creates_one_event(self) -> None: + row_a = self.add_row( + self.company_a, own_account="6222000000000001", + cp_account="6222000000000002", expense="100.00", + ) + row_b = self.add_row( + self.company_b, own_account="6222000000000002", + cp_account="6222000000000001", income="100.00", + ) + errors: list[Exception] = [] + + def run() -> None: + db = connect(self.db_path) + try: + matching.reconcile_rows(db, [row_a, row_b]) + except Exception as exc: # pragma: no cover + errors.append(exc) + finally: + db.close() + + threads = [threading.Thread(target=run) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual([], errors) + self.assertEqual(1, self.event_count()) + eligible = self.eligible() + self.assertEqual(1, len(eligible)) + claims = self.connection.execute( + "SELECT COUNT(*) AS n FROM transfer_observation_claims" + ).fetchone()["n"] + self.assertEqual(2, claims) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_matching_api.py b/tests/test_matching_api.py new file mode 100644 index 0000000..2481628 --- /dev/null +++ b/tests/test_matching_api.py @@ -0,0 +1,463 @@ +"""HTTP integration tests for the canonical transfer event APIs (B-43). + +Covers the admin event list/detail, manual decisions and reconcile endpoints, +personal transit mapping workflow, upload-account persistence, and the +company-side read scoping with masked counterparty evidence. Uses a real +``ThreadingHTTPServer`` like ``test_server_auth``. +""" + +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" +ACCOUNT_C = "6222000000000003" + + +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"): + return [own, "测试公司", at, amount, "", "50000.00", "RMB", "对方", cp, "某银行", "货款", ""] + + +def incoming(own: str, cp: str, amount: str, at: str = "2026-01-05 11:00:00"): + return [own, "测试公司", at, "", amount, "50000.00", "RMB", "对方", cp, "某银行", "收款", ""] + + +class MatchingApiTests(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 + + cls.company_a = cls._create_company("甲公司", "cashier-a") + cls.company_b = cls._create_company("乙公司", "cashier-b") + cls.company_c = cls._create_company("丙公司", "cashier-c") + cls.cashier_a = cls._login_company("cashier-a", cls.company_a) + cls.cashier_b = cls._login_company("cashier-b", cls.company_b) + cls.cashier_c = cls._login_company("cashier-c", cls.company_c) + + cls.account_a = cls._approve_account(cls.company_a, ACCOUNT_A) + cls.account_b = cls._approve_account(cls.company_b, ACCOUNT_B) + cls.account_c = cls._approve_account(cls.company_c, ACCOUNT_C) + + # One A<->B matched event and one B<->C matched event. + cls._upload_and_confirm(cls.cashier_a, cls.company_a, [outgoing(ACCOUNT_A, ACCOUNT_B, "100.00")]) + cls._upload_and_confirm(cls.cashier_b, cls.company_b, [incoming(ACCOUNT_B, ACCOUNT_A, "100.00")]) + cls._upload_and_confirm(cls.cashier_b, cls.company_b, [outgoing(ACCOUNT_B, ACCOUNT_C, "200.00")]) + cls._upload_and_confirm(cls.cashier_c, cls.company_c, [incoming(ACCOUNT_C, ACCOUNT_B, "200.00")]) + + @classmethod + def _create_company(cls, name: str, username: str) -> int: + status, _, data = cls.admin.post_json( + "/api/admin/companies", + {"name": name, "username": username}, + ) + assert status == 200, data + cls.initial_passwords.setdefault(username, as_json(data)["initial_password"]) + return as_json(data)["company_id"] + + initial_passwords: dict[str, str] = {} + + @classmethod + def _login_company(cls, username: str, company_id: int) -> Client: + client = Client("127.0.0.1", cls.port) + initial = cls.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 + + @classmethod + def _approve_account(cls, company_id: int, number: str) -> int: + # Build the account through company submission + admin review. + company_client = { + cls.company_a: cls.cashier_a, + cls.company_b: cls.cashier_b, + cls.company_c: cls.cashier_c, + }[company_id] + status, _, data = company_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 = cls.admin.post_json( + f"/api/admin/accounts/{account_id}/review", + {"decision": "approve", "reason": "测试启用", + "effective_from": "2026-01-01"}, + ) + assert status == 200, data + return account_id + + @classmethod + def _upload_and_confirm(cls, client, company_id: int, rows) -> int: + content = workbook_bytes(rows) + status, _, data = cls.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") + assert status == 200, data + 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 + + @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 events(self, client) -> list[dict]: + status, _, data = client.get("/api/admin/transfer-events") + self.assertEqual(200, status, data) + return as_json(data)["events"] + + def company_events(self, client) -> list[dict]: + status, _, data = client.get("/api/company/transfer-events") + self.assertEqual(200, status, data) + return as_json(data)["events"] + + # ------------------------------------------------------------------ + # Admin event views + # ------------------------------------------------------------------ + + def test_admin_lists_matched_events(self) -> None: + events = self.events(self.admin) + matched = [e for e in events if e["status"] == "matched"] + self.assertGreaterEqual(len(matched), 2) + by_amount = {e["amount"]: e for e in matched} + self.assertEqual(self.company_a, by_amount["100.00"]["payer_company_id"]) + self.assertEqual(self.company_b, by_amount["100.00"]["payee_company_id"]) + self.assertEqual("paired", by_amount["100.00"]["pairing"]) + self.assertEqual(2, by_amount["100.00"]["evidence_count"]) + + def test_admin_event_detail_has_history_and_observations(self) -> None: + events = self.events(self.admin) + event = next(e for e in events if e["status"] == "matched") + status, _, data = self.admin.get(f"/api/admin/transfer-events/{event['event_id']}") + self.assertEqual(200, status, data) + detail = as_json(data)["event"] + self.assertEqual("matched", detail["status"]) + self.assertEqual(2, len(detail["observations"])) + self.assertTrue(detail["history"]) + self.assertTrue(detail["candidates"]) + + def test_admin_match_exceptions(self) -> None: + status, _, data = self.admin.get("/api/admin/match-exceptions") + self.assertEqual(200, status, data) + self.assertIsInstance(as_json(data)["exceptions"], list) + + # ------------------------------------------------------------------ + # Admin manual decisions and reconcile via API + # ------------------------------------------------------------------ + + def test_admin_reconcile_by_batch_is_idempotent(self) -> None: + # Re-running reconcile on an already settled batch changes nothing. + status, _, data = self.admin.post_json( + "/api/admin/transfer-events/reconcile", {"batch_id": 1} + ) + self.assertEqual(200, status, data) + result = as_json(data)["matching"] + self.assertEqual(0, result["created_events"]) + self.assertEqual(0, result["updated_events"]) + + def test_admin_confirm_single_and_reverse_via_api(self) -> None: + # A uploads its side only -> internal_single. + status, _, data = self.admin.post_multipart( + "/api/parse", {"company_id": str(self.company_a)}, + "单边.xlsx", workbook_bytes([outgoing(ACCOUNT_A, ACCOUNT_B, "300.00", "2026-02-01 10:00:00")]), + ) + self.assertEqual(200, status, data) + batch_id = as_json(data)["batch_id"] + status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets") + names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"] + status, _, data = self.cashier_a.post_json( + f"/api/batches/{batch_id}/confirm", {"sheets": names} + ) + self.assertEqual(200, status, data) + events = self.events(self.admin) + single = next(e for e in events if e["status"] == "internal_single" and e["amount"] == "300.00") + detail = self._admin_detail(single["event_id"]) + revision = detail["revision"] + + # Company users cannot write manual decisions. + status, _, data = self.cashier_a.post_json( + f"/api/admin/transfer-events/{single['event_id']}/decisions", + {"action": "reverse", "reason": "不应允许", "expected_revision": revision}, + ) + self.assertEqual(403, status, data) + + # Admin confirms the single event based on evidence. + status, _, data = self.admin.post_json( + f"/api/admin/transfer-events/{single['event_id']}/decisions", + {"action": "assign_participant", "reason": "函证确认", + "expected_revision": revision, "request_key": "confirm-300", + "participant": {"role": "payee", "company_id": self.company_b}}, + ) + self.assertEqual(200, status, data) + decision = as_json(data)["decision"] + self.assertEqual("intercompany", decision["classification"]) + self.assertTrue(decision["locked"]) + + # Replaying the same request key is idempotent. + status, _, data = self.admin.post_json( + f"/api/admin/transfer-events/{single['event_id']}/decisions", + {"action": "assign_participant", "reason": "函证确认", + "expected_revision": revision, "request_key": "confirm-300", + "participant": {"role": "payee", "company_id": self.company_b}}, + ) + self.assertEqual(200, status, data) + + # Stale revision conflicts. + status, _, data = self.admin.post_json( + f"/api/admin/transfer-events/{single['event_id']}/decisions", + {"action": "reverse", "reason": "撤销", "expected_revision": revision}, + ) + self.assertEqual(409, status, data) + + def _admin_detail(self, event_id: int) -> dict: + status, _, data = self.admin.get(f"/api/admin/transfer-events/{event_id}") + self.assertEqual(200, status, data) + return as_json(data)["event"] + + # ------------------------------------------------------------------ + # Company scope and masking + # ------------------------------------------------------------------ + + def test_company_sees_only_events_it_participates_in(self) -> None: + events = self.company_events(self.cashier_a) + self.assertTrue(events) + for event in events: + self.assertEqual(self.company_a, event["own_company_id"]) + matched = [e for e in events if e["status"] == "matched"] + self.assertTrue(matched) + self.assertEqual( + {"乙公司"}, + {e["counterparty_company_name"] for e in matched}, + ) + # B<->C event is invisible to A. + status, _, data = self.cashier_a.get("/api/company/transfer-events") + bc_events = [ + e for e in as_json(data)["events"] + if e["counterparty_company_name"] == "丙公司" + ] + self.assertEqual([], bc_events) + + def test_company_detail_masks_counterparty_and_own_rows_only(self) -> None: + events = self.company_events(self.cashier_a) + event = next(e for e in events if e["status"] == "matched") + status, _, data = self.cashier_a.get( + f"/api/company/transfer-events/{event['event_id']}" + ) + self.assertEqual(200, status, data) + detail = as_json(data)["event"] + self.assertEqual("乙公司", detail["counterparty"]["company_name"]) + self.assertTrue(detail["counterparty"].get("account_number_masked", "").startswith("****")) + # The counterparty's full account number never leaves the server. + self.assertNotIn(ACCOUNT_B, json.dumps(detail, ensure_ascii=False)) + # Only A's own observation rows are exposed. + self.assertEqual(1, len(detail["observations"])) + observation = detail["observations"][0] + self.assertTrue(observation["own_account_masked"].startswith("****")) + self.assertEqual(self.company_a, observation.get("batch_company_id")) + + def test_company_cannot_read_event_it_does_not_participate_in(self) -> None: + # The B<->C event id comes from B's own list; A probing it returns 404. + status, _, data = self.cashier_b.get("/api/company/transfer-events") + bc_event_ids = [ + e["event_id"] for e in as_json(data)["events"] + if e["counterparty_company_name"] == "丙公司" + ] + self.assertTrue(bc_event_ids) + for event_id in bc_event_ids: + status, _, data = self.cashier_a.get(f"/api/company/transfer-events/{event_id}") + self.assertEqual(404, status, data) + status, _, data = self.cashier_a.get(f"/api/admin/transfer-events/{event_id}") + self.assertEqual(403, status, data) + + def test_company_match_exceptions_scoped_to_own_company(self) -> None: + status, _, data = self.admin.post_multipart( + "/api/parse", {"company_id": str(self.company_a)}, + "未决.xlsx", + workbook_bytes([outgoing(ACCOUNT_A, "9999999999999999", "77.00", "2026-02-05 10:00:00")]), + ) + self.assertEqual(200, status, data) + batch_id = as_json(data)["batch_id"] + status, _, data = self.cashier_a.get(f"/api/batches/{batch_id}/sheets") + names = [s["sheet_name"] for s in as_json(data)["sheets"] if s["outcome"] == "parsed"] + status, _, data = self.cashier_a.post_json( + f"/api/batches/{batch_id}/confirm", {"sheets": names} + ) + self.assertEqual(200, status, data) + + status, _, data = self.cashier_a.get("/api/company/match-exceptions") + self.assertEqual(200, status, data) + mine = [e for e in as_json(data)["exceptions"] if e["amount"] == "77.00"] + self.assertEqual(1, len(mine)) + self.assertEqual("unresolved", mine[0]["status"]) + + status, _, data = self.cashier_b.get("/api/company/match-exceptions") + self.assertEqual(200, status, data) + self.assertEqual( + [], + [e for e in as_json(data)["exceptions"] if e["amount"] == "77.00"], + ) + + def test_company_forbidden_on_admin_event_endpoints(self) -> None: + for call in ( + lambda: self.cashier_a.get("/api/admin/transfer-events"), + lambda: self.cashier_a.get("/api/admin/match-exceptions"), + lambda: self.cashier_a.post_json( + "/api/admin/transfer-events/reconcile", {"batch_id": 1} + ), + lambda: self.cashier_a.post_json( + "/api/admin/personal-transit-mappings", {} + ), + lambda: self.cashier_a.get("/api/admin/personal-transit-mappings"), + ): + status, _, data = call() + self.assertEqual(403, status, data) + + # ------------------------------------------------------------------ + # Personal transit mappings + # ------------------------------------------------------------------ + + def test_personal_transit_mapping_workflow(self) -> None: + status, _, data = self.admin.post_json( + "/api/admin/personal-transit-mappings", + {"account_number": "880088008800", "account_name": "张个人", + "represented_company_id": self.company_b, + "allowed_direction": "incoming", "effective_from": "2026-01-01"}, + ) + self.assertEqual(200, status, data) + mapping_id = as_json(data)["mapping"]["id"] + self.assertEqual("pending", as_json(data)["mapping"]["status"]) + + status, _, data = self.admin.post_json( + f"/api/admin/personal-transit-mappings/{mapping_id}/review", + {"decision": "approve", "reason": "资料齐全", "effective_from": "2026-01-01"}, + ) + self.assertEqual(200, status, data) + self.assertEqual("active", as_json(data)["mapping"]["status"]) + + status, _, data = self.admin.get("/api/admin/personal-transit-mappings") + self.assertEqual(200, status, data) + mappings = as_json(data)["mappings"] + self.assertTrue(any(m["id"] == mapping_id for m in mappings)) + + # Duplicate account number conflicts. + status, _, data = self.admin.post_json( + "/api/admin/personal-transit-mappings", + {"account_number": "880088008800", "account_name": "张个人", + "represented_company_id": self.company_b, "allowed_direction": "both"}, + ) + self.assertEqual(409, status, data) + + # ------------------------------------------------------------------ + # Upload account persistence + # ------------------------------------------------------------------ + + def test_upload_persists_approved_bank_account(self) -> None: + content = workbook_bytes([outgoing(ACCOUNT_A, ACCOUNT_B, "55.00", "2026-03-01 10:00:00")]) + status, _, data = self.cashier_a.post_multipart( + "/api/parse", {"bank_account_id": str(self.account_a)}, + "带账户.xlsx", content, + ) + self.assertEqual(200, status, data) + batch_id = as_json(data)["batch_id"] + connection = connect(self.db_path) + try: + row = connection.execute( + "SELECT upload_bank_account_id FROM import_batches WHERE id = ?", + (batch_id,), + ).fetchone() + finally: + connection.close() + self.assertEqual(self.account_a, row["upload_bank_account_id"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_persistence.py b/tests/test_persistence.py index a061702..b23c6d5 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -41,7 +41,7 @@ class PersistenceTestCase(unittest.TestCase): class MigrationTests(PersistenceTestCase): def test_migrate_creates_schema_and_is_idempotent(self) -> None: first = applied_versions(self.connection) - self.assertEqual([1, 2, 3, 4], first) + self.assertEqual([1, 2, 3, 4, 5], first) self.assertEqual([], migrate(self.connection)) self.assertEqual(first, applied_versions(self.connection)) tables = { @@ -65,19 +65,142 @@ class MigrationTests(PersistenceTestCase): "sessions", "login_attempts", "audit_log", + "personal_transit_mappings", + "canonical_transfer_events", + "transfer_match_decisions", + "transfer_decision_observations", + "transfer_decision_participants", + "transfer_match_candidates", + "current_transfer_decisions", + "transfer_observation_claims", "schema_migrations", ): self.assertIn(table, tables) def test_rollback_removes_schema_and_forward_rebuilds_it(self) -> None: - self.assertEqual([4, 3, 2, 1], rollback(self.connection, 0)) + self.assertEqual([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], migrate(self.connection)) - self.assertEqual([1, 2, 3, 4], applied_versions(self.connection)) + self.assertEqual([1, 2, 3, 4, 5], migrate(self.connection)) + self.assertEqual([1, 2, 3, 4, 5], applied_versions(self.connection)) + + def test_rollback_to_4_keeps_bank_evidence_and_drops_event_layer(self) -> None: + self.import_sample() + row_count = self.connection.execute( + "SELECT COUNT(*) AS n FROM source_rows" + ).fetchone()["n"] + self.assertGreater(row_count, 0) + self.assertEqual([5], rollback(self.connection, 4)) + # The pre-migration evidence and schema are untouched. + self.assertEqual( + row_count, + self.connection.execute("SELECT COUNT(*) AS n FROM source_rows").fetchone()["n"], + ) + self.assertEqual( + "parsed", + self.connection.execute( + "SELECT status FROM import_batches LIMIT 1" + ).fetchone()["status"], + ) + remaining = self.connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transfer_match_decisions'" + ).fetchone() + self.assertIsNone(remaining) + + def test_event_layer_views_exist_after_migration(self) -> None: + view = self.connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'view' AND name = 'eligible_intercompany_events'" + ).fetchone() + self.assertIsNotNone(view) + + def test_decision_log_immutability_triggers(self) -> None: + self.import_sample() + source_row_id = self.connection.execute( + "SELECT id FROM source_rows LIMIT 1" + ).fetchone()["id"] + with self.connection: + self.connection.execute( + "INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)", + (utc_now(),), + ) + event_id = self.connection.execute( + "SELECT id FROM canonical_transfer_events LIMIT 1" + ).fetchone()["id"] + self.connection.execute( + """ + INSERT INTO transfer_match_decisions ( + event_id, revision, classification, pairing, mode, rule_version, + locked, created_at + ) VALUES (?, 1, 'unresolved', 'not_applicable', 'auto', 'transfer-match-v1', 0, ?) + """, + (event_id, utc_now()), + ) + decision_id = self.connection.execute( + "SELECT id FROM transfer_match_decisions LIMIT 1" + ).fetchone()["id"] + self.connection.execute( + """ + INSERT INTO transfer_match_candidates ( + decision_id, source_row_id, rule_tier, rule_version, created_at + ) VALUES (?, ?, 'R1', 'transfer-match-v1', ?) + """, + (decision_id, source_row_id, utc_now()), + ) + for statement in ( + "UPDATE transfer_match_decisions SET classification = 'external'", + "DELETE FROM transfer_match_decisions", + "UPDATE transfer_match_candidates SET rule_tier = 'M1'", + "DELETE FROM transfer_match_candidates", + "DELETE FROM canonical_transfer_events", + ): + with self.subTest(statement=statement): + with self.assertRaises(sqlite3.IntegrityError): + self.connection.execute(statement) + self.connection.rollback() + + def test_observation_claims_source_row_is_unique(self) -> None: + self.import_sample() + source_row_id = self.connection.execute( + "SELECT id FROM source_rows LIMIT 1" + ).fetchone()["id"] + with self.connection: + self.connection.execute( + "INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)", + (utc_now(),), + ) + event_id = self.connection.execute( + "SELECT id FROM canonical_transfer_events LIMIT 1" + ).fetchone()["id"] + self.connection.execute( + """ + INSERT INTO transfer_match_decisions ( + event_id, revision, classification, pairing, mode, locked, created_at + ) VALUES (?, 1, 'unresolved', 'not_applicable', 'auto', 0, ?) + """, + (event_id, utc_now()), + ) + decision_id = self.connection.execute( + "SELECT id FROM transfer_match_decisions LIMIT 1" + ).fetchone()["id"] + self.connection.execute( + """ + INSERT INTO transfer_observation_claims (source_row_id, event_id, decision_id) + VALUES (?, ?, ?) + """, + (source_row_id, event_id, decision_id), + ) + with self.assertRaises(sqlite3.IntegrityError): + self.connection.execute( + """ + INSERT INTO transfer_observation_claims (source_row_id, event_id, decision_id) + VALUES (?, ?, ?) + """, + (source_row_id, event_id, decision_id), + ) + self.connection.rollback() class ImportPersistenceTests(PersistenceTestCase):