chore(openspec): рулинги R-14…R-16 (design #25–27), спека/план приведены к факту фикстур
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
df8a5285bb
commit
7f14e70aef
@ -125,7 +125,7 @@ def plan_import(rows: Sequence[ImportRow], snapshot: DirectorySnapshot, on_exist
|
||||
- `normalize_header("Базовый РФ ГК в CRM") == "базовыйрфгквcrm"`, `normalize_cell(3528052718.0) == "3528052718"`, `normalize_cell(None) == ""`.
|
||||
- `read_rows` csv `;` и `,`, utf-8-sig, перестановка колонок, лишняя колонка; без «Тип» → `FileFormatError.code == "MISSING_COLUMNS"`, `missing == ["Тип"]`; `.txt` → `UNSUPPORTED_FILE`; xlsx, собранный `openpyxl.Workbook()` с заголовком во 2-й строке, ИНН числом, 3 пустыми строками в хвосте (Review Focus 1–2) → 2 строки данных.
|
||||
- `plan_import` на синтетических строках, по одному тесту на каждый код (параметризация: строка → ожидаемый итоговый код и события), пустой снимок и снимок с существующими; `update` vs `skip`; две строки одной ГК → один `CreateGroup`; флаг у двух ЮЛ одной ГК → один `CreateRelatedGroup` и два `AddMember`; ЮЛ в другой ГК → нет `AddMember`.
|
||||
- `@pytest.mark.fixtures`: `read_rows` + `plan_import` на `fixtures_dir / "directories/ecp-export-russkiy-biskvit.xlsx"` с пустым снимком: `report.rows == 21`, `groups_created == 1`, `offices_created == 1`, `entities_created == 18`, ровно одна `CreateRelatedGroup(kind=gsk)`, два `AddMember`, нет `gsz`; коды строк ФЛ/дубля — зафиксировать по фактическому порядку в файле (посмотреть файл в тесте, не угадывать) и записать ожидание явно.
|
||||
- `@pytest.mark.fixtures`: `read_rows` + `plan_import` на `fixtures_dir / "directories/ecp-export-russkiy-biskvit.xlsx"` с пустым снимком: `report.rows == 21`, `groups_created == 1`, `offices_created == 1`, `entities_created == 20` (18 ЮЛ + 2 ИП), ровно одна `CreateRelatedGroup(kind=gsk)`, два `AddMember`, нет `gsz`; коды строк ФЛ/дубля — зафиксировать по фактическому порядку в файле (посмотреть файл в тесте, не угадывать) и записать ожидание явно.
|
||||
- [x] **Step 2:** падает → **Step 3:** реализация (+ `openpyxl` в requirements, `make venv`) → **Step 4:** `pytest tests/test_domain_directory_import.py -v`, `mypy src` (strict для `src.domain.*`), `ruff` → PASS.
|
||||
- [x] **Step 5: Commit** `feat(domain): ядро импорта справочников из выгрузки ЕЦП (§13.1–13.2)`.
|
||||
|
||||
@ -154,7 +154,7 @@ async def run(self, *, filename: str, content: bytes, dry_run: bool, on_existing
|
||||
- `load_snapshot` строит `EntitySnap.gsk_group_id/gsz_group_id` из текущих членств (`is_current`), `GroupSnap.gsk_id/gsz_id` — первая по `code` группа каждого вида у ГК.
|
||||
|
||||
- [ ] **Step 1: Тесты** (`tests/test_api_directory_import.py`, integration; клиент `admin@armka.local`; `app.state.storage` — заглушка `FakeStorage(bucket="arm-ka")` с `put_object(key, data)` в словарь):
|
||||
- срез РБ `dry_run=false, skip` на seed-базе → 200; счётчики как в Task 9; в БД 1 ГК с `crm_id="1-AZFL6BQ"`, `regional_office` «Санкт-Петербургский РФ», 18 ЮЛ, 1 ГСК (`name` = имя ГК, 2 члена с `kind=gsk`), `audit_event(DIRECTORY_IMPORT)` с `after_value["rows"] == 21`, `S3Object` с ключом `uploads/imports/<event.id>/ecp-export-russkiy-biskvit.xlsx`, байты в `FakeStorage`;
|
||||
- срез РБ `dry_run=false, skip` на seed-базе → 200; счётчики как в Task 9; в БД 1 ГК с `crm_id="1-AZFL6BQ"`, `regional_office` «Санкт-Петербургский РФ», 20 ЮЛ/ИП, 1 ГСК (`name` = имя ГК, 2 члена с `kind=gsk`), `audit_event(DIRECTORY_IMPORT)` с `after_value["rows"] == 21`, `S3Object` с ключом `uploads/imports/<event.id>/ecp-export-russkiy-biskvit.xlsx`, байты в `FakeStorage`;
|
||||
- повтор `skip` → все `LE_EXISTS`/`GK_EXISTS`, число строк не изменилось;
|
||||
- csv с изменённым именем ЮЛ + `update` → имя обновлено, `legal_entity_history(UPDATE)`, код `LE_UPDATED`;
|
||||
- ЮЛ заранее в другой ГК (фабрика) → `LE_IN_OTHER_GROUP`, ГК не изменилась;
|
||||
@ -172,7 +172,7 @@ async def run(self, *, filename: str, content: bytes, dry_run: bool, on_existing
|
||||
- Create: `api/tests/test_import_full_export.py`
|
||||
- Modify (при необходимости): `api/src/services/directory_import.py` (пакетные вставки `insert(...).values([...])`, если > 60 с)
|
||||
|
||||
- [ ] **Step 1: Тест** `@pytest.mark.integration @pytest.mark.fixtures`: путь `Path(os.environ.get("ARM_KA_DOCS") or Path(__file__).resolve().parents[3] / "docs") / "source/biq/ecp-export-lagal-entity.xlsx"`; нет файла → `pytest.skip("нет выгрузки ЕЦП: …")`. `dry_run=true` под `admin@armka.local`; `time.perf_counter()` ≤ 60 с; `rows == 8400`; `offices_created == 53`; среди кодов есть `UNSUPPORTED_TYPE` (≥ 500 строк), `INVALID_INN`, `DUPLICATE_IN_FILE`, `LE_IN_OTHER_GROUP`; `groups_created == 345`; таблицы после теста не выросли.
|
||||
- [ ] **Step 1: Тест** `@pytest.mark.integration @pytest.mark.fixtures`: путь `Path(os.environ.get("ARM_KA_DOCS") or Path(__file__).resolve().parents[3] / "docs") / "source/biq/ecp-export-lagal-entity.xlsx"`; нет файла → `pytest.skip("нет выгрузки ЕЦП: …")`. `dry_run=true` под `admin@armka.local`; `time.perf_counter()` ≤ 60 с; `rows == 8400`; `offices_created == 53`; среди кодов есть `UNSUPPORTED_TYPE` (≥ 500 строк), `INVALID_INN`, `DUPLICATE_IN_FILE` (`LE_IN_OTHER_GROUP` на пустой БД невозможен — не проверять); `groups_created == 344` (R-15); таблицы после теста не выросли.
|
||||
- [ ] **Step 2:** запустить; при превышении 60 с — профилировать (`--durations`), перевести вставки на `session.execute(insert(Model), [dict, ...])` пакетами по 1000, повторить.
|
||||
- [ ] **Step 3: Commit** `test(import): dry_run полной выгрузки ЕЦП (8400 строк) укладывается в 60 с`.
|
||||
|
||||
|
||||
@ -101,8 +101,8 @@ WHERE is_current`; `ALTER TYPE audit_action ADD VALUE` ×2 (`LEGAL_ENTITY_CHANGE
|
||||
### D-11. Тестовая инфраструктура
|
||||
`tests/factories.py`: `make_regional_office`, `make_company_group`, `make_legal_entity`, `make_related_group`, `make_user(roles=…)`;
|
||||
`conftest.py`: фикстура `auth_client(email)` (приложение с `dependency_overrides`, dev-токен) и `seeded`; импорт — unit на
|
||||
синтетических строках (все коды §13.2) + `fixtures`-тест на `ecp-export-russkiy-biskvit.xlsx` (21 строка: 1 ГК, 1 РФ, 18 ЮЛ/ИП,
|
||||
1 `UNSUPPORTED_TYPE` ФЛ, 1 `DUPLICATE_IN_FILE`, 2 члена ГСК, 0 ГСЗ) + `dry_run` полной выгрузки из `../docs/source/biq`
|
||||
синтетических строках (все коды §13.2) + `fixtures`-тест на `ecp-export-russkiy-biskvit.xlsx` (21 строка: 1 ГК, 1 РФ, 20 ЮЛ/ИП,
|
||||
1 `UNSUPPORTED_TYPE` ФЛ, 2 члена ГСК, 0 ГСЗ) + `dry_run` полной выгрузки из `../docs/source/biq`
|
||||
(`ARM_KA_DOCS`, маркер `fixtures`, время < 60 с).
|
||||
|
||||
## Risks / Trade-offs
|
||||
@ -155,3 +155,6 @@ WHERE is_current`; `ALTER TYPE audit_action ADD VALUE` ×2 (`LEGAL_ENTITY_CHANGE
|
||||
| 22 | 2026-10-04 | R-11 (SDD фаза 1, Task 8, приняты решения исполнителя): `add_member` возвращает пару (членство, ЮЛ) и проверяет сначала видимость ГСК/ГСЗ через ГК (явный join `CompanyGroup`, T4.1), затем ЮЛ через scope; список ГСК/ГСЗ ГК — простой массив (не `Page`; групп у ГК единицы); `DELETE …/members/{id}` требует `DIRECTORY_EDIT` (спека: `DIRECTORY_DELETE` — только удаление ГСК/ГСЗ и алиасов); при удалении группы весь её состав сохраняется в `before_value` аудита; общий `src/common/clock.py:utc_now` вместо локальных `_now` | спека и бриф; единообразие аудита («для списков — весь набор») | смена формы списка до фронта бесплатна; удаление группы, на которую ссылаются consolidation/ex_snapshot, даст 500 вместо 409 (T8.1, deferred к OQ-34) |
|
||||
| 23 | 2026-10-04 | R-12 (SDD фаза 1→2, контроллер, T8.3): список ГСК/ГСЗ одной ГК остаётся простым массивом; требование «списки `{items,total,limit,offset}`» относится к спискам верхнего уровня (ГК, ЮЛ, пользователи) | размер ограничен составом ГК (единицы групп); спека `directories-api` пагинацию для него не требует; фронт Среза 1 его не использует | обернуть в `Page` до появления UI справочника; смена формата до фронта бесплатна |
|
||||
| 24 | 2026-10-04 | R-13 (SDD фаза 1→2, контроллер, O-5): дубль `RegionalOfficeOut` в `schemas/me.py` и `schemas/directories.py` устраняется — одна модель в `schemas/directories.py`, `schemas/me.py` импортирует её (Task 12); ответ `/me` не меняется | две одноимённые Pydantic-модели порождают OpenAPI-компоненты с длинными именами и расхождение схем | вернуть локальную модель в `me.py` |
|
||||
| 25 | 2026-10-04 | R-14 (SDD фаза 2, pre-flight): Task 10 выносит логику update ГК и ЮЛ в модульные функции `apply_group_update` / `apply_entity_update` (`services/company_groups.py`, `services/legal_entities.py`), `*Service.update` вызывает их; план называл их существующими, но в коде фазы 1 были только методы | импорт обязан писать историю/аудит так же, как ручная правка; дублирование — расхождение поведения | вернуть дублирование; регрессию фазы 1 ловят её тесты |
|
||||
| 26 | 2026-10-04 | R-15 (SDD фаза 2, Task 9, находка исполнителя): на фикстуре «Русский Бисквит» ожидается 20 ЮЛ/ИП (18 ЮЛ + 2 ИП), `DUPLICATE_IN_FILE` в этом файле не возникает (ФЛ с тем же ИНН идёт после ИП и отсекается типом); на полной выгрузке `groups_created = 344` (ГК `1-5AL0CDM` целиком из дублей), `LE_IN_OTHER_GROUP` на пустой БД недостижим и в ожиданиях не проверяется; текст спеки `directory-import`, tasks.md, design.md и план приведены к факту | данные важнее ориентировочных цифр спеки; ожидания фиксируются тестами по реальным файлам | поправить числа в тестах и спеке |
|
||||
| 27 | 2026-10-04 | R-16 (SDD фаза 2, Task 9, приняты решения исполнителя): `errors` считает итоговые коды-пропуски; `GK_EXISTS` и для неизменной ГК (идемпотентность); РФ разрешается один раз на ГК после проверок пустого iD ГК и конфликта имени (нет «осиротевших» филиалов, `RF_CREATED = 53`); ИНН считается «встречавшимся» после проверок типа и формата; офис нового ЮЛ = действующий РФ ГК; ГСК/ГСЗ создаётся по флагу даже если у ЮЛ уже есть членство того же вида в другой группе (буквально §13.2, возможна пустая группа — T9.5); флаги обрабатываются и при `skip` (добавляется отсутствующее членство); значение колонки ГСК «входит в ГСЗ» → `UNKNOWN_FLAG`; xlsx читается с `reset_dimensions()`; пины `openpyxl==3.1.5`, `et_xmlfile==2.0.0`, `types-openpyxl` (dev) | единообразие со спекой и идемпотентность повторного импорта | каждое — локальная правка `plan_import` и теста |
|
||||
|
||||
@ -36,9 +36,8 @@
|
||||
#### Scenario: Срез «Русский Бисквит», пустая база
|
||||
- **WHEN** импорт `ecp-export-russkiy-biskvit.xlsx` с `dry_run = false`, `on_existing = skip` на базе после `seed`
|
||||
- **THEN** создана 1 ГК (`crm_id = 1-AZFL6BQ`, имя «Русский Бисквит»), 1 филиал «Санкт-Петербургский РФ» (`RF_CREATED`),
|
||||
18 ЮЛ/ИП (`LE_CREATED`), 1 ГСК с именем ГК и двумя членами (АО «Русский бисквит», ООО «Пекарня Счастливочная»), 0 ГСЗ;
|
||||
строка типа `ФЛ` → `UNSUPPORTED_TYPE`, строка ИП с тем же ИНН после неё → не затронута (ФЛ идёт второй → `DUPLICATE_IN_FILE`
|
||||
у ФЛ или `UNSUPPORTED_TYPE` — зависит от порядка: ожидаемые коды фиксируются тестом по фактическому порядку строк)
|
||||
20 ЮЛ/ИП (`LE_CREATED`: 18 ЮЛ и 2 ИП), 1 ГСК с именем ГК и двумя членами (АО «Русский бисквит», ООО «Пекарня Счастливочная»), 0 ГСЗ;
|
||||
строка типа `ФЛ` (идёт после ИП с тем же ИНН) → `UNSUPPORTED_TYPE` (тип проверяется первым, `DUPLICATE_IN_FILE` в этом файле не возникает)
|
||||
|
||||
#### Scenario: Повторный импорт идемпотентен
|
||||
- **WHEN** тот же файл импортируется второй раз с `on_existing = skip`
|
||||
@ -68,4 +67,4 @@ section = DIRECTORIES, object_type = 'directory_import', after_value = отчё
|
||||
#### Scenario: Полная выгрузка
|
||||
- **WHEN** `docs/source/biq/ecp-export-lagal-entity.xlsx` импортируется с `dry_run = true` (маркер `fixtures`, `ARM_KA_DOCS`)
|
||||
- **THEN** ответ 200 за ≤ 60 с, `rows = 8400`, в отчёте есть коды `UNSUPPORTED_TYPE` (≥ 500), `INVALID_INN`, `DUPLICATE_IN_FILE`,
|
||||
`LE_IN_OTHER_GROUP`, `RF_CREATED` = 53
|
||||
`RF_CREATED` = 53, `groups_created` = 344 (из 345 ГК файла одна целиком состоит из строк-дублей и не создаётся)
|
||||
|
||||
@ -50,7 +50,7 @@ test-integration`). Пути — от корня репозитория. Доп
|
||||
`read_rows(file) -> (headers, rows)` для xlsx/csv, нормализация заголовков (`MISSING_COLUMNS`), `ImportCode` (StrEnum = §13.2),
|
||||
`plan_import(rows, snapshot, on_existing) -> ImportPlan(operations, report)` — чистая функция; `ASSUMPTION(OQ-87..91)` в коде.
|
||||
Unit-тесты без БД: синтетический csv, покрывающий каждый код §13.2 ровно один раз; xlsx-фикстура
|
||||
`../fixtures/directories/ecp-export-russkiy-biskvit.xlsx` → ожидаемый план (1 ГК, 1 РФ, 18 ЮЛ/ИП, 1 ГСК с 2 членами,
|
||||
`../fixtures/directories/ecp-export-russkiy-biskvit.xlsx` → ожидаемый план (1 ГК, 1 РФ, 20 ЮЛ/ИП, 1 ГСК с 2 членами,
|
||||
коды ФЛ/дубля по фактическому порядку строк).
|
||||
- [ ] 3.2 **Сервис и эндпоинт.** `services/directory_import.py` (снимок справочника одним запросом на таблицу, применение плана,
|
||||
`dry_run` → rollback, `audit_event(DIRECTORY_IMPORT, after_value = отчёт)`, файл в S3 `uploads/imports/{audit_event_id}/{name}`
|
||||
@ -58,7 +58,7 @@ test-integration`). Пути — от корня репозитория. Доп
|
||||
`schemas/directory_import.py`. Интеграционные тесты: сценарии спеки `directory-import` (срез РБ, идемпотентность, `update`,
|
||||
`LE_IN_OTHER_GROUP`, dry_run без следов, `MISSING_COLUMNS`); S3 — через `app.state.storage` с заглушкой в тестах.
|
||||
- [ ] 3.3 **Полная выгрузка (fixtures-тест).** Тест с маркером `fixtures`: `ARM_KA_DOCS/source/biq/ecp-export-lagal-entity.xlsx`,
|
||||
`dry_run`, `rows = 8400`, `RF_CREATED = 53`, присутствие кодов, время ≤ 60 с; пропуск с причиной, если файла нет.
|
||||
`dry_run`, `rows = 8400`, `RF_CREATED = 53`, `groups_created = 344`, присутствие кодов, время ≤ 60 с; пропуск с причиной, если файла нет.
|
||||
Профилирование: при превышении — пакетные вставки (`insert().values([...])`).
|
||||
|
||||
## 4. Администрирование
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user