add-preset-shahmatka #141

Merged
aep merged 24 commits from add-preset-shahmatka into main 2026-10-22 17:57:14 +03:00
12 changed files with 10 additions and 234 deletions
Showing only changes of commit e9dc67b2e9 - Show all commits

View File

@ -872,7 +872,7 @@ API: список счетов `GET /directories/accounts`, правила ве
`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица
`GET …/intercompany-matrix/result-table`. Режим PRESET (счета и показатель из правила элиминации):
`GET …/intercompany-matrix/preset/{rule_id}`, его выгрузка `…/preset/{rule_id}/export` и строки сводной ОСВ без пары
`…/preset/{rule_id}/unpaired-osv`; в ячейке `correction_ids_debit/credit` (применённые корректировки правила).
`…/preset/{rule_id}/unpaired-osv`.
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной
версии правил — открытые правила (`valid_to IS NULL`). Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. В шапке xlsx — строка «Показатель»,

View File

@ -31,7 +31,6 @@ from src.models.directories import CompanyGroup, LegalEntity, LegalEntityAlias,
from src.models.enums import (
ConsolidationApproach,
ConsolidationStatus,
CorrectionStatus,
DocumentStatus,
DocumentType,
FileStatus,
@ -41,7 +40,6 @@ from src.models.enums import (
RelatedGroupKind,
)
from src.models.files import Document, StoredFile
from src.models.kfo import Correction
from src.models.osv import OsvEntry
from src.repositories.directories import LIKE_ESCAPE, escape_like
@ -565,22 +563,3 @@ async def current_related_groups(
for entity_id, group in await session.execute(stmt):
groups[entity_id][group.kind] = group
return dict(groups)
async def correction_for_version_consolidation(
session: AsyncSession, version_id: uuid.UUID, rule_id: uuid.UUID
) -> list[Correction]:
"""Применённые корректировки правила для версии консолидации."""
if not version_id or not rule_id:
return []
stmt = (
select(Correction)
.where(
Correction.consolidation_version_id == version_id,
Correction.rule_id == rule_id,
Correction.status == CorrectionStatus.APPLIED,
)
.order_by(Correction.id)
)
return list((await session.scalars(stmt)).all())

View File

@ -367,8 +367,6 @@ class IntercompanyMatrixCellOut(BaseModel):
)
osv_entry_ids_debit: list[uuid.UUID]
osv_entry_ids_credit: list[uuid.UUID]
correction_ids_debit: list[uuid.UUID] = Field(default_factory=list)
correction_ids_credit: list[uuid.UUID] = Field(default_factory=list)
@field_validator("amount_a", "amount_b", "variance_amount", "variance_percent")
@classmethod

View File

@ -17,7 +17,6 @@ from src.models.accounting import ChartOfAccount, EliminationRule
from src.models.consolidation import Consolidation, ConsolidationVersion
from src.models.directories import CompanyGroup
from src.models.enums import AccountKind, PeriodQuarter
from src.repositories import consolidations as consolidation_repo
from src.repositories import directories as directory_repo
from src.repositories import elimination_rules as elimination_rules_repo
from src.schemas.consolidations import (
@ -407,63 +406,6 @@ class IntercompanyMatrixService:
unmatched = get_unpaired_rows(result.rows, result.matrix)
return UnpairedOsvOut(items=[row_out(row) for row in unmatched], total=len(unmatched))
async def _apply_corrections(
self, matrix: IntercompanyMatrixOut, version_id: uuid.UUID, rule_id: uuid.UUID
) -> IntercompanyMatrixOut:
"""Исключение суммы применённых корректировок из сторон пары пресета."""
corrections = await consolidation_repo.correction_for_version_consolidation(
self._session, version_id=version_id, rule_id=rule_id
)
if not corrections:
return matrix
debit_by_pair: dict[tuple[uuid.UUID, uuid.UUID], Decimal] = defaultdict(lambda: ZERO)
credit_by_pair: dict[tuple[uuid.UUID, uuid.UUID], Decimal] = defaultdict(lambda: ZERO)
debit_ids_by_pair: dict[tuple[uuid.UUID, uuid.UUID], set[uuid.UUID]] = defaultdict(set)
credit_ids_by_pair: dict[tuple[uuid.UUID, uuid.UUID], set[uuid.UUID]] = defaultdict(set)
for correction in corrections:
owner, counterparty = correction.legal_entity_id, correction.counterparty_id
if owner is None or counterparty is None:
continue
pair = (owner, counterparty)
if correction.debit_adjustment is not None:
debit_by_pair[pair] += correction.debit_adjustment
debit_ids_by_pair[pair].add(correction.id)
if correction.credit_adjustment is not None:
credit_by_pair[pair] += correction.credit_adjustment
credit_ids_by_pair[pair].add(correction.id)
cells = []
for cell in matrix.cells:
debit_pair = (cell.company_a_id, cell.company_b_id)
credit_pair = (cell.company_b_id, cell.company_a_id)
amount_a = cell.amount_a - debit_by_pair.get(debit_pair, ZERO)
amount_b = cell.amount_b - credit_by_pair.get(credit_pair, ZERO)
debit_ids = list(debit_ids_by_pair.get(debit_pair, ()))
credit_ids = list(credit_ids_by_pair.get(credit_pair, ()))
if not debit_ids and not credit_ids:
cells.append(cell)
continue
variance_amount = abs(amount_a - amount_b)
base = min(amount_a, amount_b)
variance_percent = None if base == ZERO else variance_amount / base * 100
cells.append(
IntercompanyMatrixCellOut.model_validate(
{
**cell.model_dump(),
"amount_a": amount_a,
"amount_b": amount_b,
"correction_ids_debit": debit_ids,
"correction_ids_credit": credit_ids,
"variance_amount": variance_amount,
"variance_percent": variance_percent,
"exceeds_threshold": variance_percent is not None
and variance_percent > VARIANCE_THRESHOLD_PERCENT,
}
)
)
return IntercompanyMatrixOut(companies=matrix.companies, cells=cells)
async def _resolve_preset_selection(
self,
rule_id: uuid.UUID | None,
@ -514,7 +456,6 @@ class IntercompanyMatrixService:
rows = osv_result.consolidated.rows
accounts = await directory_repo.list_accounts(self._session)
by_id, children = _make_account_tree(accounts)
rule: EliminationRule | None = None
if mode == "ALL":
if debit_account_ids or credit_account_ids:

Тут все же буду настаивать. Когда функция\метод возвращает два значения это еще туда сюда, но когда уже больше - код превращается в месиво. Не очевидно, какой параметр возвращается каким (например, надо помнить, что объект MatrixSelection возвращается 5, нужно заходить в определение функции и отсчитывать, в каком он месте. К тому же, если тебе нужен только 2 каких-то придется писать либо matrix, _, _, _, selection, _ = self._build_accounts(...) либо built_account = self._build_account(...)\matrix, selection = built_account[0], built_account[4]). Исходя из вышеперечисленного предлагаю сделать в файле класс

@dataclasses.dataclass
class BuitAccount:
    matrix: IntercompanyMatrixOut
    consolidation_version: ConsolidationVersion
    ... 

Ну либо через pydantic аналогичный. Вроде _build_account только в двух местах вызывается, там поправить получение даннх в связи с этим

Тут все же буду настаивать. Когда функция\метод возвращает два значения это еще туда сюда, но когда уже больше - код превращается в месиво. Не очевидно, какой параметр возвращается каким (например, надо помнить, что объект MatrixSelection возвращается 5, нужно заходить в определение функции и отсчитывать, в каком он месте. К тому же, если тебе нужен только 2 каких-то придется писать либо matrix, _, _, _, selection, _ = self._build_accounts(...) либо built_account = self._build_account(...)\matrix, selection = built_account[0], built_account[4]). Исходя из вышеперечисленного предлагаю сделать в файле класс ``` @dataclasses.dataclass class BuitAccount: matrix: IntercompanyMatrixOut consolidation_version: ConsolidationVersion ... ``` Ну либо через pydantic аналогичный. Вроде _build_account только в двух местах вызывается, там поправить получение даннх в связи с этим

Ну и, соответственно, получение данных теперь будет matrix = (await self._build_account(...)).matrix или просто брать built_account = await self._build_account(...) и далее просто брать че надо, built_account.matrix или там built_account.selection и т д

Ну и, соответственно, получение данных теперь будет matrix = (await self._build_account(...)).matrix или просто брать built_account = await self._build_account(...) и далее просто брать че надо, built_account.matrix или там built_account.selection и т д

исправила

исправила
raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета")
@ -528,12 +469,10 @@ class IntercompanyMatrixService:
credit = _select_accounts(credit_account_ids, (), "K", by_id, children)
selection = MatrixSelection(debit, credit, "closing")
elif mode == "PRESET":
selection, rule = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children)
selection, _ = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children)
else:
raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов")
matrix = self._calculate(rows, osv_result.members, selection)
if rule is not None:
matrix = await self._apply_corrections(matrix, version_id, rule.id)
return MatrixBuildResult(
matrix=matrix,
version=osv_result.version,

View File

@ -133,8 +133,6 @@ async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seed
assert {
"osv_entry_ids_debit",
"osv_entry_ids_credit",
"correction_ids_debit",
"correction_ids_credit",
"exceeds_threshold",
} <= cell.keys()
assert {"entry_osv_ids_debit", "entry_osv_ids_credit", "is_more_5_percent"}.isdisjoint(cell)
@ -142,7 +140,6 @@ async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seed
assert cell["variance_percent"] is None
assert cell["one_sided"] is False
assert cell["osv_entry_ids_debit"] == cell["osv_entry_ids_credit"] == []
assert cell["correction_ids_debit"] == cell["correction_ids_credit"] == []
assert cell["skipped_osv_entry_count"] == 0

View File

@ -1,34 +0,0 @@
"""Запрос корректировок к строкам ОСВ версии консолидации."""
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock
from sqlalchemy.dialects import postgresql
from src.repositories.consolidations import correction_for_version_consolidation
async def test_correction_query_returns_all_applied_records_without_matching_osv() -> None:
session = AsyncMock()
corrections = [object(), object()]
session.scalars.return_value = SimpleNamespace(all=lambda: corrections)
version_id, rule_id = uuid.uuid4(), uuid.uuid4()
assert await correction_for_version_consolidation(session, version_id, rule_id) == corrections
stmt = session.scalars.await_args.args[0]
sql = str(stmt.compile(dialect=postgresql.dialect()))
assert "FROM dkb.correction" in sql
assert "osv_entry" not in sql
assert "correction.consolidation_version_id" in sql
assert "correction.rule_id" in sql
assert "correction.status" in sql
async def test_correction_query_requires_version_and_rule() -> None:
session = AsyncMock()
assert await correction_for_version_consolidation(session, None, uuid.uuid4()) == []
assert await correction_for_version_consolidation(session, uuid.uuid4(), None) == []
session.scalars.assert_not_awaited()

View File

@ -82,7 +82,6 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None:
]
assert result.cells[0].amount_a == Decimal("100")
assert result.cells[0].amount_b == Decimal("90")
assert result.cells[0].correction_ids_debit == result.cells[0].correction_ids_credit == []
assert result.cells[0].variance_percent == Decimal("11.11")
assert isinstance(result, IntercompanyMatrixOut)
response = result
@ -143,7 +142,6 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
monkeypatch.setattr(
matrix.elimination_rules_repo, "rule_with_account", AsyncMock(return_value=(rule, rule_accounts))
)
monkeypatch.setattr(matrix.consolidation_repo, "correction_for_version_consolidation", AsyncMock(return_value=[]))
session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(scalar_one=lambda: "ГК")))
service = matrix.IntercompanyMatrixService(session, None, osv_service)
captured = []
@ -164,79 +162,6 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
assert info.measure == "turnover"
async def test_preset_corrections_exclude_amounts_without_changing_osv(monkeypatch: pytest.MonkeyPatch) -> None:
company_a, company_b = uuid.uuid4(), uuid.uuid4()
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
debit_row = _row(company_a, company_b, debit_account, debit="1500")
credit_row = _row(company_b, company_a, credit_account, credit="1500")
debit_row.entry_ids = (uuid.uuid4(),)
credit_row.entry_ids = (uuid.uuid4(),)
companies = [
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
]
original = matrix.calculate_matrix(
[debit_row, credit_row],
companies,
matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing"),
)
debit_correction_id, credit_correction_id, child_correction_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
corrections = [
SimpleNamespace(
id=debit_correction_id,
legal_entity_id=company_a,
counterparty_id=company_b,
debit_adjustment=Decimal("1000"),
credit_adjustment=None,
),
SimpleNamespace(
id=credit_correction_id,
legal_entity_id=company_b,
counterparty_id=company_a,
debit_adjustment=None,
credit_adjustment=Decimal("500"),
),
SimpleNamespace(
id=child_correction_id,
legal_entity_id=company_b,
counterparty_id=company_a,
debit_adjustment=None,
credit_adjustment=Decimal("500"),
),
]
monkeypatch.setattr(
matrix.consolidation_repo, "correction_for_version_consolidation", AsyncMock(return_value=corrections)
)
adjusted = await matrix.IntercompanyMatrixService(object(), None, object())._apply_corrections(
original, uuid.uuid4(), uuid.uuid4()
)
assert adjusted.cells[0].amount_a == Decimal("500")
assert adjusted.cells[0].amount_b == Decimal("500")
assert adjusted.cells[0].variance_amount == Decimal("0")
assert adjusted.cells[0].osv_entry_ids_debit == [debit_row.entry_ids[0]]
assert adjusted.cells[0].osv_entry_ids_credit == [credit_row.entry_ids[0]]
assert adjusted.cells[0].correction_ids_debit == [debit_correction_id]
assert set(adjusted.cells[0].correction_ids_credit) == {credit_correction_id, child_correction_id}
assert adjusted.cells[0].model_dump(mode="json")["correction_ids_debit"] == [str(debit_correction_id)]
assert original.cells[0].correction_ids_debit == original.cells[0].correction_ids_credit == []
assert original.cells[0].amount_a == Decimal("1500")
assert original.cells[0].amount_b == Decimal("1500")
assert credit_row.closing_credit == Decimal("1500")
async def test_preset_without_corrections_keeps_matrix(monkeypatch: pytest.MonkeyPatch) -> None:
original = matrix.IntercompanyMatrixOut(companies=[], cells=[])
monkeypatch.setattr(matrix.consolidation_repo, "correction_for_version_consolidation", AsyncMock(return_value=[]))
adjusted = await matrix.IntercompanyMatrixService(object(), None, object())._apply_corrections(
original, uuid.uuid4(), uuid.uuid4()
)
assert adjusted is original
def test_one_sided_reflects_osv_sources_even_when_amount_is_zero() -> None:
company_a, company_b = uuid.uuid4(), uuid.uuid4()
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()

View File

@ -134,14 +134,6 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold
for offset in range(4)
)
debit_correction_id = UUID("796d1add-eaed-4802-8c79-8b3cdae2cca1")
credit_correction_id = UUID("55555555-5555-5555-5555-555555555555")
matrix.cells[0].correction_ids_debit = [debit_correction_id]
matrix.cells[0].correction_ids_credit = [credit_correction_id]
preset_sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, _info(mode="PRESET")))).active
assert preset_sheet is not None
assert preset_sheet["D12"].comment is None
assert preset_sheet["D13"].comment is None
async def test_export_routes_use_matrix_calculation() -> None:

View File

@ -37,7 +37,7 @@ change не входит. Шахматка — чистая функция от
3. **Корректировки из шахматки убираются** (решение 4 выше и `correction_ids_*` в ячейке). Причины: корректировки в
ядре никто не создаёт (спринт корректировок), семантика ключа `(rule, A, B)` относительно ячейки (A, B) / (B, A) не
утверждена, счета корректировки и `parent_correction_id` не учитывались; §6 описывает шахматку как суммы первички.
Код сохранён в ветке `wip/shahmatka-corrections-unpaired` (`0e95f95`) и возвращается после решения по аналитике
Код корректировок выделен в ветку `AURORA-1747` и возвращается после решения по аналитике
AURORA-1643 «Учитывание корректировок в шахматке».
4. **Ручка `…/preset/{rule_id}/unpaired-osv` убирается** (решение 5 выше). Причины: «без пары» по наличию строк
(дефект AURORA-1651), ложные срабатывания при пересечении счетов сторон (76 в правиле № 6), нет ячейки и стороны

View File

@ -1,7 +1,7 @@
## Why
Шахматка ВГО (ТЗ §6) считалась только по всем счетам (ALL) или по счетам, выбранным вручную (MANUAL). Аналитику нужен
режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3), с учётом применённых корректировок и со
режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3) и со
списком строк сводной ОСВ без пары. Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`, автор — Потапова).
Требования оформлены как дельты этого change'а при ревью (решение владельца 21.10.2026); спорное поведение вынесено в
`design.md`, `## Known issues`, и в спеке не закреплено как окончательное.
@ -10,8 +10,7 @@
- Режим PRESET шахматки: `GET …/intercompany-matrix/preset/{rule_id}`, выгрузка `…/preset/{rule_id}/export`, список
строк без пары `…/preset/{rule_id}/unpaired-osv`.
- Показатель пресета (`closing` / `turnover`) берётся из правила; в ячейке появляются `correction_ids_debit/credit`,
суммы уменьшаются на применённые корректировки правила.
- Показатель пресета (`closing` / `turnover`) берётся из правила.
- `GET …/elimination-rules` возвращает объект `{version_no, is_version_fixed, rules}` вместо массива; у правила есть
`id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`).
- xlsx: для PRESET имя файла с `_preset-<rule_id>`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode`
@ -24,11 +23,11 @@
### Modified Capabilities
- `consolidations-api`: ответ правил элиминации версии.
- `consolidated-osv`: режим PRESET шахматки, корректировки, строки без пары, xlsx пресета.
- `consolidated-osv`: режим PRESET шахматки, строки без пары, xlsx пресета.
- `audit-trail`: аудит экспорта шахматки (режим PRESET).
## Impact
Только `arm-core` (API, сервисы, схемы), таблицы и DBML не меняются. Ломающее изменение контракта
`GET …/elimination-rules` (массив → объект); поле `as_of` в ответ не входит. Потребителей старого формата в этом репозитории не проверялось — проверить при слиянии. Non-goals: фиксация версии правил, расчёт «актуальных» правил на отчётную дату, создание корректировок,
`GET …/elimination-rules` (массив → объект); поле `as_of` в ответ не входит. Потребителей старого формата в этом репозитории не проверялось — проверить при слиянии. Non-goals: фиксация версии правил, расчёт «актуальных» правил на отчётную дату, создание и применение корректировок,
исправление `one_sided` (AURORA-1651).

View File

@ -28,26 +28,6 @@
- **WHEN** `rule_id` не существует
- **THEN** 404
### Requirement: Корректировки в шахматке пресета
В режиме PRESET расчёт SHALL учитывать корректировки `correction` со статусом `APPLIED`, у которых
`consolidation_version_id` равен версии, а `rule_id` — правилу пресета. Корректировка с `legal_entity_id = A` и
`counterparty_id = B` SHALL уменьшать `amount_a` ячейки `(A, B)` на `debit_adjustment` и `amount_b` ячейки `(B, A)` на
`credit_adjustment`. Затронутые ячейки SHALL получать пересчитанные `variance_amount`, `variance_percent`,
`exceeds_threshold` и ID корректировок в `correction_ids_debit`/`correction_ids_credit`; у остальных ячеек эти массивы
пусты (поля есть в ответе всех режимов). Корректировки без `legal_entity_id` или `counterparty_id` SHALL игнорироваться.
#### Scenario: Корректировка по дебету
- **WHEN** есть применённая корректировка правила `(A, B)` с `debit_adjustment = 10`
- **THEN** у ячейки `(A, B)` `amount_a` меньше на 10, расхождение пересчитано, `correction_ids_debit` содержит её ID
#### Scenario: Корректировка по кредиту
- **WHEN** есть применённая корректировка правила `(A, B)` с `credit_adjustment = 5`
- **THEN** у ячейки `(B, A)` `amount_b` меньше на 5, `correction_ids_credit` содержит её ID
#### Scenario: Корректировка чужого правила или не применённая
- **WHEN** корректировка относится к другому правилу либо имеет статус не `APPLIED`
- **THEN** суммы ячеек не меняются, `correction_ids_*` пусты
### Requirement: Строки сводной ОСВ без пары по пресету
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/unpaired-osv`
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{items, total}`: все строки сводной ОСВ версии, чьи записи ОСВ

View File

@ -5,8 +5,9 @@
## 2. Доработки по решениям владельца 2026-10-21 (автор)
- [ ] 2.1 Отдельным коммитом убрать учёт корректировок (`_apply_corrections`, `correction_for_version_consolidation`,
`correction_ids_*`) и ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами;
- [x] 2.1 Отдельным коммитом убрать учёт корректировок (`_apply_corrections`, `correction_for_version_consolidation`,
`correction_ids_*`) вместе с тестами; код выделен в `AURORA-1747`.
- [ ] 2.1a Убрать ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами;
код сохранён в `wip/shahmatka-corrections-unpaired`.
- [ ] 2.2 PRESET — только правила `statement_type = balance`; правило ОФР — 422 `INVALID_RULE`, тест.
- [ ] 2.3 Несколько открытых `version_no` — одна версия (max) или явная ошибка, тест.