- 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)
4.9 KiB
4.9 KiB
006: Intercompany positions, subject review and drill-down evidence (B-44)
Status: accepted (2026-08-19)
Decision
The intercompany ledger adds one canonical layer above B-43's eligible events:
- Append-only ledger events. A canonical
ledger_eventcarries an append-onlyledger_event_revisionschain. A B-43 eligible bank event first becomes apending_subjectrevision; an administrator confirms the subject into aconfirmedrevision. Corrections are never in-place edits — a reversal or adjustment is a new ledger event with its own effective date, and the original event keeps its history so earlier cutoffs are not rewritten. - Manual records as immutable submitted facts. Companies submit
manual_records; only an administrator-approved record becomes a confirmed ledger event (approve_new) or joins one (approve_link). Returned, exception and pending records never affect a balance and never leak to the counterparty. Approved facts change only throughreverse(a new opposite event) — the original is never edited. - One perspective, fixed mirror. Subjects are stored from one participating
company's perspective (
receivable/payable/other_receivable/other_payable); the other side is the fixed mirror (应收<->应付, 其他应收<->其他应付), so the two companies can never book conflicting subjects. - Subjects are confirmed, never auto-posted. Bank summary/purpose text only
feeds a deterministic suggestion dictionary (
subject-suggest-draft-v1, not group-approved). Without an approved trade dictionary every bank event stays in subject review until an administrator confirms. - Decimal-only aggregation. All money is stored as TEXT decimal strings and
aggregated with Python
Decimal. SQLiteSUM, JavaScriptNumberand Pythonfloatnever touch financial math. Different currencies are aggregated and displayed separately; nothing is converted to a group total. - Conservation is asserted. For every company pair and currency the two
perspectives must mirror exactly (
C_A == -C_B); a violation raises a calculation exception instead of rendering an unbalanced number. - Unresolved is absolute gross. Unresolved amounts are summed by absolute
value per currency (never netted), broken down by reason
(
subject_review,unmatched_single,manual_pending), with count and gross amount exposed on every balance response. - B-45 boundary. Until the B-45 opening balance exists, every response
returns
opening.status=unavailable,opening.amount=nullandresult.kind=period_net_change; the UI labels this "期间净变动", never "期末余额".
Background
B-43 produces eligible_intercompany_events as the only bank-event entry
point. Before B-44 there was no canonical financial event, no statutory
subject, no manual-record approval, and no server-side balance API. The
revision-chain design is inherited from transfer_match_decisions in
migration 5 and from the append-only audit posture of the rest of the system.
Consequences
- Positive: balances are deterministic, conservable, auditable and drillable from a group directory down to bank source rows; manual records cannot double count; corrections never mutate evidence; company portals are tenant-scoped on the server.
- Negative: pending bank events and returned/exception manual records are intentionally invisible to counterparties, which can surprise cashiers who expect symmetric disclosure; subject confirmation is manual until a group-approved dictionary exists.
- Operational: migration 6 is forward-only for production once
approvals/revisions exist; pre-production it can be rolled back with
--rollback-to 5. Read aggregation runs against the rebuildable current projection (no day snapshots yet); if B-45 monthly close needs them, immutable monthly snapshots can be added behind the same API contract.
Files
src/bank_importer/ledger_events.py— event lifecycle, revision chain, bank reconciliation, reversal/adjustment/reopen, projection rebuild.src/bank_importer/subjects.py— subject constants/mirror, suggestion dictionary,confirm_subject.src/bank_importer/manual_records.py— submission, approval (new/link), return/exception/reverse, candidate hints, idempotency.src/bank_importer/positions.py— Decimal aggregation, directories, pairs, events, evidence visibility, unresolved buckets, keyset pagination.server.py—/api/admin/intercompany/*and/api/company/intercompany/*plus/api/admin/subject-reviewsand/api/admin/manual-records.db.pymigration 6 —manual_records,manual_record_decisions,ledger_events,ledger_event_revisions, current-pointer projections, source claim tables,ledger_subject_suggestions,eligible_position_events.- Tests:
test_ledger_events.py,test_manual_records.py,test_positions.py,test_positions_api.py, extendedtest_persistence.py.