Merge pull request 'Горячая папка: запись прямо в Input, как в старом АРМ; таймаут 20 мин (#26)' (#27) from feat/26-hotfolder-direct-write into main

Reviewed-on: #27
This commit is contained in:
aep 2026-10-09 16:22:05 +03:00
commit f37a3cd706
20 changed files with 808 additions and 597 deletions

View File

@ -4,12 +4,12 @@
## Что это ## Что это
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: положить файл во «Входящие» как `<job_id>.<ext>` Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: положить файл во «Входящие» как `<job_id>.<ext>`
(расширение `original_name` в нижнем регистре; `.part` пишется в `SCAN_TMP_DIR`, по умолчанию `Tmp`, на той же ФС/шаре и (расширение `original_name` в нижнем регистре; пишется **сразу** открытием `wb`, существующий файл перезаписывается — как в
публикуется **без замены**), забрать **все** `Исходящие/<job_id>/*.json` (ИС кладёт по одному json на каждый старом АРМ), забрать **все** `Исходящие/<job_id>/*.json` (ИС кладёт по одному json на каждый
распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить
горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json` горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json`
(`statementDocument` v1.2: по одному документу на таблицу `table_structure[i]`), опубликовать `files.tabular_parsed` (`statementDocument` v1.2: по одному документу на таблицу `table_structure[i]`), опубликовать `files.tabular_parsed`
(`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=300)`, (`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=1200)`,
старт — запись файла во «Входящие». **Нет доступа к БД.** старт — запись файла во «Входящие». **Нет доступа к БД.**
## Правила ## Правила
@ -28,9 +28,9 @@
и golden. и golden.
- Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть - Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть
в fixtures и объяснять; эталоны меняются отдельным PR fixtures. в fixtures и объяснять; эталоны меняются отдельным PR fixtures.
- Горячие папки (раскладка FileWorker): имя `<job_id>.<ext>`, `.part` только в `Tmp`, запись без замены (`os.link` / - Горячие папки (раскладка FileWorker): имя `<job_id>.<ext>`, запись сразу в `Input` открытием `wb` с перезаписью, без
`smbclient.rename`; существующее назначение = «доставлено»). `.part`, ставший целью публикации, снимается самой записью временного файла и `Tmp` — как в старом АРМ (change `hotfolder-direct-write`, ASSUMPTION(OQ-79)); `Tmp` — только у
независимо от флага (№26); любое другое удаление (ответ, входящий файл, `.done`, осиротевшие `.part`) — только при эмулятора. Любое удаление (ответ, входящий файл, `.done`, недописанный при ошибке записи входящий файл) — только при
`SCAN_CLEANUP=true` и только после сохранения `raw/` (не при `TIMEOUT`, `FILE_READ_ERROR`, ошибке записи во входящий каталог или `raw/`). Ошибка `SCAN_CLEANUP=true` и только после сохранения `raw/` (не при `TIMEOUT`, `FILE_READ_ERROR`, ошибке записи во входящий каталог или `raw/`). Ошибка
удаления — одна строка лога, конверт не меняется. Нет расширения у `original_name` → `RECOGNITION_FAILED`. удаления — одна строка лога, конверт не меняется. Нет расширения у `original_name` → `RECOGNITION_FAILED`.
- Набор ответа готов по settle: есть `*.json`, а имена и размеры не менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2; - Набор ответа готов по settle: есть `*.json`, а имена и размеры не менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2;

View File

@ -28,7 +28,7 @@ files.img_uploaded (NATS, queue group scan-gateway)
2. ack: files.tabular_parsed, status=processing (до чтения S3 и до ожидания слота) 2. ack: files.tabular_parsed, status=processing (до чтения S3 и до ожидания слота)
3. слот (SCAN_MAX_INFLIGHT одновременных заданий) 3. слот (SCAN_MAX_INFLIGHT одновременных заданий)
4. файл из S3 (s3.bucket/s3.key) 4. файл из S3 (s3.bucket/s3.key)
5. горячая папка: <Input>/<job_id>.<ext> (через <Tmp>/.<job_id>.<ext>.part, без замены) 5. горячая папка: <Input>/<job_id>.<ext> (сразу, wb, существующий перезаписывается)
6. опрос <Output>/<job_id>/ каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда 6. опрос <Output>/<job_id>/ каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда
не менялся SCAN_SETTLE_SECONDS не менялся SCAN_SETTLE_SECONDS
7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации) 7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации)
@ -60,19 +60,15 @@ ack (с `attempt` повторного запроса); файл в ИС вто
`original_name` запроса (разделители пути `/` и `\` учитываются), в нижнем регистре, 1–16 букв и цифр ASCII `original_name` запроса (разделители пути `/` и `\` учитываются), в нижнем регистре, 1–16 букв и цифр ASCII
(`aep_5.PDF` → `<job_id>.pdf`): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не (`aep_5.PDF` → `<job_id>.pdf`): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не
попадает. Нет допустимого расширения — `RECOGNITION_FAILED` без записи в горячую папку. попадает. Нет допустимого расширения — `RECOGNITION_FAILED` без записи в горячую папку.
- Запись атомарна и идёт **без замены**: сначала временный файл `.<job_id>.<ext>.part` в служебном каталоге - Запись — **сразу в `Input`**, как в старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`):
`<SCAN_TMP_DIR>` (по умолчанию `Tmp`), затем он публикуется в `Input` под настоящим именем. `Tmp` лежит рядом с `Input` файл открывается на запись с усечением (`wb`) и пишется целиком; временного файла, служебного каталога и
и `Output`, на той же ФС/шаре: публикация атомарна, а ИС не может забрать недописанный файл (временных файлов в переименования нет. В режиме `smb` — `smbclient.open_file(<Input>\<имя>, "wb")`: файл открыт монопольно, пока идёт
`Input` нет — решение владельца 06.10.2026, у FileWorker `.tmp` лежит рядом с назначением). В режиме `local` публикация — запись, и ИС его не прочитает (ASSUMPTION(OQ-79) — так работал старый АРМ). Если `Input/<job_id>.<ext>` уже существует
жёсткая ссылка `.part` → `Input/<имя>` и удаление `.part`; на файловой системе без жёстких ссылок (повтор задания ядром после потери ack в другом процессе), он **перезаписывается** содержимым объекта S3, задание
(`EPERM`/`EXDEV`/`ENOTSUP`/`EOPNOTSUPP` от самой ссылки) — `os.replace`, если назначения ещё нет (решение №17). продолжается опросом `Output/<job_id>/`. Ошибка посреди записи → `RECOGNITION_FAILED`; недописанный файл снимается
`.part` создаётся заново: остаток прежней записи удаляется (ошибка удаления подавляется), файл открывается с `O_EXCL`; только при `SCAN_CLEANUP=true` (иначе остаётся в `Input`). Каталог `Input` создаётся при необходимости (без
поверх оставшегося `.part` пишем только если это не жёсткая ссылка (`st_nlink == 1`) — иначе исключение (решение №32), родителей; отказ в правах при существующем каталоге — не ошибка), корень горячей папки сам не создаётся: нет корня —
чтобы не испортить уже опубликованный файл. В режиме `smb` — `smbclient.rename` (поднимает `FileExistsError`, если `RECOGNITION_FAILED`.
назначение есть). Если `Input/<job_id>.<ext>` уже существует (повторная публикация задания после потери ack в другом
процессе), файл считается доставленным: ничего не пишется, назначение не перезаписывается, `.part` не трогается (при
`SCAN_CLEANUP=true` существующий `.part` снимается), задание продолжается опросом `Output/<job_id>/` (решение №35). Каталоги `Input` и `Tmp`
создаются при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`.
- Ответ ИС — каталог `<SCAN_OUTPUT_DIR>/<job_id>/`: ИС кладёт туда **все** распознанные документы задания одновременно, - Ответ ИС — каталог `<SCAN_OUTPUT_DIR>/<job_id>/`: ИС кладёт туда **все** распознанные документы задания одновременно,
по одному `*.json` на файл (`Output/<job_id>/*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один по одному `*.json` на файл (`Output/<job_id>/*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один
ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть
@ -96,24 +92,19 @@ ack (с `attempt` повторного запроса); файл в ИС вто
- **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в - **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в
`raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС). `raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС).
- **Что удаляется** (всё, что есть): **всё содержимое** каталога `Output/<job_id>/` (не только `*.json`) и сам каталог, - **Что удаляется** (всё, что есть): **всё содержимое** каталога `Output/<job_id>/` (не только `*.json`) и сам каталог,
`Input/<job_id>.<ext>`, `Input/<job_id>.<ext>.done` (след эмулятора), `Tmp/.<job_id>.<ext>.part`. Отсутствующий файл `Input/<job_id>.<ext>`, `Input/<job_id>.<ext>.done` (след эмулятора). `Tmp` шлюз не трогает. Отсутствующий файл
или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а
подкаталог внутри `Output/<job_id>/` не даст снять каталог (`rmdir` → `STATUS_DIRECTORY_NOT_EMPTY`): ошибка очистки подкаталог внутри `Output/<job_id>/` не даст снять каталог (`rmdir` → `STATUS_DIRECTORY_NOT_EMPTY`): ошибка очистки
поднимается и логируется, остальные пункты (входящий файл, `.done`, `.part`) в этом задании не удаляются. поднимается и логируется, остальные пункты (входящий файл, `.done`) в этом задании не удаляются.
- **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во - **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во
входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются. входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются.
- **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт, - **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт,
`metrics.warnings` и `/healthcheck` не меняются — исход задания определён разбором ответа. `metrics.warnings` и `/healthcheck` не меняются — исход задания определён разбором ответа.
- **`.part` и флаг (решение №26).** `.part`, ставший целью успешной публикации, снимается **самой записью**, независимо - **Флаг управляет всем удалением:** при `SCAN_CLEANUP=false` сервис не удаляет ничего — ни каталог ответа, ни входящий
от `SCAN_CLEANUP` (в `local` — удаление после жёсткой ссылки, ошибка удаления подавляется; в `smb` `.part` файл и `.done`, ни недописанный при ошибке записи входящий файл — и не пишет об этом в лог (решение №27). Остатки
переименовывается во входящий и отдельного удаления не требует): иначе при выключенной очистке на каждое задание `Tmp/.*.part` от версий шлюза до change `hotfolder-direct-write` сервис не трогает — убрать вручную.
оставалась бы лишняя ссылка, а ошибка удаления на шаре с неподтверждёнными правами превращала бы доставленный файл в - Права на SMB: для записи учётной записи шары нужно создание и перезапись файлов в `Input`, для чтения ответа —
`RECOGNITION_FAILED`. **Флаг управляет остатками:** осиротевшие `.part` (оборванная запись, `.part` при уже листинг и чтение `Output`; для очистки — удаление в `Input` и `Output` (включая каталог `<job_id>`). Пока права на удаление не
существующем назначении), каталог ответа, входящий файл и `.done`. При `SCAN_CLEANUP=false` сервис не удаляет ничего из
этого и не пишет в лог об осиротевших `.part` (решение №27): они накапливаются в `Tmp` и убираются вручную или
включением флага.
- Права на SMB: для очистки учётной записи шары нужны права на удаление в `Input`, `Output` (включая каталог
`<job_id>`) и `Tmp`; для записи — создание файлов в `Tmp` и `Input` и переименование. Пока права на удаление не
подтверждены, оставляйте `SCAN_CLEANUP=false`. подтверждены, оставляйте `SCAN_CLEANUP=false`.
### Эмулятор ИС в режиме `local` ### Эмулятор ИС в режиме `local`
@ -315,12 +306,11 @@ pydantic-валидацией; битый файл (код не из 4–5 ци
| `SCAN_HOTFOLDER_PATH` | пусто | корень горячей папки в режиме `local` (обязателен, каталог должен существовать) | | `SCAN_HOTFOLDER_PATH` | пусто | корень горячей папки в режиме `local` (обязателен, каталог должен существовать) |
| `SCAN_INPUT_DIR` | `Input` | «Входящие» относительно корня | | `SCAN_INPUT_DIR` | `Input` | «Входящие» относительно корня |
| `SCAN_OUTPUT_DIR` | `Output` | «Исходящие» относительно корня | | `SCAN_OUTPUT_DIR` | `Output` | «Исходящие» относительно корня |
| `SCAN_TMP_DIR` | `Tmp` | служебный каталог относительно корня (на той же ФС/шаре, что `Input`/`Output`): временный `.<job_id>.<ext>.part` записи входящего файла |
| `SCAN_POLL_SECONDS` | `5` | период опроса «Исходящих», секунды | | `SCAN_POLL_SECONDS` | `5` | период опроса «Исходящих», секунды |
| `SCAN_SETTLE_SECONDS` | `2` | набор `*.json` в `Output/<job_id>/` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) | | `SCAN_SETTLE_SECONDS` | `2` | набор `*.json` в `Output/<job_id>/` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) |
| `SCAN_TIMEOUT_SECONDS` | `300` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`) | | `SCAN_TIMEOUT_SECONDS` | `1200` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`; 20 мин — в паре со сроком ответа OCR ядра) |
| `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий | | `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий |
| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output/<job_id>/` и каталог, `Input/<job_id>.<ext>` (и `.done`), `Tmp/.<job_id>.<ext>.part`; `false` — ничего не удаляется (кроме `.part`, снятого самой записью); см. «Очистка горячей папки» | | `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output/<job_id>/` и каталог, `Input/<job_id>.<ext>` (и `.done`); `false` — ничего не удаляется; см. «Очистка горячей папки» |
| `SCAN_RULES_DIR` | `rules` | каталог справочников нормализатора (`statement_lines.yaml`, `ocr_lookalikes.yaml`, `statement_labels.yaml`), путь от текущего каталога (`api/`); битый справочник — сервис не стартует | | `SCAN_RULES_DIR` | `rules` | каталог справочников нормализатора (`statement_lines.yaml`, `ocr_lookalikes.yaml`, `statement_labels.yaml`), путь от текущего каталога (`api/`); битый справочник — сервис не стартует |
| `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте | | `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте |
| `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) | | `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) |
@ -335,7 +325,8 @@ MinIO и NATS); те же значения подхватываются из `EN
| Переменная | По умолчанию | Назначение | | Переменная | По умолчанию | Назначение |
|---|---|---| |---|---|---|
| `SCAN_HOTFOLDER_PATH` | — (обязательна) | корень горячей папки; должен существовать, иначе код выхода 2 | | `SCAN_HOTFOLDER_PATH` | — (обязательна) | корень горячей папки; должен существовать, иначе код выхода 2 |
| `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR`, `SCAN_TMP_DIR` | `Input`, `Output`, `Tmp` | подкаталоги корня (как у шлюза); в `Tmp` эмулятор собирает набор ответа перед rename в `Output/<uuid>/` | | `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR` | `Input`, `Output` | подкаталоги корня (как у шлюза) |
| `SCAN_TMP_DIR` | `Tmp` | служебный каталог эмулятора: в нём собирается набор ответа перед rename в `Output/<uuid>/` (шлюз эту переменную не читает) |
| `SCAN_FIXTURES_DIR` | `../../fixtures/ocr` | каталог эталонных пар: исходник (PDF и др.) и ответы ИС (`aep_N.json`, `aep_N_<суффикс>.json`); пара ищется по sha256 содержимого входящего файла; путь от `api/` | | `SCAN_FIXTURES_DIR` | `../../fixtures/ocr` | каталог эталонных пар: исходник (PDF и др.) и ответы ИС (`aep_N.json`, `aep_N_<суффикс>.json`); пара ищется по sha256 содержимого входящего файла; путь от `api/` |
| `SCAN_EMULATOR_DEFAULT` | пусто | основа имени набора (например, `aep_6`), который отдаётся при промахе по хешу; пусто или набора нет — ответ-ошибка `INVALID_DOCUMENT` | | `SCAN_EMULATOR_DEFAULT` | пусто | основа имени набора (например, `aep_6`), который отдаётся при промахе по хешу; пусто или набора нет — ответ-ошибка `INVALID_DOCUMENT` |
| `SCAN_EMULATOR_DELAY` | `2` | задержка перед выдачей ответа, секунды (>= 0) | | `SCAN_EMULATOR_DELAY` | `2` | задержка перед выдачей ответа, секунды (>= 0) |
@ -508,8 +499,9 @@ make golden-update # пересобрать эталоны нормализ
- Недописанный набор ответа ИС: защита — settle (`SCAN_SETTLE_SECONDS`), а не разбор содержимого. Если ИС дописывает - Недописанный набор ответа ИС: защита — settle (`SCAN_SETTLE_SECONDS`), а не разбор содержимого. Если ИС дописывает
файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается: файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается:
`response = null`, `parse_error` в `raw/`, ошибка этого файла (`INVALID_FORMAT`) — в `metrics.warnings`. `response = null`, `parse_error` в `raw/`, ошибка этого файла (`INVALID_FORMAT`) — в `metrics.warnings`.
- Запись без замены на файловой системе без жёстких ссылок (`EPERM`/`EXDEV`/`ENOTSUP`): между проверкой назначения и - Запись сразу в `Input` (change `hotfolder-direct-write`): защита от чтения недописанного файла — только монопольное
`os.replace` есть крошечное окно гонки (повтор задания с тем же `job_id` редок, решение №17). открытие `smbclient` на время записи (ASSUMPTION(OQ-79), как в старом АРМ). Если ИС всё же возьмёт файл во время
записи, распознавание даст ошибку; откат — вернуть запись через временный файл.
- Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе - Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе
он кладёт ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре и пишет ошибку в лог. Индекс хешей он кладёт ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре и пишет ошибку в лог. Индекс хешей
пересчитывается при изменении списка файлов каталога фикстур: изменённый, но не переименованный PDF не пересчитывается при изменении списка файлов каталога фикстур: изменённый, но не переименованный PDF не

View File

@ -43,19 +43,17 @@ SCAN_HOTFOLDER_PATH=./.hotfolder
# Подкаталоги корня: «Входящие» (сюда кладётся UUID_имя) и «Исходящие» (оттуда забираются все *.json из <UUID>/) # Подкаталоги корня: «Входящие» (сюда кладётся UUID_имя) и «Исходящие» (оттуда забираются все *.json из <UUID>/)
SCAN_INPUT_DIR=Input SCAN_INPUT_DIR=Input
SCAN_OUTPUT_DIR=Output SCAN_OUTPUT_DIR=Output
# Служебный каталог корня: временный .part пишется сюда и переименовывается во «Входящие» (та же ФС/шара — rename
# атомарен, во «Входящих» не бывает недописанных файлов); создаётся при первой записи
SCAN_TMP_DIR=Tmp
# Период опроса «Исходящих», секунды # Период опроса «Исходящих», секунды
SCAN_POLL_SECONDS=5 SCAN_POLL_SECONDS=5
# Набор *.json в <UUID>/ считается готовым, когда имена и размеры файлов не менялись столько секунд (>= 0) # Набор *.json в <UUID>/ считается готовым, когда имена и размеры файлов не менялись столько секунд (>= 0)
SCAN_SETTLE_SECONDS=2 SCAN_SETTLE_SECONDS=2
# Потолок ожидания ответа ИС, секунды (дополнительно ограничен reply_deadline запроса); повторной отправки нет # Потолок ожидания ответа ИС, секунды (дополнительно ограничен reply_deadline запроса; 20 мин — в паре со сроком
SCAN_TIMEOUT_SECONDS=300 # ответа OCR ядра); повторной отправки нет
SCAN_TIMEOUT_SECONDS=1200
# Сколько заданий обрабатывается одновременно # Сколько заданий обрабатывается одновременно
SCAN_MAX_INFLIGHT=4 SCAN_MAX_INFLIGHT=4
# Очистка горячей папки после прочтения ответа (D-2): true — удалить всё содержимое Output/<job_id>/, входящий файл # Очистка горячей папки после прочтения ответа (D-2): true — удалить всё содержимое Output/<job_id>/, входящий файл
# (и его .done), осиротевший .part в Tmp; false (по умолчанию) — ничего не удалять # (и его .done); false (по умолчанию) — ничего не удалять
SCAN_CLEANUP=false SCAN_CLEANUP=false
# Каталог справочников нормализатора OCR (statement_lines.yaml, ocr_lookalikes.yaml, statement_labels.yaml); # Каталог справочников нормализатора OCR (statement_lines.yaml, ocr_lookalikes.yaml, statement_labels.yaml);
# относительный путь — от каталога запуска (api/); битый справочник — сервис не стартует # относительный путь — от каталога запуска (api/); битый справочник — сервис не стартует
@ -70,7 +68,9 @@ SCAN_RULES_DIR=rules
# --- Эмулятор ИС Сканирование (python -m src.scan_emulator, make emulator; только dev) --- # --- Эмулятор ИС Сканирование (python -m src.scan_emulator, make emulator; только dev) ---
# Эти параметры читает сам эмулятор, сервис шлюза их не использует. Корень горячей папки и подкаталоги # Эти параметры читает сам эмулятор, сервис шлюза их не использует. Корень горячей папки и подкаталоги
# эмулятор берёт из SCAN_HOTFOLDER_PATH / SCAN_INPUT_DIR / SCAN_OUTPUT_DIR выше; набор ответа на задание он собирает # эмулятор берёт из SCAN_HOTFOLDER_PATH / SCAN_INPUT_DIR / SCAN_OUTPUT_DIR выше; набор ответа на задание он собирает
# во временном каталоге SCAN_TMP_DIR (выше) и переименовывает в <Output>/<uuid>/ целиком. # во временном каталоге SCAN_TMP_DIR и переименовывает в <Output>/<uuid>/ целиком (шлюз SCAN_TMP_DIR не читает: файл
# во «Входящие» он пишет сразу, как в старом АРМ).
SCAN_TMP_DIR=Tmp
# Каталог с фикстурами ответов ИС (aep_N.json); путь от api/ (репозиторий fixtures лежит рядом с workspace) # Каталог с фикстурами ответов ИС (aep_N.json); путь от api/ (репозиторий fixtures лежит рядом с workspace)
SCAN_FIXTURES_DIR=../../fixtures/ocr SCAN_FIXTURES_DIR=../../fixtures/ocr
# Задержка перед выдачей ответа, секунды (имитация работы ИС) # Задержка перед выдачей ответа, секунды (имитация работы ИС)

View File

@ -83,13 +83,13 @@ class Settings(BaseSettings):
SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать) SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать)
SCAN_INPUT_DIR: str = "Input" SCAN_INPUT_DIR: str = "Input"
SCAN_OUTPUT_DIR: str = "Output" SCAN_OUTPUT_DIR: str = "Output"
SCAN_TMP_DIR: str = "Tmp" # служебный каталог временных файлов записи (рядом с Input, на той же ФС/шаре)
SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих» SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих»
# набор *.json ответа готов, когда имена и размеры не менялись столько секунд; 0 — готов сразу (тесты) # набор *.json ответа готов, когда имена и размеры не менялись столько секунд; 0 — готов сразу (тесты)
SCAN_SETTLE_SECONDS: NonNegativeFloat = 2 SCAN_SETTLE_SECONDS: NonNegativeFloat = 2
SCAN_TIMEOUT_SECONDS: PositiveFloat = 300 # потолок ожидания ответа ИС (дополнительно к reply_deadline запроса) # Потолок ожидания ответа ИС (дополнительно к reply_deadline запроса): 20 мин, в паре со сроком ответа OCR ядра
SCAN_TIMEOUT_SECONDS: PositiveFloat = 1200
SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий
# Очистка горячей папки после прочтения набора ответа (D-2): любое удаление, включая .part в Tmp, только при true. # Очистка горячей папки после прочтения набора ответа (D-2): ответ, входящий файл, .done — только при true.
# На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено # На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено
SCAN_CLEANUP: bool = False SCAN_CLEANUP: bool = False
SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/) SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/)

View File

@ -18,7 +18,6 @@ def create_hotfolder(settings: Settings) -> HotFolder:
settings.SCAN_HOTFOLDER_PATH, settings.SCAN_HOTFOLDER_PATH,
settings.SCAN_INPUT_DIR, settings.SCAN_INPUT_DIR,
settings.SCAN_OUTPUT_DIR, settings.SCAN_OUTPUT_DIR,
settings.SCAN_TMP_DIR,
settle_seconds=settings.SCAN_SETTLE_SECONDS, settle_seconds=settings.SCAN_SETTLE_SECONDS,
cleanup=settings.SCAN_CLEANUP, cleanup=settings.SCAN_CLEANUP,
) )
@ -31,7 +30,6 @@ def create_hotfolder(settings: Settings) -> HotFolder:
smb.SMB_PATH, smb.SMB_PATH,
settings.SCAN_INPUT_DIR, settings.SCAN_INPUT_DIR,
settings.SCAN_OUTPUT_DIR, settings.SCAN_OUTPUT_DIR,
settings.SCAN_TMP_DIR,
login=smb.SMB_LOGIN, login=smb.SMB_LOGIN,
password=smb.SMB_PASSWORD, password=smb.SMB_PASSWORD,
settle_seconds=settings.SCAN_SETTLE_SECONDS, settle_seconds=settings.SCAN_SETTLE_SECONDS,

View File

@ -1,17 +1,16 @@
"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, D-2, R-4, R-5). """Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, D-2, R-4, R-5).
Файл кладётся во входящий каталог как `<job_id>.<ext>` (D-1): сначала временный `.<имя>.part` в служебном каталоге Файл кладётся во входящий каталог как `<job_id>.<ext>` (D-1) сразу, открытием `wb` с перезаписью существующего — как в
`Tmp` (на той же ФС/шаре), затем жёсткая ссылка (без замены) во входящий (решение №17). Ответ ИС — набор файлов каталога старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`). Ответ ИС — набор файлов каталога
`<output>/<job_id>/`: ИС кладёт туда все распознанные документы задания, по одному `*.json` на документ (OQ-78). `<output>/<job_id>/`: ИС кладёт туда все распознанные документы задания, по одному `*.json` на документ (OQ-78).
`poll` отдаёт все `*.json` по возрастанию имени, когда набор готов: есть хотя бы один `*.json`, а набор имён и размеров `poll` отдаёт все `*.json` по возрастанию имени, когда набор готов: есть хотя бы один `*.json`, а набор имён и размеров
не менялся `settle_seconds` (`SCAN_SETTLE_SECONDS`, `SettleTracker`). Имена каталогов (`Input`/`Output`/`Tmp`) не менялся `settle_seconds` (`SCAN_SETTLE_SECONDS`, `SettleTracker`). Имена каталогов (`Input`/`Output`) настраиваются,
настраиваются, а не зашиты в код. а не зашиты в код; `Tmp` — служебный каталог эмулятора (сборка набора ответа), шлюз его не использует.
Очистка (D-2) удаляет следы задания: ответ, входящий файл, временные файлы — при `SCAN_CLEANUP=true`. Очистка (D-2) удаляет следы задания: ответ, входящий файл и его `.done` — при `SCAN_CLEANUP=true`.
""" """
import contextlib import contextlib
import errno
import os import os
import re import re
import shutil import shutil
@ -29,8 +28,6 @@ _ILLEGAL_CHARS = frozenset(':<>"|?*') | frozenset(chr(code) for code in range(0x
_FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS) _FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS)
# Расширение входящего файла — суффикс после последней точки, в нижнем регистре, 1–16 символов ASCII [0-9a-z] # Расширение входящего файла — суффикс после последней точки, в нижнем регистре, 1–16 символов ASCII [0-9a-z]
_EXTENSION_RE = re.compile(r"[0-9a-z]{1,16}") _EXTENSION_RE = re.compile(r"[0-9a-z]{1,16}")
# errno `os.link`, означающие «ФС не умеет жёсткие ссылки» (запасной путь записи — `os.replace`)
_NO_HARD_LINKS = (errno.EPERM, errno.EXDEV, errno.ENOTSUP, errno.EOPNOTSUPP)
def validate_name(name: str) -> None: def validate_name(name: str) -> None:
@ -171,7 +168,8 @@ class LocalHotFolder:
"""Горячая папка в обычном каталоге: dev, compose-том, unit-тесты. """Горячая папка в обычном каталоге: dev, compose-том, unit-тесты.
`root`, `input_dir`, `output_dir`, `tmp_dir`, `answer_dir` и `answer_path` открыты намеренно (R-4): эмулятор `root`, `input_dir`, `output_dir`, `tmp_dir`, `answer_dir` и `answer_path` открыты намеренно (R-4): эмулятор
ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа). ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа);
`tmp_dir` нужен только эмулятору (сборка набора ответа), `put` его не использует.
`settle_seconds` и `clock` (монотонные часы) — готовность набора ответа (`SettleTracker`). `settle_seconds` и `clock` (монотонные часы) — готовность набора ответа (`SettleTracker`).
`cleanup_enabled` (D-2) — очистка включена при `SCAN_CLEANUP`. `cleanup_enabled` (D-2) — очистка включена при `SCAN_CLEANUP`.
""" """
@ -204,76 +202,25 @@ class LocalHotFolder:
return self.answer_dir(job_id) / f"{job_id}.json" return self.answer_dir(job_id) / f"{job_id}.json"
def put(self, name: str, data: bytes) -> None: def put(self, name: str, data: bytes) -> None:
"""Атомарная запись без замены: `.<name>.part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/<name>` и """Записать `input_dir/<name>` сразу (`wb`, перезапись существующего, как в старом АРМ — решение владельца
удаление `.part` как часть успешной публикации (R-8). Назначение уже есть — файл считается доставленным 09.10.2026). `Input` создаётся без родителей, корень — нет (нет корня — `FileNotFoundError`). При ошибке записи
(повтор задания, R-17): ничего не пишется, `.part` снимается только при включённой очистке. Корень не недописанный файл снимается только при включённой очистке (R-3), ошибка снятия подавляется. Ошибка самого
создаётся (нет корня — `FileNotFoundError`).""" открытия ничего не удаляет: на месте может лежать целый файл прошлой доставки (I-1)."""
validate_name(name) validate_name(name)
self.tmp_dir.mkdir(exist_ok=True)
self.input_dir.mkdir(exist_ok=True) self.input_dir.mkdir(exist_ok=True)
part = self.tmp_dir / f".{name}.part"
target = self.input_dir / name target = self.input_dir / name
if target.exists(): handle = open(target, "wb") # noqa: SIM115 — открытие вне try: его ошибка не снимает чужой файл (I-1)
# R-17: `.part` от прошлой публикации может быть жёсткой ссылкой на этот файл (удаление после публикации
# подавляется, R-8, OQ-144) — писать и даже удалять его незачем, файл уже доставлен
self._drop_part(part)
return
# `.part` создаётся заново (№32): остаток от прерванной записи не должен ронять задание, поэтому ошибка его
# удаления подавляется; если он всё же остался, `_open_part` не даст записать «поверх» ссылки на чужой файл
with contextlib.suppress(OSError):
part.unlink(missing_ok=True)
fd = self._open_part(part)
try: try:
with os.fdopen(fd, "wb") as handle: with handle:
handle.write(data) handle.write(data)
except BaseException: except BaseException:
self._drop_part(part) self._drop_partial(target)
raise
try:
os.link(part, target) # без замены: существующее назначение даёт FileExistsError
except FileExistsError:
self._drop_part(part)
return
except OSError as err:
# ФС без жёстких ссылок (некоторые тома Docker/сетевые монтирования): запасной путь через replace с
# проверкой назначения (окно гонки крошечное, повтор задания с тем же job_id редок). Только для самой
# линковки: ошибка записи выше поднимается как есть
if err.errno in _NO_HARD_LINKS and not target.exists():
self._replace_part(part, target)
return
self._drop_part(part)
raise
except BaseException:
self._drop_part(part)
raise
# завершение успешной записи (как rename в старой версии), не управляется флагом очистки:
# файл уже доставлен, ошибка удаления .part её не должна отменить (R-8)
with contextlib.suppress(OSError):
part.unlink(missing_ok=True)
@staticmethod
def _open_part(part: Path) -> int:
"""Открыть `.part` на запись: новый файл (`O_EXCL`). Если старый не удалился (`FileExistsError`) — усечь его
только когда это обычный файл без других ссылок (`st_nlink == 1`); ссылку на опубликованный файл не трогаем,
исключение поднимается как есть (R-17)."""
try:
return os.open(part, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
if os.lstat(part).st_nlink != 1:
raise
return os.open(part, os.O_WRONLY | os.O_TRUNC)
def _replace_part(self, part: Path, target: Path) -> None:
try:
os.replace(part, target)
except BaseException:
self._drop_part(part)
raise raise
def _drop_part(self, part: Path) -> None: def _drop_partial(self, path: Path) -> None:
if self.cleanup_enabled: if self.cleanup_enabled:
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
part.unlink(missing_ok=True) path.unlink(missing_ok=True)
def cleanup(self, job_id: str, name: str) -> bool: def cleanup(self, job_id: str, name: str) -> bool:
if not self.cleanup_enabled: if not self.cleanup_enabled:
@ -282,7 +229,7 @@ class LocalHotFolder:
folder = self.answer_dir(job_id) folder = self.answer_dir(job_id)
if folder.is_dir(): if folder.is_dir():
shutil.rmtree(folder) shutil.rmtree(folder)
for path in (self.input_dir / name, self.input_dir / f"{name}.done", self.tmp_dir / f".{name}.part"): for path in (self.input_dir / name, self.input_dir / f"{name}.done"):
try: try:
os.unlink(path) os.unlink(path)
except FileNotFoundError: except FileNotFoundError:

View File

@ -4,10 +4,10 @@ r"""Горячая папка на SMB-шаре платформы (`SCAN_HOTFOL
в lifespan приложения (`app.py`); логин/пароль, если заданы, передаются ещё и в каждый вызов `smbclient`, чтобы в lifespan приложения (`app.py`); логин/пароль, если заданы, передаются ещё и в каждый вызов `smbclient`, чтобы
сессия поднималась заново по требованию (после сбоя регистрации при старте или сброса соединения). сессия поднималась заново по требованию (после сбоя регистрации при старте или сброса соединения).
Пути — UNC: временный файл `\\<host>\<SMB_PATH>\<tmp_dir>\.<имя>.part` переименовывается (rename без замены) Пути — UNC: входящий файл пишется сразу в `\\<host>\<SMB_PATH>\<input_dir>\<имя>` (`wb`, существующий перезаписывается,
во входящий `\\<host>\<SMB_PATH>\<input_dir>\<имя>` (одна шара — rename атомарен, OQ-79); ответ — все `*.json` как в старом АРМ — решение владельца 09.10.2026, change `hotfolder-direct-write`); ответ — все `*.json` каталога
каталога `\\<host>\<SMB_PATH>\<output_dir>\<job_id>\` (OQ-78), готовность — по стабилизации набора `\\<host>\<SMB_PATH>\<output_dir>\<job_id>\` (OQ-78), готовность — по стабилизации набора (`SettleTracker`).
(`SettleTracker`). Очистка (D-2) удаляет ответы, входящий файл и временные файлы при `SCAN_CLEANUP=true`. Очистка (D-2) удаляет ответы, входящий файл и его `.done` при `SCAN_CLEANUP=true`.
Ошибки smbprotocol — `SMBOSError` (подкласс `OSError`, но НЕ `FileNotFoundError`): «нет файла/каталога» распознаётся Ошибки smbprotocol — `SMBOSError` (подкласс `OSError`, но НЕ `FileNotFoundError`): «нет файла/каталога» распознаётся
по `errno == ENOENT`. по `errno == ENOENT`.
""" """
@ -66,9 +66,9 @@ def build_unc_root(host: str, path: str) -> str:
class SmbHotFolder: class SmbHotFolder:
"""Горячая папка ИС Сканирование на SMB-шаре. Методы синхронные (блокирующий SMB), семантика как у """Горячая папка ИС Сканирование на SMB-шаре. Методы синхронные (блокирующий SMB), семантика как у
`LocalHotFolder`: те же правила имён, атомарная запись `Tmp\\.<name>.part` + rename без замены в `Input`, `LocalHotFolder`: те же правила имён, запись сразу в `Input` с перезаписью, `Input` создаётся без родителей, корень
`Tmp` и `Input` создаются без родителей, корень не создаётся, готовность набора ответа — по стабилизации не создаётся, готовность набора ответа — по стабилизации (`SettleTracker`). Очистка (D-2) под флагом
(`SettleTracker`). Очистка (D-2) под флагом `SCAN_CLEANUP`.""" `SCAN_CLEANUP`."""
def __init__( # noqa: PLR0913 — адрес и учётные данные D-1 + имена каталогов и готовность, как у LocalHotFolder def __init__( # noqa: PLR0913 — адрес и учётные данные D-1 + имена каталогов и готовность, как у LocalHotFolder
self, self,
@ -76,7 +76,6 @@ class SmbHotFolder:
path: str, path: str,
input_dir: str = "Input", input_dir: str = "Input",
output_dir: str = "Output", output_dir: str = "Output",
tmp_dir: str = "Tmp",
*, *,
login: str = "", login: str = "",
password: str = "", password: str = "",
@ -87,7 +86,6 @@ class SmbHotFolder:
self.root: str = build_unc_root(host, path) self.root: str = build_unc_root(host, path)
self.input_dir: str = ntpath.join(self.root, input_dir) self.input_dir: str = ntpath.join(self.root, input_dir)
self.output_dir: str = ntpath.join(self.root, output_dir) self.output_dir: str = ntpath.join(self.root, output_dir)
self.tmp_dir: str = ntpath.join(self.root, tmp_dir)
self._credentials: dict[str, Any] = {"username": login, "password": password} if login else {} self._credentials: dict[str, Any] = {"username": login, "password": password} if login else {}
self._settle = SettleTracker(settle_seconds, clock) self._settle = SettleTracker(settle_seconds, clock)
self.cleanup_enabled = cleanup self.cleanup_enabled = cleanup
@ -102,29 +100,22 @@ class SmbHotFolder:
return ntpath.join(self.answer_dir(job_id), f"{job_id}.json") return ntpath.join(self.answer_dir(job_id), f"{job_id}.json")
def put(self, name: str, data: bytes) -> None: def put(self, name: str, data: bytes) -> None:
"""Записать `.<name>.part` в служебный каталог, затем `smbclient.rename` в `<input>\\<name>` без замены. """Записать `<input>\\<name>` сразу, открытием `wb` (как в старом АРМ, решение владельца 09.10.2026): временного
Имя проверяется до любого обращения к шаре. Назначение уже есть (повтор задания) — это не ошибка: файл файла и rename нет, существующий файл перезаписывается. `smbclient.open_file` открывает файл монопольно — пока
считается доставленным и не заменяется. В этом случае, как и при любой другой ошибке записи или переименования, запись идёт, ИС его не прочитает (ASSUMPTION(OQ-79)). Имя проверяется до любого обращения к шаре. При ошибке
`.part` снимается только при включённой очистке (`SCAN_CLEANUP`, D-2), иначе остаётся в `Tmp`; ошибка снятия записи недописанный файл снимается только при включённой очистке (`SCAN_CLEANUP`, R-3); ошибка снятия исходную
исходную не маскирует. При успехе `.part` забирает сам rename. Нет корня — `SMBOSError` (ENOENT) от первого не маскирует. Ошибка самого открытия (файл занят ИС, нет прав) ничего не удаляет: недописанного файла нет, а на
`mkdir`. месте может лежать целый файл прошлой доставки (I-1). Нет корня — `SMBOSError` (ENOENT) от `mkdir`.
""" """
validate_name(name) validate_name(name)
self._ensure_dir(self.tmp_dir)
self._ensure_dir(self.input_dir) self._ensure_dir(self.input_dir)
part = ntpath.join(self.tmp_dir, f".{name}.part") target = ntpath.join(self.input_dir, name)
fd = smbclient.open_file(target, mode="wb", **self._credentials)
try: try:
with smbclient.open_file(part, mode="wb", **self._credentials) as fd: with fd:
fd.write(data) fd.write(data)
smbclient.rename(part, ntpath.join(self.input_dir, name), **self._credentials)
except OSError as err:
self._drop_part(part)
ntstatus = getattr(err, "ntstatus", None)
if _has_errno(err, errno.EEXIST, FileExistsError) or ntstatus == NtStatus.STATUS_OBJECT_NAME_COLLISION:
return
raise
except BaseException: except BaseException:
self._drop_part(part) self._drop_partial(target)
raise raise
def poll(self, job_id: str) -> list[AnswerFile] | None: def poll(self, job_id: str) -> list[AnswerFile] | None:
@ -166,14 +157,14 @@ class SmbHotFolder:
"""Число заданий с ненастоявшимся набором (для тестов и диагностики).""" """Число заданий с ненастоявшимся набором (для тестов и диагностики)."""
return len(self._settle) return len(self._settle)
def _drop_part(self, path: str) -> None: def _drop_partial(self, path: str) -> None:
"""Удалить временный файл при ошибке, только если очистка включена (D-2).""" """Удалить недописанный входящий файл при ошибке записи, только если очистка включена (D-2, R-3)."""
if self.cleanup_enabled: if self.cleanup_enabled:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
smbclient.remove(path, **self._credentials) smbclient.remove(path, **self._credentials)
def cleanup(self, job_id: str, name: str) -> bool: def cleanup(self, job_id: str, name: str) -> bool:
"""Удалить следы задания: ответ из `output`, входящий файл, временные файлы. Возвращает `False`, если """Удалить следы задания: ответ из `output`, входящий файл и его `.done`. Возвращает `False`, если
очистка выключена; `True` при успешной очистке. Ошибки удаления (кроме ENOENT) поднимаются.""" очистка выключена; `True` при успешной очистке. Ошибки удаления (кроме ENOENT) поднимаются."""
if not self.cleanup_enabled: if not self.cleanup_enabled:
return False return False
@ -194,7 +185,6 @@ class SmbHotFolder:
for path in ( for path in (
ntpath.join(self.input_dir, name), ntpath.join(self.input_dir, name),
ntpath.join(self.input_dir, f"{name}.done"), ntpath.join(self.input_dir, f"{name}.done"),
ntpath.join(self.tmp_dir, f".{name}.part"),
): ):
try: try:
smbclient.remove(path, **self._credentials) smbclient.remove(path, **self._credentials)

View File

@ -3,7 +3,7 @@
Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` со справочниками `api/rules/`) и Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` со справочниками `api/rules/`) и
эмулятор ИС Сканирование (`src.scan_emulator.run` в потоке, `SCAN_EMULATOR_DEFAULT` пуст) работают в процессе эмулятор ИС Сканирование (`src.scan_emulator.run` в потоке, `SCAN_EMULATOR_DEFAULT` пуст) работают в процессе
теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из `api/.env.test` (`make up`), горячая папка — tmp-каталог. теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из `api/.env.test` (`make up`), горячая папка — tmp-каталог.
Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, `.part` живёт в `Tmp`), пару фикстур Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, пишется прямо в `Input`), пару фикстур
эмулятор находит по sha256 содержимого: в его каталоге фикстур лежат PDF и ответы (реальные `aep_*` из эмулятор находит по sha256 содержимого: в его каталоге фикстур лежат PDF и ответы (реальные `aep_*` из
`fixtures/ocr` и синтетический `ok_doc`). `fixtures/ocr` и синтетический `ok_doc`).
@ -16,7 +16,7 @@
3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`); 3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`);
4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → `needs_check` (редакция `fsbu4` пуста) с `result_s3`; 4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → `needs_check` (редакция `fsbu4` пуста) с `result_s3`;
5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора; 5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора;
6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output/<job_id>/` пусты, `Tmp/` без `.part`. 6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output/<job_id>/` пусты.
Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; файл результата Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; файл результата
из MinIO — схемой `recognition-result`; склеенный сырой ответ в MinIO содержит ровно те файлы, что лежат в каталоге из MinIO — схемой `recognition-result`; склеенный сырой ответ в MinIO содержит ровно те файлы, что лежат в каталоге
ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора
@ -178,10 +178,6 @@ class Roundtrip:
def input_dir(self) -> Path: def input_dir(self) -> Path:
return self.root / self.settings.SCAN_INPUT_DIR return self.root / self.settings.SCAN_INPUT_DIR
@property
def tmp_dir(self) -> Path:
return self.root / self.settings.SCAN_TMP_DIR
def answer_dir(self, job_id: str) -> Path: def answer_dir(self, job_id: str) -> Path:
return self.root / self.settings.SCAN_OUTPUT_DIR / job_id return self.root / self.settings.SCAN_OUTPUT_DIR / job_id
@ -260,12 +256,6 @@ class Roundtrip:
def inbox(self, job_id: str) -> list[str]: def inbox(self, job_id: str) -> list[str]:
return sorted(p.name for p in self.input_dir.iterdir() if p.name.startswith(job_id)) return sorted(p.name for p in self.input_dir.iterdir() if p.name.startswith(job_id))
def stray_parts(self) -> list[str]:
"""Временные `.part` записи во входящий каталог: после успешной публикации их в `Tmp` нет (решение №26)."""
return (
sorted(p.name for p in self.tmp_dir.iterdir() if p.name.endswith(".part")) if self.tmp_dir.is_dir() else []
)
async def run(self, original_name: str, data: bytes, *, cleanup: bool = False) -> tuple[dict[str, Any], Envelope]: async def run(self, original_name: str, data: bytes, *, cleanup: bool = False) -> tuple[dict[str, Any], Envelope]:
"""Запрос → ack → итоговый конверт; общие проверки сквозного сценария. Возвращает запрос и итог.""" """Запрос → ack → итоговый конверт; общие проверки сквозного сценария. Возвращает запрос и итог."""
body = await self.request(original_name, data) body = await self.request(original_name, data)
@ -283,8 +273,7 @@ class Roundtrip:
"""Сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий файл `<job_id>.pdf` = объект S3. """Сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий файл `<job_id>.pdf` = объект S3.
Без очистки (`SCAN_CLEANUP=false`) каталог ответа и входящий файл (с суффиксом `.done` эмулятора) остаются; при Без очистки (`SCAN_CLEANUP=false`) каталог ответа и входящий файл (с суффиксом `.done` эмулятора) остаются; при
очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output/<job_id>/` нет. `.part` в `Tmp` нет очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output/<job_id>/` нет.
в обоих режимах.
""" """
job_id = body["job_id"] job_id = body["job_id"]
assert result.body["file_id"] == body["file_id"] assert result.body["file_id"] == body["file_id"]
@ -297,7 +286,6 @@ class Roundtrip:
assert entry["response"]["file_uid"] == job_id assert entry["response"]["file_uid"] == job_id
stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json") stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json")
assert stat.content_type == JSON_CONTENT_TYPE assert stat.content_type == JSON_CONTENT_TYPE
assert self.stray_parts() == []
if cleanup: if cleanup:
assert list(self.input_dir.iterdir()) == [] assert list(self.input_dir.iterdir()) == []
assert not self.answer_dir(job_id).exists() assert not self.answer_dir(job_id).exists()
@ -393,7 +381,7 @@ def _start_emulator(stack: AsyncExitStack, settings: Settings, root: Path, fixtu
hotfolder_path=root, hotfolder_path=root,
input_dir=settings.SCAN_INPUT_DIR, input_dir=settings.SCAN_INPUT_DIR,
output_dir=settings.SCAN_OUTPUT_DIR, output_dir=settings.SCAN_OUTPUT_DIR,
tmp_dir=settings.SCAN_TMP_DIR, tmp_dir="Tmp", # служебный каталог эмулятора; у шлюза его нет (change hotfolder-direct-write)
fixtures_dir=fixtures, fixtures_dir=fixtures,
delay=EMULATOR_DELAY, delay=EMULATOR_DELAY,
poll_interval=0.05, poll_interval=0.05,
@ -650,4 +638,3 @@ async def test_cleanup_enabled_leaves_input_and_answer_empty(roundtrip_cleanup:
assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json"] assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json"]
assert list(roundtrip_cleanup.input_dir.iterdir()) == [] assert list(roundtrip_cleanup.input_dir.iterdir()) == []
assert not roundtrip_cleanup.answer_dir(job_id).exists() assert not roundtrip_cleanup.answer_dir(job_id).exists()
assert roundtrip_cleanup.stray_parts() == []

View File

@ -58,7 +58,6 @@ async def make_env(tmp_path: Path) -> AsyncIterator[EnvFactory]:
root, root,
settings.SCAN_INPUT_DIR, settings.SCAN_INPUT_DIR,
settings.SCAN_OUTPUT_DIR, settings.SCAN_OUTPUT_DIR,
settings.SCAN_TMP_DIR,
settle_seconds=settings.SCAN_SETTLE_SECONDS, settle_seconds=settings.SCAN_SETTLE_SECONDS,
cleanup=settings.SCAN_CLEANUP, cleanup=settings.SCAN_CLEANUP,
) )

View File

@ -29,10 +29,10 @@ def test_defaults_local_mode_without_smb_and_nats_credentials(monkeypatch, minim
assert s.SCAN_HOTFOLDER_MODE == "local" assert s.SCAN_HOTFOLDER_MODE == "local"
assert s.SCAN_INPUT_DIR == "Input" assert s.SCAN_INPUT_DIR == "Input"
assert s.SCAN_OUTPUT_DIR == "Output" assert s.SCAN_OUTPUT_DIR == "Output"
assert s.SCAN_TMP_DIR == "Tmp" assert not hasattr(s, "SCAN_TMP_DIR") # служебный каталог только у эмулятора (change hotfolder-direct-write)
assert s.SCAN_SETTLE_SECONDS == 2 assert s.SCAN_SETTLE_SECONDS == 2
assert s.SCAN_POLL_SECONDS == 5 assert s.SCAN_POLL_SECONDS == 5
assert s.SCAN_TIMEOUT_SECONDS == 300 assert s.SCAN_TIMEOUT_SECONDS == 1200
assert s.SCAN_MAX_INFLIGHT == 4 assert s.SCAN_MAX_INFLIGHT == 4
assert s.OPENBAO.SMB.SMB_HOST == "" assert s.OPENBAO.SMB.SMB_HOST == ""
assert s.OPENBAO.NATS.NATS_SEED == "" assert s.OPENBAO.NATS.NATS_SEED == ""
@ -80,6 +80,16 @@ def test_negative_settle_seconds_rejected(monkeypatch):
get_settings() get_settings()
def test_legacy_scan_tmp_dir_is_ignored(monkeypatch, minimal_env_file):
"""Старый `.env` с `SCAN_TMP_DIR` (до change hotfolder-direct-write) не ломает старт: `extra="ignore"`."""
_clean_env(monkeypatch)
monkeypatch.setenv("SCAN_TMP_DIR", "Tmp")
s = get_settings()
assert not hasattr(s, "SCAN_TMP_DIR")
@pytest.mark.parametrize("settle", ["10", "11"]) @pytest.mark.parametrize("settle", ["10", "11"])
def test_settle_not_less_than_timeout_rejected(monkeypatch, settle): def test_settle_not_less_than_timeout_rejected(monkeypatch, settle):
"""M-4: набор не может настояться за срок, который короче или равен settle — каждое задание давало бы TIMEOUT.""" """M-4: набор не может настояться за срок, который короче или равен settle — каждое задание давало бы TIMEOUT."""
@ -156,7 +166,7 @@ def test_env_test_file_loads_for_tests():
assert s.SCAN_HOTFOLDER_PATH == "./.hotfolder-test" assert s.SCAN_HOTFOLDER_PATH == "./.hotfolder-test"
assert s.SCAN_POLL_SECONDS == 0.1 assert s.SCAN_POLL_SECONDS == 0.1
assert s.SCAN_TIMEOUT_SECONDS == 5 assert s.SCAN_TIMEOUT_SECONDS == 5
assert s.SCAN_TMP_DIR == "Tmp" assert not hasattr(s, "SCAN_TMP_DIR")
assert s.SCAN_SETTLE_SECONDS == 0 # R-23: тесты воркера не ждут стабилизации набора assert s.SCAN_SETTLE_SECONDS == 0 # R-23: тесты воркера не ждут стабилизации набора
assert s.OPENBAO.MINIO.MINIO_URL == "127.0.0.1:9000" assert s.OPENBAO.MINIO.MINIO_URL == "127.0.0.1:9000"
assert s.OPENBAO.MINIO.MINIO_SECURE is False assert s.OPENBAO.MINIO.MINIO_SECURE is False
@ -172,7 +182,7 @@ def test_env_example_loads(monkeypatch):
assert s.SCAN_HOTFOLDER_MODE == "local" assert s.SCAN_HOTFOLDER_MODE == "local"
assert s.SCAN_INPUT_DIR == "Input" assert s.SCAN_INPUT_DIR == "Input"
assert s.SCAN_TMP_DIR == "Tmp" assert not hasattr(s, "SCAN_TMP_DIR")
assert s.SCAN_SETTLE_SECONDS == 2 assert s.SCAN_SETTLE_SECONDS == 2
assert s.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka" assert s.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka"
assert s.OPENBAO.NATS.NATS_JWT == "" assert s.OPENBAO.NATS.NATS_JWT == ""

View File

@ -20,7 +20,6 @@ def _local_settings(root: Path) -> Settings:
"SCAN_HOTFOLDER_MODE": "local", "SCAN_HOTFOLDER_MODE": "local",
"SCAN_HOTFOLDER_PATH": str(root), "SCAN_HOTFOLDER_PATH": str(root),
"SCAN_INPUT_DIR": "In", "SCAN_INPUT_DIR": "In",
"SCAN_TMP_DIR": "Служебный",
"SCAN_SETTLE_SECONDS": 1.5, "SCAN_SETTLE_SECONDS": 1.5,
} }
) )
@ -33,7 +32,6 @@ def _smb_settings() -> Settings:
update={ update={
"SCAN_HOTFOLDER_MODE": "smb", "SCAN_HOTFOLDER_MODE": "smb",
"SCAN_OUTPUT_DIR": "Out", "SCAN_OUTPUT_DIR": "Out",
"SCAN_TMP_DIR": "T",
"SCAN_SETTLE_SECONDS": 3, "SCAN_SETTLE_SECONDS": 3,
"OPENBAO": settings.OPENBAO.model_copy(update={"SMB": smb}), "OPENBAO": settings.OPENBAO.model_copy(update={"SMB": smb}),
} }
@ -46,7 +44,7 @@ def test_local_mode_builds_local_hotfolder(tmp_path: Path) -> None:
assert hf.root == tmp_path assert hf.root == tmp_path
assert hf.input_dir == tmp_path / "In" assert hf.input_dir == tmp_path / "In"
assert hf.output_dir == tmp_path / "Output" assert hf.output_dir == tmp_path / "Output"
assert hf.tmp_dir == tmp_path / "Служебный" assert hf.tmp_dir == tmp_path / "Tmp" # служебный каталог эмулятора, шлюз его не использует
assert hf.settle_seconds == 1.5 assert hf.settle_seconds == 1.5
assert hf.healthy() is True assert hf.healthy() is True
@ -66,7 +64,7 @@ def test_smb_mode_builds_smb_hotfolder() -> None:
assert hf.root == "\\\\smb.example.local\\share\\scan" assert hf.root == "\\\\smb.example.local\\share\\scan"
assert hf.input_dir == "\\\\smb.example.local\\share\\scan\\Input" assert hf.input_dir == "\\\\smb.example.local\\share\\scan\\Input"
assert hf.output_dir == "\\\\smb.example.local\\share\\scan\\Out" assert hf.output_dir == "\\\\smb.example.local\\share\\scan\\Out"
assert hf.tmp_dir == "\\\\smb.example.local\\share\\scan\\T" assert not hasattr(hf, "tmp_dir") # у шлюза служебного каталога нет (change hotfolder-direct-write)
assert hf.settle_seconds == 3 assert hf.settle_seconds == 3

View File

@ -1,6 +1,6 @@
"""Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton). """Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton).
Быстрые тесты на `tmp_path`: атомарная запись входящего файла через служебный каталог `Tmp`, опрос набора ответов ИС Быстрые тесты на `tmp_path`: запись входящего файла прямо в `Input` с перезаписью, опрос набора ответов ИС
с готовностью по стабилизации (settle, часы подменены), проверка здоровья, валидация имён. с готовностью по стабилизации (settle, часы подменены), проверка здоровья, валидация имён.
""" """
@ -12,6 +12,7 @@ from typing import Any
import pytest import pytest
from src.gateway import hotfolder as hotfolder_module
from src.gateway.hotfolder import AnswerFile, HotFolder, LocalHotFolder, SettleTracker, inbox_name from src.gateway.hotfolder import AnswerFile, HotFolder, LocalHotFolder, SettleTracker, inbox_name
@ -41,6 +42,31 @@ def _settled(tmp_path: Path, settle: float = 2) -> tuple[LocalHotFolder, FakeClo
return LocalHotFolder(tmp_path, settle_seconds=settle, clock=clock), clock return LocalHotFolder(tmp_path, settle_seconds=settle, clock=clock), clock
class _FailingFile:
"""Файл, открытый на запись: `write` пишет первые два байта и падает (диск полон)."""
def __init__(self, path: Any, mode: str) -> None:
self._handle = open(path, mode) # noqa: SIM115 — закрывается в __exit__
def __enter__(self) -> "_FailingFile":
return self
def __exit__(self, *exc: object) -> None:
self._handle.close()
def write(self, data: bytes) -> int:
self._handle.write(data[:2])
raise OSError(errno.ENOSPC, "диск полон")
def _failing_open(path: Any, mode: str = "r", *args: Any, **kwargs: Any) -> _FailingFile:
return _FailingFile(path, mode)
def _failing_unlink(self: Path, missing_ok: bool = False) -> None:
raise PermissionError(errno.EACCES, "нет прав")
class TestInboxName: class TestInboxName:
def test_job_id_and_lowercase_extension(self) -> None: def test_job_id_and_lowercase_extension(self) -> None:
job_id = _job_id() job_id = _job_id()
@ -57,34 +83,12 @@ class TestInboxName:
inbox_name("job", original) inbox_name("job", original)
class TestPutWithoutReplace:
def test_existing_destination_is_kept(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
hf.put("job.pdf", b"first")
hf.put("job.pdf", b"second")
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
# R-17: назначение есть — повтор ничего не пишет; прежнего `.part` после успешной публикации нет
assert not (hf.tmp_dir / ".job.pdf.part").exists()
def test_existing_destination_part_removed_when_cleanup_enabled(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path, cleanup=True)
hf.put("job.pdf", b"first")
hf.put("job.pdf", b"second")
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
assert not (hf.tmp_dir / ".job.pdf.part").exists()
def test_no_temp_files_in_input(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
hf.put("job.pdf", b"x")
assert [p.name for p in hf.input_dir.iterdir()] == ["job.pdf"]
assert not (hf.tmp_dir / ".job.pdf.part").exists() # после успешной публикации .part нет
class TestCleanup: class TestCleanup:
def _fill(self, hf: LocalHotFolder, job_id: str) -> None: def _fill(self, hf: LocalHotFolder, job_id: str) -> None:
hf.put(f"{job_id}.pdf", b"x") hf.put(f"{job_id}.pdf", b"x")
(hf.input_dir / f"{job_id}.pdf.done").write_bytes(b"") (hf.input_dir / f"{job_id}.pdf.done").write_bytes(b"")
(hf.tmp_dir / f".{job_id}.pdf.part").write_bytes(b"orphan") hf.tmp_dir.mkdir(exist_ok=True)
(hf.tmp_dir / f".{job_id}.pdf.part").write_bytes(b"legacy") # остаток прежних версий шлюза — не наш
_write_answer(hf, job_id, b"{}") _write_answer(hf, job_id, b"{}")
_write_answer(hf, job_id, b"{}", name=f"{job_id}_ofr.json") _write_answer(hf, job_id, b"{}", name=f"{job_id}_ofr.json")
(hf.answer_dir(job_id) / "readme.txt").write_bytes(b"keep") (hf.answer_dir(job_id) / "readme.txt").write_bytes(b"keep")
@ -104,7 +108,7 @@ class TestCleanup:
assert hf.cleanup(job_id, f"{job_id}.pdf") is True assert hf.cleanup(job_id, f"{job_id}.pdf") is True
assert not (hf.input_dir / f"{job_id}.pdf").exists() assert not (hf.input_dir / f"{job_id}.pdf").exists()
assert not (hf.input_dir / f"{job_id}.pdf.done").exists() assert not (hf.input_dir / f"{job_id}.pdf.done").exists()
assert not (hf.tmp_dir / f".{job_id}.pdf.part").exists() assert (hf.tmp_dir / f".{job_id}.pdf.part").exists() # Tmp очистка не трогает
assert not hf.answer_dir(job_id).exists() # каталог удалён вместе с readme.txt assert not hf.answer_dir(job_id).exists() # каталог удалён вместе с readme.txt
assert hf.cleanup(job_id, f"{job_id}.pdf") is True # повтор — не ошибка assert hf.cleanup(job_id, f"{job_id}.pdf") is True # повтор — не ошибка
@ -181,60 +185,57 @@ class TestPut:
hf.put(name, data) hf.put(name, data)
assert (tmp_path / "Input" / name).read_bytes() == data assert (tmp_path / "Input" / name).read_bytes() == data
def test_leaves_no_part_files(self, tmp_path: Path) -> None: def test_creates_input_dir_only(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path) hf = LocalHotFolder(tmp_path)
job_id = _job_id()
name = inbox_name(job_id, "aep_5.pdf")
hf.put(name, b"data")
assert [p.name for p in hf.input_dir.iterdir()] == [name]
assert list(hf.tmp_dir.iterdir()) == []
def test_part_is_written_in_tmp_not_in_input(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""OQ-79: во время записи во входящем каталоге нет временного файла — он в `Tmp/`, затем link в `Input`."""
hf = LocalHotFolder(tmp_path)
job_id = _job_id()
name = inbox_name(job_id, "aep_5.pdf")
seen: dict[str, Any] = {}
real_link = os.link
def spy(src: Any, dst: Any) -> None:
seen["src"], seen["dst"] = Path(src), Path(dst)
seen["input"] = sorted(p.name for p in hf.input_dir.iterdir())
seen["tmp"] = sorted(p.name for p in hf.tmp_dir.iterdir())
seen["part"] = Path(src).read_bytes()
real_link(src, dst)
monkeypatch.setattr("src.gateway.hotfolder.os.link", spy)
hf.put(name, b"data")
assert seen == {
"src": hf.tmp_dir / f".{name}.part",
"dst": hf.input_dir / name,
"input": [],
"tmp": [f".{name}.part"],
"part": b"data",
}
assert [p.name for p in hf.input_dir.iterdir()] == [name]
assert list(hf.tmp_dir.iterdir()) == []
def test_creates_input_and_tmp_dirs_only(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
assert not hf.input_dir.exists()
assert not hf.tmp_dir.exists()
hf.put("a.pdf", b"1") hf.put("a.pdf", b"1")
assert hf.input_dir.is_dir() assert sorted(p.name for p in tmp_path.iterdir()) == ["Input"] # Tmp не создаётся
assert hf.tmp_dir.is_dir()
assert not hf.output_dir.exists()
def test_existing_tmp_dir_is_fine(self, tmp_path: Path) -> None: def test_existing_file_is_overwritten(self, tmp_path: Path) -> None:
(tmp_path / "Tmp").mkdir() """Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл."""
LocalHotFolder(tmp_path).put("a.pdf", b"1") hf = LocalHotFolder(tmp_path, settle_seconds=0)
assert (tmp_path / "Input" / "a.pdf").read_bytes() == b"1" hf.put("job.pdf", b"first, longer")
hf.put("job.pdf", b"second")
assert (hf.input_dir / "job.pdf").read_bytes() == b"second"
def test_custom_tmp_dir_name(self, tmp_path: Path) -> None: def test_write_failure_keeps_partial_file_when_cleanup_disabled(
hf = LocalHotFolder(tmp_path, tmp_dir="Служебный") self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
hf.put("a.pdf", b"1") ) -> None:
assert (tmp_path / "Служебный").is_dir() hf = LocalHotFolder(tmp_path, settle_seconds=0)
assert not (tmp_path / "Tmp").exists() monkeypatch.setattr(hotfolder_module, "open", _failing_open, raising=False)
with pytest.raises(OSError, match="диск полон"):
hf.put("job.pdf", b"data")
assert (hf.input_dir / "job.pdf").read_bytes() == b"da"
def test_write_failure_removes_partial_file_when_cleanup_enabled(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
monkeypatch.setattr(hotfolder_module, "open", _failing_open, raising=False)
with pytest.raises(OSError, match="диск полон"):
hf.put("job.pdf", b"data")
assert not (hf.input_dir / "job.pdf").exists()
def test_remove_failure_does_not_mask_write_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
monkeypatch.setattr(hotfolder_module, "open", _failing_open, raising=False)
monkeypatch.setattr(Path, "unlink", _failing_unlink)
with pytest.raises(OSError, match="диск полон"):
hf.put("job.pdf", b"data")
@pytest.mark.parametrize("cleanup", [False, True])
def test_open_failure_never_removes_existing_file(self, tmp_path: Path, cleanup: bool) -> None:
"""I-1: открыть не удалось — недописанного файла нет, целый файл прошлой доставки не трогаем."""
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=cleanup)
hf.input_dir.mkdir()
delivered = hf.input_dir / "job.pdf"
delivered.write_bytes(b"delivered")
delivered.chmod(0o444)
try:
with pytest.raises(PermissionError):
hf.put("job.pdf", b"new")
assert delivered.read_bytes() == b"delivered"
finally:
delivered.chmod(0o644)
def test_existing_input_dir_is_fine(self, tmp_path: Path) -> None: def test_existing_input_dir_is_fine(self, tmp_path: Path) -> None:
(tmp_path / "Input").mkdir() (tmp_path / "Input").mkdir()
@ -285,227 +286,6 @@ class TestPut:
hf.put(name, b"x") hf.put(name, b"x")
assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names) assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names)
def test_cleans_part_file_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""При cleanup=True и ошибке link: .part удаляется."""
hf = LocalHotFolder(tmp_path, cleanup=True)
def boom(src: Any, dst: Any) -> None:
raise OSError("диск недоступен")
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
with pytest.raises(OSError, match="диск недоступен"):
hf.put("a.pdf", b"data")
assert list(hf.input_dir.iterdir()) == []
assert list(hf.tmp_dir.iterdir()) == []
def test_keeps_part_file_on_failure_when_cleanup_disabled(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""При cleanup=False и ошибке link: .part остаётся в Tmp (T11.1)."""
hf = LocalHotFolder(tmp_path, cleanup=False)
def boom(src: Any, dst: Any) -> None:
raise OSError("диск недоступен")
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
with pytest.raises(OSError, match="диск недоступен"):
hf.put("a.pdf", b"data")
assert list(hf.input_dir.iterdir()) == []
assert [p.name for p in hf.tmp_dir.iterdir()] == [".a.pdf.part"]
def test_successful_put_despite_final_unlink_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Успешный link, но ошибка в завершающем unlink .part (R-8): put НЕ падает, файл доставлен (T11.2)."""
hf = LocalHotFolder(tmp_path)
unlink_calls = []
real_unlink = os.unlink
def unlink_with_failure(path: Any) -> None:
unlink_calls.append(path)
if str(path).endswith(".part"):
raise PermissionError("удаление запрещено")
real_unlink(path)
monkeypatch.setattr("src.gateway.hotfolder.os.unlink", unlink_with_failure)
hf.put("a.pdf", b"data") # НЕ падает несмотря на ошибку unlink
assert (hf.input_dir / "a.pdf").read_bytes() == b"data"
assert (hf.tmp_dir / ".a.pdf.part").exists() # .part не удалён из-за ошибки
assert unlink_calls # попытка удаления была
def test_cleanup_failure_does_not_mask_original_error(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ошибка удаления `.part` на пути ошибки link подавляется (T11.1); исходное исключение проходит."""
hf = LocalHotFolder(tmp_path, cleanup=True)
def boom(src: Any, dst: Any) -> None:
raise OSError("диск недоступен")
def unlink_fails(self: Path, missing_ok: bool = False) -> None:
raise PermissionError("удаление .part запрещено")
monkeypatch.setattr("src.gateway.hotfolder.os.link", boom)
monkeypatch.setattr(Path, "unlink", unlink_fails)
with pytest.raises(OSError, match="диск недоступен"):
hf.put("a.pdf", b"data")
class TestPartIsCreatedFresh:
"""Решение №32: `.part` создаётся заново (удаление старого + `O_EXCL`), fallback — только вокруг `os.link`."""
@pytest.mark.parametrize("cleanup", [False, True])
def test_existing_destination_is_not_touched_by_repeat(self, tmp_path: Path, cleanup: bool) -> None:
"""R-17: назначение есть, `.part` — жёсткая ссылка на него: байты не меняются, put не падает."""
hf = LocalHotFolder(tmp_path, cleanup=cleanup)
hf.put("job.pdf", b"first")
published = hf.input_dir / "job.pdf"
stale = hf.tmp_dir / ".job.pdf.part"
os.link(published, stale)
hf.put("job.pdf", b"different data")
assert published.read_bytes() == b"first"
assert stale.exists() is (not cleanup) # очистка снимает `.part`, иначе он остаётся как был
if not cleanup:
assert stale.read_bytes() == b"first" and published.stat().st_nlink == 2
def test_existing_destination_with_undeletable_part_does_not_fail(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
hf = LocalHotFolder(tmp_path) # очистка выключена: удаление `.part` вообще не пытается
hf.put("job.pdf", b"first")
os.link(hf.input_dir / "job.pdf", hf.tmp_dir / ".job.pdf.part")
def deny(self: Path, missing_ok: bool = False) -> None:
raise PermissionError("удаление запрещено")
monkeypatch.setattr(Path, "unlink", deny)
hf.put("job.pdf", b"second")
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
def test_undeletable_stale_part_with_single_link_is_truncated(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Назначения нет, старый `.part` не удаляется, но это обычный файл (nlink == 1): put успешен."""
hf = LocalHotFolder(tmp_path)
hf.tmp_dir.mkdir()
stale = hf.tmp_dir / ".job.pdf.part"
stale.write_bytes(b"old and longer content")
def deny(self: Path, missing_ok: bool = False) -> None:
raise PermissionError("удаление запрещено")
monkeypatch.setattr(Path, "unlink", deny)
hf.put("job.pdf", b"new")
assert (hf.input_dir / "job.pdf").read_bytes() == b"new"
def test_undeletable_stale_part_with_other_links_is_not_written(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Назначения нет, старый `.part` не удаляется и имеет nlink == 2 (синтетически): запись не идёт, ошибка не
маскируется, второй файл не изменён."""
hf = LocalHotFolder(tmp_path)
hf.tmp_dir.mkdir()
other = tmp_path / "other.bin"
other.write_bytes(b"precious")
os.link(other, hf.tmp_dir / ".job.pdf.part")
def deny(self: Path, missing_ok: bool = False) -> None:
raise PermissionError("удаление запрещено")
monkeypatch.setattr(Path, "unlink", deny)
with pytest.raises(FileExistsError):
hf.put("job.pdf", b"new")
assert other.read_bytes() == b"precious"
assert not (hf.input_dir / "job.pdf").exists()
def test_stale_part_is_replaced_before_publication(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path)
hf.tmp_dir.mkdir()
(hf.tmp_dir / ".job.pdf.part").write_bytes(b"old and longer content")
hf.put("job.pdf", b"new")
assert (hf.input_dir / "job.pdf").read_bytes() == b"new"
assert not (hf.tmp_dir / ".job.pdf.part").exists()
def test_part_is_opened_exclusively(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
hf = LocalHotFolder(tmp_path)
flags: list[int] = []
real_open = os.open
def spy(path: Any, flag: int, mode: int = 0o777, **kwargs: Any) -> int:
flags.append(flag)
return real_open(path, flag, mode, **kwargs)
monkeypatch.setattr("src.gateway.hotfolder.os.open", spy)
hf.put("job.pdf", b"x")
assert flags and flags[0] & os.O_EXCL and flags[0] & os.O_CREAT
def test_write_error_propagates_as_itself(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""EPERM записи не превращается в `FileNotFoundError` от `os.replace` несуществующего `.part`."""
hf = LocalHotFolder(tmp_path, cleanup=True)
class FailingFile:
def __enter__(self) -> "FailingFile":
return self
def __exit__(self, *exc: object) -> None:
return None
def write(self, data: bytes) -> int:
raise OSError(errno.EPERM, "запись запрещена")
monkeypatch.setattr("src.gateway.hotfolder.os.fdopen", lambda fd, mode: (os.close(fd), FailingFile())[1])
with pytest.raises(OSError, match="запись запрещена") as caught:
hf.put("job.pdf", b"data")
assert caught.value.errno == errno.EPERM and not isinstance(caught.value, FileNotFoundError)
assert list(hf.input_dir.iterdir()) == [] and list(hf.tmp_dir.iterdir()) == []
@pytest.mark.parametrize("code", [errno.EPERM, errno.EXDEV, errno.ENOTSUP, errno.EOPNOTSUPP])
def test_fallback_to_replace_when_link_unsupported(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, code: int
) -> None:
hf = LocalHotFolder(tmp_path)
def no_links(src: Any, dst: Any) -> None:
raise OSError(code, "жёсткие ссылки не поддерживаются")
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
hf.put("job.pdf", b"data")
assert (hf.input_dir / "job.pdf").read_bytes() == b"data"
assert list(hf.tmp_dir.iterdir()) == [] # `.part` стал назначением
def test_no_fallback_when_destination_appeared_during_link(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Назначение появилось между проверкой и линковкой, а `os.link` дал EPERM: `os.replace` не затирает."""
hf = LocalHotFolder(tmp_path, cleanup=True)
hf.input_dir.mkdir()
def no_links(src: Any, dst: Any) -> None:
Path(dst).write_bytes(b"first") # файл успел появиться (параллельный повтор того же задания)
raise OSError(errno.EPERM, "жёсткие ссылки не поддерживаются")
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
with pytest.raises(OSError, match="жёсткие ссылки"):
hf.put("job.pdf", b"second")
assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
def test_replace_failure_after_link_fallback_propagates(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
hf = LocalHotFolder(tmp_path, cleanup=True)
def no_links(src: Any, dst: Any) -> None:
raise OSError(errno.EPERM, "жёсткие ссылки не поддерживаются")
def broken_replace(src: Any, dst: Any) -> None:
raise OSError(errno.EIO, "замена не удалась")
monkeypatch.setattr("src.gateway.hotfolder.os.link", no_links)
monkeypatch.setattr("src.gateway.hotfolder.os.replace", broken_replace)
with pytest.raises(OSError, match="замена не удалась"):
hf.put("job.pdf", b"data")
assert list(hf.tmp_dir.iterdir()) == [] # при очистке `.part` снят
class TestPoll: class TestPoll:
"""`poll` — все `*.json` каталога `<output>/<job_id>/` по имени, когда набор настоялся `settle_seconds`.""" """`poll` — все `*.json` каталога `<output>/<job_id>/` по имени, когда набор настоялся `settle_seconds`."""

View File

@ -21,7 +21,6 @@ HOST = "smb.example.local"
ROOT = "\\\\smb.example.local\\share\\scan" ROOT = "\\\\smb.example.local\\share\\scan"
INPUT = ROOT + "\\Input" INPUT = ROOT + "\\Input"
OUTPUT = ROOT + "\\Output" OUTPUT = ROOT + "\\Output"
TMP = ROOT + "\\Tmp"
class FakeClock: class FakeClock:
@ -171,6 +170,26 @@ def _write_answer(share_root: Path, job_id: str, data: bytes, name: str | None =
return path return path
def _fail_write(fake: FakeSmbClient) -> SMBOSError:
"""`open_file` открывает файл, но `write` пишет два байта и падает (диск полон): недописанный файл остаётся."""
error = SMBOSError(NtStatus.STATUS_DISK_FULL, INPUT)
real_open = fake.open_file
def open_file(path: str, mode: str = "r", **kwargs: Any) -> IO[Any]:
handle = real_open(path, mode, **kwargs)
def write(data: bytes) -> int:
handle.raw.write(data[:2]) if hasattr(handle, "raw") else None
handle.flush()
raise error
handle.write = write # type: ignore[method-assign]
return handle
fake.open_file = open_file # type: ignore[method-assign]
return error
class TestPaths: class TestPaths:
def test_default_layout(self) -> None: def test_default_layout(self) -> None:
hf = _hotfolder() hf = _hotfolder()
@ -178,7 +197,6 @@ class TestPaths:
assert hf.root == ROOT assert hf.root == ROOT
assert hf.input_dir == INPUT assert hf.input_dir == INPUT
assert hf.output_dir == OUTPUT assert hf.output_dir == OUTPUT
assert hf.tmp_dir == TMP
assert hf.answer_dir(job_id) == f"{OUTPUT}\\{job_id}" assert hf.answer_dir(job_id) == f"{OUTPUT}\\{job_id}"
assert hf.answer_path(job_id) == f"{OUTPUT}\\{job_id}\\{job_id}.json" assert hf.answer_path(job_id) == f"{OUTPUT}\\{job_id}\\{job_id}.json"
@ -194,10 +212,9 @@ class TestPaths:
assert SmbHotFolder(HOST, "share").root == "\\\\smb.example.local\\share" assert SmbHotFolder(HOST, "share").root == "\\\\smb.example.local\\share"
def test_custom_dir_names(self) -> None: def test_custom_dir_names(self) -> None:
hf = SmbHotFolder(HOST, "share/scan", input_dir="Входящие", output_dir="Исходящие", tmp_dir="Служебный") hf = SmbHotFolder(HOST, "share/scan", input_dir="Входящие", output_dir="Исходящие")
assert hf.input_dir == ROOT + "\\Входящие" assert hf.input_dir == ROOT + "\\Входящие"
assert hf.output_dir == ROOT + "\\Исходящие" assert hf.output_dir == ROOT + "\\Исходящие"
assert hf.tmp_dir == ROOT + "\\Служебный"
assert hf.answer_path("abc") == ROOT + "\\Исходящие\\abc\\abc.json" assert hf.answer_path("abc") == ROOT + "\\Исходящие\\abc\\abc.json"
@pytest.mark.parametrize(("host", "path"), [(HOST, ""), (HOST, "/"), ("", "share/scan"), ("", "\\\\srv")]) @pytest.mark.parametrize(("host", "path"), [(HOST, ""), (HOST, "/"), ("", "share/scan"), ("", "\\\\srv")])
@ -226,63 +243,13 @@ class TestPaths:
class TestPut: class TestPut:
def test_writes_part_in_tmp_then_renames_into_input(self, fake: FakeSmbClient, share_root: Path) -> None: def test_writes_directly_into_input(self, fake: FakeSmbClient, share_root: Path) -> None:
name = inbox_name(_job_id(), "aep_5.PDF") name = inbox_name(_job_id(), "aep_5.PDF")
data = bytes(range(256)) * 10 data = bytes(range(256)) * 10
_hotfolder().put(name, data) _hotfolder().put(name, data)
part = f"{TMP}\\.{name}.part" assert fake.calls == [("mkdir", (INPUT,), {}), ("open_file", (f"{INPUT}\\{name}", "wb"), {})]
assert fake.calls == [
("mkdir", (TMP,), {}),
("mkdir", (INPUT,), {}),
("open_file", (part, "wb"), {}),
("rename", (part, f"{INPUT}\\{name}"), {}),
]
assert (share_root / "Input" / name).read_bytes() == data assert (share_root / "Input" / name).read_bytes() == data
assert [p.name for p in (share_root / "Input").iterdir()] == [name] assert sorted(p.name for p in share_root.iterdir()) == ["Input"] # Tmp не создаётся
assert list((share_root / "Tmp").iterdir()) == []
def test_input_has_no_part_during_write(self, fake: FakeSmbClient, share_root: Path) -> None:
"""OQ-79: пока пишется временный файл, во входящем каталоге его нет — он в `Tmp`."""
name = "a.pdf"
seen: dict[str, list[str]] = {}
real_rename = fake.rename
def spy(src: str, dst: str, **kwargs: Any) -> None:
seen["input"] = sorted(p.name for p in (share_root / "Input").iterdir())
seen["tmp"] = sorted(p.name for p in (share_root / "Tmp").iterdir())
real_rename(src, dst, **kwargs)
fake.rename = spy # type: ignore[method-assign]
_hotfolder().put(name, b"data")
assert seen == {"input": [], "tmp": [".a.pdf.part"]}
assert [p.name for p in (share_root / "Input").iterdir()] == [name]
def test_existing_tmp_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
(share_root / "Tmp").mkdir()
_hotfolder().put("a.pdf", b"1")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
def test_tmp_mkdir_denied_but_tmp_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
(share_root / "Tmp").mkdir()
(share_root / "Input").mkdir()
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP)
_hotfolder().put("a.pdf", b"1")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
assert fake.calls[:4] == [
("mkdir", (TMP,), {}),
("isdir", (TMP,), {}),
("mkdir", (INPUT,), {}),
("isdir", (INPUT,), {}),
]
def test_tmp_mkdir_denied_and_no_tmp_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
(share_root / "Input").mkdir()
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP)
fake.fail["mkdir"] = denied
with pytest.raises(SMBOSError) as excinfo:
_hotfolder().put("a.pdf", b"1")
assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"]
def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
(share_root / "Input").mkdir() (share_root / "Input").mkdir()
@ -298,41 +265,29 @@ class TestPut:
def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
"""Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно.""" """Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно."""
(share_root / "Tmp").mkdir()
(share_root / "Input").mkdir() (share_root / "Input").mkdir()
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
_hotfolder().put("a.pdf", b"1") _hotfolder().put("a.pdf", b"1")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
assert fake.funcs() == ["mkdir", "isdir", "mkdir", "isdir", "open_file", "rename"] assert fake.funcs() == ["mkdir", "isdir", "open_file"]
assert fake.calls[3] == ("isdir", (INPUT,), {})
def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
(share_root / "Tmp").mkdir()
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["mkdir"] = denied fake.fail["mkdir"] = denied
with pytest.raises(SMBOSError) as excinfo: with pytest.raises(SMBOSError) as excinfo:
_hotfolder().put("a.pdf", b"1") _hotfolder().put("a.pdf", b"1")
assert excinfo.value is denied assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir", "mkdir", "isdir"] assert fake.funcs() == ["mkdir", "isdir"]
def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP) denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["mkdir"] = denied fake.fail["mkdir"] = denied
fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP) fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
with pytest.raises(SMBOSError) as excinfo: with pytest.raises(SMBOSError) as excinfo:
_hotfolder().put("a.pdf", b"1") _hotfolder().put("a.pdf", b"1")
assert excinfo.value is denied assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"] assert fake.funcs() == ["mkdir", "isdir"]
@pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"])
def test_no_replace_on_existing_file(self, fake: FakeSmbClient, share_root: Path, plain: bool) -> None:
fake.plain_collision = plain
hf = _hotfolder()
hf.put("a.pdf", b"old")
hf.put("a.pdf", b"new")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"old"
assert (share_root / "Tmp" / ".a.pdf.part").read_bytes() == b"new"
def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None: def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None:
_hotfolder().put("empty.pdf", b"") _hotfolder().put("empty.pdf", b"")
assert (share_root / "Input" / "empty.pdf").read_bytes() == b"" assert (share_root / "Input" / "empty.pdf").read_bytes() == b""
@ -371,58 +326,61 @@ class TestPut:
_hotfolder().put(name, b"x") _hotfolder().put(name, b"x")
assert fake.calls == [] assert fake.calls == []
def test_cleans_part_file_on_rename_failure(self, fake: FakeSmbClient, share_root: Path) -> None: def test_write_failure_keeps_partial_file_when_cleanup_disabled(
fake.fail["rename"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) self, fake: FakeSmbClient, share_root: Path
with pytest.raises(OSError): ) -> None:
_hotfolder(cleanup=True).put("a.pdf", b"data") _fail_write(fake)
part = f"{TMP}\\.a.pdf.part" with pytest.raises(SMBOSError):
assert fake.calls[-1] == ("remove", (part,), {}) _hotfolder().put("a.pdf", b"data")
assert list((share_root / "Input").iterdir()) == [] assert (share_root / "Input" / "a.pdf").read_bytes() == b"da"
assert list((share_root / "Tmp").iterdir()) == [] assert "remove" not in fake.funcs()
def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None: def test_write_failure_removes_partial_file_when_cleanup_enabled(
fake.fail["rename"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) self, fake: FakeSmbClient, share_root: Path
fake.fail["remove"] = SMBException("соединение потеряно") ) -> None:
_fail_write(fake)
with pytest.raises(SMBOSError): with pytest.raises(SMBOSError):
_hotfolder(cleanup=True).put("a.pdf", b"data") _hotfolder(cleanup=True).put("a.pdf", b"data")
assert fake.funcs() == ["mkdir", "mkdir", "open_file", "rename", "remove"] assert fake.calls[-1] == ("remove", (f"{INPUT}\\a.pdf",), {})
assert not (share_root / "Input" / "a.pdf").exists()
def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None:
original = _fail_write(fake)
fake.fail["remove"] = SMBException("соединение потеряно")
with pytest.raises(SMBOSError) as excinfo:
_hotfolder(cleanup=True).put("a.pdf", b"data")
assert excinfo.value is original
assert fake.funcs() == ["mkdir", "open_file", "remove"]
@pytest.mark.parametrize("cleanup", [False, True])
def test_open_failure_never_removes_existing_file(
self, fake: FakeSmbClient, share_root: Path, cleanup: bool
) -> None:
"""I-1: открыть не удалось (например, ИС держит файл повторённого задания) — недописанного файла нет, целый
файл прошлой доставки не трогаем даже при включённой очистке; ошибка открытия поднимается как есть."""
(share_root / "Input").mkdir()
(share_root / "Input" / "a.pdf").write_bytes(b"delivered")
denied = SMBOSError(NtStatus.STATUS_SHARING_VIOLATION, INPUT + "\\a.pdf")
fake.fail["open_file"] = denied
with pytest.raises(SMBOSError) as excinfo:
_hotfolder(cleanup=cleanup).put("a.pdf", b"new")
assert excinfo.value is denied
assert "remove" not in fake.funcs()
assert (share_root / "Input" / "a.pdf").read_bytes() == b"delivered"
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None: def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None:
_hotfolder(login="user", password="secret").put("a.pdf", b"1") _hotfolder(login="user", password="secret").put("a.pdf", b"1")
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 4 assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 2
class TestPutWithoutReplace: class TestPutOverwrite:
def test_uses_rename_not_replace(self, fake: FakeSmbClient, share_root: Path) -> None: def test_existing_file_is_overwritten(self, fake: FakeSmbClient, share_root: Path) -> None:
"""Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл."""
hf = _hotfolder() hf = _hotfolder()
hf.put("job.pdf", b"x") hf.put("job.pdf", b"first, longer")
names = [call[0] for call in fake.calls]
assert "rename" in names and "replace" not in names
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"x"
assert not fake._local(TMP + "\\.job.pdf.part").exists()
@pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"])
def test_existing_destination_kept_and_part_left(self, fake: FakeSmbClient, share_root: Path, plain: bool) -> None:
fake.plain_collision = plain
hf = _hotfolder()
hf.put("job.pdf", b"first")
hf.put("job.pdf", b"second") hf.put("job.pdf", b"second")
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"first" assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"second"
assert fake._local(TMP + "\\.job.pdf.part").read_bytes() == b"second" assert "rename" not in fake.funcs() and "replace" not in fake.funcs()
@pytest.mark.parametrize("plain", [False, True], ids=["SMBOSError", "FileExistsError"])
def test_existing_destination_drops_part_when_cleanup_enabled(
self, fake: FakeSmbClient, share_root: Path, plain: bool
) -> None:
"""Назначение есть (повтор задания) и очистка включена: файл не заменён, `.part` снят."""
fake.plain_collision = plain
hf = _hotfolder(cleanup=True)
hf.put("job.pdf", b"first")
fake.calls.clear()
hf.put("job.pdf", b"second") # исключения нет: назначение есть — файл считается доставленным
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"first"
assert not fake._local(TMP + "\\.job.pdf.part").exists()
assert fake.calls[-1] == ("remove", (f"{TMP}\\.job.pdf.part",), {})
class TestCleanup: class TestCleanup:
@ -481,7 +439,7 @@ class TestCleanupDetails:
hf.cleanup(_job_id(), name) hf.cleanup(_job_id(), name)
assert fake.calls == [] assert fake.calls == []
def test_orphan_part_and_done_marker_are_removed(self, fake: FakeSmbClient) -> None: def test_done_marker_is_removed_and_tmp_is_not_touched(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake) hf = self._hf(fake)
job_id = _job_id() job_id = _job_id()
name = f"{job_id}.pdf" name = f"{job_id}.pdf"
@ -490,14 +448,14 @@ class TestCleanupDetails:
(root / folder).mkdir() (root / folder).mkdir()
(root / "Input" / name).write_bytes(b"pdf") (root / "Input" / name).write_bytes(b"pdf")
(root / "Input" / f"{name}.done").write_bytes(b"") # маркер ИС (root / "Input" / f"{name}.done").write_bytes(b"") # маркер ИС
(root / "Tmp" / f".{name}.part").write_bytes(b"orphan") # `.part` осиротевшей записи (root / "Tmp" / f".{name}.part").write_bytes(b"legacy") # остаток прежних версий шлюза — не наш
other = root / "Input" / f"{_job_id()}.pdf" other = root / "Input" / f"{_job_id()}.pdf"
other.write_bytes(b"other job") # чужое задание не трогаем other.write_bytes(b"other job") # чужое задание не трогаем
assert hf.cleanup(job_id, name) is True assert hf.cleanup(job_id, name) is True
assert sorted(p.name for p in (root / "Input").iterdir()) == [other.name] assert sorted(p.name for p in (root / "Input").iterdir()) == [other.name]
assert list((root / "Tmp").iterdir()) == [] assert (root / "Tmp" / f".{name}.part").exists()
removed = [args[0] for func, args, _ in fake.calls if func == "remove"] removed = [args[0] for func, args, _ in fake.calls if func == "remove"]
assert removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done", f"{TMP}\\.{name}.part"] assert removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done"]
def test_scandir_access_denied_propagates_and_nothing_else_is_touched(self, fake: FakeSmbClient) -> None: def test_scandir_access_denied_propagates_and_nothing_else_is_touched(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake) hf = self._hf(fake)
@ -523,7 +481,7 @@ class TestCleanupDetails:
assert excinfo.value.ntstatus == NtStatus.STATUS_DIRECTORY_NOT_EMPTY assert excinfo.value.ntstatus == NtStatus.STATUS_DIRECTORY_NOT_EMPTY
assert not (answer / f"{job_id}.json").exists() # файлы сняты, каталог остался вместе с подкаталогом assert not (answer / f"{job_id}.json").exists() # файлы сняты, каталог остался вместе с подкаталогом
assert (answer / "nested").is_dir() assert (answer / "nested").is_dir()
assert fake.funcs()[-1] == "rmdir" # дальше (входящий файл, `.done`, `.part`) очистка не пошла assert fake.funcs()[-1] == "rmdir" # дальше (входящий файл, `.done`) очистка не пошла
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient) -> None: def test_passes_credentials_to_every_call(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake, login="user", password="secret") hf = self._hf(fake, login="user", password="secret")
@ -536,13 +494,13 @@ class TestCleanupDetails:
(answer / f"{job_id}_ofr.json").write_bytes(b"{}") (answer / f"{job_id}_ofr.json").write_bytes(b"{}")
fake.calls.clear() fake.calls.clear()
assert hf.cleanup(job_id, name) is True assert hf.cleanup(job_id, name) is True
assert fake.funcs() == ["scandir", "remove", "remove", "rmdir", "remove", "remove", "remove"] assert fake.funcs() == ["scandir", "remove", "remove", "rmdir", "remove", "remove"]
assert all(kwargs == {"username": "user", "password": "secret"} for _, _, kwargs in fake.calls) assert all(kwargs == {"username": "user", "password": "secret"} for _, _, kwargs in fake.calls)
def test_missing_answer_dir_and_files_are_fine(self, fake: FakeSmbClient) -> None: def test_missing_answer_dir_and_files_are_fine(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake) hf = self._hf(fake)
assert hf.cleanup(_job_id(), "nothing.pdf") is True assert hf.cleanup(_job_id(), "nothing.pdf") is True
assert fake.funcs() == ["scandir", "remove", "remove", "remove"] assert fake.funcs() == ["scandir", "remove", "remove"]
def test_answer_dir_is_a_file_is_fine(self, fake: FakeSmbClient) -> None: def test_answer_dir_is_a_file_is_fine(self, fake: FakeSmbClient) -> None:
hf = self._hf(fake) hf = self._hf(fake)

View File

@ -953,13 +953,14 @@ class TestSlice3a:
assert "Очистка горячей папки не удалась" in warnings[0] and job_id in warnings[0] and "нет прав" in warnings[0] assert "Очистка горячей папки не удалась" in warnings[0] and job_id in warnings[0] and "нет прав" in warnings[0]
async def test_existing_input_waits_for_answer(self, make_env: EnvFactory) -> None: async def test_existing_input_waits_for_answer(self, make_env: EnvFactory) -> None:
"""Review Focus 4: входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT, файл не перезаписан.""" """Входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT; файл перезаписан объектом S3, как в старом
АРМ (change hotfolder-direct-write, решение владельца 09.10.2026)."""
env = await make_env() env = await make_env()
body = env.request(deadline_s=0.3) body = env.request(deadline_s=0.3)
env.local.put(f"{body['job_id']}.pdf", b"already there") env.local.put(f"{body['job_id']}.pdf", b"already there")
result = await env.run(body) result = await env.run(body)
assert result["error"]["code"] == "TIMEOUT" assert result["error"]["code"] == "TIMEOUT"
assert (env.local.input_dir / f"{body['job_id']}.pdf").read_bytes() == b"already there" assert (env.local.input_dir / f"{body['job_id']}.pdf").read_bytes() == PDF_BYTES
assert env.inbox() == [f"{body['job_id']}.pdf"] assert env.inbox() == [f"{body['job_id']}.pdf"]
async def test_ocr_normalizer_produces_documents(self, make_env: EnvFactory) -> None: async def test_ocr_normalizer_produces_documents(self, make_env: EnvFactory) -> None:

View File

@ -0,0 +1,359 @@
# Запись в горячую папку прямо в Input — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (план из 3 задач, ≤5 — Native по
> `~/.claude/CLAUDE.md`). Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `put` пишет `<job_id>.<ext>` сразу в `Input` (`wb`, перезапись), без `Tmp/.part` и rename; `SCAN_TMP_DIR`
уходит из шлюза; `SCAN_TIMEOUT_SECONDS` по умолчанию 1200.
**Architecture:** Меняются только `SmbHotFolder.put/cleanup` и `LocalHotFolder.put/cleanup`; протокол `HotFolder`,
воркер и эмулятор не меняются. `LocalHotFolder.tmp_dir` остаётся для эмулятора.
**Tech Stack:** Python 3.11, smbprotocol (`smbclient`), pytest, ruff, mypy.
**Spec:** `openspec/changes/hotfolder-direct-write/` (proposal, design D-1…D-5, R-1…R-4, specs/image-recognition-gateway)
Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway` (ветка
`feat/26-hotfolder-direct-write`). Git — `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway`.
`pytest` — из `api/` (`.venv/bin/pytest`), `make` — из корня репозитория.
## Global Constraints
- Запись: `smbclient.open_file(<Input>\<имя>, mode="wb", **credentials)` / `open(<input>/<имя>, "wb")`; существующий файл перезаписывается.
- Временных файлов, `Tmp` и rename у шлюза нет; `Input` создаётся терпимо (`_ensure_dir` / `mkdir(exist_ok=True)`), корень — нет.
- Ошибка записи: недописанный `<Input>/<имя>` снимается только при `cleanup=True`; ошибка снятия подавляется, поднимается исходная.
- При `SCAN_CLEANUP=false` ничего не удаляется; `cleanup` удаляет `Output/<job_id>/`, `Input/<имя>`, `Input/<имя>.done` — и больше ничего.
- `SCAN_TIMEOUT_SECONDS: PositiveFloat = 1200`; `SCAN_TMP_DIR` в `Settings` нет (у эмулятора — есть).
- Учётные данные передаются в каждый вызов `smbclient` (как сейчас).
## Review Focus
- Повторный `put` того же имени с другим содержимым → файл перезаписан новым содержимым (оба режима).
- Ошибка посреди `write` при `cleanup=False` → обрезанный файл остаётся, исключение исходное.
- Ошибка посреди `write` при `cleanup=True` и падающем `remove` → поднимается исходная ошибка записи.
- Старый `.env` с `SCAN_TMP_DIR=Tmp` → шлюз стартует (`extra="ignore"`).
- `put` не создаёт `Tmp` ни в одном режиме; эмулятор по-прежнему собирает ответ в `Tmp`.
---
### Task 1: `SmbHotFolder` — прямая запись
**Files:**
- Modify: `api/src/gateway/smb.py` (докстринг модуля, `__init__`, `put`, `_drop_part` → `_drop_partial`, `cleanup`)
- Test: `api/tests/unit/test_smb_hotfolder.py` (`TMP`, `TestPaths`, `TestPut`, `TestPutWithoutReplace` → `TestPutOverwrite`, `TestCleanup*`)
**Interfaces:**
- Produces: `SmbHotFolder(host, path, input_dir="Input", output_dir="Output", *, login="", password="", settle_seconds=2, clock=..., cleanup=False)` — **без** `tmp_dir` и атрибута `tmp_dir`.
- [ ] **Step 1: Переписать тесты `put` и `cleanup`**
Удалить константу `TMP`, из `TestPaths.test_default_layout` — `assert hf.tmp_dir == TMP`, из `test_custom_dir_names` —
`tmp_dir=...` и его assert. `TestPut` и `TestPutWithoutReplace` заменить на:
```python
class TestPut:
def test_writes_directly_into_input(self, fake: FakeSmbClient, share_root: Path) -> None:
name = inbox_name(_job_id(), "aep_5.PDF")
data = bytes(range(256)) * 10
_hotfolder().put(name, data)
assert fake.calls == [("mkdir", (INPUT,), {}), ("open_file", (f"{INPUT}\\{name}", "wb"), {})]
assert (share_root / "Input" / name).read_bytes() == data
assert sorted(p.name for p in share_root.iterdir()) == ["Input"] # Tmp не создаётся
def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
(share_root / "Input").mkdir()
_hotfolder().put("a.pdf", b"1")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
def test_missing_root_raises_and_writes_nothing(self, fake: FakeSmbClient) -> None:
with pytest.raises(OSError) as excinfo:
_hotfolder().put("a.pdf", b"1")
assert isinstance(excinfo.value, SMBOSError)
assert fake.funcs() == ["mkdir"]
assert not fake.base.exists()
def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
"""Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно."""
(share_root / "Input").mkdir()
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
_hotfolder().put("a.pdf", b"1")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
assert fake.funcs() == ["mkdir", "isdir", "open_file"]
def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["mkdir"] = denied
with pytest.raises(SMBOSError) as excinfo:
_hotfolder().put("a.pdf", b"1")
assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"]
def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["mkdir"] = denied
fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
with pytest.raises(SMBOSError) as excinfo:
_hotfolder().put("a.pdf", b"1")
assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"]
def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None:
_hotfolder().put("empty.pdf", b"")
assert (share_root / "Input" / "empty.pdf").read_bytes() == b""
def test_spaces_and_cyrillic_are_accepted(self, fake: FakeSmbClient, share_root: Path) -> None:
name = inbox_name(_job_id(), "Отчёт за 2024 год (копия).pdf")
_hotfolder().put(name, b"x")
assert (share_root / "Input" / name).read_bytes() == b"x"
# test_rejects_invalid_names_before_any_call — без изменений
def test_write_failure_keeps_partial_file_when_cleanup_disabled(
self, fake: FakeSmbClient, share_root: Path
) -> None:
fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
with pytest.raises(SMBOSError):
_hotfolder().put("a.pdf", b"data")
assert "remove" not in fake.funcs()
def test_write_failure_removes_partial_file_when_cleanup_enabled(
self, fake: FakeSmbClient, share_root: Path
) -> None:
fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
with pytest.raises(SMBOSError):
_hotfolder(cleanup=True).put("a.pdf", b"data")
assert fake.calls[-1] == ("remove", (f"{INPUT}\\a.pdf",), {})
def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None:
original = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["open_file"] = original
fake.fail["remove"] = SMBException("соединение потеряно")
with pytest.raises(SMBOSError) as excinfo:
_hotfolder(cleanup=True).put("a.pdf", b"data")
assert excinfo.value is original
assert fake.funcs() == ["mkdir", "open_file", "remove"]
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None:
_hotfolder(login="user", password="secret").put("a.pdf", b"1")
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 2
class TestPutOverwrite:
def test_existing_file_is_overwritten(self, fake: FakeSmbClient, share_root: Path) -> None:
"""Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл."""
hf = _hotfolder()
hf.put("job.pdf", b"first, longer")
hf.put("job.pdf", b"second")
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"second"
assert "rename" not in fake.funcs() and "replace" not in fake.funcs()
```
В `TestCleanupDetails.test_orphan_part_and_done_marker_are_removed` → переименовать в
`test_done_marker_is_removed_and_tmp_is_not_touched`: `.part` в `Tmp` **остаётся**,
`removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done"]`, assert `(root / "Tmp" / f".{name}.part").exists()`.
`test_passes_credentials_to_every_call` (cleanup): `["scandir", "remove", "remove", "rmdir", "remove", "remove"]`.
`test_missing_answer_dir_and_files_are_fine`: `["scandir", "remove", "remove"]`.
- [ ] **Step 2: Прогнать — ожидаемо падают**
Run: `cd api && .venv/bin/pytest -q tests/unit/test_smb_hotfolder.py`
Expected: FAIL (`TestPut`, `TestPutOverwrite`, `TestCleanupDetails`).
- [ ] **Step 3: Реализация `smb.py`**
`__init__`: убрать параметр `tmp_dir` и `self.tmp_dir`. `put`:
```python
def put(self, name: str, data: bytes) -> None:
"""Записать `<input>\\<name>` сразу, открытием `wb` (как в старом АРМ, решение владельца 09.10.2026): временного
файла и rename нет, существующий файл перезаписывается. `smbclient.open_file` открывает файл монопольно — пока
запись идёт, ИС его не прочитает (ASSUMPTION(OQ-79)). Имя проверяется до любого обращения к шаре. При ошибке
записи недописанный файл снимается только при включённой очистке (`SCAN_CLEANUP`, R-3); ошибка снятия исходную
не маскирует. Нет корня — `SMBOSError` (ENOENT) от `mkdir`.
"""
validate_name(name)
self._ensure_dir(self.input_dir)
target = ntpath.join(self.input_dir, name)
try:
with smbclient.open_file(target, mode="wb", **self._credentials) as fd:
fd.write(data)
except BaseException:
self._drop_partial(target)
raise
```
`_drop_part` переименовать в `_drop_partial` (тело то же, докстринг «недописанный входящий файл»). В `cleanup` из
кортежа путей убрать `ntpath.join(self.tmp_dir, f".{name}.part")`; докстринг — «ответ из `output`, входящий файл и
`.done`». Импорт `NtStatus` и `_has_errno(..., EEXIST ...)` в `put` больше не нужны — удалить, если не используются
(`_NOT_READY` использует `NtStatus` — оставить). Докстринг модуля: «файл пишется сразу в `\\<host>\<SMB_PATH>\<input_dir>\<имя>`
(`wb`, перезапись, как в старом АРМ)»; убрать упоминания `tmp_dir`/`.part`/OQ-79-rename. Комментарий `noqa: PLR0913`
у `__init__` оставить, если ruff его требует (параметров по-прежнему > 5).
- [ ] **Step 4: Прогнать**
Run: `cd api && .venv/bin/pytest -q tests/unit/test_smb_hotfolder.py`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add api/src/gateway/smb.py api/tests/unit/test_smb_hotfolder.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "feat(smb): запись прямо в Input с перезаписью, как в старом АРМ (#26)"
```
### Task 2: `LocalHotFolder`, фабрика, настройки
**Files:**
- Modify: `api/src/gateway/hotfolder.py` (докстринг модуля, `LocalHotFolder` докстринг, `put`, удалить `_open_part`/`_replace_part`, `_drop_part` → `_drop_partial`, `cleanup`, `_NO_HARD_LINKS`)
- Modify: `api/src/gateway/__init__.py` (не передавать `SCAN_TMP_DIR`), `api/src/core/config.py` (`SCAN_TMP_DIR` удалить, `SCAN_TIMEOUT_SECONDS = 1200`, комментарий `SCAN_CLEANUP`)
- Test: `api/tests/unit/test_local_hotfolder.py`, `api/tests/unit/test_hotfolder_factory.py`, `api/tests/unit/test_config.py`, `api/tests/integration/test_roundtrip.py`
**Interfaces:**
- Consumes: `SmbHotFolder` без `tmp_dir` (Task 1).
- Produces: `LocalHotFolder(root, input_dir="Input", output_dir="Output", tmp_dir="Tmp", *, ...)` — сигнатура та же,
`tmp_dir` используется только эмулятором; `Settings` без `SCAN_TMP_DIR`.
- [ ] **Step 1: Тесты**
`test_local_hotfolder.py`: удалить `TestPutWithoutReplace`, `TestPartIsCreatedFresh` и из `TestPut` —
`test_leaves_no_part_files`, `test_part_is_written_in_tmp_not_in_input`, `test_existing_tmp_dir_is_fine`,
`test_custom_tmp_dir_name`, `test_cleans_part_file_on_failure`, `test_keeps_part_file_on_failure_when_cleanup_disabled`,
`test_successful_put_despite_final_unlink_failure`, `test_cleanup_failure_does_not_mask_original_error`;
`test_creates_input_and_tmp_dirs_only` → `test_creates_input_dir_only` (`sorted(p.name for p in root.iterdir()) == ["Input"]`).
Добавить в `TestPut`:
```python
def test_existing_file_is_overwritten(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path, settle_seconds=0)
hf.put("job.pdf", b"first, longer")
hf.put("job.pdf", b"second")
assert (hf.input_dir / "job.pdf").read_bytes() == b"second"
def test_write_failure_keeps_partial_file_when_cleanup_disabled(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
hf = LocalHotFolder(tmp_path, settle_seconds=0)
monkeypatch.setattr(Path, "write_bytes", _failing_write)
with pytest.raises(OSError, match="диск полон"):
hf.put("job.pdf", b"data")
assert (hf.input_dir / "job.pdf").read_bytes() == b"da"
def test_write_failure_removes_partial_file_when_cleanup_enabled(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
monkeypatch.setattr(Path, "write_bytes", _failing_write)
with pytest.raises(OSError, match="диск полон"):
hf.put("job.pdf", b"data")
assert not (hf.input_dir / "job.pdf").exists()
def test_remove_failure_does_not_mask_write_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
monkeypatch.setattr(Path, "write_bytes", _failing_write)
monkeypatch.setattr(Path, "unlink", _failing_unlink)
with pytest.raises(OSError, match="диск полон"):
hf.put("job.pdf", b"data")
```
с модульными помощниками:
```python
def _failing_write(self: Path, data: bytes) -> int:
"""Недописанный файл: на диск попадают первые два байта, затем ошибка ввода-вывода."""
with open(self, "wb") as handle:
handle.write(data[:2])
raise OSError(errno.ENOSPC, "диск полон")
def _failing_unlink(self: Path, missing_ok: bool = False) -> None:
raise PermissionError(errno.EACCES, "нет прав")
```
`TestCleanup`: ожидания про `Tmp/.<name>.part` — `.part` **не удаляется** (файл в `Tmp` остаётся, если тест его создаёт).
`test_hotfolder_factory.py`: в `_local_settings` убрать `SCAN_TMP_DIR`; assert'ы `hf.tmp_dir == tmp_path / "Служебный"`
и `hf.tmp_dir == "...\\T"` удалить, для local — `assert hf.tmp_dir == tmp_path / "Tmp"` (умолчание эмулятора).
`test_config.py`: строки `assert s.SCAN_TMP_DIR == "Tmp"` → `assert not hasattr(s, "SCAN_TMP_DIR")`; добавить
`assert s.SCAN_TIMEOUT_SECONDS == 1200` в тест умолчаний (строка 32) и тест:
```python
def test_legacy_scan_tmp_dir_is_ignored(monkeypatch):
monkeypatch.setenv("SCAN_TMP_DIR", "Tmp")
s = Settings(_env_file=None)
assert not hasattr(s, "SCAN_TMP_DIR")
```
(обязательные переменные — как в соседних тестах файла; взять их фикстуру/хелпер). `test_roundtrip.py`: убрать
`stray_parts`, `tmp_dir` у объекта стенда, `tmp_dir=settings.SCAN_TMP_DIR` (эмулятору передать `"Tmp"`), докстринги
про `.part`; в сценарии очистки — `Input/` и `Output/<job_id>/` пусты.
- [ ] **Step 2: Прогнать — ожидаемо падают**
Run: `cd api && .venv/bin/pytest -q tests/unit/test_local_hotfolder.py tests/unit/test_hotfolder_factory.py tests/unit/test_config.py`
Expected: FAIL.
- [ ] **Step 3: Реализация**
`LocalHotFolder.put`:
```python
def put(self, name: str, data: bytes) -> None:
"""Записать `input_dir/<name>` сразу (`wb`, перезапись существующего, как в старом АРМ — решение владельца
09.10.2026). `Input` создаётся без родителей, корень — нет (нет корня — `FileNotFoundError`). При ошибке записи
недописанный файл снимается только при включённой очистке (R-3), ошибка снятия подавляется."""
validate_name(name)
self.input_dir.mkdir(exist_ok=True)
target = self.input_dir / name
try:
target.write_bytes(data)
except BaseException:
self._drop_partial(target)
raise
def _drop_partial(self, path: Path) -> None:
if self.cleanup_enabled:
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
```
Удалить `_open_part`, `_replace_part`, `_NO_HARD_LINKS` и неиспользуемые импорты (`errno`, `os` — проверить ruff).
`cleanup`: кортеж путей без `self.tmp_dir / f".{name}.part"`. Докстринги модуля и класса: прямая запись в `Input`;
`tmp_dir` — служебный каталог эмулятора (сборка набора ответа), `put` его не использует.
`gateway/__init__.py`: в `LocalHotFolder(...)` не передавать `settings.SCAN_TMP_DIR` (умолчание `"Tmp"`), в
`SmbHotFolder(...)` убрать аргумент. `config.py`: удалить `SCAN_TMP_DIR`; `SCAN_TIMEOUT_SECONDS: PositiveFloat = 1200`
(комментарий: «20 мин, в паре со сроком ответа OCR ядра»); комментарий `SCAN_CLEANUP` — без `.part в Tmp`.
- [ ] **Step 4: Прогнать unit**
Run: `cd api && .venv/bin/pytest -q tests/unit`
Expected: PASS (включая `test_emulator.py` — эмулятор пользуется `tmp_dir`).
- [ ] **Step 5: Commit**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add -A api
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "feat(hotfolder): local тоже пишет прямо в Input; без SCAN_TMP_DIR; таймаут 1200 с (#26)"
```
### Task 3: Документация и полная проверка
**Files:**
- Modify: `README.md` (схема конвейера п. 5, раздел протокола горячих папок, «Очистка», таблица env: убрать `SCAN_TMP_DIR` шлюза, `SCAN_TIMEOUT_SECONDS` = `1200`, права на SMB), `CLAUDE.md` («Что это», «Горячие папки»), `api/.env.example` (`SCAN_TMP_DIR` — в блок эмулятора, `SCAN_TIMEOUT_SECONDS`), `openspec/changes/hotfolder-direct-write/tasks.md` (отметки)
- [ ] **Step 1: Правки текста**
`git grep -n "Tmp\|\.part\|SCAN_TMP_DIR\|SCAN_TIMEOUT_SECONDS\|300" -- README.md CLAUDE.md api/.env.example` и привести
каждое место к: «файл пишется сразу в `Input/<job_id>.<ext>` (`wb`, существующий перезаписывается, как в старом АРМ;
ИС не берёт файл, пока он открыт на запись — ASSUMPTION(OQ-79))»; `Tmp` и `SCAN_TMP_DIR` упоминаются только у
эмулятора; права учётки шары — запись в `Input`, чтение `Output`, удаление при `SCAN_CLEANUP`; таймаут 1200 с.
- [ ] **Step 2: Полная проверка**
Run: `make check-full` (из корня репозитория; нужен поднятый стенд `make up`)
Expected: exit 0. Вывод — в описание PR.
- [ ] **Step 3: Commit**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add -A README.md CLAUDE.md api/.env.example openspec
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "docs: прямая запись в Input, SCAN_TMP_DIR только у эмулятора, таймаут 1200 с (#26)"
```

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-09

View File

@ -0,0 +1,62 @@
## Context
Запись входящего файла сейчас — двухфазная: `Tmp/.<имя>.part`, затем rename (SMB) / жёсткая ссылка (local) в `Input`
без замены (решения №17, №26, №32, №35 Среза 3a). Старый АРМ писал файл прямо в `Input` и в контуре работал. Владелец
09.10.2026 выбрал повторить старый АРМ и перезаписывать существующий файл.
## Goals / Non-Goals
**Goals:** прямая запись в `Input` в обоих режимах; меньше требований к правам на шаре; срок ожидания ответа 20 мин.
**Non-Goals:** см. proposal.
## Decisions
### D-1. Прямая запись с усечением (решение владельца 09.10.2026)
`SmbHotFolder.put`: `smbclient.open_file(<Input>\<имя>, mode="wb", **credentials)` и `write`. `LocalHotFolder.put`:
`open(<input>/<имя>, "wb")` (`Path.write_bytes`). Существующий файл перезаписывается. Повторная запись в рамках одного
процесса по-прежнему исключена дедупликацией по `job_id` в воркере (требование «Подтверждение запроса до обработки»);
перезапись возможна только при повторе задания ядром в другом процессе.
### D-2. Каталог `Input` создаётся, как раньше
Перед записью `Input` создаётся без родителей, «уже есть» и отказ в правах при существующем каталоге — не ошибка
(`_ensure_dir` в `smb`, `mkdir(exist_ok=True)` в `local`); корень не создаётся. Старый АРМ каталог не создавал, но
терпимое создание поведения в контуре не меняет, а dev-режим и тесты на нём держатся.
### D-3. `Tmp` остаётся только у эмулятора
`SCAN_TMP_DIR` удаляется из настроек шлюза (`Settings`, `extra="ignore"` — старое значение в `.env` не ломает старт).
Эмулятор читает свою переменную `SCAN_TMP_DIR` (`EmulatorSettings`) и собирает набор ответа через
`LocalHotFolder.tmp_dir`, поэтому параметр `tmp_dir` у `LocalHotFolder` остаётся, но `put` его не использует.
### D-4. Срок ожидания 20 мин
`SCAN_TIMEOUT_SECONDS` по умолчанию 1200. Эффективный дедлайн — `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`,
поэтому arm-core поднимает срок ответа OCR до 20 мин отдельным PR (RSHB/arm-core#87).
### D-5. Ошибка записи
При исключении во время открытия/записи `put` снимает недописанный `<Input>/<имя>` только при включённой очистке
(ошибка снятия подавляется, исходное исключение поднимается) — R-3.
## Risks / Trade-offs
- ИС может увидеть недописанный файл (OQ-79) → смягчение: монопольное открытие `smbclient` на время записи; так же
работал старый АРМ. Откат — вернуть двухфазную запись из истории ветки.
- Ошибка посреди записи оставляет обрезанный файл в `Input` → удаляется только при `SCAN_CLEANUP=true` (как `.part`
раньше, D-5); иначе остаётся, конверт `RECOGNITION_FAILED`.
## Decisions made without discussion
| # | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-1 | 2026-10-09 | Режим `local` тоже пишет прямо в `Input` (не только `smb`) | Одинаковая семантика режимов, один набор тестов; эмулятор пишет/читает локально мгновенно | Эмулятор может прочитать недописанный файл — на локальной ФС практически невозможно; откат — вернуть `.part` в `local` |
| R-2 | 2026-10-09 | `Input` по-прежнему создаётся перед записью (D-2), хотя старый АРМ этого не делал | Отказ в правах на `mkdir` при существующем каталоге уже не ошибка; dev и тесты на этом держатся | Лишний вызов `mkdir` на шаре на каждое задание |
| R-3 | 2026-10-09 | Ошибка записи: недописанный файл в `Input` удаляется только при `SCAN_CLEANUP=true` (D-5) | Требование «при `SCAN_CLEANUP=false` ничего не удалять» (OQ-144) сохраняется | Обрезанный файл остаётся в `Input`, ИС может его распознать с ошибкой; ядро получит `RECOGNITION_FAILED` от шлюза раньше |
| R-4 | 2026-10-09 | `SCAN_TMP_DIR` удаляется из шлюза, у эмулятора остаётся (D-3) | Шлюзу каталог больше не нужен; эмулятору нужен для атомарной сборки ответа | Нет: `extra="ignore"` — старые `.env` стартуют |
| R-5 | 2026-10-09 | Правка вызова `SmbHotFolder` в фабрике (`gateway/__init__.py`) перенесена из задачи 2 плана в задачу 1 | Без неё mypy красный между задачами (лишний позиционный аргумент) | Нет |
| R-6 | 2026-10-09 | Сверх плана: `tests/unit/conftest.py` не передаёт `SCAN_TMP_DIR`; `test_worker.py::test_existing_input_waits_for_answer` ждёт перезаписи объектом S3; в `test_roundtrip.py` эмулятору передаётся литерал `"Tmp"` | Прямые следствия D-1 и D-3 | Нет |
| R-7 | 2026-10-09 | Финальное ревью I-1: ошибка **открытия** входящего файла ничего не удаляет (открытие вынесено из блока снятия недописанного файла); снимается только файл, сбойнувший после открытия | Иначе при `SCAN_CLEANUP=true` удалялся бы целый файл прошлой доставки, который, возможно, читает ИС | Нет |
| R-8 | 2026-10-09 | Финальное ревью I-1(б): если при повторе задания в другом процессе файл занят ИС (`STATUS_SHARING_VIOLATION` на открытии), шлюз публикует `RECOGNITION_FAILED`, а не считает файл доставленным — как старый АРМ (`wb` без особых случаев) | Решение владельца «как в старом АРМ»; повтор в другом процессе — только после потери ack (редко) | Ядро может получить `RECOGNITION_FAILED` раньше успешного ответа первого экземпляра (OCR `max_attempts` = 1) — аналитику придётся перезагрузить файл; вынесено вопросом владельцу |

View File

@ -0,0 +1,43 @@
## Why
Решение владельца 09.10.2026: запись входящего файла в горячую папку ИС Сканирование — «как в старом АРМ», где она
работала в контуре: `smbclient.open_file('<SMB_PATH>/Input/<имя>', 'wb')` прямо во входящий каталог
(`reference-old-arm/api/src/core/clients.py:111`). Нынешняя схема (временный `Tmp/.<имя>.part` + rename без замены,
OQ-79, OQ-137) требует от учётной записи шары каталога `Tmp`, прав на его создание и на переименование и в контуре не
проверялась. Там же владелец решил: срок ожидания ответа OCR — 20 минут.
Issue — RSHB/arm-scan-gateway#26. Вне среза `09-delivery-plan.md` (доработка Среза 3a по эксплуатации).
## What Changes
- `put` (режимы `smb` и `local`): файл `<job_id>.<ext>` пишется сразу во входящий каталог, открытием на запись с
усечением (`wb`); существующий файл **перезаписывается** (как в старом АРМ). Временного файла, каталога `Tmp` и
переименования больше нет.
- Настройка шлюза `SCAN_TMP_DIR` удаляется; параметр `tmp_dir` у `SmbHotFolder` удаляется. У `LocalHotFolder` он остаётся
только для эмулятора (сборка набора ответа, переменная эмулятора `SCAN_TMP_DIR` не меняется).
- Очистка (`SCAN_CLEANUP=true`) больше не удаляет `Tmp/.<job_id>.<ext>.part`.
- `SCAN_TIMEOUT_SECONDS` по умолчанию 300 → 1200 (20 мин) — в паре с `reply_deadline` OCR 20 мин в arm-core
(RSHB/arm-core#87).
- README, `CLAUDE.md`, `api/.env.example`, тесты — под новую семантику.
## Non-goals
- Протокол ответа (`Output/<job_id>/*.json`, стабилизация набора, OQ-78) не меняется.
- Эмулятор ИС не меняется: он по-прежнему пропускает `*.part`/`*.tmp`/dot-файлы и собирает ответ через свой `Tmp`.
- Аутентификация SMB не меняется: `OPENBAO__SMB__SMB_LOGIN`/`SMB_PASSWORD` уже передаются в сессию и в каждый вызов.
- Таймаут вызовов `smbclient` (соединение ~60 с по умолчанию библиотеки) не трогаем.
- Таблицы DBML не затрагиваются.
## Capabilities
### New Capabilities
### Modified Capabilities
- `image-recognition-gateway`: запись входящего файла без временного файла и с перезаписью; очистка без `Tmp`.
## Impact
- ASSUMPTION(OQ-79): ИС Сканирование не берёт файл, пока шлюз держит его открытым на запись (`smbclient.open_file`
по умолчанию открывает монопольно), — так работал старый АРМ. Если подтвердится обратное, вернуть запись через
временный файл.
- Учётной записи шары больше не нужны права на `Tmp` и на переименование; нужна запись в `Input`.
- Остатки `Tmp/.*.part` от прежних версий шлюз больше не трогает — убрать вручную.

View File

@ -0,0 +1,67 @@
## MODIFIED Requirements
### Requirement: Передача файла в ИС Сканирование через горячие папки
После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как
`<job_id>.<ext>`, где `<ext>` — расширение `original_name` запроса в нижнем регистре; запрос без расширения SHALL
завершаться конвертом `RECOGNITION_FAILED` без записи в горячую папку. Файл SHALL записываться сразу во входящий каталог
открытием на запись с усечением, как в старом АРМ (решение владельца 09.10.2026): временных файлов, служебного каталога
и переименования нет; если `<Input>/<job_id>.<ext>` уже существует, он SHALL быть перезаписан содержимым объекта S3.
Затем сервис SHALL опрашивать исходящий каталог `<SCAN_OUTPUT_DIR>/<job_id>/` каждые `SCAN_POLL_SECONDS` до
`min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)` (`SCAN_TIMEOUT_SECONDS` по умолчанию 1200). ИС кладёт в этот каталог
все распознанные документы задания — по одному `*.json` на документ, набор появляется целиком и больше не меняется;
пустой или отсутствующий каталог означает ожидание. Ответ SHALL считаться готовым, когда в каталоге есть хотя бы один
`*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (по умолчанию 2); готовый набор SHALL
включать **все** `*.json` каталога по возрастанию имени, остальные файлы игнорируются.
#### Scenario: Файл положен во входящий каталог
- **WHEN** получен запрос для `aep_5.PDF` с `job_id = 3f1c…`
- **THEN** во входящем каталоге появляется `3f1c….pdf`, байт в байт равный объекту S3; каталог `Tmp/` не создаётся, временных файлов нет
#### Scenario: Назначение уже существует
- **WHEN** запрос для `aep_5.pdf` с `job_id = 3f1c…` обрабатывается, а `Input/3f1c….pdf` уже лежит с другим содержимым (задание повторено ядром после потери ack в другом процессе)
- **THEN** `Input/3f1c….pdf` перезаписан и байт в байт равен объекту S3, задание продолжается опросом `Output/3f1c…/`, конверт `RECOGNITION_FAILED` не публикуется
#### Scenario: Запрос без расширения
- **WHEN** получен запрос с `original_name = "scan"` без точки
- **THEN** опубликован конверт `error`, `error.code = RECOGNITION_FAILED`, сообщение о расширении; во входящем каталоге файла нет
#### Scenario: Ответ из одного файла
- **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS`
- **THEN** опрос прекращается не позднее чем через `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после первого опроса, увидевшего окончательный набор (отсчёт стабилизации — от этого опроса, а не от времени изменения файла), ответ из одного файла передаётся дальше по конвейеру
#### Scenario: Ответ из нескольких файлов
- **WHEN** в `Output/3f1c…/` лежат `3f1c….json` и `3f1c…_ofr.json`, посторонний файл `readme.txt`, и набор не меняется `SCAN_SETTLE_SECONDS`
- **THEN** дальше по конвейеру передаются оба `*.json` по возрастанию имени, `readme.txt` игнорируется
#### Scenario: Набор ещё пишется
- **WHEN** размер файла или число файлов в `Output/3f1c…/` менялось менее `SCAN_SETTLE_SECONDS` назад
- **THEN** ответ не считается готовым, опрос продолжается до дедлайна
#### Scenario: Ошибка посреди записи
- **WHEN** запись `Input/3f1c….pdf` прервалась ошибкой ввода-вывода
- **THEN** опубликован конверт `RECOGNITION_FAILED`; недописанный файл удалён только при `SCAN_CLEANUP=true`, при `false` остаётся на месте
### Requirement: Очистка горячей папки под флагом
При `SCAN_CLEANUP=true` после того, как готовый набор ответа прочитан и склеен в `raw/{job_id}.json` (независимо от
исхода нормализации), сервис SHALL удалить всё содержимое `<SCAN_OUTPUT_DIR>/<job_id>/` (json-файлы ответа и любые
прочие) и сам каталог `<job_id>`, входящий файл `<Input>/<job_id>.<ext>` и его вариант `<Input>/<job_id>.<ext>.done`,
если они существуют. При `SCAN_CLEANUP=false` (по умолчанию) сервис MUST NOT удалять ничего, включая недописанный при
ошибке записи входящий файл. При `TIMEOUT`, `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи очистка MUST NOT выполняться
(при ошибке записи и `SCAN_CLEANUP=true` снимается только недописанный входящий файл). Ошибка удаления SHALL быть
записана в лог одной строкой на задание и MUST NOT менять конверт, `metrics.warnings` и `/healthcheck`.
#### Scenario: Очистка включена
- **WHEN** `SCAN_CLEANUP=true`, ответ на `3f1c….pdf` прочитан из `Output/3f1c…/`
- **THEN** после публикации конверта в горячей папке нет `Output/3f1c…/`, `Input/3f1c….pdf` и `Input/3f1c….pdf.done`
#### Scenario: Очистка выключена
- **WHEN** `SCAN_CLEANUP=false`, ответ прочитан
- **THEN** все файлы остаются на месте, сервис ничего не удаляет и не пишет в лог о очистке (решение №27)
#### Scenario: Нет прав на удаление
- **WHEN** `SCAN_CLEANUP=true`, удаление `Output/3f1c…/3f1c….json` завершается `PermissionError`
- **THEN** конверт опубликован с тем же статусом, что и без очистки; в логе одна строка об ошибке удаления; `/healthcheck.hotfolder = ok`
#### Scenario: Таймаут не чистит
- **WHEN** `SCAN_CLEANUP=true`, ответ не появился до дедлайна
- **THEN** опубликован `TIMEOUT`, входящий файл и каталог `Output/<job_id>/` (если есть) не тронуты

View File

@ -0,0 +1,18 @@
## 1. Тесты (scan-gateway, `api/tests/`)
- [x] 1.1 `unit/test_smb_hotfolder.py`: `put` пишет `open_file(<Input>\<имя>, "wb")` без `Tmp`/`rename`; существующий
файл перезаписывается; ошибка записи — снятие файла только при `cleanup=True`; `cleanup` не трогает `.part`
- [x] 1.2 `unit/test_local_hotfolder.py`: то же для `LocalHotFolder`; `Tmp` не создаётся `put`
- [x] 1.3 `unit/test_config.py`, `unit/test_hotfolder_factory.py`: нет `SCAN_TMP_DIR` у шлюза, `SCAN_TIMEOUT_SECONDS`
по умолчанию 1200; интеграционные `test_roundtrip.py` — без ожиданий про `Tmp/.part`
## 2. Реализация (scan-gateway, `api/src/`)
- [x] 2.1 `gateway/smb.py`: `put` — прямая запись (D-1, D-2, D-5), убрать `tmp_dir`, `.part` из `cleanup`
- [x] 2.2 `gateway/hotfolder.py`: `LocalHotFolder.put` — прямая запись; `tmp_dir` только для эмулятора (D-3)
- [x] 2.3 `core/config.py`, `gateway/__init__.py`: убрать `SCAN_TMP_DIR` у шлюза; `SCAN_TIMEOUT_SECONDS = 1200`
## 3. Документация
- [x] 3.1 `README.md`, `CLAUDE.md`, `api/.env.example` — прямая запись, `SCAN_TMP_DIR` только у эмулятора, таймаут 1200
- [x] 3.2 `make check-full`