feat(ledger): импорт отчётов в леджер — приём, дедупликация, pending_instrument

Поток: upload -> raw_report_file (sha256 UNIQUE) -> parse -> raw_report_line,
событий в леджере ещё нет -> preview -> POST /imports/{id}/commit ->
ledger/ingest.py резолвит инструмент, считает dedupe_key, пишет event
confirmed | shadow (plan §1.6 B) — сравнивая account.primary_event_source
с источником отчёта, а не гадая. Нерезолвленный инструмент ждёт в
pending_instrument, никогда не угадывается; POST /instruments/pending/{id}/resolve
привязывает и пересобирает лоты.

ledger/dedupe.py — shadow-матчинг случая B двумя проходами (точная дата, затем
±1 рабочий день, жадно 1:1, |price| ±0,5 %). Шаги shadow_dedupe и
report_reconcile зарегистрированы перед quality: оба говорят через FINDINGS.

/instruments/pending регистрируется в app.py ДО routers/instruments.py:
FastAPI сопоставляет маршруты по порядку, и /instruments/{id} с типом int
отвечает 422 на нечисловой сегмент, а не проваливается дальше.

Контракт — docs/ai/import-contract.md, общий для бэкенда и Flutter.
This commit is contained in:
Dmitry
2026-09-19 10:40:04 +03:00
parent 2e742a093b
commit ff3b76871d
16 changed files with 4484 additions and 0 deletions
@@ -0,0 +1,168 @@
"""импорт брокерских отчётов: raw_report_file, raw_report_line, pending_instrument
Revision ID: ee170b3e7872
Revises: 3e2977b9e577
Create Date: 2026-09-18 15:35:07.287919
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "ee170b3e7872"
down_revision: str | None = "3e2977b9e577"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Autogenerate cannot see a new member of an existing Postgres enum — reports are a
# primary_event_source too, and a universal CSV export is one of them.
op.execute("ALTER TYPE event_source ADD VALUE IF NOT EXISTS 'csv' BEFORE 'manual'")
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"raw_report_file",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("broker", sa.String(length=32), nullable=False),
sa.Column("filename", sa.String(length=512), nullable=False),
sa.Column("sha256", sa.String(length=64), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("content", sa.LargeBinary(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("account_external_id", sa.String(length=128), nullable=True),
sa.Column("period_from", sa.Date(), nullable=True),
sa.Column("period_to", sa.Date(), nullable=True),
sa.Column("parser_name", sa.String(length=32), nullable=True),
sa.Column("parser_version", sa.String(length=32), nullable=True),
sa.Column(
"parse_status",
sa.Enum("uploaded", "parsed", "committed", "failed", name="report_parse_status"),
nullable=False,
),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("warnings", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("counts", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("uploaded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("committed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["account_id"],
["account.id"],
name=op.f("fk_raw_report_file_account_id_account"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_raw_report_file")),
)
op.create_index("ix_raw_report_file_account", "raw_report_file", ["account_id"], unique=False)
op.create_index("uq_raw_report_file_sha256", "raw_report_file", ["sha256"], unique=True)
op.create_table(
"pending_instrument",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("source", sa.String(length=32), nullable=False),
sa.Column("source_key", sa.String(length=256), nullable=False),
sa.Column("isin", sa.String(length=12), nullable=True),
sa.Column("ticker", sa.String(length=32), nullable=True),
sa.Column("board", sa.String(length=16), nullable=True),
sa.Column("name", sa.String(length=256), nullable=True),
sa.Column("currency", sa.String(length=3), nullable=True),
sa.Column("asset_class_hint", sa.String(length=16), nullable=True),
sa.Column("first_seen_file_id", sa.Integer(), nullable=True),
sa.Column("occurrences", sa.BigInteger(), nullable=False),
sa.Column("sample_quantity", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column("sample_price", sa.Numeric(precision=24, scale=10), nullable=True),
sa.Column(
"status",
sa.Enum("pending", "resolved", "ignored", name="pending_instrument_status"),
nullable=False,
),
sa.Column("instrument_id", sa.Integer(), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("meta", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["first_seen_file_id"],
["raw_report_file.id"],
name=op.f("fk_pending_instrument_first_seen_file_id_raw_report_file"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["instrument_id"],
["instrument.id"],
name=op.f("fk_pending_instrument_instrument_id_instrument"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_pending_instrument")),
sa.UniqueConstraint(
"source", "source_key", name=op.f("uq_pending_instrument_source_source_key")
),
)
op.create_table(
"raw_report_line",
sa.Column("file_id", sa.Integer(), nullable=False),
sa.Column("line_no", sa.Integer(), nullable=False),
sa.Column("section", sa.String(length=64), nullable=True),
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("dedupe_key", sa.String(length=256), nullable=True),
sa.Column("event_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["event_id"],
["event.id"],
name=op.f("fk_raw_report_line_event_id_event"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["file_id"],
["raw_report_file.id"],
name=op.f("fk_raw_report_line_file_id_raw_report_file"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("file_id", "line_no", name=op.f("pk_raw_report_line")),
)
op.create_index(
op.f("ix_raw_report_line_dedupe_key"), "raw_report_line", ["dedupe_key"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_raw_report_line_dedupe_key"), table_name="raw_report_line")
op.drop_table("raw_report_line")
op.drop_table("pending_instrument")
op.drop_index("uq_raw_report_file_sha256", table_name="raw_report_file")
op.drop_index("ix_raw_report_file_account", table_name="raw_report_file")
op.drop_table("raw_report_file")
# ### end Alembic commands ###
# Dropping a table leaves its enum type behind, and a re-upgrade would then fail.
# `event_source` keeps its extra member: Postgres cannot remove one, and an unused
# value is harmless.
sa.Enum(name="pending_instrument_status").drop(op.get_bind(), checkfirst=True)
sa.Enum(name="report_parse_status").drop(op.get_bind(), checkfirst=True)