Compare commits
No commits in common. "5682897ed05d68fd1563dab1e7f91e8a31f95a85" and "f9f25e824505fa5cb584ddd5f7167290f47a2255" have entirely different histories.
5682897ed0
...
f9f25e8245
@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-16
|
||||
@ -1,50 +0,0 @@
|
||||
## Context
|
||||
|
||||
Реализация — PR RSHB/arm-core#114 (AURORA-1537). Шахматка — чистая функция от сводной ОСВ версии, периметра и выбранных
|
||||
счетов; в БД не хранится (инвариант «производные консолидации не хранятся», DECISION-5).
|
||||
|
||||
## Decisions
|
||||
|
||||
1. Матрица считается поверх `ConsolidatedOsvService.build_with_members` (бывший приватный `_build`): строки свода и
|
||||
участники периметра берутся из того же расчёта, что и сводная ОСВ; в `SourceRow`/`ConsolidatedRow` добавлено
|
||||
`account_id`.
|
||||
2. Пара `(A, B)`: сторона 1 — дебет ОСВ A по контрагенту B, сторона 2 — кредит ОСВ B по контрагенту A. Расчёт
|
||||
поддерживает показатели `closing`/`turnover`, но в режимах ALL и MANUAL всегда используется конечное сальдо
|
||||
(`closing`).
|
||||
3. Режим ALL берёт все счета вида `A`/`AP` (дебет) и `P`/`AP` (кредит); MANUAL раскрывает выбранные счета по иерархии
|
||||
`chart_of_account` и фильтрует по виду.
|
||||
4. Порог расхождения 5 % проверяется по точному значению до округления; суммы в JSON — десятичные строки с двумя
|
||||
знаками (`ROUND_HALF_UP`).
|
||||
5. Строки пары без `account_id` в суммы не входят и считаются в `skipped_osv_entry_count`.
|
||||
6. Результирующая таблица — пять фиксированных статей (`RESULT_ARTICLES`) со списками кодов счетов и исключений;
|
||||
отсутствие любого кода в плане счетов — 422 `RESULT_ACCOUNTS_MISSING`.
|
||||
7. xlsx: имя файла `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>.xlsx`; каждая выгрузка пишет
|
||||
собственное событие `audit_event(EXPORT)`.
|
||||
8. Сверка с эталоном «Русский бисквит» — только по точным парам счетов и компаний; общая сумма элиминации эталона не
|
||||
приравнивается к минимуму по паре (решение владельца 08.10.2026).
|
||||
|
||||
## Known issues
|
||||
|
||||
Требования ниже описывают текущее поведение; дефекты вынесены в задачи Jira и в этом change не исправляются.
|
||||
|
||||
- AURORA-1651: `one_sided` считается по наличию исходных записей, а не по суммам и строкам-основаниям — пара «100 млн
|
||||
против строки с нулевым сальдо» не подсвечивается, «0 против 0» подсвечивается ложно; подсветка таких пар в xlsx;
|
||||
режим ALL захватывает забалансовые счета 001–012.
|
||||
- AURORA-1652: `GET …/elimination-rules` пуст до задачи 1536, хотя требование описывает непустой ответ; формат сумм
|
||||
результирующей таблицы `"0"` вместо `"0.00"` (расхождение с форматом матрицы); ссылки на §§ в docstring; ссылка README
|
||||
на гайд фронта.
|
||||
- AURORA-1653: переход «ячейка → сводная ОСВ» — фильтр по паре в `GET …/osv`; `osv_entry_ids_*` содержат ID нулевых
|
||||
строк и не отсортированы.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
- 16.10.2026. Требования автора перенесены из `openspec/specs/*` в дельты `ADDED` дословно, без правки содержания.
|
||||
Обоснование: владелец поручил оформить change процессно, а содержательные замечания ревью уже вынесены в задачи.
|
||||
Цена ошибки: низкая — текст можно поправить в этом же change'е до архивации.
|
||||
- 16.10.2026. Спека фиксирует текущее поведение, включая семантику `one_sided` и формат `"0"` результирующей таблицы;
|
||||
исправление требований идёт вместе с кодом в AURORA-1651/1652/1653 (там же MODIFIED-дельты). Обоснование: иначе спека
|
||||
разойдётся с кодом PR. Цена ошибки: до исправления спека описывает известные дефекты (отмечены в `## Known issues`).
|
||||
- 16.10.2026. Все требования оформлены как `ADDED` в существующих capability, новая capability не заводится.
|
||||
Обоснование: шахматка — отчёт по сводной ОСВ (consolidated-osv), API версий (consolidations-api), справочников и
|
||||
аудита; отдельная capability дробила бы уже согласованную структуру. Цена ошибки: низкая, перенос между capability —
|
||||
правка путей дельт.
|
||||
@ -1,32 +0,0 @@
|
||||
## Why
|
||||
|
||||
Отчёт «Шахматка ВГО» (ТЗ §6): аналитик сверяет внутригрупповые обороты пар компаний периметра по строкам сводной ОСВ
|
||||
версии, видит расхождения сверх порога, получает результирующую таблицу по пяти статьям и выгрузку в xlsx. Jira
|
||||
AURORA-1537, реализация — PR RSHB/arm-core#114 (автор — Потапова). Требования автор дописала прямо в
|
||||
`openspec/specs/`; при подготовке к слиянию они оформлены как дельты этого change'а (решение владельца 16.10.2026).
|
||||
|
||||
## What Changes
|
||||
|
||||
- Расчёт шахматки без хранения в БД (`GET …/intercompany-matrix/all`, `POST …/intercompany-matrix/manual`) по строкам
|
||||
сводной ОСВ версии и её периметру; режимы ALL и MANUAL.
|
||||
- Результирующая таблица ВГО из пяти фиксированных статей (`GET …/intercompany-matrix/result-table`).
|
||||
- Выгрузка xlsx шахматки (`…/all/export`, `…/manual/export`) и аудит `EXPORT` каждой выгрузки.
|
||||
- Правила элиминации версии (`GET …/elimination-rules`).
|
||||
- Плоский список счетов для фильтров (`GET /api/v1/directories/accounts`).
|
||||
- В сводную ОСВ добавлен `account_id` строки (внутреннее поле домена, в API свода не выходит).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
- `consolidated-osv`: шахматка по сводной ОСВ и её xlsx.
|
||||
- `consolidations-api`: правила элиминации версии, результирующая таблица ВГО.
|
||||
- `directories-api`: плоский список счетов.
|
||||
- `audit-trail`: аудит экспорта шахматки.
|
||||
|
||||
## Impact
|
||||
|
||||
Только `arm-core` (API, сервисы, схемы), таблицы и DBML не меняются. Код реализован и протестирован в PR #114; этот
|
||||
change фиксирует требования к текущему поведению ветки, включая известные дефекты (см. `design.md`, `## Known issues`).
|
||||
@ -1,13 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Аудит экспорта шахматки
|
||||
Каждый успешный `GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/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, cells}`. `mode` — `ALL` или `MANUAL`, массивы счетов — ID из запроса (в ALL пустые),
|
||||
`cells` — число ячеек. Выгрузка сводной ОСВ продолжает писать собственное событие `EXPORT`.
|
||||
|
||||
#### Scenario: Две выгрузки
|
||||
- **WHEN** пользователь дважды выгружает ту же шахматку
|
||||
- **THEN** в `audit_event` две разные записи `EXPORT`, каждая со своим payload и числом ячеек
|
||||
@ -1,49 +0,0 @@
|
||||
## ADDED 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 включает все счета вида `A`/`AP` для дебета и `P`/`AP` для кредита. MANUAL принимает
|
||||
обязательные непустые `debit_account_ids: UUID[]` и `credit_account_ids: UUID[]`, раскрывает выбранные счета
|
||||
по иерархии и оставляет подходящий вид. Неизвестный счёт 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 при наличии исходных записей ОСВ только с одной стороны,
|
||||
в том числе при нулевой сумме. `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** у пары есть строка свода с `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`, шапкой (ГК, период, версия, единицы, режим,
|
||||
выбранные счета) и строками разницы, дебета, кредита и процента расхождения для каждой пары.
|
||||
|
||||
#### Scenario: Экспорт выбранных счетов
|
||||
- **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1
|
||||
- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования
|
||||
@ -1,29 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Правила элиминации версии
|
||||
`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК)
|
||||
SHALL возвращать все правила версии массивом. Каждое правило SHALL содержать `rule_no`, `version_no`,
|
||||
`statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), `company_1`, `company_2`;
|
||||
каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов.
|
||||
Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`.
|
||||
|
||||
#### Scenario: Правило с исключением
|
||||
- **WHEN** у правила первая сторона включает счёт 76 и исключает 76.07.5
|
||||
- **THEN** ID 76 находится в `company_1.accounts`, ID 76.07.5 — в `company_1.excluded`
|
||||
|
||||
### 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` — сумма по компаниям.
|
||||
Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
|
||||
|
||||
#### Scenario: Компания без ОСВ
|
||||
- **WHEN** в периметре есть компания без подходящих строк ОСВ
|
||||
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0"`
|
||||
|
||||
#### Scenario: Нет обязательного счёта
|
||||
- **WHEN** в плане счетов отсутствует счёт 58.03
|
||||
- **THEN** ответ 422 `RESULT_ACCOUNTS_MISSING`, сообщение содержит `58.03`
|
||||
@ -1,16 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Плоский список счетов для фильтров шахматки
|
||||
`GET /api/v1/directories/accounts` SHALL возвращать пользователю с `DIRECTORY_VIEW` все строки
|
||||
`chart_of_account` одним массивом в естественном порядке кодов. Каждая строка SHALL содержать `id`, `code`,
|
||||
`parent_id`, `name`, `kind` (`A`/`P`/`AP`/null), `row_type`, `is_off_balance`, `is_debit`, `is_credit`;
|
||||
корневые счета имеют `parent_id = null`. `is_debit = true` для `A` и `AP`, `is_credit = true` для `P` и `AP`;
|
||||
при `kind = null` оба признака ложны.
|
||||
|
||||
#### Scenario: Счета разных уровней
|
||||
- **WHEN** в плане счетов есть счёт, субсчёт и аналитика
|
||||
- **THEN** ответ содержит все три строки без вложенности, а `parent_id` указывает на родительскую строку
|
||||
|
||||
#### Scenario: Нет права на справочник
|
||||
- **WHEN** пользователь без `DIRECTORY_VIEW` запрашивает список счетов
|
||||
- **THEN** ответ 403
|
||||
@ -1,4 +0,0 @@
|
||||
## 1. Реализация
|
||||
|
||||
Реализация уже выполнена автором в PR RSHB/arm-core#114 (AURORA-1537); задач плана нет. Доработки по замечаниям ревью —
|
||||
AURORA-1651, AURORA-1652, AURORA-1653.
|
||||
@ -145,3 +145,15 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD
|
||||
#### Scenario: Автоматический переход
|
||||
- **WHEN** подтверждение документа перевело версию в `DATA_CORRECTION`
|
||||
- **THEN** событие `CONSOLIDATION_STATUS_CHANGE` с `user_id` подтвердившего, `before_value.status = INITIATED`
|
||||
|
||||
### Requirement: Аудит экспорта шахматки
|
||||
Каждый успешный `GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/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, cells}`. `mode` — `ALL` или `MANUAL`, массивы счетов — ID из запроса (в ALL пустые),
|
||||
`cells` — число ячеек. Выгрузка сводной ОСВ продолжает писать собственное событие `EXPORT`.
|
||||
|
||||
#### Scenario: Две выгрузки
|
||||
- **WHEN** пользователь дважды выгружает ту же шахматку
|
||||
- **THEN** в `audit_event` две разные записи `EXPORT`, каждая со своим payload и числом ячеек
|
||||
|
||||
@ -162,3 +162,51 @@ filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<кварт
|
||||
- **WHEN** экспорт версии с 3 строками и 1 пробелом открыт `openpyxl`
|
||||
- **THEN** лист «Сводная ОСВ» содержит заголовок и 3 строки, суммы — числа с двумя знаками, равные API; лист «Пробелы»
|
||||
— одну строку с `Q2`; `audit_event(EXPORT)` с `after_value.rows = 3`
|
||||
|
||||
### 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 включает все счета вида `A`/`AP` для дебета и `P`/`AP` для кредита. MANUAL принимает
|
||||
обязательные непустые `debit_account_ids: UUID[]` и `credit_account_ids: UUID[]`, раскрывает выбранные счета
|
||||
по иерархии и оставляет подходящий вид. Неизвестный счёт 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 при наличии исходных записей ОСВ только с одной стороны,
|
||||
в том числе при нулевой сумме. `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** у пары есть строка свода с `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`, шапкой (ГК, период, версия, единицы, режим,
|
||||
выбранные счета) и строками разницы, дебета, кредита и процента расхождения для каждой пары.
|
||||
|
||||
#### Scenario: Экспорт выбранных счетов
|
||||
- **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1
|
||||
- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования
|
||||
|
||||
@ -223,3 +223,31 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account`
|
||||
#### Scenario: Невидимая версия при занятой строке
|
||||
- **WHEN** версию чужого филиала держит `FOR UPDATE` другая транзакция, пользователь без видимости вызывает `POST …/lock`
|
||||
- **THEN** 404 `VERSION_NOT_FOUND` без ожидания
|
||||
|
||||
### Requirement: Правила элиминации версии
|
||||
`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК)
|
||||
SHALL возвращать все правила версии массивом. Каждое правило SHALL содержать `rule_no`, `version_no`,
|
||||
`statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), `company_1`, `company_2`;
|
||||
каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов.
|
||||
Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`.
|
||||
|
||||
#### Scenario: Правило с исключением
|
||||
- **WHEN** у правила первая сторона включает счёт 76 и исключает 76.07.5
|
||||
- **THEN** ID 76 находится в `company_1.accounts`, ID 76.07.5 — в `company_1.excluded`
|
||||
|
||||
### 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` — сумма по компаниям.
|
||||
Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
|
||||
|
||||
#### Scenario: Компания без ОСВ
|
||||
- **WHEN** в периметре есть компания без подходящих строк ОСВ
|
||||
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0"`
|
||||
|
||||
#### Scenario: Нет обязательного счёта
|
||||
- **WHEN** в плане счетов отсутствует счёт 58.03
|
||||
- **THEN** ответ 422 `RESULT_ACCOUNTS_MISSING`, сообщение содержит `58.03`
|
||||
|
||||
@ -189,3 +189,18 @@ SHALL выполняться в одной транзакции с запись
|
||||
#### Scenario: Ошибка внутри мутации
|
||||
- **WHEN** при переводе ЮЛ закрытие членства завершается ошибкой БД
|
||||
- **THEN** ЮЛ остаётся в прежней ГК, строк истории и аудита нет
|
||||
|
||||
### Requirement: Плоский список счетов для фильтров шахматки
|
||||
`GET /api/v1/directories/accounts` SHALL возвращать пользователю с `DIRECTORY_VIEW` все строки
|
||||
`chart_of_account` одним массивом в естественном порядке кодов. Каждая строка SHALL содержать `id`, `code`,
|
||||
`parent_id`, `name`, `kind` (`A`/`P`/`AP`/null), `row_type`, `is_off_balance`, `is_debit`, `is_credit`;
|
||||
корневые счета имеют `parent_id = null`. `is_debit = true` для `A` и `AP`, `is_credit = true` для `P` и `AP`;
|
||||
при `kind = null` оба признака ложны.
|
||||
|
||||
#### Scenario: Счета разных уровней
|
||||
- **WHEN** в плане счетов есть счёт, субсчёт и аналитика
|
||||
- **THEN** ответ содержит все три строки без вложенности, а `parent_id` указывает на родительскую строку
|
||||
|
||||
#### Scenario: Нет права на справочник
|
||||
- **WHEN** пользователь без `DIRECTORY_VIEW` запрашивает список счетов
|
||||
- **THEN** ответ 403
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"printWidth": 140,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"bracketSameLine": true,
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"arrowParens": "always",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.css",
|
||||
"options": { "singleQuote": false }
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -13,22 +13,9 @@ SPA кредитного аналитика поверх API `arm-core` (`/api/v
|
||||
`ARM_CORE_URL` (по умолчанию `http://localhost:8000`), `REACT_APP_API_URL` в dev не нужен.
|
||||
4. Вход — через dev-форму (`POST /dev/token`), например `ka_go@armka.local`.
|
||||
|
||||
## Форматирование и линтеры
|
||||
|
||||
**Перед PR — `npm run format`** (или `make format`): `eslint --fix` → `biome check --write` (импорты, безопасные
|
||||
исправления) → `prettier --write`. Команда приводит `src/` к единому виду; повторный запуск ничего не меняет.
|
||||
|
||||
- Prettier (`.prettierrc.json`) — единственный форматтер: табы, ширина 140, одинарные кавычки (в CSS — двойные),
|
||||
`>` тега JSX на той же строке. Форматтер biome выключен, чтобы два инструмента не спорили о пробелах.
|
||||
- ESLint (`eslint.config.js`) — правила, повторяющие `biome.json`; biome остаётся линтером в `make lint`/`make check`.
|
||||
- `npm run lint` — `eslint` + `prettier --check` без исправлений: ошибки валят команду, предупреждения (`any`,
|
||||
`console`, зависимости хуков) — нет.
|
||||
- В редакторе: расширения Prettier и ESLint, «Format on Save» с Prettier — результат совпадёт с `npm run format`.
|
||||
|
||||
## Проверка
|
||||
|
||||
`make check` — install, biome + ESLint + Prettier (`make lint`), `tsc --noEmit`, vitest, `rsbuild build`. Зелёным
|
||||
считается всё вместе.
|
||||
`make check` — install, biome, `tsc --noEmit`, vitest, `rsbuild build`. Зелёным считается всё вместе.
|
||||
|
||||
## Демо-сценарий
|
||||
|
||||
|
||||
@ -10,7 +10,9 @@
|
||||
"ignore": ["node_modules"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
"enabled": true,
|
||||
"lineWidth": 140,
|
||||
"formatWithErrors": true
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
@ -79,6 +81,9 @@
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"formatter": {
|
||||
"enabled": true
|
||||
},
|
||||
"parser": {
|
||||
"cssModules": true
|
||||
}
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
// ESLint для разработчиков фронта. Правила повторяют biome.json (biome остаётся в make lint/check);
|
||||
// форматирование — только Prettier, поэтому eslint-config-prettier подключается последним.
|
||||
import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['node_modules', 'dist'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: { globals: globals.browser },
|
||||
plugins: { react, 'react-hooks': reactHooks },
|
||||
settings: { react: { version: 'detect' } },
|
||||
rules: {
|
||||
// correctness
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
// nursery
|
||||
'no-dupe-else-if': 'error',
|
||||
'no-nested-ternary': 'error',
|
||||
'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'never' }],
|
||||
// style
|
||||
'default-case': 'error',
|
||||
'@typescript-eslint/prefer-for-of': 'error',
|
||||
'react/jsx-fragments': ['error', 'syntax'],
|
||||
'@typescript-eslint/array-type': ['error', { default: 'array' }],
|
||||
'operator-assignment': 'error',
|
||||
// suspicious
|
||||
'no-console': 'warn',
|
||||
'no-empty': 'error',
|
||||
'@typescript-eslint/no-empty-function': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'require-await': 'error',
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
);
|
||||
@ -6,8 +6,6 @@
|
||||
"scripts": {
|
||||
"build": "rsbuild build",
|
||||
"test": "vitest run",
|
||||
"format": "eslint --fix src && biome check --write src && prettier --write src",
|
||||
"lint": "eslint src && prettier --check src",
|
||||
"start": "rsbuild dev --open",
|
||||
"preview": "rsbuild preview"
|
||||
},
|
||||
@ -25,21 +23,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@eslint/js": "9.17.0",
|
||||
"@rsbuild/core": "1.6.7",
|
||||
"@rsbuild/plugin-react": "^1.1.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"daisyui": "^4.12.10",
|
||||
"eslint": "9.17.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-react": "7.37.3",
|
||||
"eslint-plugin-react-hooks": "5.1.0",
|
||||
"globals": "15.14.0",
|
||||
"prettier": "3.3.3",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "8.18.2",
|
||||
"vitest": "3.1.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@ -12,8 +12,6 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
output: {
|
||||
// wasm-декодеры pdf.js (JBIG2, JPEG2000, ICC): без них скан не рисуется (AURORA-1635). Путь = PDFJS_WASM_DIR
|
||||
copy: [{ from: 'node_modules/pdfjs-dist/wasm', to: 'static/pdfjs/wasm' }],
|
||||
filename: {
|
||||
js: '[name]_script.[contenthash:8].js',
|
||||
css: '[name]_style.[contenthash:8].css',
|
||||
|
||||
@ -37,7 +37,7 @@ export interface TableConfig<T> {
|
||||
columns: ColumnDef<T>[];
|
||||
searchableKeys?: Extract<keyof T, string>[];
|
||||
}
|
||||
export type ColumnFilters<T> = Partial<Record<Extract<keyof T, string>, (string | number)[]>>;
|
||||
export type ColumnFilters<T> = Partial<Record<Extract<keyof T, string>, Array<string | number>>>;
|
||||
export function isGroup<T>(c: ColumnDef<T>): c is GroupColumnDef<T> {
|
||||
return (c as GroupColumnDef<T>).children !== undefined;
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ export function useTableFilters<T>() {
|
||||
const hasActiveFilters =
|
||||
rawQuery.trim().length > 0 ||
|
||||
sort !== null ||
|
||||
(Object.values(columnFilters) as ((string | number)[] | undefined)[]).some((v) => v && v.length > 0);
|
||||
(Object.values(columnFilters) as Array<Array<string | number> | undefined>).some((v) => v && v.length > 0);
|
||||
|
||||
return {
|
||||
rawQuery,
|
||||
|
||||
@ -40,7 +40,8 @@ createRoot(rootElement).render(
|
||||
headerBg: 'rgb(248, 250, 252)',
|
||||
},
|
||||
},
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</ConfigProvider>,
|
||||
// </StrictMode>,
|
||||
|
||||
@ -49,7 +49,7 @@ export function parseAmountInput(input: string): { ok: true; value: string | nul
|
||||
|
||||
/** Точная сумма строк Decimal из API (копейками, без float) → «123.45»; пустые пропускаются, все пустые — null.
|
||||
* Сумма, которую не удалось разобрать (больше двух знаков, текст), делает итог null — итог не выдумываем. */
|
||||
export function sumAmounts(values: readonly (string | null | undefined)[]): string | null {
|
||||
export function sumAmounts(values: ReadonlyArray<string | null | undefined>): string | null {
|
||||
let cents = 0n;
|
||||
let any = false;
|
||||
for (const value of values) {
|
||||
|
||||
@ -20,7 +20,13 @@ const ITEMS = [
|
||||
{},
|
||||
] as const;
|
||||
|
||||
const RowActions = memo(function RowActions({ row, onAction }: { row: ConsRow; onAction: (key: ActionKey, _row: ConsRow) => void }) {
|
||||
const RowActions = memo(function RowActions({
|
||||
row,
|
||||
onAction,
|
||||
}: {
|
||||
row: ConsRow;
|
||||
onAction: (key: ActionKey, _row: ConsRow) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
|
||||
@ -6,7 +6,7 @@ export function flattenCons(cons: IConsolidation[]): ConsRow[] {
|
||||
for (const con of cons) {
|
||||
// if a con has no versions, emit nothing — or emit a placeholder row if you want
|
||||
con.versions.forEach((v, i) => {
|
||||
const rep_date = new Date(con.reporting_date);
|
||||
const rep_date = new Date(con.reporting_date) ?? null;
|
||||
out.push({
|
||||
id: v.id,
|
||||
group_id: con.consolidation_id,
|
||||
|
||||
@ -6,11 +6,7 @@ export function useGrouping(rows: ConsRow[]) {
|
||||
const toggle = (group_id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(group_id)) {
|
||||
next.delete(group_id);
|
||||
} else {
|
||||
next.add(group_id);
|
||||
}
|
||||
next.has(group_id) ? next.delete(group_id) : next.add(group_id);
|
||||
return next;
|
||||
});
|
||||
const visibleRows = useMemo(() => rows.filter((r) => r.isPrimary || expanded.has(r.group_id)), [rows, expanded]);
|
||||
|
||||
@ -37,12 +37,7 @@ function DropdownShell({
|
||||
onReset,
|
||||
close,
|
||||
children,
|
||||
}: {
|
||||
onApply: () => void;
|
||||
onReset: () => void;
|
||||
close: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
}: { onApply: () => void; onReset: () => void; close: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ padding: 8, display: 'flex', flexDirection: 'column', gap: 8, width: 260 }}>
|
||||
{children}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Config } from '../../conf/config';
|
||||
import { pdfjsLib } from './pdfjs';
|
||||
import { pdfjsWasmUrl } from './pdfjsOptions';
|
||||
|
||||
export const MIN_ZOOM = 0.5;
|
||||
export const MAX_ZOOM = 3;
|
||||
@ -24,7 +22,7 @@ export default function PdfPages({ data, zoom }: { data: ArrayBuffer; zoom: numb
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
// pdf.js забирает переданный буфер себе, поэтому отдаём копию (эффект может выполниться повторно)
|
||||
pdf = await pdfjsLib.getDocument({ data: data.slice(0), wasmUrl: pdfjsWasmUrl(Config.APP_ADDRESS) }).promise;
|
||||
pdf = await pdfjsLib.getDocument({ data: data.slice(0) }).promise;
|
||||
if (cancelled) {
|
||||
pdf.destroy();
|
||||
return;
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PDFJS_WASM_DIR, pdfjsWasmUrl } from './pdfjsOptions';
|
||||
|
||||
describe('pdfjsWasmUrl', () => {
|
||||
it('кладёт wasm под префикс приложения и заканчивается слэшем (pdf.js дописывает к нему имя файла)', () => {
|
||||
expect(pdfjsWasmUrl('/aurora/apps/fastapi-dkb-armka')).toBe(`/aurora/apps/fastapi-dkb-armka/${PDFJS_WASM_DIR}/`);
|
||||
});
|
||||
|
||||
it('не удваивает слэши, если префикс уже заканчивается слэшем', () => {
|
||||
expect(pdfjsWasmUrl('/aurora/apps/fastapi-dkb-armka/')).toBe(`/aurora/apps/fastapi-dkb-armka/${PDFJS_WASM_DIR}/`);
|
||||
});
|
||||
|
||||
it('при корневом префиксе даёт путь от корня', () => {
|
||||
expect(pdfjsWasmUrl('/')).toBe(`/${PDFJS_WASM_DIR}/`);
|
||||
expect(pdfjsWasmUrl('')).toBe(`/${PDFJS_WASM_DIR}/`);
|
||||
});
|
||||
});
|
||||
@ -1,14 +0,0 @@
|
||||
// Опции pdf.js для getDocument. Чистый модуль (без импорта pdfjs-dist), чтобы его можно было тестировать.
|
||||
//
|
||||
// pdf.js 5.x декодирует сканы JBIG2 и JPEG2000 и цветовые профили ICC через wasm-файлы (jbig2.wasm, openjpeg.wasm,
|
||||
// qcms_bg.wasm). Без опции wasmUrl он их не находит: «JBig2 failed to initialize», «Unable to decode image», страница
|
||||
// остаётся пустой (AURORA-1635). Файлы копирует в сборку rsbuild.config.ts (output.copy) в каталог PDFJS_WASM_DIR.
|
||||
|
||||
/** Каталог wasm-файлов pdf.js в собранном приложении (относительно корня приложения). Тот же путь — в rsbuild.config.ts. */
|
||||
export const PDFJS_WASM_DIR = 'static/pdfjs/wasm';
|
||||
|
||||
/** Адрес каталога wasm: с учётом префикса приложения (ROOT_PATH) и обязательным слэшем в конце — pdf.js дописывает к нему имя файла. */
|
||||
export function pdfjsWasmUrl(appAddress: string): string {
|
||||
const base = appAddress.replace(/\/+$/, '');
|
||||
return `${base}/${PDFJS_WASM_DIR}/`;
|
||||
}
|
||||
@ -138,7 +138,13 @@ function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) {
|
||||
const Section = memo(function Section({
|
||||
isCollapsed,
|
||||
group,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
group: Group;
|
||||
}) {
|
||||
return !isCollapsed ? (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{group.title}</div>
|
||||
|
||||
@ -30,9 +30,7 @@
|
||||
color: rgba(55, 65, 81, 0.88);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* .linkActive:hover {
|
||||
@ -74,9 +72,7 @@
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
@ -115,9 +111,7 @@
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user