AURORA-1756 #218
@ -938,7 +938,7 @@ API: список счетов `GET /directories/accounts`, правила ве
|
||||
`GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export`
|
||||
(аудит `EXPORT`, `report = "intercompany_result_table"`). Режим PRESET (счета и показатель из правила элиминации):
|
||||
`GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`.
|
||||
Пустой список стороны выбирает все подходящие балансовые счета (без 001–012); непустой список может включать и забалансовые. `one_sided` — ровно одна сумма
|
||||
Пустой список стороны выбирает все подходящие балансовые счета (без 001–012); непустой список может включать и забалансовые; `legal_entity_ids` сужает обе оси и итоги до выбранных ЮЛ. Итоги `total_debit` («Итого Дт», строка) и `total_credit` («Итого Кт», столбец) — по ID ЮЛ, в xlsx — колонка «Дебет» и строка «Кредит»; у результирующей таблицы итога по статьям нет. `one_sided` — ровно одна сумма
|
||||
пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651).
|
||||
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): правила версии
|
||||
`consolidation_version.elimination_rules_version` — она фиксируется при создании версии консолидации (действующая на дату
|
||||
|
||||
@ -667,8 +667,18 @@ class IntercompanyMatrixOut(BaseModel):
|
||||
|
||||
companies: list[IntercompanyMatrixCompanyOut]
|
||||
cells: list[IntercompanyMatrixCellOut]
|
||||
total_debet: dict[uuid.UUID, Decimal] = Field(default_factory=dict)
|
||||
total_credit: dict[uuid.UUID, Decimal] = Field(default_factory=dict)
|
||||
total_debit: dict[uuid.UUID, Decimal] = Field(
|
||||
default_factory=dict, description="Итого Дт: сумма amount_a ячеек строки компании (ключ — ID ЮЛ)"
|
||||
)
|
||||
total_credit: dict[uuid.UUID, Decimal] = Field(
|
||||
default_factory=dict, description="Итого Кт: сумма amount_b ячеек столбца компании (ключ — ID ЮЛ)"
|
||||
)
|
||||
|
||||
@field_validator("total_debit", "total_credit")
|
||||
@classmethod
|
||||
def _two_decimals(cls, value: dict[uuid.UUID, Decimal]) -> dict[uuid.UUID, Decimal]:
|
||||
"""Итоги в JSON всегда с двумя знаками, в том числе нулевые."""
|
||||
return {key: amount.quantize(CENT, rounding=ROUND_HALF_UP) for key, amount in value.items()}
|
||||
|
||||
|
||||
class IntercompanyResultAmountOut(BaseModel):
|
||||
@ -702,9 +712,3 @@ class IntercompanyResultTableOut(BaseModel):
|
||||
|
||||
companies: list[IntercompanyMatrixCompanyOut]
|
||||
articles: list[IntercompanyResultArticleOut]
|
||||
grand_total: Decimal
|
||||
|
||||
@field_validator("grand_total")
|
||||
@classmethod
|
||||
def _two_decimals(cls, value: Decimal) -> Decimal:
|
||||
return value.quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
|
||||
@ -265,13 +265,13 @@ def calculate_matrix(
|
||||
osv_entry_ids_credit=credit_ids,
|
||||
)
|
||||
)
|
||||
total_debet = {company_id: ZERO for company_id in company_ids}
|
||||
total_debit = {company_id: ZERO for company_id in company_ids}
|
||||
total_credit = {company_id: ZERO for company_id in company_ids}
|
||||
for cell in cells:
|
||||
total_debet[cell.company_a_id] += cell.amount_a
|
||||
total_debit[cell.company_a_id] += cell.amount_a
|
||||
total_credit[cell.company_b_id] += cell.amount_b
|
||||
return IntercompanyMatrixOut(
|
||||
companies=list(company_info), cells=cells, total_debet=total_debet, total_credit=total_credit
|
||||
companies=list(company_info), cells=cells, total_debit=total_debit, total_credit=total_credit
|
||||
)
|
||||
|
||||
|
||||
@ -374,11 +374,7 @@ def calculate_result_table(
|
||||
group_total=sum((item.amount for item in company_amounts), ZERO),
|
||||
)
|
||||
)
|
||||
return IntercompanyResultTableOut(
|
||||
companies=list(companies),
|
||||
articles=articles,
|
||||
grand_total=sum((article.group_total for article in articles), ZERO),
|
||||
)
|
||||
return IntercompanyResultTableOut(companies=list(companies), articles=articles)
|
||||
|
||||
|
||||
def _pair_row_out(row: ConsolidatedRow) -> EliminationPairRowOut:
|
||||
|
||||
@ -99,7 +99,7 @@ def _write_matrix_totals(
|
||||
end_row=start_row + len(METRICS) - 1,
|
||||
end_column=total_column,
|
||||
)
|
||||
debit_total = sheet.cell(start_row, total_column, matrix.total_debet[company.id])
|
||||
debit_total = sheet.cell(start_row, total_column, matrix.total_debit[company.id])
|
||||
debit_total.number_format = MONEY_FORMAT
|
||||
debit_total.font = Font(bold=True)
|
||||
debit_total.alignment = Alignment(horizontal="right", vertical="center")
|
||||
@ -210,13 +210,6 @@ def build_result_table_workbook(result: IntercompanyResultTableOut, info: Result
|
||||
total.number_format = MONEY_FORMAT
|
||||
total.font = Font(bold=True)
|
||||
|
||||
summary_row = header_row + len(result.articles) + 1
|
||||
_text(sheet, summary_row, 1, "Итого")
|
||||
sheet.cell(summary_row, 1).font = Font(bold=True)
|
||||
grand_total = sheet.cell(summary_row, total_column, result.grand_total)
|
||||
grand_total.number_format = MONEY_FORMAT
|
||||
grand_total.font = Font(bold=True)
|
||||
|
||||
sheet.freeze_panes = "B8"
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
|
||||
@ -99,7 +99,7 @@ async def test_empty_perimeter(auth_client: AuthClientFactory, seeded: AsyncSess
|
||||
world = await _world(seeded, members=0)
|
||||
response = await _request(await auth_client(KA_RF), world.version.id, "matrix", selection)
|
||||
assert response.status_code == OK, response.text
|
||||
assert response.json() == {"companies": [], "cells": [], "total_debet": {}, "total_credit": {}}
|
||||
assert response.json() == {"companies": [], "cells": [], "total_debit": {}, "total_credit": {}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", ROUTES)
|
||||
@ -162,7 +162,7 @@ async def test_company_filter_applies_to_matrix_and_export(
|
||||
data = response.json()
|
||||
assert [company["id"] for company in data["companies"]] == selected
|
||||
assert len(data["cells"]) == 2 # noqa: PLR2004 - одна направленная ячейка на каждую сторону пары
|
||||
assert set(data["total_debet"]) == set(data["total_credit"]) == set(selected)
|
||||
assert set(data["total_debit"]) == set(data["total_credit"]) == set(selected)
|
||||
sheet = load_workbook(BytesIO(export.content))["Шахматка"]
|
||||
assert [sheet.cell(10, column).value for column in range(3, 6)] == [
|
||||
world.members[0].name,
|
||||
@ -619,7 +619,7 @@ async def test_all_excludes_off_balance_accounts_but_manual_can_select_them(
|
||||
assert manual_response.status_code == OK, manual_response.text
|
||||
cell = next(c for c in manual_response.json()["cells"] if c["company_a_id"] == str(world.members[0].id))
|
||||
assert Decimal(cell["amount_a"]) == Decimal("100")
|
||||
assert manual_response.json()["total_debet"][str(world.members[0].id)] == "100.00"
|
||||
assert manual_response.json()["total_debit"][str(world.members[0].id)] == "100.00"
|
||||
assert all(amount == "0.00" for amount in manual_response.json()["total_credit"].values())
|
||||
|
||||
|
||||
@ -730,8 +730,7 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory,
|
||||
assert sorted(item["amount"] for item in receivables["amounts"]) == ["0.00", "100.25"]
|
||||
assert receivables["group_total"] == "100.25"
|
||||
assert all(article["group_total"] == "0.00" for article in data["articles"] if article != receivables)
|
||||
assert "company_totals" not in data
|
||||
assert data["grand_total"] == "100.25"
|
||||
assert {"company_totals", "grand_total"}.isdisjoint(data)
|
||||
sheet = load_workbook(BytesIO(export.content))["Результирующая таблица"]
|
||||
assert [sheet.cell(7, col).value for col in range(2, len(data["companies"]) + 2)] == [
|
||||
company["name"] for company in data["companies"]
|
||||
@ -742,11 +741,7 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory,
|
||||
for col, company in enumerate(data["companies"], start=2):
|
||||
assert Decimal(str(sheet.cell(row, col).value)) == amounts[company["id"]]
|
||||
assert Decimal(str(sheet.cell(row, len(data["companies"]) + 2).value)) == Decimal(article["group_total"])
|
||||
summary_row = 8 + len(data["articles"])
|
||||
assert sheet.cell(summary_row, 1).value == "Итого"
|
||||
for col in range(2, len(data["companies"]) + 2):
|
||||
assert sheet.cell(summary_row, col).value is None
|
||||
assert Decimal(str(sheet.cell(summary_row, len(data["companies"]) + 2).value)) == Decimal(data["grand_total"])
|
||||
assert sheet.max_row == 7 + len(data["articles"])
|
||||
event = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
|
||||
assert event.after_value == {"report": "intercompany_result_table", "articles": len(data["articles"])}
|
||||
|
||||
|
||||
@ -111,9 +111,9 @@ def test_matrix_totals_accumulate_each_company_and_side() -> None:
|
||||
matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing"),
|
||||
)
|
||||
|
||||
assert result.total_debet == {company_a: Decimal("120"), company_b: Decimal("0"), company_c: Decimal("5")}
|
||||
assert result.total_debit == {company_a: Decimal("120"), company_b: Decimal("0"), company_c: Decimal("5")}
|
||||
assert result.total_credit == {company_a: Decimal("3"), company_b: Decimal("70"), company_c: Decimal("10")}
|
||||
assert result.model_dump(mode="json")["total_debet"][str(company_a)] == "120.00"
|
||||
assert result.model_dump(mode="json")["total_debit"][str(company_a)] == "120.00"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -343,7 +343,8 @@ async def test_all_excludes_off_balance_accounts_only(monkeypatch: pytest.Monkey
|
||||
assert (credit_only.cells[0].amount_a, credit_only.cells[0].amount_b) == (Decimal("30"), Decimal("40"))
|
||||
assert [company.id for company in filtered.companies] == [company_a]
|
||||
assert filtered.cells == []
|
||||
assert filtered.total_debet == filtered.total_credit == {company_a: Decimal("0")}
|
||||
assert filtered.total_debit == filtered.total_credit == {company_a: Decimal("0")}
|
||||
assert filtered.model_dump(mode="json")["total_debit"] == {str(company_a): "0.00"}
|
||||
with pytest.raises(matrix.DomainValidationError) as error:
|
||||
await service.build_accounts(uuid.uuid4(), legal_entity_ids=[uuid.uuid4()])
|
||||
assert error.value.code == "ENTITY_NOT_IN_PERIMETER"
|
||||
@ -500,8 +501,7 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None:
|
||||
assert isinstance(result, IntercompanyResultTableOut)
|
||||
response = result
|
||||
assert response.articles[0].amounts[0].company_id == company_a
|
||||
assert "company_totals" not in result.model_dump(mode="json")
|
||||
assert result.grand_total == Decimal("250.00")
|
||||
assert {"company_totals", "grand_total"}.isdisjoint(result.model_dump(mode="json"))
|
||||
|
||||
|
||||
def test_pairs_use_signed_sums_and_leave_negative_rows_unpaired() -> None:
|
||||
|
||||
@ -95,7 +95,7 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold
|
||||
osv_entry_ids_credit=[],
|
||||
),
|
||||
],
|
||||
total_debet={company_a: "100.25", company_b: "20.00"},
|
||||
total_debit={company_a: "100.25", company_b: "20.00"},
|
||||
total_credit={company_a: "20.00", company_b: "90.00"},
|
||||
)
|
||||
|
||||
@ -189,7 +189,7 @@ def test_xlsx_highlights_one_sided_amount_but_not_two_zero_balances(amount: str,
|
||||
osv_entry_ids_credit=[],
|
||||
)
|
||||
],
|
||||
total_debet={company_a: amount, company_b: "0"},
|
||||
total_debit={company_a: amount, company_b: "0"},
|
||||
total_credit={company_a: "0", company_b: "0"},
|
||||
)
|
||||
|
||||
@ -223,7 +223,7 @@ async def test_export_routes_use_matrix_calculation() -> None:
|
||||
osv_entry_ids_credit=[],
|
||||
)
|
||||
],
|
||||
total_debet={company_a.id: "0", company_b.id: "0"},
|
||||
total_debit={company_a.id: "0", company_b.id: "0"},
|
||||
total_credit={company_a.id: "0", company_b.id: "0"},
|
||||
)
|
||||
calls = []
|
||||
@ -410,7 +410,6 @@ async def test_result_table_export_matches_result_and_records_audit() -> None:
|
||||
group_total="120.25",
|
||||
)
|
||||
],
|
||||
grand_total="120.25",
|
||||
)
|
||||
info = ResultTableExportInfo('ГК "Кавычки" / Бисквит', 2024, PeriodQuarter.Q3, 2)
|
||||
service = SimpleNamespace(build_result_export=AsyncMock(return_value=(result, info)))
|
||||
@ -431,7 +430,7 @@ async def test_result_table_export_matches_result_and_records_audit() -> None:
|
||||
assert sheet["B7"].data_type == "s"
|
||||
assert [sheet.cell(8, column).value for column in range(1, 5)] == ["Дебиторская задолженность", 100.25, 20, 120.25]
|
||||
assert all(sheet.cell(8, column).number_format == "#,##0.00" for column in range(2, 5))
|
||||
assert [sheet.cell(9, column).value for column in range(1, 5)] == ["Итого", None, None, 120.25]
|
||||
assert sheet.max_row == 8 # noqa: PLR2004 — под статьями нет строки «Итого»
|
||||
assert sheet.freeze_panes == "B8"
|
||||
assert audit.record.await_args.kwargs == {
|
||||
"action": AuditAction.EXPORT,
|
||||
@ -452,7 +451,7 @@ async def test_failed_result_table_export_does_not_write_audit(monkeypatch: pyte
|
||||
service = SimpleNamespace(
|
||||
build_result_export=AsyncMock(
|
||||
return_value=(
|
||||
IntercompanyResultTableOut(companies=[], articles=[], grand_total="0"),
|
||||
IntercompanyResultTableOut(companies=[], articles=[]),
|
||||
info,
|
||||
)
|
||||
)
|
||||
@ -475,7 +474,6 @@ def test_result_table_workbook_rejects_article_without_company_amount() -> None:
|
||||
result = IntercompanyResultTableOut(
|
||||
companies=[company],
|
||||
articles=[IntercompanyResultArticleOut(code="receivables", name="ДЗ", amounts=[], group_total="0")],
|
||||
grand_total="0",
|
||||
)
|
||||
with pytest.raises(KeyError):
|
||||
build_result_table_workbook(result, ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2))
|
||||
|
||||
18
openspec/changes/intercompany-matrix-totals/design.md
Normal file
18
openspec/changes/intercompany-matrix-totals/design.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Design
|
||||
|
||||
## Решения
|
||||
|
||||
- **Один маршрут вместо ALL/MANUAL.** Режим выводится из тела: обе стороны пусты — ALL, иначе MANUAL; пресет —
|
||||
отдельный маршрут, как раньше. Одна пустая сторона = все балансовые счета этой стороны.
|
||||
- **Фильтр ЮЛ на бэке.** `legal_entity_ids` сужает периметр до расчёта: оси, ячейки и итоги считаются только между
|
||||
выбранными ЮЛ (DOC-221277: «оба итога должны пересчитываться по выбранным компаниям»).
|
||||
- **Итоги — словарь по ID ЮЛ.** `total_debit[id]` — сумма `amount_a` строки, `total_credit[id]` — сумма `amount_b`
|
||||
столбца; значения квантуются до двух знаков в схеме (иначе пустая выборка из одного ЮЛ дала бы `"0"`).
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|
|
||||
| 2026-11-06 | Поле `total_debet` переименовано в `total_debit` (ревью #218, M-1) | во всём API — `debit` (`debit_account_ids`, `osv_entry_ids_debit`); фронт поле ещё не читает | нулевая: переименование до первого потребителя |
|
||||
| 2026-11-06 | Итоги квантуются валидатором схемы (M-2) | спека обещает `"0.00"`; при одном ЮЛ ячеек нет и сумма — `Decimal("0")` | минимальная |
|
||||
| 2026-11-06 | `grand_total` результирующей таблицы убран из API и XLSX (I-1, решение владельца в чате) | аналитик 03.11 попросил убрать итог снизу; сумма ДЗ + КЗ + ФВ + займов смысла не имеет; в DOC-221277 итоговой строки нет | если заказчику понадобится итог — отдельная задача с правилом |
|
||||
31
openspec/changes/intercompany-matrix-totals/proposal.md
Normal file
31
openspec/changes/intercompany-matrix-totals/proposal.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Итоги шахматки («Итого Дт» справа, «Итого Кт» снизу) считал фронт (PR #158, решение владельца 22.10.2026: агрегаты
|
||||
отдаёт бэк). Аналитик задал правило итогов (DOC-221277, «Итоги шахматки», 03.11.2026) и попросил пересчитывать итоги
|
||||
по выбранным в фильтре компаниям — значит, выбор компаний должен уйти в запрос. AURORA-1756.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Единый `POST …/intercompany-matrix` и `POST …/intercompany-matrix/export` вместо `GET …/all`, `POST …/manual` и их
|
||||
выгрузок: тело `{debit_account_ids?, credit_account_ids?, legal_entity_ids?}`; пустая сторона — все балансовые
|
||||
счета; пустой `legal_entity_ids` — весь периметр. **BREAKING** для клиента фронта (переводится в AURORA-1757,
|
||||
сливается одновременно).
|
||||
- В ответе матрицы `total_debit` и `total_credit` — итог по каждому ЮЛ, строки с двумя знаками.
|
||||
- В XLSX шахматки — колонка «Дебет» справа и строка «Кредит» снизу.
|
||||
- Аудит выгрузки сохраняет `legal_entity_ids`.
|
||||
- Результирующая таблица: итога по всем статьям нет (аналитик, 03.11.2026).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `consolidated-osv`: шахматка и её XLSX.
|
||||
- `audit-trail`: payload аудита выгрузки шахматки.
|
||||
- `consolidations-api`: результирующая таблица без итоговой строки.
|
||||
|
||||
## Impact
|
||||
|
||||
`arm-core/api`: маршруты, схемы и сервис шахматки, XLSX. Клиент фронта (`web/src/services/api/intercompanyMatrix.ts`)
|
||||
переводится в AURORA-1757. PRESET не меняется.
|
||||
@ -0,0 +1,19 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Аудит экспорта шахматки
|
||||
Каждый успешный `POST …/intercompany-matrix/export` и `GET …/intercompany-matrix/preset/{rule_id}/export`
|
||||
SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`,
|
||||
`object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора,
|
||||
`before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids,
|
||||
credit_account_ids, legal_entity_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и
|
||||
`rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`,
|
||||
`MANUAL` или `PRESET`, массивы счетов и ЮЛ — ID из запроса (счета в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка
|
||||
сводной ОСВ продолжает писать собственное событие `EXPORT`.
|
||||
|
||||
#### Scenario: Две выгрузки
|
||||
- **WHEN** пользователь дважды выгружает ту же шахматку
|
||||
- **THEN** в `audit_event` две разные записи `EXPORT`, каждая со своим payload и числом ячеек
|
||||
|
||||
#### Scenario: Выгрузка пресета
|
||||
- **WHEN** пользователь выгружает `GET …/intercompany-matrix/preset/{rule_id}/export`
|
||||
- **THEN** событие `EXPORT` содержит `mode = PRESET`, `rule_id`, `rule_no`, `rule_version_no`, пустые массивы счетов и число ячеек
|
||||
@ -0,0 +1,99 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Шахматка по сводной ОСВ версии
|
||||
`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix` принимает
|
||||
`{debit_account_ids?: UUID[], credit_account_ids?: UUID[], legal_entity_ids?: UUID[]}`. Если список стороны отсутствует или пуст,
|
||||
для неё выбираются все подходящие балансовые счета (`is_off_balance = false`); непустой список раскрывается
|
||||
по иерархии и может содержать забалансовые счета. Пустые списки обеих сторон эквивалентны режиму ALL,
|
||||
хотя бы один непустой список — MANUAL. Маршрут (`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу
|
||||
без сохранения в БД, из строк сводной ОСВ версии и её периметра. Для пустой стороны выбираются счета вида
|
||||
`A`/`AP` для дебета или `P`/`AP` для кредита; забалансовые счета (001–012) MUST NOT входить в такую выборку.
|
||||
Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный вид — 422 `ACCOUNT_KIND_MISMATCH`.
|
||||
Пустой `legal_entity_ids` выбирает все ЮЛ периметра; непустой ограничивает обе оси матрицы и суммы выбранными ЮЛ.
|
||||
ID вне периметра SHALL давать 422 `ENTITY_NOT_IN_PERIMETER`. Невидимая и несуществующая версия SHALL
|
||||
отвечать одинаковым 404 `NOT_FOUND`.
|
||||
|
||||
Ответ SHALL содержать `companies[] {id, name}` для периметра (при непустом `legal_entity_ids` — только выбранные ЮЛ в порядке периметра), `total_debit` и `total_credit`
|
||||
с суммой по каждому ЮЛ (ключ — ID ЮЛ, значение — десятичная строка с двумя знаками), и `cells[]` для каждой упорядоченной пары
|
||||
разных компаний: `company_a_id`, `company_b_id`, `amount_a`, `amount_b`, `variance_amount`, `variance_percent`,
|
||||
`exceeds_threshold`, `one_sided`, `skipped_osv_entry_count`, `osv_entry_ids_debit`, `osv_entry_ids_credit`.
|
||||
Для пары `(A, B)` `amount_a` — сумма `closing_debit` строк A с контрагентом B по дебетовым счетам,
|
||||
`amount_b` — сумма `closing_credit` строк B с контрагентом A по кредитовым. Расхождение — модуль разности;
|
||||
процент — расхождение / меньшую сумму × 100 или null при нуле. `exceeds_threshold` SHALL быть true при точном
|
||||
проценте > 5, до округления; суммы и процент в JSON SHALL быть десятичными строками с двумя знаками после
|
||||
запятой (`ROUND_HALF_UP`). `one_sided` (пара без встречной стороны) SHALL быть true тогда и только тогда, когда
|
||||
ровно одна из сумм `amount_a`, `amount_b` равна нулю (знак ненулевой суммы не важен); наличие строк ОСВ с нулевым
|
||||
сальдо на признак не влияет. Пара подсвечивается, если `exceeds_threshold` или `one_sided`. `skipped_osv_entry_count`
|
||||
SHALL считать исходные строки ОСВ пары с `account_id = null` на обеих сторонах; они не входят в суммы. Массивы
|
||||
`osv_entry_ids_debit/credit` SHALL содержать ID исходных записей соответствующей стороны.
|
||||
`total_debit` («Итого Дт», колонка справа) суммирует `amount_a` ячеек строки по `company_a_id`, `total_credit`
|
||||
(«Итого Кт», строка снизу) — `amount_b` ячеек столбца по `company_b_id`; ключи — ID всех ЮЛ из `companies`.
|
||||
Диагональ не участвует, отрицательные суммы входят со знаком, суммы ячеек точные (`numeric(18,2)`). Для ЮЛ без
|
||||
подходящих строк, в том числе когда в выборке одно ЮЛ, итог равен `"0.00"` (правило аналитика, DOC-221277
|
||||
«Итоги шахматки», 03.11.2026).
|
||||
|
||||
#### Scenario: Пустой периметр и отсутствие ОСВ
|
||||
- **WHEN** у версии нет членов периметра
|
||||
- **THEN** `companies = []`, `cells = []`, `total_debit = {}`, `total_credit = {}`; если компании есть, но ОСВ по выбранным счетам нет, ячейки присутствуют с нулевыми суммами
|
||||
|
||||
#### Scenario: Только одна сторона пары
|
||||
- **WHEN** у A есть строка ОСВ с контрагентом B и ненулевым сальдо, а у B встречной строки нет
|
||||
- **THEN** для `(A, B)` `one_sided = true`, `variance_percent = null`, соответствующий массив `osv_entry_ids_*` пуст
|
||||
|
||||
#### Scenario: Встречная строка с нулевым сальдо
|
||||
- **WHEN** у A по B сальдо 100 000 000, а у B по A есть строка ОСВ с нулевым сальдо (одни обороты)
|
||||
- **THEN** `one_sided = true`, `exceeds_threshold = false`, пара подсвечивается
|
||||
|
||||
#### Scenario: Обе стороны нулевые
|
||||
- **WHEN** у A и у B есть строки ОСВ друг на друга, но обе суммы равны 0
|
||||
- **THEN** `one_sided = false`, `exceeds_threshold = false`, пара не подсвечивается
|
||||
|
||||
#### Scenario: Отрицательное сальдо
|
||||
- **WHEN** у A по B сальдо −50, у B по A — 0
|
||||
- **THEN** `one_sided = true`
|
||||
|
||||
#### Scenario: Забалансовые счета
|
||||
- **WHEN** у A по B есть сальдо на счёте 001 (`is_off_balance = true`)
|
||||
- **THEN** в режиме ALL оно в суммы не входит; в MANUAL с выбранным счётом 001 — входит
|
||||
|
||||
#### Scenario: Счёт не сопоставлен
|
||||
- **WHEN** у пары есть строка свода с `account_id = null` и двумя `entry_ids`
|
||||
- **THEN** она не входит в суммы, `skipped_osv_entry_count` обеих направленных ячеек пары увеличивается на 2
|
||||
|
||||
#### Scenario: Эталон «Русский бисквит»
|
||||
- **WHEN** матрица строится по ОСВ РБ и Пекарни Счастливочной из эталонных фикстур
|
||||
- **THEN** суммы сравниваются с эталоном только для точных пар счетов и компаний; общая сумма элиминации эталона не приравнивается к минимуму по паре в API (решение владельца 08.10.2026)
|
||||
|
||||
#### Scenario: Итоги Дт и Кт
|
||||
- **WHEN** в периметре A, B, C; `amount_a` ячеек (A, B) = 100 и (A, C) = 20, `amount_b` ячеек (A, B) = 70 и (C, B) = 5
|
||||
- **THEN** `total_debit[A] = "120.00"`, `total_credit[B] = "75.00"`
|
||||
|
||||
#### Scenario: Фильтр по ЮЛ
|
||||
- **WHEN** запрос с `legal_entity_ids = [A, B]` при периметре A, B, C
|
||||
- **THEN** `companies` и обе оси — только A и B, ячейки и итоги не учитывают C; ID вне периметра — 422 `ENTITY_NOT_IN_PERIMETER`
|
||||
|
||||
#### Scenario: Пустая одна сторона
|
||||
- **WHEN** `debit_account_ids = [62]`, `credit_account_ids` пуст
|
||||
- **THEN** дебет — счёт 62 с потомками, кредит — все балансовые счета вида `P`/`AP`; режим выгрузки и аудита — `MANUAL`
|
||||
|
||||
### Requirement: XLSX шахматки
|
||||
`POST …/intercompany-matrix/export` принимает то же тело, что единый JSON-маршрут, и строит XLSX
|
||||
по той же выборке ЮЛ и счетов. В шапке пустая сторона обозначается «Все балансовые счета»; аудит сохраняет
|
||||
`legal_entity_ids` из запроса и использует
|
||||
`mode = ALL`, когда пусты обе стороны, иначе `mode = MANUAL`.
|
||||
|
||||
Ответ — XLSX с именем
|
||||
`intercompany-matrix_<ГК>_<год>-<квартал>_v<n>.xlsx`, шапкой (ГК, период, версия, единицы, режим,
|
||||
выбранные счета; для ALL — «Все балансовые счета») и строками разницы, дебета, кредита и процента расхождения для
|
||||
каждой пары. Пара SHALL заливаться цветом порога, если `exceeds_threshold` или `one_sided` — так же, как в API.
|
||||
Справа от колонок компаний SHALL быть колонка «Дебет» с `total_debit` по каждому ЮЛ строки (ячейка объединена
|
||||
на строки пары); под матрицей SHALL быть строка «Кредит» с `total_credit` по каждому ЮЛ колонки; ячейка на
|
||||
пересечении пустая. Значения — те же, что в JSON.
|
||||
|
||||
#### Scenario: Экспорт выбранных счетов
|
||||
- **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1
|
||||
- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования
|
||||
|
||||
#### Scenario: Подсветка пары без встречной стороны
|
||||
- **WHEN** выгружается ALL, у пары `amount_a = 100000000`, `amount_b = 0`
|
||||
- **THEN** все строки пары залиты цветом порога, в шапке «Режим» и «Счета дебета/кредита» — «Все балансовые счета»
|
||||
@ -0,0 +1,25 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Результирующая таблица ВГО
|
||||
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table`
|
||||
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра
|
||||
по имени; статья содержит `code`, `name`, `amounts[] {company_id, amount}` и `group_total`.
|
||||
Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения,
|
||||
долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра
|
||||
из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям.
|
||||
`amount` и `group_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP`
|
||||
(например, `"0.00"`).
|
||||
Итоговой строки по всем статьям (по компании или по группе) в ответе нет: суммы разных статей не складываются
|
||||
(решение аналитика 03.11.2026, согласовано с заказчиком). Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
|
||||
|
||||
#### Scenario: Компания без ОСВ
|
||||
- **WHEN** в периметре есть компания без подходящих строк ОСВ
|
||||
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0.00"`
|
||||
|
||||
#### Scenario: Нет обязательного счёта
|
||||
- **WHEN** в плане счетов отсутствует счёт 58.03
|
||||
- **THEN** ответ 422 `RESULT_ACCOUNTS_MISSING`, сообщение содержит `58.03`
|
||||
|
||||
#### Scenario: Нет итога по статьям
|
||||
- **WHEN** запрашивается результирующая таблица
|
||||
- **THEN** в ответе нет `grand_total` и итогов по компании через все статьи; в XLSX под статьями нет строки «Итого»
|
||||
12
openspec/changes/intercompany-matrix-totals/tasks.md
Normal file
12
openspec/changes/intercompany-matrix-totals/tasks.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Бэк (PR arm-core #218, автор — Потапова)
|
||||
|
||||
- [x] 1.1 Единый маршрут матрицы и выгрузки, `legal_entity_ids`, `ENTITY_NOT_IN_PERIMETER`
|
||||
- [x] 1.2 `total_debit` / `total_credit` в ответе и XLSX
|
||||
- [x] 1.3 Аудит выгрузки с `legal_entity_ids`
|
||||
- [x] 1.4 Ревью: `grand_total` убран, переименование `total_debit`, квантование итогов, change вместо правки `specs/`
|
||||
|
||||
## 2. Фронт (AURORA-1757, отдельный PR)
|
||||
|
||||
- [ ] 2.1 Клиент на единый маршрут, итоги из API, фильтр «Компании» в запрос
|
||||
@ -147,11 +147,12 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD
|
||||
- **THEN** событие `CONSOLIDATION_STATUS_CHANGE` с `user_id` подтвердившего, `before_value.status = INITIATED`
|
||||
|
||||
### Requirement: Аудит экспорта шахматки
|
||||
Каждый успешный `POST …/intercompany-matrix/export` и `GET …/intercompany-matrix/preset/{rule_id}/export`
|
||||
Каждый успешный `GET …/intercompany-matrix/all/export`, `POST …/intercompany-matrix/manual/export` и
|
||||
`GET …/intercompany-matrix/preset/{rule_id}/export`
|
||||
SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`,
|
||||
`object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора,
|
||||
`before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids,
|
||||
credit_account_ids, legal_entity_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и
|
||||
credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и
|
||||
`rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`,
|
||||
`MANUAL` или `PRESET`, массивы счетов — ID из запроса (в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка
|
||||
сводной ОСВ продолжает писать собственное событие `EXPORT`.
|
||||
|
||||
@ -175,20 +175,17 @@ filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<кварт
|
||||
— одну строку с `Q2`; `audit_event(EXPORT)` с `after_value.rows = 3`
|
||||
|
||||
### Requirement: Шахматка по сводной ОСВ версии
|
||||
`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix` принимает
|
||||
`{debit_account_ids?: UUID[], credit_account_ids?: UUID[], legal_entity_ids?: UUID[]}`. Если список стороны отсутствует или пуст,
|
||||
для неё выбираются все подходящие балансовые счета (`is_off_balance = false`); непустой список раскрывается
|
||||
по иерархии и может содержать забалансовые счета. Пустые списки обеих сторон эквивалентны режиму ALL,
|
||||
хотя бы один непустой список — MANUAL. Маршрут (`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу
|
||||
без сохранения в БД, из строк сводной ОСВ версии и её периметра. Для пустой стороны выбираются счета вида
|
||||
`A`/`AP` для дебета или `P`/`AP` для кредита; забалансовые счета (001–012) MUST NOT входить в такую выборку.
|
||||
Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный вид — 422 `ACCOUNT_KIND_MISMATCH`.
|
||||
Пустой `legal_entity_ids` выбирает все ЮЛ периметра; непустой ограничивает обе оси матрицы и суммы выбранными ЮЛ.
|
||||
ID вне периметра SHALL давать 422 `ENTITY_NOT_IN_PERIMETER`. Невидимая и несуществующая версия SHALL
|
||||
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/all` и
|
||||
`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual`
|
||||
(`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу без сохранения в БД, из строк сводной ОСВ версии
|
||||
и её периметра. ALL включает балансовые счета (`is_off_balance = false`) вида `A`/`AP` для дебета и `P`/`AP` для
|
||||
кредита; забалансовые счета (001–012) в ALL MUST NOT входить. MANUAL принимает обязательные непустые
|
||||
`debit_account_ids: UUID[]` и `credit_account_ids: UUID[]`, раскрывает выбранные счета по иерархии и оставляет
|
||||
подходящий вид; забалансовые счета в MANUAL допускаются. Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный
|
||||
вид — 422 `ACCOUNT_KIND_MISMATCH`; пустой массив — 422 валидации тела. Невидимая и несуществующая версия SHALL
|
||||
отвечать одинаковым 404 `NOT_FOUND`.
|
||||
|
||||
Ответ SHALL содержать `companies[] {id, name}` для всего периметра, `total_debet` и `total_credit`
|
||||
с суммой по каждому ЮЛ (ключ — ID ЮЛ, значение — десятичная строка с двумя знаками), и `cells[]` для каждой упорядоченной пары
|
||||
Ответ SHALL содержать `companies[] {id, name}` для всего периметра и `cells[]` для каждой упорядоченной пары
|
||||
разных компаний: `company_a_id`, `company_b_id`, `amount_a`, `amount_b`, `variance_amount`, `variance_percent`,
|
||||
`exceeds_threshold`, `one_sided`, `skipped_osv_entry_count`, `osv_entry_ids_debit`, `osv_entry_ids_credit`.
|
||||
Для пары `(A, B)` `amount_a` — сумма `closing_debit` строк A с контрагентом B по дебетовым счетам,
|
||||
@ -200,8 +197,6 @@ ID вне периметра SHALL давать 422 `ENTITY_NOT_IN_PERIMETER`.
|
||||
сальдо на признак не влияет. Пара подсвечивается, если `exceeds_threshold` или `one_sided`. `skipped_osv_entry_count`
|
||||
SHALL считать исходные строки ОСВ пары с `account_id = null` на обеих сторонах; они не входят в суммы. Массивы
|
||||
`osv_entry_ids_debit/credit` SHALL содержать ID исходных записей соответствующей стороны.
|
||||
`total_debet` суммирует `amount_a` ячеек по `company_a_id`, `total_credit` — `amount_b` по `company_b_id`.
|
||||
Для ЮЛ без подходящих строк обе суммы равны `"0.00"`.
|
||||
|
||||
#### Scenario: Пустой периметр и отсутствие ОСВ
|
||||
- **WHEN** у версии нет членов периметра
|
||||
@ -236,17 +231,11 @@ SHALL считать исходные строки ОСВ пары с `account_i
|
||||
- **THEN** суммы сравниваются с эталоном только для точных пар счетов и компаний; общая сумма элиминации эталона не приравнивается к минимуму по паре в API (решение владельца 08.10.2026)
|
||||
|
||||
### Requirement: XLSX шахматки
|
||||
`POST …/intercompany-matrix/export` принимает то же тело, что единый JSON-маршрут, и строит XLSX
|
||||
по той же выборке ЮЛ и счетов. В шапке пустая сторона обозначается «Все балансовые счета»; аудит сохраняет
|
||||
`legal_entity_ids` из запроса и использует
|
||||
`mode = ALL`, когда пусты обе стороны, иначе `mode = MANUAL`.
|
||||
|
||||
Ответ — XLSX с именем
|
||||
`GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/export` SHALL использовать
|
||||
те же режимы, тело и права, что и соответствующие JSON-маршруты. Ответ — XLSX с именем
|
||||
`intercompany-matrix_<ГК>_<год>-<квартал>_v<n>.xlsx`, шапкой (ГК, период, версия, единицы, режим,
|
||||
выбранные счета; для ALL — «Все балансовые счета») и строками разницы, дебета, кредита и процента расхождения для
|
||||
каждой пары. Пара SHALL заливаться цветом порога, если `exceeds_threshold` или `one_sided` — так же, как в API.
|
||||
Справа от колонок компаний SHALL быть колонка «Дебет» с `total_debet` по каждому ЮЛ строки;
|
||||
под матрицей SHALL быть строка «Кредит» с `total_credit` по каждому ЮЛ колонки.
|
||||
|
||||
#### Scenario: Экспорт выбранных счетов
|
||||
- **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1
|
||||
|
||||
@ -253,13 +253,12 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account`
|
||||
|
||||
### Requirement: Результирующая таблица ВГО
|
||||
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table`
|
||||
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[], grand_total}`. Компании — все члены периметра
|
||||
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра
|
||||
по имени; статья содержит `code`, `name`, `amounts[] {company_id, amount}` и `group_total`.
|
||||
Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения,
|
||||
долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра
|
||||
из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям.
|
||||
`grand_total` — сумму `group_total` всех статей. Итоги считаются из округлённых значений ответа.
|
||||
`amount`, `group_total` и `grand_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP`
|
||||
`amount` и `group_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP`
|
||||
(например, `"0.00"`).
|
||||
Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
|
||||
|
||||
@ -274,9 +273,7 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account`
|
||||
### Requirement: XLSX результирующей таблицы ВГО
|
||||
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export` (`CONSOLIDATION_VIEW`,
|
||||
policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что `GET …/intercompany-matrix/result-table`,
|
||||
из того же расчёта сводной ОСВ версии. Последняя строка «Итого» содержит только `grand_total` в колонке группы;
|
||||
ячейки этой строки под отдельными ЮЛ пусты.
|
||||
Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы
|
||||
из того же расчёта сводной ОСВ версии. Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы
|
||||
«руб.») и таблицей «Статья ВГО» × компании периметра + колонка «Итого по группе»; суммы — числа с двумя знаками. Имя
|
||||
файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v<n>.xlsx`. Ошибки — как у JSON-маршрута: невидимая или
|
||||
несуществующая версия — 404, отсутствие необходимого кода счёта — 422 `RESULT_ACCOUNTS_MISSING`.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user