diff --git a/api/src/domain/checklist.py b/api/src/domain/checklist.py index 9dd9198..1cd24cf 100644 --- a/api/src/domain/checklist.py +++ b/api/src/domain/checklist.py @@ -17,23 +17,21 @@ """ import enum -import re import uuid from collections import defaultdict from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from typing import Protocol, TypeVar +from src.domain.consolidation_periods import Sources, quarters_through, select_sources +from src.domain.osv_mapping import account_sort_key from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter __all__ = ["Cell", "CellStatus", "EntityFilesState", "Rule", "build", "order_rules"] -_QUARTERS = tuple(PeriodQuarter) -_QUARTER_ORDER = {quarter: position for position, quarter in enumerate(_QUARTERS)} # порядок столбцов: ББ, ОФР, ОСВ по коду счёта; прочие типы (в правилах подходов их нет) — в конце _TYPE_ORDER = {DocumentType.BALANCE: 0, DocumentType.INCOME: 1, DocumentType.OSV: 2} _UNORDERED = len(_TYPE_ORDER) -_UNCONFIRMED_PRIORITY = {DocumentStatus.NEEDS_CHECK: 0, DocumentStatus.RECOGNIZED: 1} class CellStatus(enum.StrEnum): @@ -47,6 +45,15 @@ class CellStatus(enum.StrEnum): MISSING = "MISSING" +# неподтверждённый документ → статус ячейки (T8.4: явная таблица вместо равенства строк); порядок ключей — приоритет: +# `NEEDS_CHECK` важнее `RECOGNIZED` +_UNCONFIRMED_STATUS: dict[DocumentStatus, CellStatus] = { + DocumentStatus.NEEDS_CHECK: CellStatus.NEEDS_CHECK, + DocumentStatus.RECOGNIZED: CellStatus.RECOGNIZED, +} +_UNCONFIRMED_PRIORITY = {status: position for position, status in enumerate(_UNCONFIRMED_STATUS)} + + @dataclass(frozen=True) class EntityFilesState: """Состояние файлов ЮЛ: `has_in_progress` — есть файл `UPLOADED`/`PROCESSING`; `last_file_status` — статус @@ -101,16 +108,11 @@ class DocumentLike(Protocol): def status(self) -> DocumentStatus: ... -def _natural_key(code: str) -> tuple[tuple[int, int, str], ...]: - """Натуральный ключ кода счёта: числовые части — по значению (`01 < 58 < 58.03 < 60`), прочие — по тексту.""" - return tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in re.split(r"[.\s]+", code) if part) - - def order_rules(rules: Iterable[Rule]) -> list[Rule]: """Порядок столбцов чек-листа: `BALANCE`, `INCOME`, затем ОСВ по натуральной сортировке кода счёта.""" return sorted( rules, - key=lambda rule: (_TYPE_ORDER.get(rule.document_type, _UNORDERED), _natural_key(rule.account or "")), + key=lambda rule: (_TYPE_ORDER.get(rule.document_type, _UNORDERED), account_sort_key(rule.account or "")), ) @@ -124,28 +126,20 @@ class _Coverage: def _osv_coverage(version_quarter: PeriodQuarter, documents: Sequence[DocumentLike]) -> _Coverage: - """Покрытие ОСВ одного счёта ЮЛ: нарастающий за квартал версии, либо квартальные Q1..Qn версии все; нарастающий - приоритетнее квартальных; пробел — `missing_quarters` и документы имеющихся кварталов. + """Покрытие ОСВ одного счёта ЮЛ — адаптер над `consolidation_periods.select_sources` (D-2, T8.1). - Для версии Q1 квартальный Q1 равен нарастающему. Документы вне квартала версии (нарастающий за другой квартал, - квартальный позже версии) покрытия не дают. - - # Task 9 заменит на consolidation_periods.select_sources + Полное покрытие — источники без пробела (нарастающий за квартал версии или квартальные `Q1..Qn` все). При пробеле + `Gap` документов не несёт, поэтому адаптер сам берёт документы имеющихся кварталов (квартальные `Q1..Qn` в порядке + кварталов) и перечисляет `missing_quarters` только при частичном покрытии: без единого документа — `()` (№46). """ - cumulative = [d for d in documents if d.is_cumulative is not False and d.period_quarter == version_quarter] - if cumulative: - return _Coverage(True, (), tuple(d.id for d in cumulative)) - last = _QUARTER_ORDER[version_quarter] - needed = _QUARTERS[: last + 1] - by_quarter: dict[PeriodQuarter, list[DocumentLike]] = defaultdict(list) - for document in documents: - if document.is_cumulative is False and document.period_quarter in needed and document.period_quarter: - by_quarter[document.period_quarter].append(document) - present = [d.id for quarter in needed for d in by_quarter.get(quarter, [])] - missing = tuple(quarter for quarter in needed if quarter not in by_quarter) - if not missing: - return _Coverage(True, (), tuple(present)) - return _Coverage(False, missing if present else (), tuple(present)) + selected = select_sources(version_quarter, documents) + if isinstance(selected, Sources): + return _Coverage(True, (), tuple(d.id for d in selected.documents)) + needed = quarters_through(version_quarter) + present = tuple( + d.id for quarter in needed for d in documents if d.is_cumulative is False and d.period_quarter == quarter + ) + return _Coverage(False, selected.missing_quarters if present else (), present) def _coverage(version_quarter: PeriodQuarter, rule: Rule, documents: Sequence[DocumentLike]) -> _Coverage: @@ -176,10 +170,10 @@ def _cell( def partial(status: CellStatus) -> Cell: return Cell(status, coverage.missing_quarters, coverage.document_ids) - candidates = [d for d in unbound if _same_slot(d, rule) and d.status in _UNCONFIRMED_PRIORITY] + candidates = [d for d in unbound if _same_slot(d, rule) and d.status in _UNCONFIRMED_STATUS] if candidates: best = min(candidates, key=lambda d: _UNCONFIRMED_PRIORITY[d.status]) - return partial(CellStatus(best.status.value)) + return partial(_UNCONFIRMED_STATUS[best.status]) if state.has_in_progress: return partial(CellStatus.PROCESSING) if state.last_file_status == FileStatus.ERROR: diff --git a/api/src/domain/consolidated_osv.py b/api/src/domain/consolidated_osv.py new file mode 100644 index 0000000..def4de7 --- /dev/null +++ b/api/src/domain/consolidated_osv.py @@ -0,0 +1,505 @@ +"""Сводная ОСВ версии консолидации (spec consolidated-osv; design D-1, D-2, D-3, D-16; §2, §3 `05-domain-rules.md`). + +Чистая функция от версии (DECISION-5): результат не хранится. Вход — структурные dataclass'ы без ORM (`SourceRow` — +строка `osv_entry` `group_type = COUNTERPARTY`, `data_type IN (BU, SUM)`; `SourceDocument` — привязанный к версии +ОСВ-документ; `Entity` — ЮЛ справочника), справочник для дорезолва — `Resolver` (`CounterpartyIndex` подходит). + +1. Пары (ЮЛ периметра, счёт документа) → `consolidation_periods.select_sources`: нарастающий за квартал версии — + строки «как есть» (`is_cumulative = True`, дубли ключа не схлопываются, суммы `None` остаются `None`); квартальные + `Q1..Qn` — склейка по ключу (счёт строки, имя `strip()`, ИНН без пробелов): `opening_*` из `Q1`, `turnover_*` = + Σ, `closing_*` из `Qn`, отсутствующая в квартале строка и сумма `None` — нули, дубли ключа в квартале суммируются; + пробел квартала → `GapItem`, строк по паре нет. +2. Контрагент: непустой `counterparty_id` самого позднего квартала (расхождение с другими — в `warnings`, решение + №8); пустой → `Resolver.resolve(inn, name)` на лету, `match_stored = False`, в БД не пишется. +3. Признак (`classify`): `GSK` — контрагент в периметре версии; `GK` — его ГК = ГК консолидации; иначе `EXTERNAL`. +4. Порядок строк: имя ЮЛ, счёт (натурально), номер строки (пустой — в конце), первый `entry_id`. + +Деньги — `Decimal`, `float` не используется. +""" + +import enum +import uuid +from collections import defaultdict +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from decimal import Decimal +from typing import Protocol + +from src.domain.consolidation_periods import Gap, quarters_through, select_sources +from src.domain.inn import normalize_inn +from src.domain.osv_mapping import Resolution, account_sort_key +from src.models.enums import CounterpartyMatchKind, PeriodQuarter + +__all__ = [ + "AUTO_MATCH_KINDS", + "Consolidated", + "ConsolidatedRow", + "CounterpartyType", + "Entity", + "GapItem", + "OsvFilter", + "Resolver", + "SourceDocument", + "SourceRow", + "apply_filter", + "build", + "classify", + "glue_key", + "summary", +] + +ZERO = Decimal("0") +#: Способы автоматического сопоставления по имени — счётчик `auto_matched_total` (независимо от `match_stored`). +AUTO_MATCH_KINDS = frozenset({CounterpartyMatchKind.NAME, CounterpartyMatchKind.ALIAS}) + + +class CounterpartyType(enum.StrEnum): + """Признак контрагента относительно версии (§3 п. 5; единственное определение: схема API берёт его отсюда).""" + + GSK = "GSK" # ЮЛ периметра версии (consolidation_member) + GK = "GK" # ЮЛ той же ГК, вне периметра + EXTERNAL = "EXTERNAL" # прочие, в том числе несопоставленные + + +@dataclass(frozen=True) +class Entity: + """ЮЛ справочника: имя, ИНН и ГК (`None` — монозаёмщик).""" + + id: uuid.UUID + name: str + inn: str + company_group_id: uuid.UUID | None + + +@dataclass(frozen=True) +class SourceDocument: + """ОСВ-документ, привязанный к версии (`consolidation_document`); поля — как в DBML `document`.""" + + id: uuid.UUID + legal_entity_id: uuid.UUID + osv_account_code: str | None + period_quarter: PeriodQuarter | None + is_cumulative: bool | None + + +@dataclass(frozen=True) +class SourceRow: + """Строка `osv_entry` (`group_type = COUNTERPARTY`, `data_type IN (BU, SUM)`) — как хранится.""" + + id: uuid.UUID + document_id: uuid.UUID + row_number: int | None + account_code: str | None + account_root: str | None + counterparty_name: str | None + counterparty_inn: str | None + counterparty_id: uuid.UUID | None + match_kind: CounterpartyMatchKind | None + opening_debit: Decimal | None + opening_credit: Decimal | None + turnover_debit: Decimal | None + turnover_credit: Decimal | None + closing_debit: Decimal | None + closing_credit: Decimal | None + + +@dataclass(frozen=True) +class ConsolidatedRow: + """Строка сводной ОСВ (D-16): исходные `osv_entry`, ЮЛ, счёт, период, контрагент как в файле и после резолва, + признак, способ сопоставления, шесть сумм, предупреждения.""" + + entry_ids: tuple[uuid.UUID, ...] + legal_entity_id: uuid.UUID + legal_entity_name: str + legal_entity_inn: str + account_code: str | None + account_root: str | None + period: str # "<год> Q" + is_cumulative: bool + row_number: int | None + counterparty_name: str | None + counterparty_inn: str | None + counterparty_id: uuid.UUID | None + counterparty_entity_name: str | None + counterparty_type: CounterpartyType + match_kind: CounterpartyMatchKind | None + match_stored: bool # `False` — найдено дорезолвом на чтении (или не найдено), в `osv_entry` не записано + opening_debit: Decimal | None + opening_credit: Decimal | None + turnover_debit: Decimal | None + turnover_credit: Decimal | None + closing_debit: Decimal | None + closing_credit: Decimal | None + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True) +class GapItem: + """Пробел свода: у ЮЛ по счёту нет квартальных ОСВ за `missing_quarters` — строк по паре нет.""" + + legal_entity_id: uuid.UUID + legal_entity_name: str + account_code: str + missing_quarters: tuple[PeriodQuarter, ...] + + +@dataclass(frozen=True) +class Consolidated: + """Свод версии: строки в порядке D-16, пробелы (по имени ЮЛ и счёту), число привязанных ОСВ-документов.""" + + rows: tuple[ConsolidatedRow, ...] + gaps: tuple[GapItem, ...] + documents_total: int + + +class Resolver(Protocol): + """Справочник контрагентов для дорезолва (§3 п. 4): ИНН → точное имя → нормализованное имя/алиас.""" + + def resolve(self, inn: str | None, name: str | None) -> Resolution | None: ... + + +@dataclass(frozen=True) +class OsvFilter: + """Фильтры свода (`GET …/osv`, экспорт): пустые поля не фильтруют; `q` — подстрока имени без регистра или префикс + ИНН контрагента; `unmapped` — `counterparty_id` пуст после дорезолва (`False` — только сопоставленные).""" + + legal_entity_id: uuid.UUID | None = None + account_root: str | None = None + counterparty_type: CounterpartyType | None = None + unmapped: bool | None = None + match_kinds: frozenset[CounterpartyMatchKind] = frozenset() + q: str | None = None + + +GlueKey = tuple[str, str, str] + + +def glue_key(row: SourceRow) -> GlueKey: + """Ключ склейки квартальных строк (D-2): счёт строки, имя контрагента без краевых пробелов, ИНН без пробелов.""" + return (row.account_code or "", (row.counterparty_name or "").strip(), normalize_inn(row.counterparty_inn or "")) + + +def classify( + counterparty_id: uuid.UUID | None, + *, + members: Mapping[uuid.UUID, Entity], + entities: Mapping[uuid.UUID, Entity], + group_id: uuid.UUID, +) -> CounterpartyType: + """Признак контрагента относительно версии (D-3): `GSK` — в периметре; `GK` — ЮЛ ГК консолидации вне периметра; + иначе (пустой, чужая ГК, монозаёмщик, неизвестный id) — `EXTERNAL`.""" + if counterparty_id is None: + return CounterpartyType.EXTERNAL + if counterparty_id in members: + return CounterpartyType.GSK + entity = entities.get(counterparty_id) + if entity is not None and entity.company_group_id == group_id: + return CounterpartyType.GK + return CounterpartyType.EXTERNAL + + +# --------------------------------------------------------------------------- суммы + + +@dataclass(frozen=True) +class _Amounts: + """Шесть сумм строки; `None` при сложении — ноль.""" + + opening_debit: Decimal = ZERO + opening_credit: Decimal = ZERO + turnover_debit: Decimal = ZERO + turnover_credit: Decimal = ZERO + closing_debit: Decimal = ZERO + closing_credit: Decimal = ZERO + + @classmethod + def of(cls, row: SourceRow) -> "_Amounts": + return cls( + row.opening_debit or ZERO, + row.opening_credit or ZERO, + row.turnover_debit or ZERO, + row.turnover_credit or ZERO, + row.closing_debit or ZERO, + row.closing_credit or ZERO, + ) + + @classmethod + def total(cls, rows: Iterable[SourceRow]) -> "_Amounts": + result = cls() + for row in rows: + part = cls.of(row) + result = cls( + result.opening_debit + part.opening_debit, + result.opening_credit + part.opening_credit, + result.turnover_debit + part.turnover_debit, + result.turnover_credit + part.turnover_credit, + result.closing_debit + part.closing_debit, + result.closing_credit + part.closing_credit, + ) + return result + + +@dataclass(frozen=True) +class _Values: + """Суммы строки свода: у нарастающего — как в файле (с `None`), у склейки — `Decimal`.""" + + opening_debit: Decimal | None + opening_credit: Decimal | None + turnover_debit: Decimal | None + turnover_credit: Decimal | None + closing_debit: Decimal | None + closing_credit: Decimal | None + + +def _as_is(row: SourceRow) -> _Values: + return _Values( + row.opening_debit, + row.opening_credit, + row.turnover_debit, + row.turnover_credit, + row.closing_debit, + row.closing_credit, + ) + + +def _glued(first: _Amounts, turnover: _Amounts, last: _Amounts) -> _Values: + """§2: `opening_*` — из первого квартала, `turnover_*` — сумма, `closing_*` — из последнего.""" + return _Values( + first.opening_debit, + first.opening_credit, + turnover.turnover_debit, + turnover.turnover_credit, + last.closing_debit, + last.closing_credit, + ) + + +# --------------------------------------------------------------------------- сборка строки + + +@dataclass(frozen=True) +class _Context: + period: str + group_id: uuid.UUID + members: Mapping[uuid.UUID, Entity] + entities: Mapping[uuid.UUID, Entity] + resolver: Resolver + + +QuarterRows = Sequence[tuple[PeriodQuarter, Sequence[SourceRow]]] + + +def _row_order(row: SourceRow) -> tuple[bool, int, uuid.UUID]: + """Порядок строк внутри документа/квартала: номер строки (пустой — в конце), затем `id` (детерминизм).""" + return (row.row_number is None, row.row_number or 0, row.id) + + +def _stored_counterparty(quarters: QuarterRows) -> tuple[SourceRow | None, tuple[str, ...]]: + """Строка-источник `counterparty_id` (D-3, решение №8): первая сопоставленная в самом позднем квартале, где они + есть; строки с другим непустым `counterparty_id` — в `warnings` (пустые — не расхождение).""" + mapped = [(quarter, row) for quarter, rows in quarters for row in rows if row.counterparty_id is not None] + if not mapped: + return None, () + latest = mapped[-1][0] + chosen = next(row for quarter, row in mapped if quarter == latest) + warnings = tuple( + f"{quarter.value}: строка {row.id} сопоставлена с {row.counterparty_id}, в своде — {chosen.counterparty_id}" + for quarter, row in mapped + if row.counterparty_id != chosen.counterparty_id + ) + return chosen, warnings + + +def _compose( # noqa: PLR0913 - сборка строки свода из частей: ЮЛ, источники, представитель, суммы, признак склейки + context: _Context, + entity: Entity, + quarters: QuarterRows, + representative: SourceRow, + values: _Values, + *, + is_cumulative: bool, +) -> ConsolidatedRow: + """Строка свода: «как в файле» — из `representative`; контрагент — хранимый или дорезолв на лету.""" + stored, warnings = _stored_counterparty(quarters) + counterparty_id: uuid.UUID | None + match_kind: CounterpartyMatchKind | None + if stored is not None: + counterparty_id, match_kind, match_stored = stored.counterparty_id, stored.match_kind, True + else: + # дорезолв на чтение (D-3): результат показывается, но не пишется + resolution = context.resolver.resolve(representative.counterparty_inn, representative.counterparty_name) + counterparty_id = resolution.entity_id if resolution is not None else None + match_kind = resolution.kind if resolution is not None else None + match_stored = False + counterparty = None + if counterparty_id is not None: + counterparty = context.entities.get(counterparty_id) or context.members.get(counterparty_id) + return ConsolidatedRow( + entry_ids=tuple(row.id for _, rows in quarters for row in rows), + legal_entity_id=entity.id, + legal_entity_name=entity.name, + legal_entity_inn=entity.inn, + account_code=representative.account_code, + account_root=representative.account_root, + period=context.period, + is_cumulative=is_cumulative, + row_number=representative.row_number, + counterparty_name=representative.counterparty_name, + counterparty_inn=representative.counterparty_inn, + counterparty_id=counterparty_id, + counterparty_entity_name=counterparty.name if counterparty is not None else None, + counterparty_type=classify( + counterparty_id, members=context.members, entities=context.entities, group_id=context.group_id + ), + match_kind=match_kind, + match_stored=match_stored, + opening_debit=values.opening_debit, + opening_credit=values.opening_credit, + turnover_debit=values.turnover_debit, + turnover_credit=values.turnover_credit, + closing_debit=values.closing_debit, + closing_credit=values.closing_credit, + warnings=warnings, + ) + + +def _cumulative_rows( + context: _Context, + entity: Entity, + documents: Sequence[SourceDocument], + rows_by_document: Mapping[uuid.UUID, Sequence[SourceRow]], +) -> list[ConsolidatedRow]: + """Нарастающий документ: строка свода = строка файла (§3 п. 3, 6), суммы как есть.""" + result = [] + for document in documents: + quarter = document.period_quarter or PeriodQuarter.Q1 # select_sources отдаёт только документы с кварталом + for row in rows_by_document.get(document.id, ()): + result.append(_compose(context, entity, [(quarter, [row])], row, _as_is(row), is_cumulative=True)) + return result + + +def _glued_rows( + context: _Context, + entity: Entity, + needed: Sequence[PeriodQuarter], + documents: Sequence[SourceDocument], + rows_by_document: Mapping[uuid.UUID, Sequence[SourceRow]], +) -> list[ConsolidatedRow]: + """Квартальные документы `Q1..Qn`: склейка по `glue_key` (§2, D-2, решение №1).""" + by_key: dict[GlueKey, dict[PeriodQuarter, list[SourceRow]]] = defaultdict(lambda: defaultdict(list)) + for document in documents: + if document.period_quarter is None: # select_sources отдаёт только документы с кварталом + continue + for row in rows_by_document.get(document.id, ()): + by_key[glue_key(row)][document.period_quarter].append(row) + result = [] + first, last = needed[0], needed[-1] + for per_quarter in by_key.values(): + quarters = [(q, sorted(per_quarter[q], key=_row_order)) for q in needed if q in per_quarter] + # «как в файле» и номер строки — из последнего квартала, где ключ есть (минимальный номер строки в нём) + representative = quarters[-1][1][0] + values = _glued( + _Amounts.total(per_quarter.get(first, ())), + _Amounts.total(row for _, rows in quarters for row in rows), + _Amounts.total(per_quarter.get(last, ())), + ) + result.append(_compose(context, entity, quarters, representative, values, is_cumulative=False)) + return result + + +def _row_sort_key(row: ConsolidatedRow) -> tuple[object, ...]: + """D-16: имя ЮЛ, счёт (натурально), номер строки (пустой — в конце), первый `entry_id`.""" + code = row.account_code or "" + return ( + row.legal_entity_name, + row.legal_entity_id, + account_sort_key(code), + code, + row.row_number is None, + row.row_number or 0, + row.entry_ids[0], + ) + + +def build( # noqa: PLR0913 - сигнатура зафиксирована планом: период, ГК, периметр, справочник, документы, строки + *, + year: int, + quarter: PeriodQuarter, + group_id: uuid.UUID, + members: Mapping[uuid.UUID, Entity], + entities: Mapping[uuid.UUID, Entity], + documents: Sequence[SourceDocument], + rows: Sequence[SourceRow], + resolver: Resolver, +) -> Consolidated: + """Свод версии за `year`/`quarter` ГК `group_id` с периметром `members` (D-1…D-3). + + `documents` — привязанные к версии ОСВ-документы (документы не членов периметра защитно игнорируются), + `rows` — их строки (строки документов вне `documents` игнорируются), `entities` — весь справочник без policy + (имя и ГК контрагента). Входной порядок на результат не влияет. + """ + context = _Context(f"{year} {quarter.value}", group_id, members, entities, resolver) + rows_by_document: dict[uuid.UUID, list[SourceRow]] = defaultdict(list) + for row in rows: + rows_by_document[row.document_id].append(row) + for document_rows in rows_by_document.values(): + document_rows.sort(key=_row_order) + pairs: dict[tuple[uuid.UUID, str], list[SourceDocument]] = defaultdict(list) + for document in sorted(documents, key=lambda d: d.id): + if document.legal_entity_id in members: + pairs[(document.legal_entity_id, document.osv_account_code or "")].append(document) + + result: list[ConsolidatedRow] = [] + gaps: list[GapItem] = [] + for (entity_id, account), pair_documents in pairs.items(): + entity = members[entity_id] + selected = select_sources(quarter, pair_documents) + if isinstance(selected, Gap): + gaps.append(GapItem(entity.id, entity.name, account, selected.missing_quarters)) + elif selected.cumulative: + result.extend(_cumulative_rows(context, entity, selected.documents, rows_by_document)) + else: + needed = quarters_through(quarter) + result.extend(_glued_rows(context, entity, needed, selected.documents, rows_by_document)) + result.sort(key=_row_sort_key) + gaps.sort(key=lambda g: (g.legal_entity_name, g.legal_entity_id, account_sort_key(g.account_code), g.account_code)) + return Consolidated(rows=tuple(result), gaps=tuple(gaps), documents_total=len(documents)) + + +# --------------------------------------------------------------------------- фильтры и сводка + + +def _matches_query(row: ConsolidatedRow, q: str | None) -> bool: + text = (q or "").strip() + if not text: + return True + if text.casefold() in (row.counterparty_name or "").casefold(): + return True + return row.counterparty_inn is not None and normalize_inn(row.counterparty_inn).startswith(normalize_inn(text)) + + +def _matches(row: ConsolidatedRow, f: OsvFilter) -> bool: # noqa: PLR0911 - по одному выходу на фильтр + if f.legal_entity_id is not None and row.legal_entity_id != f.legal_entity_id: + return False + if f.account_root is not None and row.account_root != f.account_root: + return False + if f.counterparty_type is not None and row.counterparty_type != f.counterparty_type: + return False + if f.unmapped is not None and (row.counterparty_id is None) != f.unmapped: + return False + if f.match_kinds and row.match_kind not in f.match_kinds: + return False + return _matches_query(row, f.q) + + +def apply_filter(rows: Iterable[ConsolidatedRow], f: OsvFilter) -> list[ConsolidatedRow]: + """Строки, прошедшие все заданные фильтры (порядок сохраняется).""" + return [row for row in rows if _matches(row, f)] + + +def summary(c: Consolidated) -> tuple[int, int, int]: + """Сводка свода: `rows_total`, `unmapped_total` (`counterparty_id` пуст после дорезолва), `auto_matched_total` + (`match_kind` ∈ {`NAME`, `ALIAS`} независимо от `match_stored`).""" + unmapped = sum(1 for row in c.rows if row.counterparty_id is None) + auto_matched = sum(1 for row in c.rows if row.match_kind in AUTO_MATCH_KINDS) + return len(c.rows), unmapped, auto_matched diff --git a/api/src/domain/consolidation_periods.py b/api/src/domain/consolidation_periods.py index 3783760..4948bed 100644 --- a/api/src/domain/consolidation_periods.py +++ b/api/src/domain/consolidation_periods.py @@ -1,13 +1,31 @@ -"""Период консолидации: отчётная дата по году и кварталу (spec consolidations-api «Создание консолидации»). +"""Период консолидации: отчётная дата и выбор источников ОСВ (spec consolidations-api «Создание консолидации»; +spec consolidated-osv «Выбор источников и приведение периодов»; design D-2; §2 `05-domain-rules.md`). -Чистая функция без БД. ASSUMPTION(OQ-40): отчётная дата — первый день месяца, следующего за кварталом. +Чистые функции без БД и ORM: документы приходят структурно (`DocLike` — `Document`, `SourceDocument`, тестовые +dataclass'ы). ASSUMPTION(OQ-40): отчётная дата — первый день месяца, следующего за кварталом. """ import datetime +import uuid +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Generic, Protocol, TypeVar from src.models.enums import PeriodQuarter -__all__ = ["reporting_date"] +__all__ = [ + "QUARTER_ORDER", + "DocLike", + "Gap", + "Sources", + "counts_as_cumulative", + "quarters_through", + "reporting_date", + "select_sources", +] + +#: Порядок кварталов года — единственное определение (T8.5): чек-лист и привязка документов берут его отсюда. +QUARTER_ORDER: tuple[PeriodQuarter, ...] = (PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4) # квартал → (месяц отчётной даты, сдвиг года): Q4 заканчивается 31 декабря, отчётная дата — 1 января следующего года _REPORTING_MONTH: dict[PeriodQuarter, tuple[int, int]] = { @@ -25,3 +43,72 @@ def reporting_date(year: int, quarter: PeriodQuarter) -> datetime.date: """ month, year_shift = _REPORTING_MONTH[quarter] # ASSUMPTION(OQ-40) return datetime.date(year + year_shift, month, 1) + + +def quarters_through(quarter: PeriodQuarter) -> tuple[PeriodQuarter, ...]: + """Кварталы года от `Q1` до `quarter` включительно (квартальные ОСВ, нужные версии за `quarter`).""" + return QUARTER_ORDER[: QUARTER_ORDER.index(quarter) + 1] + + +def counts_as_cumulative(is_cumulative: bool | None) -> bool: + """Нарастающий ли документ: `is_cumulative` true или NULL (ББ/ОФР всегда с начала года, решение №10, R-1); + квартальный — только явный `False`.""" + return is_cumulative is not False + + +class DocLike(Protocol): + """Документ-кандидат в источники (структурный тип): поля — как в DBML `document`.""" + + @property + def id(self) -> uuid.UUID: ... + @property + def period_quarter(self) -> PeriodQuarter | None: ... + @property + def is_cumulative(self) -> bool | None: ... + + +D = TypeVar("D", bound=DocLike) + + +@dataclass(frozen=True) +class Sources(Generic[D]): + """Источники пары (ЮЛ, счёт): нарастающий документ за квартал версии (`cumulative`) либо квартальные `Q1..Qn` + в порядке кварталов.""" + + documents: tuple[D, ...] + cumulative: bool + + +@dataclass(frozen=True) +class Gap: + """Пробел пары (ЮЛ, счёт): нет квартальных ОСВ за `missing_quarters` (в порядке кварталов), строк по паре нет.""" + + missing_quarters: tuple[PeriodQuarter, ...] + + +def select_sources(version_quarter: PeriodQuarter, documents: Sequence[D]) -> Sources[D] | Gap: + """Выбор источников одной пары (ЮЛ, счёт) по документам, привязанным к версии (D-2, §2, OQ-03). + + 1. Нарастающий (`is_cumulative` true/NULL) за квартал версии есть → он один (`cumulative = True`), квартальные + рядом игнорируются (нарастающий полнее по определению). + 2. Иначе нужны квартальные (`is_cumulative = False`) за каждый квартал `Q1..Qn` версии; для версии `Q1` квартальный + `Q1` равнозначен нарастающему. Все есть → `Sources(cumulative=False)` в порядке кварталов (внутри квартала — + порядок входа). + 3. Какого-то квартала нет → `Gap(missing_quarters)`; при отсутствии подходящих документов — все кварталы `Q1..Qn`. + + Документы без квартала, нарастающие за другой квартал и квартальные позже версии источниками не бывают (такие + документы не привязываются, D-5; функция их защитно игнорирует). Дублей на квартал привязка не допускает (R-1): + если они всё же пришли, возвращаются все. + """ + cumulative = tuple( + d for d in documents if counts_as_cumulative(d.is_cumulative) and d.period_quarter == version_quarter + ) + if cumulative: + return Sources(documents=cumulative, cumulative=True) + needed = quarters_through(version_quarter) + quarterly = [d for d in documents if d.is_cumulative is False and d.period_quarter in needed] + missing = tuple(quarter for quarter in needed if all(d.period_quarter != quarter for d in quarterly)) + if missing: + return Gap(missing_quarters=missing) + ordered = tuple(d for quarter in needed for d in quarterly if d.period_quarter == quarter) + return Sources(documents=ordered, cumulative=False) diff --git a/api/src/domain/osv_mapping.py b/api/src/domain/osv_mapping.py index 06119e0..7f7e030 100644 --- a/api/src/domain/osv_mapping.py +++ b/api/src/domain/osv_mapping.py @@ -33,6 +33,7 @@ __all__ = [ "MappedSheet", "Resolution", "account_root", + "account_sort_key", "build_entries", "counterparty_check", "document_fields", @@ -51,6 +52,7 @@ _NUMERIC_SEGMENT_RE = re.compile(r"[0-9]{1,3}") _ROOT_SEGMENT_RE = re.compile(r"[0-9]+") _NON_DIGITS_RE = re.compile(r"[^0-9]") _SEGMENT_SEPARATOR = "." +_ACCOUNT_SPLIT_RE = re.compile(r"[.\s]+") @dataclass(frozen=True) @@ -126,6 +128,13 @@ def account_root(code: str) -> str | None: return first if _ROOT_SEGMENT_RE.fullmatch(first) else None +def account_sort_key(code: str) -> tuple[tuple[int, int, str], ...]: + """Натуральный ключ сортировки кода счёта: числовые сегменты — по значению (`01 < 9 < 58 < 58.03 < 60 < 60.2 < + 60.10`), буквенные — по тексту после числовых. Одно определение порядка счетов для чек-листа и свода (№48).""" + parts = (part for part in _ACCOUNT_SPLIT_RE.split(code) if part) + return tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in parts) + + def _sums(row: OsvRow) -> tuple[Decimal | None, ...]: return ( row.opening_debit, diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 14e8228..79a6396 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -8,11 +8,11 @@ import datetime import uuid from decimal import Decimal -from typing import Literal from pydantic import BaseModel, Field from src.domain.checklist import CellStatus +from src.domain.consolidated_osv import CounterpartyType # признак контрагента §3: одно определение (T2.8) from src.models.enums import ( ConsolidationApproach, ConsolidationScenario, @@ -27,8 +27,6 @@ YEAR_MAX = 2100 DEFAULT_OSV_LIMIT = 500 MAX_OSV_LIMIT = 2000 MAX_TAKEOVER_REASON = 1000 -# признак контрагента относительно версии (§3): ГСК — в периметре, ГК — в группе, но вне периметра, иначе внешний -CounterpartyType = Literal["GSK", "GK", "EXTERNAL"] class RefOut(BaseModel): diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index dd0bbce..301a110 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -27,6 +27,7 @@ from sqlalchemy import delete, select from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession +from src.domain.consolidation_periods import QUARTER_ORDER, counts_as_cumulative from src.domain.policy import IN_WORK_STATUSES from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter @@ -45,7 +46,6 @@ __all__ = [ #: Статусы, из которых подтверждение привязанного документа переводит версию в `DATA_CORRECTION` (D-6). MOVES_TO_CORRECTION = frozenset({ConsolidationStatus.INITIATED, ConsolidationStatus.DOCUMENTS_PROCESSING}) -_QUARTER_ORDER = {quarter: position for position, quarter in enumerate(PeriodQuarter)} BindingKey = tuple[uuid.UUID, DocumentType, str | None, PeriodQuarter | None, bool] @@ -56,8 +56,8 @@ def _rowcount(result: Any) -> int: def _cumulative(document: Document) -> bool: - """Нарастающий ли документ: `is_cumulative` true или NULL (ББ/ОФР всегда за период с начала года, решение №10).""" - return document.is_cumulative is not False + """Нарастающий ли документ (R-1, решение №10): `consolidation_periods.counts_as_cumulative` — NULL нарастающий.""" + return counts_as_cumulative(document.is_cumulative) def _key(document: Document) -> BindingKey: @@ -92,7 +92,7 @@ def fits( return False if _cumulative(document): return document.period_quarter == consolidation.quarter - return _QUARTER_ORDER[document.period_quarter] <= _QUARTER_ORDER[consolidation.quarter] + return QUARTER_ORDER.index(document.period_quarter) <= QUARTER_ORDER.index(consolidation.quarter) async def _member_ids(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]: diff --git a/api/tests/test_domain_consolidated_osv.py b/api/tests/test_domain_consolidated_osv.py new file mode 100644 index 0000000..f2ed683 --- /dev/null +++ b/api/tests/test_domain_consolidated_osv.py @@ -0,0 +1,499 @@ +"""Сводная ОСВ версии (Срез 4, задача 4.1; spec consolidated-osv; D-1, D-2, D-3, D-16): чистая функция без БД.""" + +import dataclasses +import random +import uuid +from collections.abc import Sequence +from decimal import Decimal + +import pytest + +from src.domain.consolidated_osv import ( + Consolidated, + ConsolidatedRow, + CounterpartyType, + Entity, + GapItem, + OsvFilter, + SourceDocument, + SourceRow, + apply_filter, + build, + classify, + summary, +) +from src.domain.osv_mapping import Resolution +from src.models.enums import CounterpartyMatchKind, PeriodQuarter + +Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3 +INN, NAME, ALIAS, MANUAL, PROPAGATED = ( + CounterpartyMatchKind.INN, + CounterpartyMatchKind.NAME, + CounterpartyMatchKind.ALIAS, + CounterpartyMatchKind.MANUAL, + CounterpartyMatchKind.PROPAGATED, +) +GSK, GK, EXTERNAL = CounterpartyType.GSK, CounterpartyType.GK, CounterpartyType.EXTERNAL +ZERO = Decimal("0") + +GROUP = uuid.uuid4() +RB = Entity(uuid.uuid4(), "АО Русский бисквит", "3528052718", GROUP) +SCH = Entity(uuid.uuid4(), "ООО Пекарня Счастливочная", "3528000001", GROUP) +CHKF = Entity(uuid.uuid4(), "ООО ЧКФ", "3528000002", GROUP) # в ГК, не в периметре версии +OUTSIDER = Entity(uuid.uuid4(), "ООО Внешний поставщик", "7700000003", uuid.uuid4()) +LONE = Entity(uuid.uuid4(), "ИП Монозаёмщик", "770000000004", None) +MEMBERS = {RB.id: RB, SCH.id: SCH} +ENTITIES = {entity.id: entity for entity in (RB, SCH, CHKF, OUTSIDER, LONE)} + + +class _Resolver: + """Справочник для дорезолва: ответы по (ИНН, имя), журнал вызовов.""" + + def __init__(self, answers: dict[tuple[str | None, str | None], Resolution] | None = None) -> None: + self.answers = answers or {} + self.calls: list[tuple[str | None, str | None]] = [] + + def resolve(self, inn: str | None, name: str | None) -> Resolution | None: + self.calls.append((inn, name)) + return self.answers.get((inn, name)) + + +def _doc( + entity: Entity = RB, account: str = "60", quarter: PeriodQuarter = Q3, *, cumulative: bool | None = False +) -> SourceDocument: + return SourceDocument(uuid.uuid4(), entity.id, account, quarter, cumulative) + + +_AMOUNT_FIELDS = { + "od": "opening_debit", + "oc": "opening_credit", + "td": "turnover_debit", + "tc": "turnover_credit", + "cd": "closing_debit", + "cc": "closing_credit", +} + + +def _amounts(**short: str | None) -> dict[str, Decimal | None]: + """Суммы строки по коротким именам (`od`, `oc`, `td`, `tc`, `cd`, `cc`): не заданные — ноль, `None` — пусто.""" + assert set(short) <= set(_AMOUNT_FIELDS), set(short) - set(_AMOUNT_FIELDS) + return { + field: None if (value := short.get(key, "0")) is None else Decimal(value) + for key, field in _AMOUNT_FIELDS.items() + } + + +def _row( # noqa: PLR0913 - тестовая фабрика строки osv_entry + document: SourceDocument, + name: str | None = "А", + *, + inn: str | None = None, + row_number: int | None = 1, + account_code: str | None = None, + counterparty_id: uuid.UUID | None = None, + match_kind: CounterpartyMatchKind | None = None, + **amounts: str | None, +) -> SourceRow: + code = account_code if account_code is not None else document.osv_account_code + return SourceRow( + id=uuid.uuid4(), + document_id=document.id, + row_number=row_number, + account_code=code, + account_root=(code or "").split(".")[0] or None, + counterparty_name=name, + counterparty_inn=inn, + counterparty_id=counterparty_id, + match_kind=match_kind, + **_amounts(**amounts), + ) + + +def _build( + documents: Sequence[SourceDocument], + rows: Sequence[SourceRow], + *, + quarter: PeriodQuarter = Q3, + resolver: _Resolver | None = None, +) -> Consolidated: + return build( + year=2024, + quarter=quarter, + group_id=GROUP, + members=MEMBERS, + entities=ENTITIES, + documents=documents, + rows=rows, + resolver=resolver or _Resolver(), + ) + + +def _sums(row: ConsolidatedRow) -> tuple[Decimal | None, ...]: + return ( + row.opening_debit, + row.opening_credit, + row.turnover_debit, + row.turnover_credit, + row.closing_debit, + row.closing_credit, + ) + + +# --------------------------------------------------------------------------- приведение периодов + + +def test_three_quarters_are_glued_into_cumulative_row() -> None: + """Сценарий «Три квартала»: opening из Q1, turnover = Σ, closing из Q3; entry_ids — три источника.""" + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + r1 = _row(q1, od="100", td="200", cd="300") + r2 = _row(q2, od="300", td="150", cd="450") + r3 = _row(q3, od="450", td="50", cd="500") + result = _build([q3, q1, q2], [r3, r2, r1]) + assert len(result.rows) == 1 + row = result.rows[0] + assert (row.opening_debit, row.turnover_debit, row.closing_debit) == (Decimal(100), Decimal(400), Decimal(500)) + assert (row.opening_credit, row.turnover_credit, row.closing_credit) == (ZERO, ZERO, ZERO) + assert row.period == "2024 Q3" + assert row.is_cumulative is False + assert row.entry_ids == (r1.id, r2.id, r3.id) + assert (row.legal_entity_id, row.legal_entity_name, row.legal_entity_inn) == (RB.id, RB.name, RB.inn) + assert (row.account_code, row.account_root, row.counterparty_name) == ("60", "60", "А") + assert result.gaps == () + assert result.documents_total == 3 # noqa: PLR2004 + + +def test_counterparty_only_in_middle_quarter() -> None: + """Сценарий «Контрагент только во втором квартале»: opening/closing — нули, turnover — из Q2.""" + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + only = _row(q2, "Б", row_number=4, td="70", cd="70") + result = _build([q1, q2, q3], [_row(q1), only, _row(q3)]) + row = next(r for r in result.rows if r.counterparty_name == "Б") + assert _sums(row) == (ZERO, ZERO, Decimal(70), ZERO, ZERO, ZERO) + assert row.entry_ids == (only.id,) + assert row.row_number == 4 # noqa: PLR2004 - ключа нет в последнем квартале: номер из последнего, где он есть + + +def test_gap_skips_pair_and_lists_missing_quarters() -> None: + """Сценарий «Пробел»: Q1 и Q3 есть, Q2 нет → строк по 60 нет, пробел с `Q2`; соседний счёт строится.""" + q1, q3 = _doc(quarter=Q1), _doc(quarter=Q3) + cumulative_62 = _doc(account="62", cumulative=True) + result = _build([q1, q3, cumulative_62], [_row(q1), _row(q3), _row(cumulative_62, "В")]) + assert [r.account_code for r in result.rows] == ["62"] + assert result.gaps == (GapItem(RB.id, RB.name, "60", (Q2,)),) + assert result.documents_total == 3 # noqa: PLR2004 + + +def test_gap_of_cumulative_from_earlier_quarter() -> None: + """Review Focus 2 (защитно): нарастающий Q2 при версии Q3 — не источник; пара — пробел Q1–Q3.""" + stale = _doc(account="62", quarter=Q2, cumulative=True) + result = _build([stale], [_row(stale)]) + assert result.rows == () + assert result.gaps == (GapItem(RB.id, RB.name, "62", (Q1, Q2, Q3)),) + + +def test_cumulative_next_to_quarterly_rows_as_is() -> None: + """Сценарий «Нарастающий рядом с квартальными»: строки нарастающего один в один (суммы `None` — как есть).""" + quarterly = [_doc(account="62", quarter=q) for q in (Q1, Q2, Q3)] + cumulative = _doc(account="62", cumulative=True) + source = _row(cumulative, row_number=9, od="1.50", oc=None, td="2", tc=None, cd="3.50", cc=None) + result = _build([*quarterly, cumulative], [*(_row(d, td="999") for d in quarterly), source]) + assert len(result.rows) == 1 + row = result.rows[0] + assert row.is_cumulative is True + assert row.entry_ids == (source.id,) + assert row.row_number == 9 # noqa: PLR2004 + assert _sums(row) == (Decimal("1.50"), None, Decimal(2), None, Decimal("3.50"), None) + assert result.gaps == () + + +def test_cumulative_document_keeps_duplicate_keys_separate() -> None: + """Решение №1: дубли ключа в нарастающем документе не суммируются — строки свода = строки файла (§3 п. 6).""" + cumulative = _doc(cumulative=True) + first, second = _row(cumulative, row_number=1, td="10"), _row(cumulative, row_number=2, td="20") + result = _build([cumulative], [second, first]) + assert [r.entry_ids for r in result.rows] == [(first.id,), (second.id,)] + + +def test_q1_version_quarterly_document_is_single_quarter_glue() -> None: + q1 = _doc(quarter=Q1) + source = _row(q1, od="5", td="6", cd="11") + result = _build([q1], [source], quarter=Q1) + assert len(result.rows) == 1 + assert result.rows[0].period == "2024 Q1" + assert result.rows[0].is_cumulative is False + assert _sums(result.rows[0]) == (Decimal(5), ZERO, Decimal(6), ZERO, Decimal(11), ZERO) + + +def test_duplicate_key_within_quarter_is_summed() -> None: + """Решение №1: две строки одного ключа в квартале суммируются, `entry_ids` — обе; row_number — минимальный.""" + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + a = _row(q1, row_number=3, od="10", td="1") + b = _row(q1, row_number=8, od="20", td="2") + c = _row(q2, td="4") + d = _row(q3, row_number=7, td="8", cd="100") + e = _row(q3, row_number=5, td="16", cd="200") + result = _build([q1, q2, q3], [b, a, c, d, e]) + assert len(result.rows) == 1 + row = result.rows[0] + assert (row.opening_debit, row.turnover_debit, row.closing_debit) == (Decimal(30), Decimal(31), Decimal(300)) + assert set(row.entry_ids) == {a.id, b.id, c.id, d.id, e.id} + assert row.entry_ids == (a.id, b.id, c.id, e.id, d.id) # по кварталам, внутри — по номеру строки + assert row.row_number == 5 # noqa: PLR2004 - минимум по последнему кварталу, не по Q1 + + +def test_none_amounts_count_as_zero_when_glued() -> None: + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [ + _row(q1, od=None, oc="7", td=None, tc="1", cd=None, cc=None), + _row(q2, td="2", tc=None), + _row(q3, td=None, tc=None, cd="2", cc=None), + ] + row = _build([q1, q2, q3], rows).rows[0] + assert _sums(row) == (ZERO, Decimal(7), Decimal(2), Decimal(1), Decimal(2), ZERO) + assert all(isinstance(value, Decimal) for value in _sums(row)) + + +def test_glue_key_strips_name_and_inn_whitespace() -> None: + """Ключ — (счёт строки, имя.strip(), ИНН без пробелов); «как в файле» — из последнего квартала.""" + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [ + _row(q1, " ООО Ромашка ", inn="7701 000 001", td="1"), + _row(q2, "ООО Ромашка", inn=" 7701000001", td="2"), + _row(q3, "ООО Ромашка ", inn="7701000001 ", td="4"), + ] + result = _build([q1, q2, q3], rows) + assert len(result.rows) == 1 + assert result.rows[0].turnover_debit == Decimal(7) + assert (result.rows[0].counterparty_name, result.rows[0].counterparty_inn) == ("ООО Ромашка ", "7701000001 ") + + +def test_glue_key_separates_inn_and_subaccount() -> None: + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [ + *(_row(d, "А", inn="7701000001") for d in (q1, q2, q3)), + *(_row(d, "А", inn="7701000002") for d in (q1, q2, q3)), + *(_row(d, "А", inn="7701000001", account_code="60.02") for d in (q1, q2, q3)), + *(_row(d, "А", inn=None) for d in (q1, q2, q3)), + ] + result = _build([q1, q2, q3], rows) + assert len(result.rows) == 4 # noqa: PLR2004 + assert all(len(r.entry_ids) == 3 for r in result.rows) # noqa: PLR2004 + + +def test_rows_of_unknown_documents_and_non_members_are_ignored() -> None: + q3 = _doc(cumulative=True) + foreign = _doc(CHKF, cumulative=True) # защитно: документ не члена периметра в свод не попадает + orphan = _row(_doc(cumulative=True)) # строка документа, которого нет во входе + result = _build([q3, foreign], [_row(q3), _row(foreign), orphan]) + assert [r.legal_entity_id for r in result.rows] == [RB.id] + assert result.gaps == () + + +def test_empty_input() -> None: + assert _build([], []) == Consolidated(rows=(), gaps=(), documents_total=0) + assert summary(_build([], [])) == (0, 0, 0) + + +# --------------------------------------------------------------------------- контрагент: id, признак, дорезолв + + +def test_counterparty_id_from_last_quarter_with_warning_on_divergence() -> None: + """Решение №8: расхождение `counterparty_id` между кварталами — значение последнего квартала + `warnings`.""" + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + early = _row(q1, counterparty_id=CHKF.id, match_kind=INN) + unmapped = _row(q2) + late = _row(q3, counterparty_id=SCH.id, match_kind=MANUAL) + resolver = _Resolver() + row = _build([q1, q2, q3], [early, unmapped, late], resolver=resolver).rows[0] + assert (row.counterparty_id, row.match_kind, row.match_stored) == (SCH.id, MANUAL, True) + assert (row.counterparty_type, row.counterparty_entity_name) == (GSK, SCH.name) + assert len(row.warnings) == 1 + assert str(early.id) in row.warnings[0] + assert str(CHKF.id) in row.warnings[0] + assert resolver.calls == [] + + +def test_counterparty_id_from_earlier_quarter_when_last_is_empty() -> None: + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [_row(q1, counterparty_id=SCH.id, match_kind=NAME), _row(q2), _row(q3)] + row = _build([q1, q2, q3], rows).rows[0] + assert (row.counterparty_id, row.match_kind, row.match_stored, row.warnings) == (SCH.id, NAME, True, ()) + + +def test_same_counterparty_in_all_quarters_has_no_warning() -> None: + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [_row(d, counterparty_id=SCH.id, match_kind=PROPAGATED) for d in (q1, q2, q3)] + row = _build([q1, q2, q3], rows).rows[0] + assert (row.counterparty_id, row.match_kind, row.warnings) == (SCH.id, PROPAGATED, ()) + + +def test_counterparty_types_on_rb_fixture_shape() -> None: + """Сценарий «Признаки на фикстурах РБ»: Счастливочная → РБ по ИНН — GSK; РБ → «Пекарня Счастливочная ООО» без + ИНН не сопоставлена — EXTERNAL; РБ → ЧКФ (в ГК, не в ГСК) — GK.""" + sch_60 = _doc(SCH, cumulative=True) + rb_60 = _doc(RB, cumulative=True) + to_rb = _row(sch_60, "Русский бисквит", inn="3528052718", counterparty_id=RB.id, match_kind=INN) + to_sch = _row(rb_60, "Пекарня Счастливочная ООО", row_number=1) + to_chkf = _row(rb_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id, match_kind=NAME) + result = _build([sch_60, rb_60], [to_rb, to_sch, to_chkf]) + by_entry = {r.entry_ids[0]: r for r in result.rows} + assert (by_entry[to_rb.id].counterparty_type, by_entry[to_rb.id].match_kind) == (GSK, INN) + assert by_entry[to_rb.id].match_stored is True + assert by_entry[to_sch.id].counterparty_type == EXTERNAL + assert (by_entry[to_sch.id].counterparty_id, by_entry[to_sch.id].match_kind) == (None, None) + assert by_entry[to_sch.id].match_stored is False + assert by_entry[to_sch.id].counterparty_entity_name is None + assert by_entry[to_chkf.id].counterparty_type == GK + assert by_entry[to_chkf.id].counterparty_entity_name == CHKF.name + + +def test_classify_counterparty_type() -> None: + def kind(counterparty_id: uuid.UUID | None) -> CounterpartyType: + return classify(counterparty_id, members=MEMBERS, entities=ENTITIES, group_id=GROUP) + + assert (kind(SCH.id), kind(RB.id), kind(CHKF.id)) == (GSK, GSK, GK) + assert (kind(OUTSIDER.id), kind(LONE.id), kind(None), kind(uuid.uuid4())) == (EXTERNAL,) * 4 + + +def test_resolve_on_read_is_not_stored() -> None: + """Сценарий «Дорезолв на чтение»: пустой `counterparty_id` резолвится на лету, `match_stored = false`, + входная строка не меняется.""" + cumulative = _doc(cumulative=True) + source = _row(cumulative, "Пекарня Счастливочная ООО", inn=" ") + resolver = _Resolver({(" ", "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)}) + row = _build([cumulative], [source], resolver=resolver).rows[0] + assert (row.counterparty_id, row.match_kind, row.match_stored) == (SCH.id, ALIAS, False) + assert (row.counterparty_type, row.counterparty_entity_name) == (GSK, SCH.name) + assert resolver.calls == [(" ", "Пекарня Счастливочная ООО")] + assert source.counterparty_id is None + assert source.match_kind is None + + +def test_resolve_on_read_for_glued_row_uses_last_quarter_values() -> None: + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [_row(q1, " ЧКФ", inn="3528 000002"), _row(q2, "ЧКФ", inn="3528000002"), _row(q3, "ЧКФ ", inn="3528000002")] + resolver = _Resolver({("3528000002", "ЧКФ "): Resolution(CHKF.id, INN)}) + result = _build([q1, q2, q3], rows, resolver=resolver) + assert len(result.rows) == 1 + row = result.rows[0] + assert resolver.calls == [("3528000002", "ЧКФ ")] + assert (row.counterparty_id, row.match_kind, row.match_stored, row.counterparty_type) == (CHKF.id, INN, False, GK) + + +def test_rows_are_frozen() -> None: + row = _build([cumulative := _doc(cumulative=True)], [_row(cumulative)]).rows[0] + with pytest.raises(dataclasses.FrozenInstanceError): + row.counterparty_id = SCH.id # type: ignore[misc] + + +# --------------------------------------------------------------------------- порядок + + +def test_rows_order_entity_name_account_row_number_entry_id() -> None: + """Порядок: имя ЮЛ, счёт (натурально: 9 < 60 < 60.2 < 60.10), номер строки (пустой — в конце), первый entry_id.""" + rb_9, rb_60, sch_60 = _doc(RB, "9", cumulative=True), _doc(RB, "60", cumulative=True), _doc(SCH, cumulative=True) + rows = [ + _row(sch_60, "С1", row_number=1), + _row(rb_60, "Р3", row_number=None, account_code="60.10"), + _row(rb_60, "Р2", row_number=2, account_code="60.10"), + _row(rb_60, "Р1", row_number=5, account_code="60.2"), + _row(rb_60, "Р0", row_number=7, account_code="60"), + _row(rb_9, "Р9", row_number=100), + ] + tied = sorted( + [_row(rb_60, "Т1", row_number=3, account_code="60.10"), _row(rb_60, "Т2", row_number=3, account_code="60.10")], + key=lambda r: r.id, + ) + result = _build([rb_9, rb_60, sch_60], [*rows, *reversed(tied)]) + names = [r.counterparty_name for r in result.rows] + assert names == ["Р9", "Р0", "Р1", "Р2", tied[0].counterparty_name, tied[1].counterparty_name, "Р3", "С1"] + + +def test_build_is_deterministic_for_shuffled_input() -> None: + documents = [ + _doc(entity, account, quarter) for entity in (RB, SCH) for account in ("60", "62") for quarter in (Q1, Q2, Q3) + ] + rows = [_row(d, name, row_number=n, td=str(n)) for d in documents for n, name in enumerate(("А", "Б", "В"))] + expected = _build(documents, rows) + shuffled_docs, shuffled_rows = documents[:], rows[:] + random.Random(7).shuffle(shuffled_docs) + random.Random(11).shuffle(shuffled_rows) + assert _build(shuffled_docs, shuffled_rows) == expected + assert len(expected.rows) == 12 # noqa: PLR2004 - 2 ЮЛ × 2 счёта × 3 контрагента + + +def test_gaps_are_ordered_by_entity_name_and_account() -> None: + documents = [_doc(SCH, "62", Q1), _doc(RB, "62", Q1), _doc(RB, "60", Q2)] + result = _build(documents, []) + assert [(g.legal_entity_name, g.account_code, g.missing_quarters) for g in result.gaps] == [ + (RB.name, "60", (Q1, Q3)), + (RB.name, "62", (Q2, Q3)), + (SCH.name, "62", (Q2, Q3)), + ] + + +# --------------------------------------------------------------------------- фильтры и сводка + + +def _filter_fixture() -> tuple[ConsolidatedRow, ...]: + rb, sch = _doc(RB, "60", cumulative=True), _doc(SCH, "62", cumulative=True) + rows = [ + _row(rb, "ООО Ромашка", inn="7701000001", row_number=1), # EXTERNAL, не сопоставлена + _row(rb, "Пекарня Счастливочная ООО", row_number=2), # ALIAS дорезолвом + _row(rb, "ЧКФ", row_number=3, account_code="60.01", counterparty_id=CHKF.id, match_kind=NAME), # GK, NAME + _row(sch, "Русский бисквит", inn="3528052718", counterparty_id=RB.id, match_kind=INN), # GSK, INN + _row(sch, "Внешний", row_number=2, counterparty_id=OUTSIDER.id, match_kind=MANUAL), # EXTERNAL + ] + resolver = _Resolver({(None, "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)}) + return _build([rb, sch], rows, resolver=resolver).rows + + +def _names(rows: Sequence[ConsolidatedRow]) -> list[str | None]: + return [r.counterparty_name for r in rows] + + +def test_empty_filter_returns_all_rows() -> None: + rows = _filter_fixture() + assert apply_filter(rows, OsvFilter()) == list(rows) + + +@pytest.mark.parametrize( + ("osv_filter", "expected"), + [ + (OsvFilter(legal_entity_id=SCH.id), ["Русский бисквит", "Внешний"]), + (OsvFilter(account_root="62"), ["Русский бисквит", "Внешний"]), + (OsvFilter(account_root="6"), []), + (OsvFilter(counterparty_type=GSK), ["Пекарня Счастливочная ООО", "Русский бисквит"]), + (OsvFilter(counterparty_type=GK), ["ЧКФ"]), + (OsvFilter(counterparty_type=EXTERNAL), ["ООО Ромашка", "Внешний"]), + (OsvFilter(unmapped=True), ["ООО Ромашка"]), + (OsvFilter(unmapped=False), ["Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]), + (OsvFilter(match_kinds=frozenset({NAME, ALIAS})), ["Пекарня Счастливочная ООО", "ЧКФ"]), + (OsvFilter(match_kinds=frozenset({MANUAL})), ["Внешний"]), + (OsvFilter(q="рОмАш"), ["ООО Ромашка"]), + (OsvFilter(q=" бисквит "), ["Русский бисквит"]), + (OsvFilter(q="7701"), ["ООО Ромашка"]), + (OsvFilter(q="1000001"), []), # ИНН — только префикс + (OsvFilter(q=" "), ["ООО Ромашка", "Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]), + (OsvFilter(legal_entity_id=RB.id, counterparty_type=EXTERNAL), ["ООО Ромашка"]), + (OsvFilter(legal_entity_id=RB.id, match_kinds=frozenset({ALIAS}), q="пекарня"), ["Пекарня Счастливочная ООО"]), + (OsvFilter(account_root="60", unmapped=True, q="ЧКФ"), []), + ], +) +def test_apply_filter(osv_filter: OsvFilter, expected: list[str]) -> None: + assert _names(apply_filter(_filter_fixture(), osv_filter)) == expected + + +def test_summary_counts_and_matches_filters() -> None: + """Сценарии «Только несопоставленные» и «Фильтр по способу»: total фильтра = счётчик сводки.""" + rows = _filter_fixture() + result = Consolidated(rows=rows, gaps=(), documents_total=2) + rows_total, unmapped_total, auto_matched_total = summary(result) + assert (rows_total, unmapped_total, auto_matched_total) == (5, 1, 2) + assert len(apply_filter(rows, OsvFilter(unmapped=True))) == unmapped_total + assert len(apply_filter(rows, OsvFilter(match_kinds=frozenset({NAME, ALIAS})))) == auto_matched_total + assert all(r.counterparty_id is None for r in apply_filter(rows, OsvFilter(unmapped=True))) + + +def test_counterparty_type_is_str_enum() -> None: + assert [t.value for t in CounterpartyType] == ["GSK", "GK", "EXTERNAL"] diff --git a/api/tests/test_domain_consolidation_periods.py b/api/tests/test_domain_consolidation_periods.py index 7a04477..501af6a 100644 --- a/api/tests/test_domain_consolidation_periods.py +++ b/api/tests/test_domain_consolidation_periods.py @@ -1,12 +1,24 @@ -"""Отчётная дата консолидации по году и кварталу (spec consolidations-api «Создание консолидации»; OQ-40).""" +"""Период консолидации (Срез 4): отчётная дата (OQ-40) и выбор источников ОСВ на пару (ЮЛ, счёт) — D-2, §2.""" import datetime +import uuid +from dataclasses import dataclass import pytest -from src.domain.consolidation_periods import reporting_date +from src.domain.consolidation_periods import ( + QUARTER_ORDER, + Gap, + Sources, + counts_as_cumulative, + quarters_through, + reporting_date, + select_sources, +) from src.models.enums import PeriodQuarter +Q1, Q2, Q3, Q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4 + @pytest.mark.parametrize( ("year", "quarter", "expected"), @@ -20,3 +32,86 @@ from src.models.enums import PeriodQuarter ) def test_reporting_date_is_first_day_after_quarter(year: int, quarter: PeriodQuarter, expected: datetime.date) -> None: assert reporting_date(year, quarter) == expected + + +@dataclass(frozen=True) +class _Doc: + """Документ для `select_sources`: структурно подходит под `DocLike` (как `Document` и `SourceDocument`).""" + + period_quarter: PeriodQuarter | None + is_cumulative: bool | None + id: uuid.UUID = uuid.UUID(int=0) + + +def _doc(quarter: PeriodQuarter | None, cumulative: bool | None = False) -> _Doc: + return _Doc(quarter, cumulative, uuid.uuid4()) + + +def test_quarter_order_and_quarters_through() -> None: + assert QUARTER_ORDER == (Q1, Q2, Q3, Q4) + assert quarters_through(Q1) == (Q1,) + assert quarters_through(Q3) == (Q1, Q2, Q3) + assert quarters_through(Q4) == QUARTER_ORDER + + +def test_counts_as_cumulative_null_is_cumulative() -> None: + """R-1, решение №10: NULL (ББ/ОФР) — нарастающий; только `False` — квартальный.""" + assert counts_as_cumulative(True) is True + assert counts_as_cumulative(None) is True + assert counts_as_cumulative(False) is False + + +def test_cumulative_of_version_quarter_is_single_source() -> None: + cumulative = _doc(Q3, True) + assert select_sources(Q3, [cumulative]) == Sources(documents=(cumulative,), cumulative=True) + + +def test_null_cumulative_flag_of_version_quarter_counts_as_cumulative() -> None: + document = _doc(Q3, None) + assert select_sources(Q3, [document]) == Sources(documents=(document,), cumulative=True) + + +def test_quarterly_q1_to_q3_are_sources_in_quarter_order() -> None: + q1, q2, q3 = _doc(Q1), _doc(Q2), _doc(Q3) + assert select_sources(Q3, [q3, q1, q2]) == Sources(documents=(q1, q2, q3), cumulative=False) + + +def test_missing_middle_quarter_is_gap() -> None: + assert select_sources(Q3, [_doc(Q1), _doc(Q3)]) == Gap(missing_quarters=(Q2,)) + + +def test_q1_version_quarterly_q1_is_enough() -> None: + """Для версии Q1 квартальный Q1 равнозначен нарастающему: источник есть, пробела нет.""" + q1 = _doc(Q1) + assert select_sources(Q1, [q1]) == Sources(documents=(q1,), cumulative=False) + + +def test_cumulative_beats_quarterly_of_same_pair() -> None: + """Review Focus 2: квартальные Q1–Q3 и нарастающий Q3 — источник один, нарастающий.""" + cumulative = _doc(Q3, True) + assert select_sources(Q3, [_doc(Q1), _doc(Q2), _doc(Q3), cumulative]) == Sources((cumulative,), cumulative=True) + + +def test_cumulative_beats_quarterly_with_gap() -> None: + cumulative = _doc(Q3, True) + assert select_sources(Q3, [_doc(Q1), cumulative]) == Sources((cumulative,), cumulative=True) + + +def test_cumulative_of_earlier_quarter_is_ignored() -> None: + """Review Focus 2 (защитно): нарастающий Q2 при версии Q3 не привязывается, но и функция его не берёт.""" + assert select_sources(Q3, [_doc(Q2, True)]) == Gap(missing_quarters=(Q1, Q2, Q3)) + + +def test_cumulative_of_earlier_quarter_does_not_fill_gap_of_quarterly() -> None: + assert select_sources(Q3, [_doc(Q1), _doc(Q2, True), _doc(Q3)]) == Gap(missing_quarters=(Q2,)) + + +def test_quarterly_after_version_and_without_quarter_are_ignored() -> None: + q1, q2, q3 = _doc(Q1), _doc(Q2), _doc(Q3) + assert select_sources(Q3, [q1, q2, q3, _doc(Q4), _doc(None), _doc(None, True)]) == Sources((q1, q2, q3), False) + assert select_sources(Q2, [_doc(Q3)]) == Gap(missing_quarters=(Q1, Q2)) + + +def test_no_documents_is_gap_of_all_quarters() -> None: + assert select_sources(Q3, []) == Gap(missing_quarters=(Q1, Q2, Q3)) + assert select_sources(Q1, []) == Gap(missing_quarters=(Q1,)) diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py index 92068a8..906bc92 100644 --- a/api/tests/test_schemas_consolidations.py +++ b/api/tests/test_schemas_consolidations.py @@ -8,7 +8,9 @@ import pytest from pydantic import ValidationError from src.domain.checklist import CellStatus, EntityFilesState +from src.domain.consolidated_osv import CounterpartyType as DomainCounterpartyType from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter +from src.schemas import consolidations as schemas from src.schemas.consolidations import ( ChecklistCellOut, ConsolidatedRowOut, @@ -89,3 +91,11 @@ def test_checklist_cell_status_is_the_domain_enum() -> None: assert cell.status is status with pytest.raises(ValidationError): ChecklistCellOut(status="UNKNOWN", missing_quarters=[], document_ids=[]) # type: ignore[arg-type] + + +def test_counterparty_type_is_the_domain_enum() -> None: + """Признак контрагента в схеме — `domain.consolidated_osv.CounterpartyType` (одно определение, T2.8).""" + assert schemas.CounterpartyType is DomainCounterpartyType + assert OsvFilters(counterparty_type="GK").counterparty_type is DomainCounterpartyType.GK + with pytest.raises(ValidationError): + OsvFilters(counterparty_type="OTHER") # type: ignore[arg-type] diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 06a9a3d..494016f 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -187,9 +187,9 @@ - `consolidated_osv.py`: входные dataclass'ы `SourceRow` (из `OsvEntry`: `id`, `document_id`, `row_number`, `account_code`, `account_root`, `counterparty_name`, `counterparty_inn`, `counterparty_id`, `match_kind`, шесть сумм), `SourceDocument` (`id`, `legal_entity_id`, `osv_account_code`, `period_quarter`, `is_cumulative`), `Entity` (`id`, `name`, `inn`, `company_group_id`); `class CounterpartyType(StrEnum): GSK, GK, EXTERNAL`; `@dataclass(frozen=True) class ConsolidatedRow` (поля D-16 + `warnings: tuple[str, ...]`); `@dataclass(frozen=True) class GapItem(legal_entity_id, legal_entity_name, account_code, missing_quarters)`; `@dataclass(frozen=True) class Consolidated: rows: tuple[ConsolidatedRow, ...]; gaps: tuple[GapItem, ...]; documents_total: int`; `class Resolver(Protocol): def resolve(self, inn, name) -> Resolution | None`; `def build(*, year: int, quarter: PeriodQuarter, group_id: uuid.UUID, members: Mapping[uuid.UUID, Entity], entities: Mapping[uuid.UUID, Entity], documents: Sequence[SourceDocument], rows: Sequence[SourceRow], resolver: Resolver) -> Consolidated`; `@dataclass(frozen=True) class OsvFilter(legal_entity_id, account_root, counterparty_type, unmapped, match_kinds: frozenset, q)`; `def apply_filter(rows, f) -> list[ConsolidatedRow]`; `def summary(c: Consolidated) -> tuple[int rows_total, int unmapped_total, int auto_matched_total]`. - Ключ склейки: `(account_code or "", (counterparty_name or "").strip(), normalize_inn(counterparty_inn or ""))`; `counterparty_id` из последнего квартала (D-3, #8), `warnings` при расхождении; `row_number` = min по последнему кварталу; нули — `Decimal("0")`; суммы `None` считаются нулями при сложении, но строка нарастающего документа отдаётся как есть. -- [ ] **Step 1: Тесты (без БД).** `select_sources`: нарастающий Q3, квартальные Q1–Q3, пробел Q2, версия Q1, нарастающий+квартальные, только нарастающий Q2 при версии Q3 (защитно: такой документ не привязывается, но функция обязана его игнорировать) → `Gap(Q1,Q2,Q3)`. `build`: сценарии спеки «Три квартала», «Контрагент только во втором квартале», «Пробел», «Нарастающий рядом с квартальными», дубль ключа внутри квартала (сумма, `entry_ids` оба), расхождение `counterparty_id` между кварталами (последний квартал + `warnings`), признаки `GSK`/`GK`/`EXTERNAL`/пустой, дорезолв с `match_stored = false`, порядок строк, `apply_filter` по каждому полю, `summary`. -- [ ] **Step 2–4:** падает → реализация → `make check` (mypy strict для `src.domain.*`), `pytest tests/test_domain_consolidated_osv.py tests/test_domain_consolidation_periods.py tests/test_domain_checklist.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(domain): сводная ОСВ — выбор источников, приведение периодов, пробелы, признак контрагента`. +- [x] **Step 1: Тесты (без БД).** `select_sources`: нарастающий Q3, квартальные Q1–Q3, пробел Q2, версия Q1, нарастающий+квартальные, только нарастающий Q2 при версии Q3 (защитно: такой документ не привязывается, но функция обязана его игнорировать) → `Gap(Q1,Q2,Q3)`. `build`: сценарии спеки «Три квартала», «Контрагент только во втором квартале», «Пробел», «Нарастающий рядом с квартальными», дубль ключа внутри квартала (сумма, `entry_ids` оба), расхождение `counterparty_id` между кварталами (последний квартал + `warnings`), признаки `GSK`/`GK`/`EXTERNAL`/пустой, дорезолв с `match_stored = false`, порядок строк, `apply_filter` по каждому полю, `summary`. +- [x] **Step 2–4:** падает → реализация → `make check` (mypy strict для `src.domain.*`), `pytest tests/test_domain_consolidated_osv.py tests/test_domain_consolidation_periods.py tests/test_domain_checklist.py -q` → PASS. +- [x] **Step 5: Commit** `feat(domain): сводная ОСВ — выбор источников, приведение периодов, пробелы, признак контрагента`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 9ee8820..6db1dec 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -234,3 +234,10 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 47 | 2026-10-06 | `document_ids` ячейки — только привязанные документы (покрывающие или частично покрывающие пару); кандидаты `NEEDS_CHECK`/`RECOGNIZED` в `document_ids` не попадают, при статусе `NEEDS_CHECK`/`RECOGNIZED`/`PROCESSING`/`ERROR` `missing_quarters` и `document_ids` сохраняют частичное покрытие | спека и схема: «привязанные документы»; пробел и статус кандидата независимы | UI не получит ссылку на документ-кандидат из чек-листа (она есть в списке документов ЮЛ) | | 48 | 2026-10-06 | Порядок правил — `domain.checklist.order_rules` (BALANCE, INCOME, ОСВ по натуральному ключу кода: числовые части по значению, `58 < 58.03 < 60`; прочие типы — после ОСВ), `rules_for` отдаёт правила в порядке `id`; порядок ячеек = порядок `rules` в ответе | порядок — предметное правило, ему место в чистом домене, а не в SQL по строке (`'9' > '60'` в текстовой сортировке) | нулевая | | 49 | 2026-10-06 | Ячейка ББ/ОФР покрыта привязанным документом того же типа с `period_quarter = квартал версии` (признак нарастающего не проверяется: ББ/ОФР всегда нарастающие, решение №10); ЮЛ без записи в `files_state` считается без файлов | `fits` уже отсекает прочее; защита от пропуска записи в `files_state` без KeyError | нулевая | +| 50 | 2026-10-06 | Склейка квартальных строк всегда даёт `Decimal`: сумма `None` и отсутствующая строка — ноль, в том числе в `opening_*` из Q1 и `closing_*` из Qn; «как есть» (с `None`) отдаются только строки нарастающего документа. Версия за Q1 с квартальным Q1 идёт путём склейки (`is_cumulative = false`, дубли ключа суммируются) | пустая ячейка ОСВ означает ноль; одно правило для всех склеенных строк; признак строки отражает тип документа | в склеенной строке пустая ячейка файла показывается как `0.00`, а не пусто | +| 51 | 2026-10-06 | «Как в файле» (`account_code`, `account_root`, `counterparty_name/inn`) и `row_number` склеенной строки — из последнего квартала, где ключ есть (минимальный номер строки в нём, пустой — в конце, затем `id`); если ключа нет в Qn (контрагент только в Q2) — из последнего квартала с ним | спека задаёт `row_number` по последнему кварталу, но не говорит про ключ, которого в нём нет; написание имени/ИНН внутри ключа может отличаться пробелами | порядок такой строки в своде и показанное написание имени | +| 52 | 2026-10-06 | `counterparty_id` склейки — первая сопоставленная строка самого позднего квартала, где сопоставленные есть; `match_kind` и `match_stored = true` — от неё же; в `warnings` (по строке на источник: «Qn: строка сопоставлена с , в своде — ») попадают только строки с ДРУГИМ непустым `counterparty_id`, пустые расхождением не считаются. Дорезолв (`Resolver.resolve(inn, name)`) вызывается только когда сопоставленных нет, по ИНН/имени «как в файле» той же строки; не найден → `match_stored = false`, `match_kind = null`, `EXTERNAL` | D-3 и решение №8; несопоставленная строка рядом с сопоставленной — обычное состояние до автоприменения | текст предупреждений — не контракт, фронт показывает как есть | +| 53 | 2026-10-06 | Порядок счетов в своде и пробелах — натуральный (`account_sort_key`, перенесён из чек-листа в `domain/osv_mapping.py`: `9 < 60 < 60.2 < 60.10`), при равенстве — код строкой; внутри имени ЮЛ — ещё `legal_entity_id` (тёзки не перемешиваются) | одно определение порядка счетов для чек-листа и свода (№48); спека говорит «account_code», текстовая сортировка ставит `60.10` раньше `60.2` | порядок строк в UI/экспорте при необычных кодах | +| 54 | 2026-10-06 | `build` защитно игнорирует документы ЮЛ вне `members` и строки документов, которых нет в `documents`; `documents_total` = число переданных документов. `select_sources` при дублях (два нарастающих за квартал версии или два квартальных за квартал) возвращает все — привязка их не допускает (R-1) | вход формирует репозиторий по `consolidation_document`; дубли невозможны по ключу привязки | при нарушении инварианта привязки — двойной счёт строк, а не ошибка | +| 55 | 2026-10-06 | `OsvFilter.q` обрезается по краям, пустой не фильтрует; имя — подстрока без регистра (`casefold`), ИНН — префикс после удаления пробелов с обеих сторон. `unmapped = false` — только сопоставленные (после дорезолва); `match_kinds` пустой — без фильтра | спека задаёт только `unmapped = true`; симметричное значение естественно для query-параметра `bool` | фронт получит сопоставленные строки на `unmapped=false` вместо всех | +| 56 | 2026-10-06 | Признак `CounterpartyType` (StrEnum) и `counts_as_cumulative`/`QUARTER_ORDER`/`quarters_through` — по одному определению в домене: схема API берёт `CounterpartyType` из `domain.consolidated_osv` (T2.8), `services.consolidation_documents` — `QUARTER_ORDER` и `counts_as_cumulative` (R-1), чек-лист — `select_sources` через адаптер `_osv_coverage` (T8.1) и явную таблицу статусов кандидатов (T8.4) | расходящиеся копии — источник тихого рассинхрона | нулевая: OpenAPI-enum и поведение те же, тесты чек-листа не менялись | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index b8ab252..4c2e5c1 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -43,7 +43,7 @@ ## 4. Сводная ОСВ -- [ ] 4.1 **Домен.** `domain/consolidation_periods.py:select_sources` (нарастающий/квартальные/пробел), `domain/consolidated_osv.py: +- [x] 4.1 **Домен.** `domain/consolidation_periods.py:select_sources` (нарастающий/квартальные/пробел), `domain/consolidated_osv.py: build(...)` (склейка по ключу, нули, дубли, `entry_ids`, `warnings`, дорезолв, признак, фильтры, порядок). Тесты без БД: все сценарии «Выбор источников», «Строки свода», фильтры, версия за Q1, дубль ключа, расхождение `counterparty_id`. - [ ] 4.2 **API свода.** `services/consolidated_osv.py:page/summary`, `GET …/osv`, `GET …/osv/summary`. Тесты: страница на