Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Основное:
* Program.cs: SuppressImplicitRequiredAttributeForNonNullableReferenceTypes=true
+ InvalidModelStateResponseFactory → ValidationProblemDetails + Serilog Warning.
Пустые необязательные строковые поля в InvoiceLine (Note, BarcodeSource и т.п.)
больше не роняют запрос в 400. humanizeError на фронте уже умеет этот формат.
* InvoiceModels.cs: Name/Source/FilePath → string?.
* Validators.cs: InvoiceSaveLinesValidator (Lines NotNull), InvoiceApplyValidator
(Guid.Empty на Store/Currency = ошибка, SupplierId опционален).
Служебный «Поставщик не указан» (адаптация sklad §11 под multi-tenant):
* Phase32b миграция добавляет Counterparty.IsPlaceholder bool + partial index
по IsPlaceholder=true.
* InvoicesController.Apply: supplierId опционален; если не указан —
ResolvePlaceholderCounterpartyAsync ищет/создаёт одного служебного per org.
Имя из накладной в комментарий документа НЕ пишется.
* CounterpartiesController: служебный всегда последним в List сортировках,
Update и Delete при IsPlaceholder → 409 Conflict.
* CounterpartyDto.IsPlaceholder + Mapster-config обновлены.
* Автоподбора/автозаведения контрагентов нигде не было — специально проверили.
Applied-накладная readOnly (§10):
* InvoiceDetailDto: добавлены supplyNumber + processedAt.
* InvoiceReviewPage: applied-режим — плашка «Черновик приёмки № … создан …»
со ссылкой на приёмку, шапка и строки readOnly, скан-блок и apply-кнопка
скрыты. После успешного apply — invalidateQueries, экран сразу readOnly.
Текст ошибки apply (§5) и список накладных (§6):
* ApplyDialog: error → detail → title → (код), __silent на axios.
* Invoices list: parseServerDate подшивает «Z» если его нет, «Загружена DD.MM.YY HH:MM»
в бейдже статуса (видимо в десктоп-таблице и в мобильной карточке — у нас общая).
Тесты + проверки:
* tests/food-market.IntegrationTests/InvoiceApplySklad0610Tests.cs — 5 кейсов:
SaveLines со всеми null, apply без поставщика с idempotency по placeholder'у,
Delete/Update placeholder → 409, applied detail содержит processedAt +
hasFiles=false, apply с Guid.Empty storeId → ValidationProblemDetails.
Все 5 ✓.
* stage retest на https://test.admin.food-market.kz: upload+SaveLines(null-fields)+
apply(без поставщика)+apply(с ИП Бегимбаев)+DELETE placeholder → весь flow
зелёный, служебный один на орг, hasFiles=false после apply.
* quality-watchdog step 9 (menu-walk): 3/3 passed (3.7m).
* Фото-накладная через Claude CLI не гонял — пайплайн apply→DeleteStored
закрыт через hasFiles=false в тестах и реальный stage smoke.
docs/supply-inventory.md — шаг 7 расписан под необязательного поставщика,
новый шаг 8 (applied → readOnly), миграция Phase32b в таблице «Файлы».
docs/sklad-0610-progress.md — финал 9/9 с подробным журналом решений.
Пред-существующие (не трогал):
* OrgAuditLogTests.Counterparty_update_diff_contains_before_after → 403 от
[RequiresAdvancedMode] на /api/admin/audit-log (commit 921bca0, s29);
* ApiReferenceDocsJobTests.Scans_endpoints_with_doubly_nested_generic_return —
регекс-тюнинг доков.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
212 lines
17 KiB
Markdown
212 lines
17 KiB
Markdown
# Приёмка и инвентаризация с телефона
|
||
|
||
С версии Sprint 31 в food-market три новых способа работы с товаром:
|
||
по фото накладной, сканированием штрихкодов, и мобильная инвентаризация
|
||
с офлайн-каталогом. Все три видны в **простом режиме**, доступны ролям
|
||
**Администратор** и **Кладовщик**.
|
||
|
||
## 1. Приёмка по фото/PDF/Excel накладной
|
||
|
||
**Когда использовать:** поставщик привёз накладную в бумаге, в PDF, в
|
||
Excel-шаблоне или прислал фото.
|
||
|
||
**Как:**
|
||
1. `/purchases/invoices` → один из четырёх способов:
|
||
- «Снять камерой» — одно/несколько фото через камеру телефона
|
||
(каждый файл = отдельная накладная).
|
||
- «Загрузить файл» — image/*, PDF, **шаблон .xlsx** или **.csv**
|
||
(`docs/supplier-invoice-template.xlsx` — заготовка для поставщиков).
|
||
- «Сфотографировать листы» — камера для многостраничной накладной
|
||
(все снимки идут как **одна** накладная, не отдельные).
|
||
- «Фото товаров + накладная» — главный сценарий: подряд снимаете
|
||
накладную и каждый товар (штрихкод + этикетка), пачка идёт одним
|
||
запросом → Claude сам собирает приёмку с миниатюрой у каждой строки.
|
||
Там же «Готовые фото» — выбрать уже снятые из галереи, можно
|
||
смешивать со снимаемыми.
|
||
2. Загрузка попадает в очередь. Статус-бейдж на карточке: `В очереди
|
||
→ Распознаётся → На проверке | Ошибка`. Страница обновляется сама
|
||
каждые 3 секунды, пока есть распознающиеся.
|
||
3. Открываем карточку «На проверке» → экран проверки:
|
||
- **Шапка** (редактируемая): поставщик, №, дата, итого.
|
||
- **Строки** карточками. Сопоставлено по штрихкоду или по памяти
|
||
прошлых накладных → зелёная плашка `→ Название товара`. Не
|
||
сопоставлено → карточка оранжевым, предупреждение сверху.
|
||
- **Общий сканер** (поле «Отсканировать…»): скан → +1 к первой
|
||
несопоставленной строке или новая строка. Удобно когда вы по
|
||
бумажке проходитесь сканером.
|
||
- **Для каждой строки** можно «Найти товар» (поиск по каталогу)
|
||
или удалить. Если товара нет — ссылка «Создать товар».
|
||
- Сумма строк сравнивается с «Итого» в накладной; если
|
||
отличается больше чем на 1 — предупреждение.
|
||
4. **Автосохранение**: каждая правка (поля шапки, количество, цена,
|
||
ручное сопоставление, скан) через ~700 мс сохраняется на сервере.
|
||
Индикатор «сопоставлено N/M · сохраняю… / сохранено / не
|
||
сохранилось» в нижней панели. Можно закрыть вкладку и открыть
|
||
снова — ничего не потеряется. Ручные сопоставления запоминаются
|
||
в `InvoiceAlias`: **на следующей накладной того же поставщика**
|
||
matcher найдёт эти товары автоматически.
|
||
5. **Общий сканер**: если fuzzy-match по названию неуверенный, появляется
|
||
панель «<товар> — в какую строку накладной?» с кнопками строк. На
|
||
этой же странице работает **аппаратный сканер** целиком — не нужно
|
||
кликать в поле, серия цифр + Enter подхватывается глобально
|
||
(если курсор не в текстовом поле, которое читает сам).
|
||
6. **Фото-приёмка**: у каждой строки — миниатюра снимка товара,
|
||
кликабельна для полного размера; пометки «цифры прочитаны
|
||
неуверенно — отсканируйте» или «штрихкода нет в каталоге».
|
||
7. «Создать приёмку» → диалог: **поставщик — необязательный**
|
||
(поиск по уже существующим контрагентам, кнопки «создать» нет),
|
||
склад, валюта → `POST /apply` создаёт нашу Supply (Draft).
|
||
Проведение — обычной кнопкой в приёмке. Если поставщик не выбран,
|
||
приёмка идёт на служебного **«Поставщик не указан»** (один на орг,
|
||
в списке контрагентов — последним, с бейджем, удалить и
|
||
переименовать нельзя); имя из накладной показывается подсказкой
|
||
и в комментарий документа **не** пишется. Это адаптация sklad 06.10
|
||
§11: автоподбор контрагента по тексту накладной и автозаведение
|
||
карточек запрещены — на реальных накладных это плодило мусор.
|
||
8. Готовая накладная (status `applied`) открывается в **режиме
|
||
просмотра**: плашка «Черновик приёмки № … создан \<время\>» со
|
||
ссылкой на приёмку, строки и шапка readOnly, кнопки «Создать»
|
||
нет. Исправления можно сделать прямо в приёмке.
|
||
|
||
**Как это распознаётся:** локальный Claude Code в headless-режиме
|
||
(`claude -p --output-format json`, промпт сверяет «Итого», отличает
|
||
штрихкод от кода поставщика, не угадывает — пустые поля = null).
|
||
Нагрузка списывается с подписки, не с API-ключа. На сервере нужен
|
||
установленный `claude` CLI и `claude login` от того же пользователя, что
|
||
запускает API. Можно пустить модель на отдельной машине через
|
||
`Invoices:ClaudeCli:RemoteHost` (ssh + scp — всё внутри сервиса).
|
||
|
||
**Хранение файла (owner-policy):** фото накладных на сервере **не копим**.
|
||
- Обычная загрузка (одно фото/PDF/Excel) — файл удаляется **сразу после
|
||
распознавания**. Распознанные строки живут в БД (`ParsedJson`).
|
||
- Фото-приёмка (папка со снимками) — хранится **только пока накладная
|
||
на проверке**: нужна для миниатюр у строк. Удаляется при создании
|
||
черновика приёмки (apply), при удалении загрузки, и через **24 часа**
|
||
— если «брошено» на статусе `needs_review`. `Invoices:KeepFilesDays`
|
||
больше не используется.
|
||
- Детальный ответ `GET /{id}` отдаёт флаг `hasFiles` (есть ли миниатюры)
|
||
и `processedAt` (время apply), чтобы фронт мог построить плашку
|
||
«Черновик приёмки № … создан …» на applied-накладной без
|
||
дополнительного запроса.
|
||
|
||
## 2. Приёмка сканированием
|
||
|
||
**Когда:** поступил товар россыпью, по бумажке лень набивать.
|
||
|
||
**Как:** `/purchases/supplies/scan` → поставщик + склад + валюта →
|
||
«Сканировать камерой». Камера непрерывная, окно не закрывается между
|
||
сканами. Товар за товаром:
|
||
- +1 к существующей строке, если такой уже есть; иначе — новая строка
|
||
сверху;
|
||
- цена подставляется **последняя закупочная** (из последней Posted
|
||
Supply);
|
||
- вибрация + вспышка на удачный скан; красным — если штрихкод не
|
||
в каталоге;
|
||
- аппаратный сканер тоже работает (details → поле + Enter).
|
||
|
||
«Создать черновик» → наш Supply (Draft) в указанном складе; проверить
|
||
и провести — на обычной странице приёмки.
|
||
|
||
**Прогресс** сохраняется в localStorage (`fm.supply-scan.rows`) — закрыли
|
||
вкладку случайно, вернулись — всё на месте. Очищается после сохранения.
|
||
|
||
## 3. Инвентаризация с телефона
|
||
|
||
**Когда:** регулярный пересчёт склада или проверка одной группы.
|
||
|
||
**Как:** `/inventory/mobile` → три шага:
|
||
|
||
### Setup
|
||
- Выбор склада.
|
||
- Опционально — **группа** (= частичная инвентаризация, непосчитанные
|
||
товары НЕ обнулятся).
|
||
- «Загрузить каталог на телефон» — компактный слепок троек
|
||
`[barcode, productId, name]` в IndexedDB. 30 тыс. штрихкодов ≈ 3 МБ.
|
||
После загрузки работает **без интернета**.
|
||
|
||
### Scan
|
||
- Камера непрерывная; +1 за скан;
|
||
- **поле аппаратного сканера на виду** (не спрятано в раскрывашку) —
|
||
можно просто сканировать, а также:
|
||
- **глобальный keydown** ловит сканер и когда курсор не в поле (серия
|
||
цифр + Enter) — удобно, если вы перешли к другому элементу страницы;
|
||
- **импорт Excel/CSV** (`Загрузить из файла`) — колонки «Штрихкод» и
|
||
«Количество», повторы штрихкодов суммируются, товары добавляются
|
||
к текущему списку. Документ при этом **не создаётся** — можно
|
||
дополнительно просканировать и уже потом провести;
|
||
- неизвестный штрихкод — в отдельный список «Нет в каталоге» с
|
||
количеством (не попадёт в документ, нужно сначала создать карточку);
|
||
- офлайн-плашка: нет сети = сканы остаются в телефоне, уйдут на сервер
|
||
когда сеть появится. Прогресс сохраняется в localStorage.
|
||
|
||
### Review
|
||
- При первом переходе создаётся наш `InventoryDoc` в Draft, `bookQty`
|
||
**каждой позиции** = текущий Stock склада (без ручной «Пересчитать»).
|
||
- Отображается сводка: `Излишек +XX ₸` / `Недостача -YY ₸` / количество
|
||
расхождений.
|
||
- Фильтр «только расхождения» (по умолчанию выключен — показываем всё).
|
||
- **Drift-плашка:** если учётный остаток ПОПЛЫЛ с момента создания
|
||
документа (были продажи или приёмки), сверху показывается список
|
||
изменившихся позиций: `Молоко: 10 → 7`. Это ВИДНО до нажатия
|
||
«Провести», не молча.
|
||
- «Провести» → `POST /post` создаёт корректирующие движения
|
||
`InventoryAdjustment` по строкам с diff ≠ 0.
|
||
|
||
## Безопасность
|
||
|
||
- Все эндпоинты требуют permission `SuppliesEdit` или `InventoryEdit`
|
||
(Admin + Storekeeper).
|
||
- Mutli-tenant: все запросы фильтруются по `OrganizationId` текущего
|
||
пользователя. Фоновый `InvoiceWorker` работает без HTTP-контекста
|
||
и использует явный `IgnoreQueryFilters + Where OrgId` чтобы не
|
||
утечь между орг.
|
||
- Файл накладной отдаётся только владеющему org'у через `/file`.
|
||
|
||
## Файлы
|
||
|
||
| Что | Где |
|
||
|---|---|
|
||
| Домен | `src/food-market.domain/Purchases/InvoiceUpload.cs` |
|
||
| Миграция | `src/food-market.infrastructure/Persistence/Migrations/20261004170000_Phase31a_InvoiceUploads.cs` |
|
||
| Служебный «Поставщик не указан» (sklad 06.10 §11) | `src/food-market.infrastructure/Persistence/Migrations/20261007100000_Phase32b_CounterpartyIsPlaceholder.cs` |
|
||
| Extractor + Matcher + Worker | `src/food-market.api/Infrastructure/Invoices/` |
|
||
| API накладных | `src/food-market.api/Controllers/Purchases/InvoicesController.cs` |
|
||
| API инвентаризации | `src/food-market.api/Controllers/Inventory/InventoriesController.cs` (mobile-catalog, add-line, book-drift) |
|
||
| Web — список накладных | `src/food-market.web/src/pages/InvoicesPage.tsx` |
|
||
| Web — проверка накладной | `src/food-market.web/src/pages/InvoiceReviewPage.tsx` |
|
||
| Web — scan приёмка | `src/food-market.web/src/pages/SupplyScanPage.tsx` |
|
||
| Web — mobile инвентаризация | `src/food-market.web/src/pages/InventoryMobilePage.tsx` |
|
||
| Камера | `src/food-market.web/src/components/CameraScanner.tsx` (@zxing/browser) |
|
||
| Offline-каталог | `src/food-market.web/src/lib/offlineCatalog.ts` (IndexedDB) |
|
||
| Тесты | `tests/food-market.UnitTests/InvoiceMatcherTests.cs`, `ClaudeCliInvoiceExtractorParseTests.cs`, `tests/food-market.IntegrationTests/InvoicePipelineTests.cs` |
|
||
|
||
## Конфиг
|
||
|
||
```json
|
||
"Invoices": {
|
||
"ClaudeCli": {
|
||
"Executable": "claude",
|
||
"Model": "claude-sonnet-5-5",
|
||
"TimeoutSeconds": 180,
|
||
"PhotoTimeoutSeconds": 600, // пачка фото — долго, отдельный тайм-аут
|
||
"RemoteHost": null, // или "user@host" — модель на другой машине
|
||
"RemoteTmpDir": "/tmp/food-market-invoices"
|
||
}
|
||
}
|
||
```
|
||
> Haiku сочиняет данные накладных — не использовать. `KeepFilesDays`
|
||
> больше не читается (owner-policy: фото не копим).
|
||
|
||
## Что НЕ сделано (откладывается до запроса)
|
||
|
||
- Создание товара прямо в экране проверки накладной — пока уходим на
|
||
`/products/new` (future wiring в onPick из search dialog).
|
||
- PWA manifest + ServiceWorker офлайн — IndexedDB работает без них, но
|
||
установка «На главный экран» потребует manifest.json. Отдельная
|
||
задача, если понадобится.
|
||
- Автоподтверждение частичной vs полной инвентаризации при проведении
|
||
(«обнулять ли непосчитанные») — сейчас сервер не обнуляет непосчитанные
|
||
в принципе (строки с diff=0 не создают движений), что совпадает с
|
||
поведением частичной. Полная «обнулить все непосчитанные» требует
|
||
явного шага в UI «добавить все остальные с actual=0» — пока нет.
|