B-44: intercompany ledger events, subject review and drill-down evidence

- migration 6: manual_records, ledger_event_revisions chain, current
  projections, source claims, subject suggestions, eligible_position_events
- ledger_events.py: bank-event reconciliation, reversal/adjustment/reopen,
  append-only revision chain and rebuildable current projection
- subjects.py: fixed subject mirror, draft suggestion dictionary, explicit
  administrator subject confirmation with expected_revision + idempotency
- manual_records.py: submit, approve new/link, return/exception/reverse,
  candidate hints, idempotent replay and concurrency-safe claims
- positions.py: Decimal aggregation, both-perspective conservation asserts,
  cutoff window, unresolved gross buckets, keyset pagination, evidence
  visibility (visible/masked/missing)
- server.py: admin + company intercompany APIs with tenant isolation (404 on
  cross-tenant reads, 403 on company writes) and auto reconcile wiring
- admin/company portals: balance directory, pair drill-down drawer, evidence
  drawer, subject/manual audit queue, company balance summary
- tests: ledger events, subjects, manual records, positions, HTTP API and
  migration persistence (233 total, all green)
This commit is contained in:
腾讯WorkBuddy
2026-08-19 11:58:25 +08:00
parent f99917321b
commit 85293b79df
18 changed files with 6902 additions and 142 deletions
+229
View File
@@ -569,6 +569,235 @@ MIGRATIONS: tuple[Migration, ...] = (
ALTER TABLE import_batches DROP COLUMN upload_bank_account_id;
""",
),
Migration(
version=6,
name="0006_intercompany_ledger_events",
# B-44 canonical intercompany ledger layer. Manual records and the
# ledger event revision chain are append-only facts; current pointers
# (current revision per ledger event / manual decision, source claims)
# are rebuildable projections. Bank events enter only through
# ``eligible_intercompany_events``; nothing here rewrites bank rows.
up="""
CREATE TABLE manual_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies (id),
counterparty_company_id INTEGER NOT NULL REFERENCES companies (id),
occurred_at TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('outgoing', 'incoming')),
amount TEXT NOT NULL,
amount_scale INTEGER NOT NULL,
currency TEXT NOT NULL,
funding_source TEXT NOT NULL CHECK (funding_source IN (
'approved_bank_account', 'personal_transit', 'other'
)),
bank_account_id INTEGER REFERENCES bank_accounts (id),
personal_transit_mapping_id INTEGER REFERENCES personal_transit_mappings (id),
related_source_row_id INTEGER REFERENCES source_rows (id),
requested_subject TEXT NOT NULL CHECK (requested_subject IN (
'receivable', 'payable', 'other_receivable', 'other_payable'
)),
summary TEXT,
reason TEXT,
evidence_json TEXT,
request_key TEXT NOT NULL,
supersedes_record_id INTEGER REFERENCES manual_records (id),
submitted_by INTEGER REFERENCES users (id),
created_at TEXT NOT NULL,
UNIQUE (company_id, request_key),
CHECK (counterparty_company_id != company_id)
);
CREATE TABLE manual_record_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_id INTEGER NOT NULL REFERENCES manual_records (id),
revision INTEGER NOT NULL,
state TEXT NOT NULL CHECK (state IN (
'pending', 'approved', 'returned', 'exception', 'reversed'
)),
action TEXT NOT NULL,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
idempotency_key TEXT,
supersedes_decision_id INTEGER REFERENCES manual_record_decisions (id),
created_at TEXT NOT NULL,
UNIQUE (record_id, revision)
);
CREATE TABLE current_manual_record_decisions (
record_id INTEGER PRIMARY KEY REFERENCES manual_records (id),
decision_id INTEGER NOT NULL UNIQUE REFERENCES manual_record_decisions (id)
);
CREATE TABLE ledger_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lifecycle TEXT NOT NULL DEFAULT 'active'
CHECK (lifecycle IN ('active', 'superseded')),
created_at TEXT NOT NULL
);
CREATE TABLE ledger_event_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
revision INTEGER NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending_subject', 'confirmed')),
effective_at TEXT NOT NULL,
amount TEXT NOT NULL,
amount_scale INTEGER NOT NULL,
currency TEXT NOT NULL,
payer_company_id INTEGER NOT NULL REFERENCES companies (id),
payee_company_id INTEGER NOT NULL REFERENCES companies (id),
perspective_company_id INTEGER REFERENCES companies (id),
subject_code TEXT CHECK (subject_code IN (
'receivable', 'payable', 'other_receivable', 'other_payable'
)),
source_kind TEXT NOT NULL CHECK (source_kind IN ('bank', 'manual', 'adjustment')),
source_revision_token TEXT,
posting_kind TEXT NOT NULL CHECK (posting_kind IN (
'normal', 'reversal', 'adjustment'
)),
reverses_ledger_event_id INTEGER REFERENCES ledger_events (id),
adjusts_ledger_event_id INTEGER REFERENCES ledger_events (id),
rule_version TEXT,
evidence_json TEXT,
idempotency_key TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
reason TEXT,
supersedes_revision_id INTEGER REFERENCES ledger_event_revisions (id),
created_at TEXT NOT NULL,
UNIQUE (ledger_event_id, revision),
CHECK (payer_company_id != payee_company_id),
CHECK (state = 'confirmed' OR subject_code IS NULL),
CHECK (state = 'confirmed' OR perspective_company_id IS NULL),
CHECK (
state != 'confirmed'
OR (perspective_company_id IS NOT NULL AND subject_code IS NOT NULL)
)
);
CREATE TABLE current_ledger_event_revisions (
ledger_event_id INTEGER PRIMARY KEY REFERENCES ledger_events (id),
revision_id INTEGER NOT NULL UNIQUE REFERENCES ledger_event_revisions (id)
);
CREATE TABLE ledger_event_bank_sources (
bank_event_id INTEGER PRIMARY KEY REFERENCES canonical_transfer_events (id),
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
UNIQUE (ledger_event_id, bank_event_id)
);
CREATE TABLE ledger_event_manual_sources (
manual_record_id INTEGER PRIMARY KEY REFERENCES manual_records (id),
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
UNIQUE (ledger_event_id, manual_record_id)
);
CREATE TABLE ledger_subject_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
source_revision_id INTEGER NOT NULL REFERENCES ledger_event_revisions (id),
suggested_perspective_company_id INTEGER NOT NULL REFERENCES companies (id),
suggested_subject_code TEXT NOT NULL CHECK (suggested_subject_code IN (
'receivable', 'payable', 'other_receivable', 'other_payable'
)),
rule_version TEXT,
evidence_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_ledger_revisions_event ON ledger_event_revisions (ledger_event_id, revision);
CREATE INDEX idx_ledger_revisions_effective ON ledger_event_revisions (effective_at);
CREATE INDEX idx_ledger_revisions_pair_currency
ON ledger_event_revisions (payer_company_id, payee_company_id, currency);
CREATE INDEX idx_ledger_revisions_state
ON ledger_event_revisions (state, effective_at);
CREATE INDEX idx_manual_records_company ON manual_records (company_id, occurred_at);
CREATE INDEX idx_manual_decisions_record ON manual_record_decisions (record_id, revision);
CREATE INDEX idx_manual_decisions_state ON manual_record_decisions (state);
CREATE INDEX idx_subject_suggestions_event ON ledger_subject_suggestions (ledger_event_id);
CREATE VIEW eligible_position_events AS
SELECT le.id AS ledger_event_id,
cur.revision_id AS ledger_revision_id,
r.effective_at AS effective_at,
r.amount AS amount, r.amount_scale AS amount_scale,
r.currency AS currency,
r.payer_company_id AS payer_company_id,
r.payee_company_id AS payee_company_id,
r.perspective_company_id AS perspective_company_id,
r.subject_code AS subject_code,
r.source_kind AS source_kind, r.posting_kind AS posting_kind,
r.reverses_ledger_event_id AS reverses_ledger_event_id,
r.adjusts_ledger_event_id AS adjusts_ledger_event_id,
COALESCE(
(SELECT bs.bank_event_id FROM ledger_event_bank_sources bs
WHERE bs.ledger_event_id = le.id LIMIT 1),
(SELECT ms.manual_record_id FROM ledger_event_manual_sources ms
WHERE ms.ledger_event_id = le.id LIMIT 1)
) AS source_id,
((SELECT COUNT(*) FROM ledger_event_bank_sources bs
WHERE bs.ledger_event_id = le.id)
+ (SELECT COUNT(*) FROM ledger_event_manual_sources ms
WHERE ms.ledger_event_id = le.id)) AS evidence_count
FROM ledger_events le
JOIN current_ledger_event_revisions cur ON cur.ledger_event_id = le.id
JOIN ledger_event_revisions r ON r.id = cur.revision_id
WHERE le.lifecycle = 'active' AND r.state = 'confirmed';
CREATE TRIGGER ledger_events_no_delete BEFORE DELETE ON ledger_events
BEGIN SELECT RAISE (ABORT, 'ledger_events rows are immutable'); END;
CREATE TRIGGER ledger_events_no_update BEFORE UPDATE ON ledger_events
BEGIN
SELECT RAISE (ABORT, 'ledger_events only allow lifecycle changes')
WHERE OLD.lifecycle = NEW.lifecycle
OR OLD.id IS NOT NEW.id
OR OLD.created_at IS NOT NEW.created_at;
END;
CREATE TRIGGER ledger_event_revisions_no_update BEFORE UPDATE ON ledger_event_revisions
BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END;
CREATE TRIGGER ledger_event_revisions_no_delete BEFORE DELETE ON ledger_event_revisions
BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END;
CREATE TRIGGER ledger_subject_suggestions_no_update BEFORE UPDATE ON ledger_subject_suggestions
BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END;
CREATE TRIGGER ledger_subject_suggestions_no_delete BEFORE DELETE ON ledger_subject_suggestions
BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END;
CREATE TRIGGER manual_records_no_update BEFORE UPDATE ON manual_records
BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END;
CREATE TRIGGER manual_records_no_delete BEFORE DELETE ON manual_records
BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END;
CREATE TRIGGER manual_record_decisions_no_update BEFORE UPDATE ON manual_record_decisions
BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END;
CREATE TRIGGER manual_record_decisions_no_delete BEFORE DELETE ON manual_record_decisions
BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END;
""",
down="""
DROP VIEW IF EXISTS eligible_position_events;
DROP TRIGGER IF EXISTS manual_record_decisions_no_delete;
DROP TRIGGER IF EXISTS manual_record_decisions_no_update;
DROP TRIGGER IF EXISTS manual_records_no_delete;
DROP TRIGGER IF EXISTS manual_records_no_update;
DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_delete;
DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_update;
DROP TRIGGER IF EXISTS ledger_event_revisions_no_delete;
DROP TRIGGER IF EXISTS ledger_event_revisions_no_update;
DROP TRIGGER IF EXISTS ledger_events_no_update;
DROP TRIGGER IF EXISTS ledger_events_no_delete;
DROP TABLE IF EXISTS ledger_subject_suggestions;
DROP TABLE IF EXISTS ledger_event_manual_sources;
DROP TABLE IF EXISTS ledger_event_bank_sources;
DROP TABLE IF EXISTS current_ledger_event_revisions;
DROP TABLE IF EXISTS ledger_event_revisions;
DROP TABLE IF EXISTS ledger_events;
DROP TABLE IF EXISTS current_manual_record_decisions;
DROP TABLE IF EXISTS manual_record_decisions;
DROP TABLE IF EXISTS manual_records;
""",
),
)
+2
View File
@@ -25,6 +25,7 @@ import sqlite3
import tempfile
from . import auth
from . import ledger_events
from . import matching
from .db import utc_now
from .models import SheetResult, StatementBatch
@@ -648,6 +649,7 @@ def review_sheets(
[item["id"] for item in confirmed_rows],
actor=actor,
)
ledger_events.reconcile_bank_events(connection, actor=actor)
if began:
connection.commit()
except Exception:
+684
View File
@@ -0,0 +1,684 @@
"""Canonical intercompany ledger events and the revision chain (B-44).
Bank source rows and approved manual records are immutable evidence. This
module turns them into one canonical ledger event each through ``reconcile_*``
functions and an append-only revision chain. Corrections are never in-place
edits: a reversal or adjustment is a new ledger event with its own effective
date, and the original event keeps its history so no earlier cutoff is
rewritten. Current revisions and source claims are rebuildable projections.
Only B-43 ``eligible_intercompany_events`` feeds bank facts here; same-company
transfers, external transactions, unresolved rows and unlocked single
observations never reach the confirmed balance.
"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
import json
import sqlite3
from .db import utc_now
from .subjects import MIRROR, SUBJECTS, mirror_subject
class LedgerConflictError(ValueError):
"""A revision/claim/idempotency conflict (mapped to HTTP 409)."""
class LedgerInputError(ValueError):
"""Invalid input for a ledger operation (mapped to HTTP 400/422)."""
SUBJECT_RULE_VERSION = "subject-suggest-draft-v1"
def amount_scale(amount: object) -> int:
"""Decimal places of a decimal-string amount, never negative."""
try:
exponent = Decimal(str(amount)).as_tuple().exponent
except InvalidOperation:
return 0
return max(0, -int(exponent))
def parse_amount(amount: object) -> Decimal:
"""Parse a positive, valid decimal-string amount."""
try:
value = Decimal(str(amount))
except InvalidOperation:
raise LedgerInputError("金额不是有效的十进制数。") from None
if not value.is_finite() or value <= 0:
raise LedgerInputError("金额必须大于零。")
return value
def _company_exists(connection: sqlite3.Connection, company_id: int, label: str) -> None:
row = connection.execute(
"SELECT id FROM companies WHERE id = ?", (company_id,)
).fetchone()
if row is None:
raise LedgerInputError(f"{label}指向的公司不存在。")
# ---------------------------------------------------------------------------
# Revision helpers
# ---------------------------------------------------------------------------
def _ensure_transaction(connection: sqlite3.Connection) -> bool:
"""Begin an immediate transaction unless one is already open.
Write helpers may run standalone (they own the transaction) or nested
inside a caller's transaction (e.g. the sheet-confirm flow); nested calls
never start their own commit.
"""
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
return began
def current_revision(connection: sqlite3.Connection, ledger_event_id: int) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT r.* FROM current_ledger_event_revisions c
JOIN ledger_event_revisions r ON r.id = c.revision_id
WHERE c.ledger_event_id = ?
""",
(ledger_event_id,),
).fetchone()
def _event_lifecycle(connection: sqlite3.Connection, ledger_event_id: int) -> str | None:
row = connection.execute(
"SELECT lifecycle FROM ledger_events WHERE id = ?", (ledger_event_id,)
).fetchone()
return row["lifecycle"] if row is not None else None
def _next_revision_number(connection: sqlite3.Connection, ledger_event_id: int) -> int:
row = connection.execute(
"SELECT COALESCE(MAX(revision), 0) AS m FROM ledger_event_revisions WHERE ledger_event_id = ?",
(ledger_event_id,),
).fetchone()
return int(row["m"]) + 1
def create_event(
connection: sqlite3.Connection,
*,
state: str,
effective_at: str,
amount: str,
currency: str,
payer_company_id: int,
payee_company_id: int,
perspective_company_id: int | None,
subject_code: str | None,
source_kind: str,
source_revision_token: str | None,
posting_kind: str,
reverses_ledger_event_id: int | None = None,
adjusts_ledger_event_id: int | None = None,
rule_version: str | None = None,
evidence_json: str | None = None,
idempotency_key: str | None = None,
actor: sqlite3.Row | None = None,
reason: str | None = None,
supersedes_revision_id: int | None = None,
) -> tuple[int, int]:
"""Insert a new ledger event with one revision. Returns ``(event_id, revision_id)``."""
if state == "confirmed":
if perspective_company_id is None or subject_code is None:
raise LedgerInputError("已确认事件必须提供视角公司与科目。")
if subject_code not in SUBJECTS:
raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。")
if int(payer_company_id) == int(payee_company_id):
raise LedgerInputError("付款公司与收款公司不能相同。")
if perspective_company_id is not None and perspective_company_id not in (
int(payer_company_id), int(payee_company_id),
):
raise LedgerInputError("视角公司必须是事件参与方。")
began = _ensure_transaction(connection)
try:
now = utc_now()
cursor = connection.execute(
"INSERT INTO ledger_events (lifecycle, created_at) VALUES ('active', ?)",
(now,),
)
event_id = int(cursor.lastrowid)
revision_id = append_revision(
connection,
event_id,
state=state,
effective_at=effective_at,
amount=amount,
currency=currency,
payer_company_id=payer_company_id,
payee_company_id=payee_company_id,
perspective_company_id=perspective_company_id,
subject_code=subject_code,
source_kind=source_kind,
source_revision_token=source_revision_token,
posting_kind=posting_kind,
reverses_ledger_event_id=reverses_ledger_event_id,
adjusts_ledger_event_id=adjusts_ledger_event_id,
rule_version=rule_version,
evidence_json=evidence_json,
idempotency_key=idempotency_key,
actor=actor,
reason=reason,
supersedes_revision_id=supersedes_revision_id,
)
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return event_id, revision_id
def append_revision(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
state: str,
effective_at: str,
amount: str,
currency: str,
payer_company_id: int,
payee_company_id: int,
perspective_company_id: int | None,
subject_code: str | None,
source_kind: str,
source_revision_token: str | None,
posting_kind: str,
reverses_ledger_event_id: int | None = None,
adjusts_ledger_event_id: int | None = None,
rule_version: str | None = None,
evidence_json: str | None = None,
idempotency_key: str | None = None,
actor: sqlite3.Row | None = None,
reason: str | None = None,
supersedes_revision_id: int | None = None,
) -> int:
"""Append one immutable revision and repoint the current projection."""
if _event_lifecycle(connection, ledger_event_id) != "active":
raise LedgerConflictError("该事件已停用,不能追加修订。")
if state == "confirmed":
if perspective_company_id is None or subject_code is None:
raise LedgerInputError("已确认事件必须提供视角公司与科目。")
if subject_code not in SUBJECTS:
raise LedgerInputError("科目必须是应收/应付/其他应收/其他应付之一。")
if perspective_company_id not in (int(payer_company_id), int(payee_company_id)):
raise LedgerInputError("视角公司必须是事件参与方。")
elif state != "pending_subject":
raise LedgerInputError("事件状态必须是 pending_subject 或 confirmed。")
revision = _next_revision_number(connection, ledger_event_id)
now = utc_now()
cursor = connection.execute(
"""
INSERT INTO ledger_event_revisions (
ledger_event_id, revision, state, effective_at, amount,
amount_scale, currency, payer_company_id, payee_company_id,
perspective_company_id, subject_code, source_kind,
source_revision_token, posting_kind, reverses_ledger_event_id,
adjusts_ledger_event_id, rule_version, evidence_json,
idempotency_key, actor_user_id, actor_username, reason,
supersedes_revision_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
ledger_event_id, revision, state, effective_at, amount,
amount_scale(amount), currency, payer_company_id, payee_company_id,
perspective_company_id, subject_code, source_kind,
source_revision_token, posting_kind, reverses_ledger_event_id,
adjusts_ledger_event_id, rule_version, evidence_json,
idempotency_key,
actor["id"] if actor is not None else None,
actor["username"] if actor is not None else None,
reason, supersedes_revision_id, now,
),
)
connection.execute(
"""
INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id)
VALUES (?, ?)
""",
(ledger_event_id, cursor.lastrowid),
)
return int(cursor.lastrowid)
# ---------------------------------------------------------------------------
# Source claims
# ---------------------------------------------------------------------------
def bank_source_claim(connection: sqlite3.Connection, bank_event_id: int) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM ledger_event_bank_sources WHERE bank_event_id = ?",
(bank_event_id,),
).fetchone()
def manual_source_claim(connection: sqlite3.Connection, manual_record_id: int) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM ledger_event_manual_sources WHERE manual_record_id = ?",
(manual_record_id,),
).fetchone()
def _facts_of(connection: sqlite3.Connection, ledger_event_id: int) -> dict[str, object]:
revision = current_revision(connection, ledger_event_id)
if revision is None:
return {}
return {
"amount": revision["amount"],
"currency": revision["currency"],
"effective_at": revision["effective_at"],
"payer_company_id": revision["payer_company_id"],
"payee_company_id": revision["payee_company_id"],
}
def _eligible_facts(event: sqlite3.Row) -> dict[str, object]:
return {
"amount": event["amount"],
"currency": event["currency"],
"effective_at": event["effective_at"],
"payer_company_id": event["payer_company_id"],
"payee_company_id": event["payee_company_id"],
}
def _has_reversal(connection: sqlite3.Connection, original_event_id: int) -> bool:
row = connection.execute(
"""
SELECT 1 FROM ledger_event_revisions r
JOIN current_ledger_event_revisions c ON c.revision_id = r.id
WHERE r.reverses_ledger_event_id = ? AND r.posting_kind = 'reversal'
LIMIT 1
""",
(original_event_id,),
).fetchone()
return row is not None
def create_reversal(
connection: sqlite3.Connection,
original_event_id: int,
*,
source_kind: str,
source_revision_token: str | None = None,
effective_at: str | None = None,
reason: str,
actor: sqlite3.Row | None,
idempotency_key: str | None = None,
rule_version: str | None = None,
) -> tuple[int, int]:
"""Create an equal-amount, opposite-direction reversal as a new ledger event.
The subject mirrors the original (应收<->应付, 其他应收<->其他应付). ``effective_at``
defaults to the original event's effective date so an earlier cutoff keeps
the original impact and later cutoffs see the net zero. The original event
is never modified or deleted.
"""
original = current_revision(connection, original_event_id)
if original is None:
raise LedgerConflictError("原事件不存在或没有当前修订。")
if original["state"] != "confirmed":
raise LedgerInputError("只有已确认事件才能生成冲销。")
perspective = mirror_perspective(original)
if effective_at is None:
effective_at = original["effective_at"]
return create_event(
connection,
state="confirmed",
effective_at=effective_at,
amount=original["amount"],
currency=original["currency"],
payer_company_id=original["payee_company_id"],
payee_company_id=original["payer_company_id"],
perspective_company_id=perspective,
subject_code=mirror_subject(original["subject_code"]),
source_kind=source_kind,
source_revision_token=source_revision_token,
posting_kind="reversal",
reverses_ledger_event_id=original_event_id,
rule_version=rule_version or original["rule_version"],
idempotency_key=idempotency_key,
actor=actor,
reason=reason,
)
def mirror_perspective(revision: sqlite3.Row) -> int:
"""The counterparty company from ``revision``'s perspective."""
perspective = int(revision["perspective_company_id"])
if perspective == int(revision["payer_company_id"]):
return int(revision["payee_company_id"])
return int(revision["payer_company_id"])
def create_adjustment(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
effective_at: str,
amount: str,
currency: str,
payer_company_id: int,
payee_company_id: int,
perspective_company_id: int,
subject_code: str,
reason: str,
actor: sqlite3.Row,
idempotency_key: str | None = None,
rule_version: str | None = None,
) -> tuple[int, int]:
"""Create an audit adjustment event; the original event stays unchanged."""
return create_event(
connection,
state="confirmed",
effective_at=effective_at,
amount=str(parse_amount(amount)),
currency=currency,
payer_company_id=payer_company_id,
payee_company_id=payee_company_id,
perspective_company_id=perspective_company_id,
subject_code=subject_code,
source_kind="adjustment",
source_revision_token=None,
posting_kind="adjustment",
adjusts_ledger_event_id=ledger_event_id,
rule_version=rule_version or SUBJECT_RULE_VERSION,
idempotency_key=idempotency_key,
actor=actor,
reason=reason,
)
def reopen_subject(
connection: sqlite3.Connection,
ledger_event_id: int,
*,
reason: str,
actor: sqlite3.Row,
idempotency_key: str | None = None,
) -> tuple[int, int]:
"""Reverse a confirmed event and re-open it for subject re-review.
Creates an equal-amount reversal plus a fresh ``pending_subject`` event
that re-claims the original bank source, so the administrator can confirm
a corrected subject. The original event and its reversal keep history.
"""
current = current_revision(connection, ledger_event_id)
if current is None or current["state"] != "confirmed":
raise LedgerConflictError("只有已确认事件可以重新进入科目审核。")
bank_claim = connection.execute(
"SELECT * FROM ledger_event_bank_sources WHERE ledger_event_id = ?",
(ledger_event_id,),
).fetchone()
if bank_claim is None:
raise LedgerInputError(
"该事件没有银行来源,无法重新进入科目审核;请改用调整或冲销。"
)
if not _has_reversal(connection, ledger_event_id):
create_reversal(
connection, ledger_event_id,
source_kind=current["source_kind"],
source_revision_token=current["source_revision_token"],
reason="科目复核:原确认事件冲销",
actor=actor,
idempotency_key=(idempotency_key + ":rev" if idempotency_key else None),
rule_version=current["rule_version"],
)
ev = connection.execute(
"SELECT * FROM eligible_intercompany_events WHERE event_id = ?",
(bank_claim["bank_event_id"],),
).fetchone()
if ev is None:
raise LedgerInputError("银行事件已不再纳入往来,无法重新入账。")
event_id, revision_id = _create_bank_event(
connection, ev, actor, reason="科目复核后重新入账,待确认科目",
replacing_claim=bank_claim,
)
return event_id, revision_id
# ---------------------------------------------------------------------------
# Bank event reconciliation
# ---------------------------------------------------------------------------
def reconcile_bank_events(
connection: sqlite3.Connection, actor: sqlite3.Row | None = None
) -> dict[str, object]:
"""Reconcile the current eligible intercompany events into ledger events.
Idempotent: first sight creates a ``pending_subject`` event; a changed B-43
decision updates a still-pending event's revision, or (for a confirmed
event) creates a reversal plus a fresh pending event. A source that left
the eligible set with a confirmed impact gets one reversal. Runs inside the
caller's transaction when one is open, otherwise in its own transaction.
"""
began = _ensure_transaction(connection)
try:
eligible = {
row["event_id"]: row
for row in connection.execute(
"SELECT * FROM eligible_intercompany_events"
).fetchall()
}
claims = {
row["bank_event_id"]: row
for row in connection.execute(
"SELECT * FROM ledger_event_bank_sources"
).fetchall()
}
stats = {
"created": 0, "updated_pending": 0, "reversal": 0,
"reopened": 0, "unchanged": 0, "sources": len(eligible),
}
for bank_event_id, event in sorted(eligible.items()):
claim = claims.get(bank_event_id)
if claim is None:
_create_bank_event(
connection, event, actor, reason="B-43 事件首次入账,待确认科目"
)
stats["created"] += 1
continue
current = current_revision(connection, claim["ledger_event_id"])
if current is None or _facts_of(connection, claim["ledger_event_id"]) != _eligible_facts(event):
if current is not None and current["state"] == "confirmed":
if not _has_reversal(connection, claim["ledger_event_id"]):
create_reversal(
connection, claim["ledger_event_id"],
source_kind="bank",
source_revision_token=event["decision_id"],
reason="B-43 事件事实变更,原确认事件冲销",
actor=actor,
)
stats["reversal"] += 1
_create_bank_event(
connection, event, actor,
reason="B-43 事件事实变更后重新入账,待确认科目",
replacing_claim=claim,
)
stats["reopened"] += 1
elif current is None or current["state"] == "pending_subject":
_append_bank_pending_revision(
connection, claim["ledger_event_id"], event, actor
)
stats["updated_pending"] += 1
else:
stats["unchanged"] += 1
else:
stats["unchanged"] += 1
for bank_event_id, claim in sorted(claims.items()):
if bank_event_id in eligible:
continue
current = current_revision(connection, claim["ledger_event_id"])
if current is not None and current["state"] == "confirmed":
if not _has_reversal(connection, claim["ledger_event_id"]):
create_reversal(
connection, claim["ledger_event_id"],
source_kind="bank",
source_revision_token=None,
reason="B-43 事件不再纳入往来,原确认事件冲销",
actor=actor,
)
stats["reversal"] += 1
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return stats
def _create_bank_event(
connection: sqlite3.Connection,
event: sqlite3.Row,
actor: sqlite3.Row | None,
*,
reason: str,
replacing_claim: sqlite3.Row | None = None,
) -> tuple[int, int]:
event_id, revision_id = create_event(
connection,
state="pending_subject",
effective_at=event["effective_at"],
amount=event["amount"],
currency=event["currency"],
payer_company_id=event["payer_company_id"],
payee_company_id=event["payee_company_id"],
perspective_company_id=None,
subject_code=None,
source_kind="bank",
source_revision_token=event["decision_id"],
posting_kind="normal",
rule_version=SUBJECT_RULE_VERSION,
evidence_json=json.dumps(
{
"bank_event_id": event["event_id"],
"decision_id": event["decision_id"],
"pairing": event["pairing"],
"evidence_count": event["evidence_count"],
},
ensure_ascii=False,
),
actor=actor,
reason=reason,
)
if replacing_claim is not None:
connection.execute(
"""
UPDATE ledger_event_bank_sources SET ledger_event_id = ?
WHERE bank_event_id = ?
""",
(event_id, event["event_id"]),
)
else:
connection.execute(
"""
INSERT INTO ledger_event_bank_sources (bank_event_id, ledger_event_id)
VALUES (?, ?)
""",
(event["event_id"], event_id),
)
from .subjects import store_suggestions
store_suggestions(connection, event_id)
return event_id, revision_id
def _append_bank_pending_revision(
connection: sqlite3.Connection,
ledger_event_id: int,
event: sqlite3.Row,
actor: sqlite3.Row | None,
) -> int:
current = current_revision(connection, ledger_event_id)
revision_id = append_revision(
connection,
ledger_event_id,
state="pending_subject",
effective_at=event["effective_at"],
amount=event["amount"],
currency=event["currency"],
payer_company_id=event["payer_company_id"],
payee_company_id=event["payee_company_id"],
perspective_company_id=None,
subject_code=None,
source_kind="bank",
source_revision_token=event["decision_id"],
posting_kind="normal",
rule_version=SUBJECT_RULE_VERSION,
evidence_json=json.dumps(
{
"bank_event_id": event["event_id"],
"decision_id": event["decision_id"],
"pairing": event["pairing"],
"evidence_count": event["evidence_count"],
},
ensure_ascii=False,
),
actor=actor,
reason="B-43 事件事实更新,追加待审修订",
supersedes_revision_id=current["id"] if current is not None else None,
)
from .subjects import store_suggestions
store_suggestions(connection, ledger_event_id)
return revision_id
# ---------------------------------------------------------------------------
# Projection rebuild
# ---------------------------------------------------------------------------
def rebuild_current_ledger_projection(connection: sqlite3.Connection) -> int:
"""Rebuild current ledger revisions from the append-only log."""
began = _ensure_transaction(connection)
try:
connection.execute("DELETE FROM current_ledger_event_revisions")
rows = connection.execute(
"""
SELECT e.id AS ledger_event_id,
(SELECT r2.id FROM ledger_event_revisions r2
WHERE r2.ledger_event_id = e.id
ORDER BY r2.revision DESC LIMIT 1) AS latest_id
FROM ledger_events e
WHERE e.lifecycle = 'active'
""",
).fetchall()
rebuilt = 0
for row in rows:
if row["latest_id"] is None:
continue
connection.execute(
"""
INSERT OR REPLACE INTO current_ledger_event_revisions (ledger_event_id, revision_id)
VALUES (?, ?)
""",
(row["ledger_event_id"], row["latest_id"]),
)
rebuilt += 1
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return rebuilt
+778
View File
@@ -0,0 +1,778 @@
"""Manual evidence records, administrator approval and audit-safe reversal.
Manual records are immutable submitted facts. Only an approved record becomes
a canonical ledger event (``approve_new``) or joins one (``approve_link``);
returned/exception/pending records never affect a balance and never leak to
the counterparty. Approved facts change only through a ``reverse`` decision
that creates an opposite new event (or detaches a linked claim) — the original
is never edited. Idempotency keys and UNIQUE claims prevent double counting.
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal, InvalidOperation
import json
import sqlite3
from .db import utc_now
from .ledger_events import (
LedgerConflictError,
LedgerInputError,
create_event,
create_reversal,
current_revision,
manual_source_claim,
)
from .subjects import SUBJECTS
MANUAL_STATES = ("pending", "approved", "returned", "exception", "reversed")
FUNDING_SOURCES = ("approved_bank_account", "personal_transit", "other")
DATE_KEYS = ("occurred_at",)
class ManualConflictError(ValueError):
"""A claim/idempotency/revision conflict (mapped to HTTP 409)."""
class ManualInputError(ValueError):
"""Invalid manual record input (mapped to HTTP 400/422)."""
def _parse_amount(amount: object) -> Decimal:
try:
value = Decimal(str(amount))
except InvalidOperation:
raise ManualInputError("金额不是有效的十进制数。") from None
if not value.is_finite() or value <= 0:
raise ManualInputError("金额必须大于零。")
return value
def _validate_date(value: object, field: str) -> str:
text = str(value or "").strip()
if len(text) < 10:
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。")
try:
datetime.fromisoformat(text[:10])
except ValueError:
raise ManualInputError(f"{field}必须是 YYYY-MM-DD 或完整时间。") from None
return text
# ---------------------------------------------------------------------------
# Submit
# ---------------------------------------------------------------------------
def submit(
connection: sqlite3.Connection,
*,
company_id: int,
counterparty_company_id: int,
occurred_at: str,
direction: str,
amount: str,
currency: str,
funding_source: str,
requested_subject: str,
request_key: str,
actor: sqlite3.Row,
bank_account_id: object = None,
personal_transit_mapping_id: object = None,
related_source_row_id: object = None,
summary: object = None,
reason: object = None,
evidence: object = None,
supersedes_record_id: object = None,
) -> dict[str, object]:
"""Submit one manual record for review. Idempotent on ``(company_id, request_key)``."""
request_key = str(request_key or "").strip()
if not request_key:
raise ManualInputError("必须提供提交幂等键 request_key。")
if int(company_id) == int(counterparty_company_id):
raise ManualInputError("对方公司不能与本公司相同。")
if direction not in ("outgoing", "incoming"):
raise ManualInputError("方向必须是 outgoing 或 incoming。")
if funding_source not in FUNDING_SOURCES:
raise ManualInputError(f"资金来源必须是:{''.join(FUNDING_SOURCES)}")
if requested_subject not in SUBJECTS:
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
_validate_date(occurred_at, "业务日期")
currency = str(currency or "").strip()
if not currency:
raise ManualInputError("币种不能为空。")
amount = str(_parse_amount(amount))
for label, raw in (
("company_id", company_id), ("counterparty_company_id", counterparty_company_id),
):
row = connection.execute("SELECT id FROM companies WHERE id = ?", (int(raw),)).fetchone()
if row is None:
raise ManualInputError(f"{label} 指向的公司不存在。")
bank_account_id = _resolve_account_ref(
connection, bank_account_id, company_id, "银行账户"
)
mapping_id = _resolve_account_ref(
connection, personal_transit_mapping_id, company_id, "个人过账映射"
)
related_row = None
if related_source_row_id not in (None, ""):
related_row = connection.execute(
"SELECT r.id, b.company_id FROM source_rows r "
"JOIN sheet_batches s ON s.id = r.sheet_batch_id "
"JOIN import_batches b ON b.id = s.import_batch_id "
"WHERE r.id = ?",
(int(related_source_row_id),),
).fetchone()
if related_row is None:
raise ManualInputError("关联银行源行不存在。")
if funding_source == "approved_bank_account" and bank_account_id is None:
raise ManualInputError("资金来源为已批准账户时必须指定银行账户。")
if funding_source == "personal_transit" and mapping_id is None:
raise ManualInputError("资金来源为个人过账时必须指定个人过账映射。")
supersedes_id = None
if supersedes_record_id not in (None, ""):
parent = connection.execute(
"SELECT id, company_id FROM manual_records WHERE id = ?",
(int(supersedes_record_id),),
).fetchone()
if parent is None or parent["company_id"] != int(company_id):
raise ManualInputError("supersedes_record_id 无效。")
supersedes_id = int(supersedes_record_id)
existing = connection.execute(
"SELECT id FROM manual_records WHERE company_id = ? AND request_key = ?",
(int(company_id), request_key),
).fetchone()
if existing is not None:
return _record_payload(connection, existing["id"], idempotent_replay=True)
now = utc_now()
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
cursor = connection.execute(
"""
INSERT INTO manual_records (
company_id, counterparty_company_id, occurred_at, direction,
amount, amount_scale, currency, funding_source, bank_account_id,
personal_transit_mapping_id, related_source_row_id,
requested_subject, summary, reason, evidence_json, request_key,
supersedes_record_id, submitted_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
int(company_id), int(counterparty_company_id), occurred_at, direction,
amount, _scale_of(amount), currency, funding_source, bank_account_id,
mapping_id, related_row["id"] if related_row is not None else None,
requested_subject, str(summary or "") or None,
str(reason or "") or None,
json.dumps(evidence, ensure_ascii=False) if evidence else None,
request_key, supersedes_id,
actor["id"], now,
),
)
record_id = int(cursor.lastrowid)
_append_decision(
connection, record_id, state="pending", action="submit",
reason=str(reason or "") or None, actor=actor,
)
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return _record_payload(connection, record_id)
def _resolve_account_ref(connection, raw, company_id: int, label: str) -> int | None:
if raw in (None, ""):
return None
row = connection.execute(
"SELECT id, company_id FROM bank_accounts WHERE id = ?", (int(raw),)
).fetchone()
if row is None:
raise ManualInputError(f"{label}不存在。")
if row["company_id"] != int(company_id):
raise ManualInputError(f"{label}必须属于提交公司。")
return int(raw)
def _scale_of(amount: str) -> int:
exponent = Decimal(amount).as_tuple().exponent
return max(0, -int(exponent))
# ---------------------------------------------------------------------------
# Decisions
# ---------------------------------------------------------------------------
def _append_decision(
connection: sqlite3.Connection,
record_id: int,
*,
state: str,
action: str,
reason: str | None,
actor: sqlite3.Row,
idempotency_key: str | None = None,
supersedes_decision_id: int | None = None,
) -> int:
row = connection.execute(
"SELECT COALESCE(MAX(revision), 0) AS m FROM manual_record_decisions WHERE record_id = ?",
(record_id,),
).fetchone()
revision = int(row["m"]) + 1
now = utc_now()
cursor = connection.execute(
"""
INSERT INTO manual_record_decisions (
record_id, revision, state, action, reason, actor_user_id,
actor_username, idempotency_key, supersedes_decision_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
record_id, revision, state, action, reason,
actor["id"], actor["username"], idempotency_key,
supersedes_decision_id, now,
),
)
decision_id = int(cursor.lastrowid)
connection.execute(
"""
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
VALUES (?, ?)
""",
(record_id, decision_id),
)
return decision_id
def _current_decision(connection: sqlite3.Connection, record_id: int) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT d.* FROM current_manual_record_decisions c
JOIN manual_record_decisions d ON d.id = c.decision_id
WHERE c.record_id = ?
""",
(record_id,),
).fetchone()
def decide(
connection: sqlite3.Connection,
record_id: int,
action: str,
*,
reason: str,
expected_decision_id: int | None,
request_key: str | None,
actor: sqlite3.Row,
subject_code: object = None,
target_ledger_event_id: object = None,
) -> dict[str, object]:
"""Apply an administrator decision to a manual record.
``approve_new`` creates a confirmed ledger event; ``approve_link`` joins an
existing ledger event without adding a second economic impact; ``return``
and ``exception`` never produce a balance; ``reverse`` creates an opposite
reversal event (or detaches a linked claim). Replays return the earlier
outcome via ``idempotency_key``.
"""
reason = (reason or "").strip()
if not reason:
raise ManualInputError("必须填写审核原因。")
if action not in ("approve_new", "approve_link", "return", "exception", "reverse"):
raise ManualInputError("未知的审核决定类型。")
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
record = connection.execute(
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
).fetchone()
if record is None:
raise ManualConflictError("手工记录不存在。")
if request_key:
existing = connection.execute(
"SELECT * FROM manual_record_decisions WHERE record_id = ? AND idempotency_key = ?",
(record_id, request_key),
).fetchone()
if existing is not None:
if began:
connection.commit()
return _decision_payload(connection, record_id, existing["id"])
current = _current_decision(connection, record_id)
if current is None:
raise ManualConflictError("该记录没有当前状态。")
if expected_decision_id is not None and int(expected_decision_id) != current["id"]:
raise ManualConflictError("记录已发生变更,请刷新后重试。")
if action == "approve_new":
outcome = _approve_new(
connection, record, current, actor, subject_code, reason, request_key
)
elif action == "approve_link":
outcome = _approve_link(
connection, record, current, actor, target_ledger_event_id, reason,
request_key,
)
elif action == "return":
if current["state"] != "pending":
raise ManualConflictError("只有待复核的记录可以退回。")
decision_id = _append_decision(
connection, record_id, state="returned", action=action,
reason=reason, actor=actor, idempotency_key=request_key,
supersedes_decision_id=current["id"],
)
outcome = {"decision_id": decision_id, "ledger_event_id": None}
elif action == "exception":
if current["state"] != "pending":
raise ManualConflictError("只有待复核的记录可以转为异常。")
decision_id = _append_decision(
connection, record_id, state="exception", action=action,
reason=reason, actor=actor, idempotency_key=request_key,
supersedes_decision_id=current["id"],
)
outcome = {"decision_id": decision_id, "ledger_event_id": None}
else: # reverse
if current["state"] != "approved":
raise ManualConflictError("只有已批准记录可以冲销。")
outcome = _reverse(connection, record, current, actor, request_key, reason)
_store_audit(connection, record, current, action, outcome, reason, actor)
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return _decision_payload(connection, record_id, outcome["decision_id"])
def _approve_new(
connection: sqlite3.Connection,
record: sqlite3.Row,
current: sqlite3.Row,
actor: sqlite3.Row,
subject_code: object,
reason: str,
idempotency_key: str | None,
) -> dict[str, object]:
subject = str(subject_code or record["requested_subject"] or "")
if subject not in SUBJECTS:
raise ManualInputError("科目必须是应收/应付/其他应收/其他应付之一。")
if record["direction"] == "outgoing":
payer, payee = record["company_id"], record["counterparty_company_id"]
else:
payer, payee = record["counterparty_company_id"], record["company_id"]
event_id, _revision_id = create_event(
connection,
state="confirmed",
effective_at=record["occurred_at"],
amount=record["amount"],
currency=record["currency"],
payer_company_id=payer,
payee_company_id=payee,
perspective_company_id=record["company_id"],
subject_code=subject,
source_kind="manual",
source_revision_token=None,
posting_kind="normal",
rule_version="manual-record-v1",
evidence_json=json.dumps({"manual_record_id": record["id"]}, ensure_ascii=False),
actor=actor,
reason=reason,
)
decision_id = _append_decision(
connection, record["id"], state="approved", action="approve_new",
reason=reason, actor=actor,
idempotency_key=idempotency_key,
supersedes_decision_id=current["id"],
)
connection.execute(
"""
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
VALUES (?, ?)
""",
(record["id"], event_id),
)
return {"decision_id": decision_id, "ledger_event_id": event_id}
def _approve_link(
connection: sqlite3.Connection,
record: sqlite3.Row,
current: sqlite3.Row,
actor: sqlite3.Row,
target_ledger_event_id: object,
reason: str,
idempotency_key: str | None,
) -> dict[str, object]:
if target_ledger_event_id in (None, ""):
raise ManualInputError("approve_link 必须指定目标往来事件。")
target = connection.execute(
"SELECT id, lifecycle FROM ledger_events WHERE id = ?",
(int(target_ledger_event_id),),
).fetchone()
if target is None or target["lifecycle"] != "active":
raise ManualConflictError("目标往来事件不存在。")
if manual_source_claim(connection, record["id"]) is not None:
raise ManualConflictError("该手工记录已关联往来事件。")
decision_id = _append_decision(
connection, record["id"], state="approved", action="approve_link",
reason=reason, actor=actor,
idempotency_key=idempotency_key,
supersedes_decision_id=current["id"],
)
connection.execute(
"""
INSERT INTO ledger_event_manual_sources (manual_record_id, ledger_event_id)
VALUES (?, ?)
""",
(record["id"], int(target_ledger_event_id)),
)
return {"decision_id": decision_id, "ledger_event_id": int(target_ledger_event_id)}
def _reverse(
connection: sqlite3.Connection,
record: sqlite3.Row,
current: sqlite3.Row,
actor: sqlite3.Row,
request_key: str | None,
reason: str,
) -> dict[str, object]:
claim = manual_source_claim(connection, record["id"])
if claim is None:
raise ManualConflictError("该记录尚未关联往来事件,无法冲销。")
event_id = claim["ledger_event_id"]
revision = current_revision(connection, event_id)
if revision is None:
raise ManualConflictError("关联的往来事件没有当前修订。")
if revision["source_kind"] == "manual" and not _event_has_other_sources(
connection, event_id, record["id"]
):
# The manual record is the only economic source: reverse it with a new
# opposite event at an independent effective date.
create_reversal(
connection,
event_id,
source_kind="manual",
source_revision_token=str(record["id"]),
reason=reason,
actor=actor,
idempotency_key=request_key,
rule_version="manual-record-v1",
)
else:
# The manual was linked evidence on a bank event: it never added a
# second impact, so reversing detaches the claim without a reversal
# event; the bank impact stays.
connection.execute(
"DELETE FROM ledger_event_manual_sources WHERE manual_record_id = ?",
(record["id"],),
)
decision_id = _append_decision(
connection, record["id"], state="reversed", action="reverse",
reason=reason, actor=actor, idempotency_key=request_key,
supersedes_decision_id=current["id"],
)
return {"decision_id": decision_id, "ledger_event_id": None}
def _event_has_other_sources(connection, event_id: int, manual_record_id: int) -> bool:
bank = connection.execute(
"SELECT 1 FROM ledger_event_bank_sources WHERE ledger_event_id = ? LIMIT 1",
(event_id,),
).fetchone()
other_manual = connection.execute(
"SELECT 1 FROM ledger_event_manual_sources "
"WHERE ledger_event_id = ? AND manual_record_id != ? LIMIT 1",
(event_id, manual_record_id),
).fetchone()
return bank is not None or other_manual is not None
def _store_audit(
connection, record, current, action, outcome, reason, actor
) -> None:
from .auth import audit
audit(
connection,
f"manual_{action}",
actor=actor,
target=f"manual_record:{record['id']}",
detail=(
f"decision:{outcome['decision_id']};"
f"ledger_event:{outcome.get('ledger_event_id')};reason:{reason}"
),
)
# ---------------------------------------------------------------------------
# Candidates and queries
# ---------------------------------------------------------------------------
def find_candidates(connection: sqlite3.Connection, record_id: int) -> list[dict[str, object]]:
"""Deterministic hints shown before approval; never auto-merged."""
record = connection.execute(
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
).fetchone()
if record is None:
return []
wanted_direction = "incoming" if record["direction"] == "outgoing" else "outgoing"
date_prefix = str(record["occurred_at"])[:10]
candidates: list[dict[str, object]] = []
bank_rows = connection.execute(
"""
SELECT e.event_id, e.amount, e.currency, e.effective_at, e.pairing,
e.payer_company_id, e.payee_company_id, e.decision_id
FROM eligible_intercompany_events e
WHERE (e.payer_company_id = ? AND e.payee_company_id = ?)
OR (e.payer_company_id = ? AND e.payee_company_id = ?)
ORDER BY e.event_id
""",
(
record["company_id"], record["counterparty_company_id"],
record["counterparty_company_id"], record["company_id"],
),
).fetchall()
for row in bank_rows:
if row["amount"] != record["amount"] or row["currency"] != record["currency"]:
continue
event_direction = (
"outgoing" if row["payer_company_id"] == record["company_id"] else "incoming"
)
if event_direction != wanted_direction:
continue
candidates.append(
{
"kind": "bank_event",
"ledger_event_id": _ledger_event_of_bank(connection, row["event_id"]),
"bank_event_id": row["event_id"],
"decision_id": row["decision_id"],
"amount": row["amount"],
"currency": row["currency"],
"effective_at": row["effective_at"],
"pairing": row["pairing"],
"hint": "已存在匹配的银行规范事件,建议关联",
}
)
manual_rows = connection.execute(
"""
SELECT m.id, m.company_id, m.counterparty_company_id, m.direction,
m.amount, m.currency, m.occurred_at, d.state
FROM manual_records m
JOIN current_manual_record_decisions c ON c.record_id = m.id
JOIN manual_record_decisions d ON d.id = c.decision_id
WHERE m.id != ? AND m.amount = ? AND m.currency = ?
AND (
(m.company_id = ? AND m.counterparty_company_id = ?)
OR (m.company_id = ? AND m.counterparty_company_id = ?)
)
ORDER BY m.id
""",
(
record["id"], record["amount"], record["currency"],
record["company_id"], record["counterparty_company_id"],
record["counterparty_company_id"], record["company_id"],
),
).fetchall()
for row in manual_rows:
if row["direction"] != wanted_direction:
continue
if row["state"] not in ("approved", "pending"):
continue
candidates.append(
{
"kind": "manual_record",
"ledger_event_id": None,
"manual_record_id": row["id"],
"amount": row["amount"],
"currency": row["currency"],
"occurred_at": row["occurred_at"],
"state": row["state"],
"hint": "存在方向相反的同额手工记录,建议核对后关联",
}
)
return candidates
def _ledger_event_of_bank(connection, bank_event_id: int) -> int | None:
claim = connection.execute(
"SELECT ledger_event_id FROM ledger_event_bank_sources WHERE bank_event_id = ?",
(bank_event_id,),
).fetchone()
return claim["ledger_event_id"] if claim is not None else None
def list_records(
connection: sqlite3.Connection,
*,
company_id: int | None = None,
state: str | None = None,
limit: int = 100,
) -> list[sqlite3.Row]:
conditions: list[str] = []
params: list[object] = []
if company_id is not None:
conditions.append("(m.company_id = ? OR m.counterparty_company_id = ?)")
params.extend([company_id, company_id])
if state is not None:
if state not in MANUAL_STATES:
raise ManualInputError("无效的记录状态。")
conditions.append("d.state = ?")
params.append(state)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
return connection.execute(
f"""
SELECT m.*, d.id AS decision_id, d.state AS state, d.revision AS decision_revision,
d.action AS action, d.reason AS decision_reason,
d.actor_username AS decision_actor, d.created_at AS decision_at,
c.name AS company_name, cc.name AS counterparty_company_name
FROM manual_records m
JOIN current_manual_record_decisions c ON c.record_id = m.id
JOIN manual_record_decisions d ON d.id = c.decision_id
LEFT JOIN companies c ON c.id = m.company_id
LEFT JOIN companies cc ON cc.id = m.counterparty_company_id
{where}
ORDER BY m.id DESC
LIMIT ?
""",
(*params, max(1, int(limit))),
).fetchall()
def rebuild_current_manual_projection(connection: sqlite3.Connection) -> int:
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
connection.execute("DELETE FROM current_manual_record_decisions")
rows = connection.execute(
"""
SELECT m.id AS record_id,
(SELECT d2.id FROM manual_record_decisions d2
WHERE d2.record_id = m.id
ORDER BY d2.revision DESC LIMIT 1) AS latest_id
FROM manual_records m
"""
).fetchall()
rebuilt = 0
for row in rows:
if row["latest_id"] is None:
continue
connection.execute(
"""
INSERT OR REPLACE INTO current_manual_record_decisions (record_id, decision_id)
VALUES (?, ?)
""",
(row["record_id"], row["latest_id"]),
)
rebuilt += 1
except Exception:
if began:
connection.rollback()
raise
else:
if began:
connection.commit()
return rebuilt
# ---------------------------------------------------------------------------
# Payloads
# ---------------------------------------------------------------------------
def _record_payload(connection: sqlite3.Connection, record_id: int, *, idempotent_replay: bool = False) -> dict[str, object]:
rows = list_records(connection, limit=1000)
row = next((item for item in rows if item["id"] == record_id), None)
if row is None:
raise ManualInputError("手工记录不存在。")
payload = _row_payload(connection, row)
if idempotent_replay:
payload["idempotent_replay"] = True
return payload
def _row_payload(connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, object]:
return {
"id": row["id"],
"company_id": row["company_id"],
"company_name": row["company_name"],
"counterparty_company_id": row["counterparty_company_id"],
"counterparty_company_name": row["counterparty_company_name"],
"occurred_at": row["occurred_at"],
"direction": row["direction"],
"amount": row["amount"],
"currency": row["currency"],
"funding_source": row["funding_source"],
"bank_account_id": row["bank_account_id"],
"personal_transit_mapping_id": row["personal_transit_mapping_id"],
"related_source_row_id": row["related_source_row_id"],
"requested_subject": row["requested_subject"],
"summary": row["summary"],
"reason": row["reason"],
"request_key": row["request_key"],
"supersedes_record_id": row["supersedes_record_id"],
"submitted_by": row["submitted_by"],
"created_at": row["created_at"],
"state": row["state"],
"decision_id": row["decision_id"],
"decision_revision": row["decision_revision"],
"decision_action": row["action"],
"decision_reason": row["decision_reason"],
"decision_actor": row["decision_actor"],
"decision_at": row["decision_at"],
"candidates": find_candidates(connection, row["id"]),
}
def _decision_payload(connection: sqlite3.Connection, record_id: int, decision_id: int) -> dict[str, object]:
row = connection.execute(
"""
SELECT d.* FROM manual_record_decisions d
WHERE d.id = ?
""",
(decision_id,),
).fetchone()
record = connection.execute(
"SELECT * FROM manual_records WHERE id = ?", (record_id,)
).fetchone()
claim = manual_source_claim(connection, record_id)
return {
"record_id": record_id,
"decision_id": decision_id,
"revision": row["revision"],
"state": row["state"],
"action": row["action"],
"reason": row["reason"],
"actor_username": row["actor_username"],
"created_at": row["created_at"],
"ledger_event_id": claim["ledger_event_id"] if claim is not None else None,
"requested_subject": record["requested_subject"],
"amount": record["amount"],
"currency": record["currency"],
"occurred_at": record["occurred_at"],
}
File diff suppressed because it is too large Load Diff
+302
View File
@@ -0,0 +1,302 @@
"""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("科目必须是应收/应付/其他应收/其他应付之一。")
subject_code = subject_code
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:
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) != current["revision"]:
raise SubjectConflictError("事件已发生变更,请刷新后重试。")
participants = {current["payer_company_id"], current["payee_company_id"]}
if perspective_company_id not in participants:
raise SubjectInputError("视角公司必须是事件参与方。")
began = False
if not connection.in_transaction:
connection.execute("BEGIN IMMEDIATE")
began = True
try:
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 _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"],
}