Merge pull request 'тех долг' (#165) from AURORA-1651 into main
This commit is contained in:
commit
6659e3f0a8
@ -873,6 +873,8 @@ 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`.
|
||||
Режим ALL — только балансовые счета (без 001–012), MANUAL принимает и забалансовые. `one_sided` — ровно одна сумма
|
||||
пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651).
|
||||
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной
|
||||
версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
|
||||
`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. Имя XLSX пресета оканчивается на
|
||||
|
||||
@ -226,7 +226,8 @@ def calculate_matrix(
|
||||
variance_amount=variance_amount,
|
||||
variance_percent=variance_percent,
|
||||
exceeds_threshold=exceeds_threshold,
|
||||
one_sided=bool(debit_ids) != bool(credit_ids),
|
||||
# пара без встречной стороны — ровно одна сумма нулевая (знак не важен: сальдо бывает отрицательным)
|
||||
one_sided=(amount_a == ZERO) != (amount_b == ZERO),
|
||||
skipped_osv_entry_count=skipped_entries[(company_a, company_b)]
|
||||
+ skipped_entries[(company_b, company_a)],
|
||||
osv_entry_ids_debit=debit_ids,
|
||||
@ -404,8 +405,8 @@ class IntercompanyMatrixService:
|
||||
) -> IntercompanyMatrixOut:
|
||||
"""Построить шахматку по выбранному режиму.
|
||||
|
||||
ALL включает все счета соответствующей стороны; MANUAL — выбранные счета и их потомков;
|
||||
PRESET берёт счета и показатель из правила с идентификатором `rule_id`.
|
||||
ALL включает балансовые счета соответствующей стороны (без забалансовых); MANUAL — выбранные счета и их
|
||||
потомков; PRESET берёт счета и показатель из правила с идентификатором `rule_id`.
|
||||
"""
|
||||
result = await self._build_accounts(
|
||||
version_id,
|
||||
@ -519,8 +520,12 @@ class IntercompanyMatrixService:
|
||||
if mode == "ALL":
|
||||
if debit_account_ids or credit_account_ids:
|
||||
raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета")
|
||||
debit = frozenset(account.id for account in accounts if account.kind in DEBIT_KINDS)
|
||||
credit = frozenset(account.id for account in accounts if account.kind in CREDIT_KINDS)
|
||||
debit = frozenset(
|
||||
account.id for account in accounts if not account.is_off_balance and account.kind in DEBIT_KINDS
|
||||
)
|
||||
credit = frozenset(
|
||||
account.id for account in accounts if not account.is_off_balance and account.kind in CREDIT_KINDS
|
||||
)
|
||||
selection = MatrixSelection(debit, credit, "closing")
|
||||
elif mode == "MANUAL":
|
||||
if not debit_account_ids or not credit_account_ids:
|
||||
|
||||
@ -51,11 +51,13 @@ def _write_details(
|
||||
|
||||
|
||||
def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> int:
|
||||
mode_name = {"ALL": "Все счета", "MANUAL": "Выбранные счета", "PRESET": "Пресет"}[info.mode]
|
||||
# режим ALL не берёт забалансовые счета (AURORA-1651), шапка говорит об этом прямо
|
||||
all_accounts = "Все балансовые счета"
|
||||
mode_name = {"ALL": all_accounts, "MANUAL": "Выбранные счета", "PRESET": "Пресет"}[info.mode]
|
||||
extra = [
|
||||
("Режим", mode_name),
|
||||
("Счета дебета", ", ".join(info.debit_account_codes) if info.mode != "ALL" else "Все счета"),
|
||||
("Счета кредита", ", ".join(info.credit_account_codes) if info.mode != "ALL" else "Все счета"),
|
||||
("Счета дебета", ", ".join(info.debit_account_codes) if info.mode != "ALL" else all_accounts),
|
||||
("Счета кредита", ", ".join(info.credit_account_codes) if info.mode != "ALL" else all_accounts),
|
||||
("Показатель", "Сальдо" if info.measure == "closing" else "Оборот"),
|
||||
]
|
||||
if info.mode == "PRESET":
|
||||
@ -134,7 +136,7 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo)
|
||||
if cell is None:
|
||||
continue
|
||||
_write_values(sheet, start_row, column, cell)
|
||||
if cell.exceeds_threshold:
|
||||
if cell.exceeds_threshold or cell.one_sided:
|
||||
for offset in range(len(METRICS)):
|
||||
marked = sheet.cell(start_row + offset, column)
|
||||
marked.fill = THRESHOLD_FILL
|
||||
|
||||
@ -264,6 +264,28 @@ async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientF
|
||||
assert old_preset.status_code == NOT_FOUND
|
||||
|
||||
|
||||
async def test_all_excludes_off_balance_accounts_but_manual_can_select_them(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
world = await _world(seeded)
|
||||
assert world.accounts["001"].is_off_balance is True
|
||||
await _source(seeded, world, credit=False, amount="100", account="001")
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
all_response = await _request(client, world.version.id, "all", world.selection)
|
||||
assert all_response.status_code == OK, all_response.text
|
||||
assert all(Decimal(cell["amount_a"]) == 0 for cell in all_response.json()["cells"])
|
||||
|
||||
manual_selection = {
|
||||
"debit_account_ids": [str(world.accounts["001"].id)],
|
||||
"credit_account_ids": [str(world.accounts["60.01"].id)],
|
||||
}
|
||||
manual_response = await _request(client, world.version.id, "manual", manual_selection)
|
||||
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")
|
||||
|
||||
|
||||
async def test_unmapped_account_source_is_visible_in_cell_count(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
@ -296,7 +318,7 @@ async def test_pair_without_counterpart(
|
||||
cell = next(c for c in response.json()["cells"] if c["company_a_id"] == str(world.members[0].id))
|
||||
assert Decimal(cell["amount_a"]) == (Decimal(0) if credit else Decimal(amount))
|
||||
assert Decimal(cell["amount_b"]) == (Decimal(amount) if credit else Decimal(0))
|
||||
assert cell["one_sided"] is True
|
||||
assert cell["one_sided"] is (Decimal(amount) != 0)
|
||||
assert cell["variance_percent"] is None
|
||||
assert cell["exceeds_threshold"] is False
|
||||
assert bool(cell["osv_entry_ids_credit"]) is credit
|
||||
@ -404,9 +426,9 @@ async def test_export_filename_header_and_percentage(
|
||||
"2024-Q1",
|
||||
"v1",
|
||||
"руб.; расхождение — %",
|
||||
"Выбранные счета" if mode == "manual" else "Все счета",
|
||||
"62.01" if mode == "manual" else "Все счета",
|
||||
"60.01" if mode == "manual" else "Все счета",
|
||||
"Выбранные счета" if mode == "manual" else "Все балансовые счета",
|
||||
"62.01" if mode == "manual" else "Все балансовые счета",
|
||||
"60.01" if mode == "manual" else "Все балансовые счета",
|
||||
]
|
||||
assert sheet["B14"].value == "Расхождение, %"
|
||||
expected_percent = Decimal("11.11")
|
||||
|
||||
@ -201,7 +201,58 @@ async def test_preset_rejects_income_rule_before_selecting_accounts(monkeypatch:
|
||||
assert error.value.code == "INVALID_RULE"
|
||||
|
||||
|
||||
def test_one_sided_reflects_osv_sources_even_when_amount_is_zero() -> None:
|
||||
async def test_all_excludes_off_balance_accounts_only(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
company_a, company_b = uuid.uuid4(), uuid.uuid4()
|
||||
off_balance = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.A, parent_id=None, is_off_balance=True)
|
||||
off_balance_credit = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.P, parent_id=None, is_off_balance=True)
|
||||
debit = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.A, parent_id=None, is_off_balance=False)
|
||||
credit = SimpleNamespace(id=uuid.uuid4(), kind=AccountKind.P, parent_id=None, is_off_balance=False)
|
||||
accounts = [off_balance, off_balance_credit, debit, credit]
|
||||
monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=accounts))
|
||||
rows = [
|
||||
_row(company_a, company_b, off_balance.id, debit="100"),
|
||||
_row(company_a, company_b, debit.id, debit="30"),
|
||||
_row(company_b, company_a, off_balance_credit.id, credit="40"),
|
||||
_row(company_b, company_a, credit.id, credit="20"),
|
||||
]
|
||||
members = {
|
||||
company_a: SimpleNamespace(id=company_a, name="Компания А"),
|
||||
company_b: SimpleNamespace(id=company_b, name="Компания Б"),
|
||||
}
|
||||
osv_service = SimpleNamespace(
|
||||
build_with_members=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
consolidated=SimpleNamespace(rows=rows), version=None, consolidation=None, members=members
|
||||
)
|
||||
)
|
||||
)
|
||||
service = matrix.IntercompanyMatrixService(SimpleNamespace(), None, osv_service)
|
||||
|
||||
all_matrix = await service.build_accounts(uuid.uuid4(), mode="ALL")
|
||||
manual_matrix = await service.build_accounts(
|
||||
uuid.uuid4(), mode="MANUAL", debit_account_ids=[off_balance.id], credit_account_ids=[off_balance_credit.id]
|
||||
)
|
||||
assert all_matrix.cells[0].amount_a == Decimal("30")
|
||||
assert all_matrix.cells[0].amount_b == Decimal("20")
|
||||
assert manual_matrix.cells[0].amount_a == Decimal("100")
|
||||
assert manual_matrix.cells[0].amount_b == Decimal("40")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("debit_amount", "credit_amount", "one_sided", "exceeds"),
|
||||
[
|
||||
("100000000", "0", True, False),
|
||||
("0", "100000000", True, False),
|
||||
("0", "0", False, False),
|
||||
("100", "100", False, False),
|
||||
("100", "50", False, True),
|
||||
("-50", "0", True, False),
|
||||
("0", "-50", True, False),
|
||||
],
|
||||
)
|
||||
def test_one_sided_uses_amounts_even_with_zero_balance_rows(
|
||||
debit_amount: str, credit_amount: str, one_sided: bool, exceeds: bool
|
||||
) -> None:
|
||||
company_a, company_b = uuid.uuid4(), uuid.uuid4()
|
||||
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
|
||||
companies = [
|
||||
@ -209,15 +260,15 @@ def test_one_sided_reflects_osv_sources_even_when_amount_is_zero() -> None:
|
||||
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
|
||||
]
|
||||
selection = matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing")
|
||||
debit = _row(company_a, company_b, debit_account)
|
||||
credit = _row(company_b, company_a, credit_account)
|
||||
debit = _row(company_a, company_b, debit_account, debit=debit_amount)
|
||||
credit = _row(company_b, company_a, credit_account, credit=credit_amount)
|
||||
debit.entry_ids = (uuid.uuid4(),)
|
||||
credit.entry_ids = (uuid.uuid4(),)
|
||||
|
||||
for rows, expected in (([], False), ([debit], True), ([credit], True), ([debit, credit], False)):
|
||||
cell = matrix.calculate_matrix(rows, companies, selection).cells[0]
|
||||
assert cell.one_sided is expected
|
||||
assert cell.model_dump(mode="json")["one_sided"] is expected
|
||||
cell = matrix.calculate_matrix([debit, credit], companies, selection).cells[0]
|
||||
assert cell.one_sided is one_sided
|
||||
assert cell.exceeds_threshold is exceeds
|
||||
assert cell.model_dump(mode="json")["one_sided"] is one_sided
|
||||
|
||||
|
||||
def test_unmapped_account_entries_are_counted_for_both_directions_of_pair() -> None:
|
||||
|
||||
@ -150,6 +150,40 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("amount", "highlighted"), [("100000000", True), ("0", False)])
|
||||
def test_xlsx_highlights_one_sided_amount_but_not_two_zero_balances(amount: str, highlighted: bool) -> None:
|
||||
company_a = UUID("11111111-1111-1111-1111-111111111111")
|
||||
company_b = UUID("22222222-2222-2222-2222-222222222222")
|
||||
matrix = IntercompanyMatrixOut(
|
||||
companies=[
|
||||
IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"),
|
||||
IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"),
|
||||
],
|
||||
cells=[
|
||||
IntercompanyMatrixCellOut(
|
||||
company_a_id=company_a,
|
||||
company_b_id=company_b,
|
||||
amount_a=amount,
|
||||
amount_b="0",
|
||||
variance_amount=amount,
|
||||
variance_percent=None,
|
||||
exceeds_threshold=False,
|
||||
one_sided=highlighted,
|
||||
skipped_osv_entry_count=0,
|
||||
osv_entry_ids_debit=[],
|
||||
osv_entry_ids_credit=[],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, _info(mode="ALL")))).active
|
||||
assert sheet is not None
|
||||
assert sheet["B6"].value == "Все балансовые счета"
|
||||
assert all(
|
||||
(sheet.cell(row, 4).fill.fgColor.rgb == THRESHOLD_FILL.fgColor.rgb) is highlighted for row in range(11, 15)
|
||||
)
|
||||
|
||||
|
||||
async def test_export_routes_use_matrix_calculation() -> None:
|
||||
version_id = UUID("11111111-1111-1111-1111-111111111111")
|
||||
account_id = UUID("22222222-2222-2222-2222-222222222222")
|
||||
@ -202,7 +236,7 @@ async def test_export_routes_use_matrix_calculation() -> None:
|
||||
assert disposition.startswith("attachment; filename=\"intercompany-matrix.xlsx\"; filename*=UTF-8''")
|
||||
assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == expected_info.filename
|
||||
book = load_workbook(BytesIO(response.body))
|
||||
assert book.active["B6"].value in {"Все счета", "Выбранные счета", "Пресет"}
|
||||
assert book.active["B6"].value in {"Все балансовые счета", "Выбранные счета", "Пресет"}
|
||||
preset_sheet = load_workbook(BytesIO(preset_response.body)).active
|
||||
assert preset_sheet["B7"].value == "62.01"
|
||||
assert preset_sheet["B8"].value == "60.01"
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-22
|
||||
31
openspec/changes/intercompany-matrix-one-sided/design.md
Normal file
31
openspec/changes/intercompany-matrix-one-sided/design.md
Normal file
@ -0,0 +1,31 @@
|
||||
## Context
|
||||
|
||||
PR RSHB/arm-core#165, задача AURORA-1651 (технический долг ревью PR #114). Шахматка — чистая функция от сводной ОСВ
|
||||
версии (DECISION-5), контракт ответа не меняется: меняются смысл `one_sided`, состав счетов режима ALL и заливка xlsx.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. `one_sided = (amount_a == 0) != (amount_b == 0)`. Формула из описания задачи (`min(amount_a, amount_b) == 0 and
|
||||
variance_amount > 0`) на неотрицательных суммах даёт то же, но на отрицательном сальдо (возможно по §1) пропускает
|
||||
пару: −50 против 0 → `min = −50`. Знак ненулевой суммы не важен.
|
||||
2. Подсветка в API и в xlsx — `exceeds_threshold or one_sided`. Поле `exceeds_threshold` сохраняет смысл «процент > 5»;
|
||||
экран шахматки красит пару по тому же правилу (PR #158, change `intercompany-matrix-ui`).
|
||||
3. Забалансовые счета (`is_off_balance = true`) исключаются только в ALL; MANUAL выбирает их явно, это не ошибка.
|
||||
4. Фильтры `GET /directories/accounts` (`kind[]`, `row_type`, `is_off_balance`) из первой редакции PR убраны: в задаче их
|
||||
нет, потребителей нет, при фильтре по `kind` в ответе оставались `parent_id` на строки вне ответа.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
- 2026-10-22. Формула `one_sided` через «ровно одна сумма нулевая» вместо формулы из описания задачи. Обоснование —
|
||||
отрицательное сальдо (решение 1). Цена ошибки: если аналитик решит, что отрицательная сторона — не «пара без
|
||||
встречной стороны», меняется одна строка и тест.
|
||||
- 2026-10-22. Влив `main` с #141 и разрешение конфликтов выполнил ревьюер (владелец: «доведём задачу до ума, потом
|
||||
сольём»). В ALL сохранены и фильтр забалансовых, и `MatrixSelection(..., "closing")` из #141; тесты #141 на старый
|
||||
смысл `one_sided` переписаны. Цена ошибки: низкая, расчёт PRESET не затронут (свои счета правила).
|
||||
|
||||
## Решения владельца (2026-10-22)
|
||||
|
||||
- Экран красит и по `one_sided` (а не бэкенд выставляет `exceeds_threshold`).
|
||||
- Фильтр `/directories/accounts` убрать из PR.
|
||||
- Счёт `000` «Вспомогательный» не трогать, вопрос — в `07-open-questions.md`.
|
||||
- Процент при отрицательной меньшей стороне — вопрос в `07-open-questions.md`, в этом PR не меняется.
|
||||
34
openspec/changes/intercompany-matrix-one-sided/proposal.md
Normal file
34
openspec/changes/intercompany-matrix-one-sided/proposal.md
Normal file
@ -0,0 +1,34 @@
|
||||
## Why
|
||||
|
||||
Технический долг повторного ревью PR #114 (AURORA-1537), задача AURORA-1651. Признак `one_sided` считался по наличию
|
||||
строк ОСВ, а не по суммам: пара «100 млн против строки с нулевым сальдо» выглядела сошедшейся (`one_sided = false`,
|
||||
`exceeds_threshold = false`), а «0.00 против 0.00» с одной строкой — подсвечивалась ложно. Книга xlsx заливала пару только
|
||||
по порогу. Режим «все счета» брал забалансовые счета 001–012: например, 009 «Обеспечения выданные» на компанию группы
|
||||
давал ложное расхождение (решение владельца 16.10.2026).
|
||||
|
||||
Реализация — PR RSHB/arm-core#165 (ветка `AURORA-1651`, автор — Потапова), доведена ревьюером по решению владельца
|
||||
22.10.2026.
|
||||
|
||||
## What Changes
|
||||
|
||||
- `one_sided` = ровно одна из сумм пары нулевая (знак не важен: сальдо в ОСВ бывает отрицательным).
|
||||
- XLSX шахматки заливает пару, если `exceeds_threshold` или `one_sided`; в шапке режима ALL — «Все балансовые счета».
|
||||
- Режим ALL — только балансовые счета (`is_off_balance = false`); MANUAL принимает любые, включая забалансовые.
|
||||
- Правила — `../docs/context/05-domain-rules.md` §6 (docs-PR вместе с этим change), открытые вопросы — OQ про счёт `000`
|
||||
и процент при отрицательной меньшей стороне.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Подсветка односторонней пары на экране шахматки — фронт, PR #158 (change `intercompany-matrix-ui`).
|
||||
- Фильтры `GET /directories/accounts` (были в первой редакции PR, убраны решением владельца 22.10.2026).
|
||||
- Процент расхождения при отрицательной меньшей стороне и исключение счёта `000` из ALL — открытые вопросы.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
- `consolidated-osv`: требования «Шахматка по сводной ОСВ версии» и «XLSX шахматки».
|
||||
|
||||
## Impact
|
||||
|
||||
- `api/src/services/intercompany_matrix.py`, `api/src/services/intercompany_matrix_export.py`; тесты шахматки.
|
||||
- Контракт API не меняется (поля те же), меняется смысл `one_sided` и состав счетов режима ALL.
|
||||
@ -0,0 +1,72 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Шахматка по сводной ОСВ версии
|
||||
`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}` для всего периметра и `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 исходных записей соответствующей стороны.
|
||||
|
||||
#### Scenario: Пустой периметр и отсутствие ОСВ
|
||||
- **WHEN** у версии нет членов периметра
|
||||
- **THEN** `companies = []`, `cells = []`; если компании есть, но ОСВ по выбранным счетам нет, ячейки присутствуют с нулевыми суммами
|
||||
|
||||
#### 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)
|
||||
|
||||
### Requirement: 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.
|
||||
|
||||
#### Scenario: Экспорт выбранных счетов
|
||||
- **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1
|
||||
- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования
|
||||
|
||||
#### Scenario: Подсветка пары без встречной стороны
|
||||
- **WHEN** выгружается ALL, у пары `amount_a = 100000000`, `amount_b = 0`
|
||||
- **THEN** все строки пары залиты цветом порога, в шапке «Режим» и «Счета дебета/кредита» — «Все балансовые счета»
|
||||
8
openspec/changes/intercompany-matrix-one-sided/tasks.md
Normal file
8
openspec/changes/intercompany-matrix-one-sided/tasks.md
Normal file
@ -0,0 +1,8 @@
|
||||
## 1. Реализация (PR RSHB/arm-core#165, AURORA-1651)
|
||||
|
||||
- [x] 1.1 Автор: `one_sided` по суммам, заливка xlsx по `exceeds_threshold or one_sided`, ALL без забалансовых счетов.
|
||||
- [x] 1.2 Ревьюер: влив `main` (#141), конфликты; `one_sided` на отрицательных суммах; шапка ALL «Все балансовые счета»;
|
||||
фильтр `/directories/accounts` убран; тесты (суммы, нули, отрицательные, xlsx, ALL/MANUAL с 001).
|
||||
- [x] 1.3 Ревьюер: правила §6 и OQ в `../docs` (docs-PR вместе с этим change), гайд фронта §5.7.1.
|
||||
- [x] 1.4 Ревьюер: подсветка `one_sided` на экране — PR #158, change `intercompany-matrix-ui`.
|
||||
- [ ] 1.5 После слияния: архив change.
|
||||
Loading…
x
Reference in New Issue
Block a user