"""Statutory subject suggestions, mirror mapping and confirmation (B-44). Subjects are stored from one participating company's perspective and the other side is the fixed mirror (应收<->应付, 其他应收<->其他应付), so the two companies can never record conflicting subjects. Bank summary/purpose text only ever produces a *suggestion*; nothing here confirms a subject automatically. Confirmation is an explicit administrator decision carrying ``expected_revision`` and an idempotency key. """ from __future__ import annotations import json import re import sqlite3 from . import matching SUBJECTS = ("receivable", "payable", "other_receivable", "other_payable") SUBJECT_RULE_VERSION = "subject-suggest-draft-v1" MIRROR = { "receivable": "payable", "payable": "receivable", "other_receivable": "other_payable", "other_payable": "other_receivable", } SUBJECT_LABELS = { "receivable": "应收", "payable": "应付", "other_receivable": "其他应收", "other_payable": "其他应付", } _FULL_WIDTH = str.maketrans( "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz0123456789", "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz0123456789", ) # Draft v1 dictionary. Exact-keyword matching only; every hit is a suggestion # and never an automatic posting. Trade-type keywords are deliberately absent # until the group supplies an approved dictionary (they always go to review). _LOAN_LIKE = ("借款", "往来款", "资金往来", "临时借款", "资金调拨", "代垫", "垫付") _REPAY_LIKE = ("还款", "归还借款", "归还往来款") class SubjectConflictError(ValueError): """A stale revision or idempotency conflict (mapped to HTTP 409).""" class SubjectInputError(ValueError): """Invalid input for a subject decision (mapped to HTTP 400/422).""" def mirror_subject(subject_code: str) -> str: if subject_code not in MIRROR: raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。") return MIRROR[subject_code] def subject_label(subject_code: str) -> str: return SUBJECT_LABELS.get(subject_code, subject_code) def _normalize(text: object) -> str: return re.sub(r"[\s\ufeff]+", "", str(text or "").translate(_FULL_WIDTH)) def _bank_evidence_texts(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, str]: """Purpose/summary text of the B-43 source rows behind a bank event.""" row = connection.execute( """ SELECT bs.bank_event_id FROM ledger_event_bank_sources bs WHERE bs.ledger_event_id = ? """, (ledger_event_id,), ).fetchone() if row is None: return {"purpose": "", "summary": ""} decision = matching._current_decision_for_event(connection, row["bank_event_id"]) if decision is None: return {"purpose": "", "summary": ""} observations = matching._decision_observations(connection, decision["id"]) texts: dict[str, list[str]] = {"purpose": [], "summary": []} for observation in observations: source = connection.execute( "SELECT purpose, summary FROM source_rows WHERE id = ?", (observation["source_row_id"],), ).fetchone() if source is None: continue for key in ("purpose", "summary"): value = str(source[key] or "").strip() if value: texts[key].append(value) return { "purpose": " ".join(texts["purpose"]), "summary": " ".join(texts["summary"]), } def compute_suggestions( connection: sqlite3.Connection, ledger_event_id: int ) -> list[dict[str, object]]: """ Deterministic draft suggestions for a pending event, never confirmation. Purpose rules take precedence over summary rules. When both loan-like and repay-like keywords match, both candidates are returned as a conflict for the reviewer; no priority breaks the tie. """ from .ledger_events import current_revision revision = current_revision(connection, ledger_event_id) if revision is None or revision["state"] != "pending_subject": return [] texts = _bank_evidence_texts(connection, ledger_event_id) purpose = _normalize(texts["purpose"]) summary = _normalize(texts["summary"]) search = purpose or summary payer = revision["payer_company_id"] payee = revision["payee_company_id"] loan_hit = next((word for word in _LOAN_LIKE if word in search), None) repay_hit = next((word for word in _REPAY_LIKE if word in search), None) suggestions: list[dict[str, object]] = [] if loan_hit: suggestions.append( { "suggested_perspective_company_id": payer, "suggested_subject_code": "other_receivable", "reason": f"匹配建议词典「{loan_hit}」,建议付款方其他应收", "rule_version": SUBJECT_RULE_VERSION, "evidence": { "keyword": loan_hit, "matched_text": search, "approved": False, }, } ) if repay_hit: suggestions.append( { "suggested_perspective_company_id": payee, "suggested_subject_code": "other_receivable", "reason": f"匹配建议词典「{repay_hit}」,建议收款方其他应收", "rule_version": SUBJECT_RULE_VERSION, "evidence": { "keyword": repay_hit, "matched_text": search, "approved": False, }, } ) return suggestions def store_suggestions(connection: sqlite3.Connection, ledger_event_id: int) -> int: """Compute and append suggestions for a pending event. Returns count stored.""" from .ledger_events import current_revision revision = current_revision(connection, ledger_event_id) if revision is None or revision["state"] != "pending_subject": return 0 stored = 0 for suggestion in compute_suggestions(connection, ledger_event_id): from .db import utc_now connection.execute( """ INSERT INTO ledger_subject_suggestions ( ledger_event_id, source_revision_id, suggested_perspective_company_id, suggested_subject_code, rule_version, evidence_json, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( ledger_event_id, revision["id"], suggestion["suggested_perspective_company_id"], suggestion["suggested_subject_code"], suggestion["rule_version"], json.dumps(suggestion.get("evidence", {}), ensure_ascii=False), utc_now(), ), ) stored += 1 return stored def confirm_subject( connection: sqlite3.Connection, ledger_event_id: int, *, perspective_company_id: int, subject_code: str, reason: str, expected_revision: int | None, request_key: str | None, actor: sqlite3.Row, ) -> dict[str, object]: """Confirm a subject, turning a pending event into a confirmed revision.""" from .ledger_events import append_revision, current_revision reason = (reason or "").strip() if not reason: raise SubjectInputError("必须填写科目确认依据。") if subject_code not in SUBJECTS: raise SubjectInputError("科目必须是应收/应付/其他应收/其他应付之一。") began = False if not connection.in_transaction: connection.execute("BEGIN IMMEDIATE") began = True try: if request_key: existing = connection.execute( """ SELECT * FROM ledger_event_revisions WHERE ledger_event_id = ? AND idempotency_key = ? ORDER BY id LIMIT 1 """, (ledger_event_id, request_key), ).fetchone() if existing is not None: if began: connection.commit() return _revision_payload(connection, existing) current = current_revision(connection, ledger_event_id) if current is None: raise SubjectConflictError("该事件不存在或没有当前修订。") if current["state"] != "pending_subject": raise SubjectConflictError("只有待确认科目的事件可以确认科目。") # ``expected_revision`` may be the revision row id (what the API/UI # sends as ``ledger_revision_id``) or the per-event sequence number; # both identify the exact revision the client saw. if expected_revision is not None and int(expected_revision) not in ( current["id"], current["revision"], ): raise SubjectConflictError("事件已发生变更,请刷新后重试。") participants = {current["payer_company_id"], current["payee_company_id"]} if perspective_company_id not in participants: raise SubjectInputError("视角公司必须是事件参与方。") revision_id = append_revision( connection, ledger_event_id, state="confirmed", effective_at=current["effective_at"], amount=current["amount"], currency=current["currency"], payer_company_id=current["payer_company_id"], payee_company_id=current["payee_company_id"], perspective_company_id=perspective_company_id, subject_code=subject_code, source_kind=current["source_kind"], source_revision_token=current["source_revision_token"], posting_kind=current["posting_kind"], reverses_ledger_event_id=current["reverses_ledger_event_id"], adjusts_ledger_event_id=current["adjusts_ledger_event_id"], rule_version=current["rule_version"] or SUBJECT_RULE_VERSION, evidence_json=current["evidence_json"], idempotency_key=request_key, actor=actor, reason=reason, supersedes_revision_id=current["id"], ) row = connection.execute( "SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,) ).fetchone() except Exception: if began: connection.rollback() raise else: if began: connection.commit() return _revision_payload(connection, row) def park_subject( connection: sqlite3.Connection, ledger_event_id: int, *, disposition: str, reason: str, expected_revision: int | None, request_key: str | None, actor: sqlite3.Row, ) -> dict[str, object]: """Record 退回/转异常 without confirming a statutory subject. The event stays ``pending_subject`` so it never enters confirmed balances. ``exception`` is hidden from the active review queue; ``return`` remains visible so the company can supplement materials. """ from .ledger_events import append_revision, current_revision if disposition not in ("return", "exception"): raise SubjectInputError("科目处理只能是退回或转异常。") reason = (reason or "").strip() if not reason: raise SubjectInputError("必须填写处理依据。") began = False if not connection.in_transaction: connection.execute("BEGIN IMMEDIATE") began = True try: if request_key: existing = connection.execute( """ SELECT * FROM ledger_event_revisions WHERE ledger_event_id = ? AND idempotency_key = ? ORDER BY id LIMIT 1 """, (ledger_event_id, request_key), ).fetchone() if existing is not None: if began: connection.commit() return _revision_payload(connection, existing) current = current_revision(connection, ledger_event_id) if current is None: raise SubjectConflictError("该事件不存在或没有当前修订。") if current["state"] != "pending_subject": raise SubjectConflictError("只有待确认科目的事件可以退回或转异常。") if expected_revision is not None and int(expected_revision) not in ( current["id"], current["revision"], ): raise SubjectConflictError("事件已发生变更,请刷新后重试。") evidence = json.loads(current["evidence_json"] or "{}") if current["evidence_json"] else {} evidence["admin_disposition"] = disposition revision_id = append_revision( connection, ledger_event_id, state="pending_subject", effective_at=current["effective_at"], amount=current["amount"], currency=current["currency"], payer_company_id=current["payer_company_id"], payee_company_id=current["payee_company_id"], perspective_company_id=None, subject_code=None, source_kind=current["source_kind"], source_revision_token=current["source_revision_token"], posting_kind=current["posting_kind"], reverses_ledger_event_id=current["reverses_ledger_event_id"], adjusts_ledger_event_id=current["adjusts_ledger_event_id"], rule_version=current["rule_version"] or SUBJECT_RULE_VERSION, evidence_json=json.dumps(evidence, ensure_ascii=False), idempotency_key=request_key, actor=actor, reason=reason, supersedes_revision_id=current["id"], ) row = connection.execute( "SELECT * FROM ledger_event_revisions WHERE id = ?", (revision_id,) ).fetchone() except Exception: if began: connection.rollback() raise else: if began: connection.commit() return _revision_payload(connection, row) def _revision_payload(connection: sqlite3.Connection, revision: sqlite3.Row) -> dict[str, object]: company = connection.execute( "SELECT name FROM companies WHERE id = ?", (revision["perspective_company_id"],) ).fetchone() return { "ledger_event_id": revision["ledger_event_id"], "revision_id": revision["id"], "revision": revision["revision"], "state": revision["state"], "effective_at": revision["effective_at"], "amount": revision["amount"], "currency": revision["currency"], "payer_company_id": revision["payer_company_id"], "payee_company_id": revision["payee_company_id"], "perspective_company_id": revision["perspective_company_id"], "perspective_company_name": company["name"] if company else None, "subject_code": revision["subject_code"], "subject_label": subject_label(revision["subject_code"]) if revision["subject_code"] else None, "posting_kind": revision["posting_kind"], "source_kind": revision["source_kind"], "reason": revision["reason"], "created_at": revision["created_at"], }