Основное:
* 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>
17 KiB
Приёмка и инвентаризация с телефона
С версии Sprint 31 в food-market три новых способа работы с товаром: по фото накладной, сканированием штрихкодов, и мобильная инвентаризация с офлайн-каталогом. Все три видны в простом режиме, доступны ролям Администратор и Кладовщик.
1. Приёмка по фото/PDF/Excel накладной
Когда использовать: поставщик привёз накладную в бумаге, в PDF, в Excel-шаблоне или прислал фото.
Как:
/purchases/invoices→ один из четырёх способов:- «Снять камерой» — одно/несколько фото через камеру телефона (каждый файл = отдельная накладная).
- «Загрузить файл» — image/*, PDF, шаблон .xlsx или .csv
(
docs/supplier-invoice-template.xlsx— заготовка для поставщиков). - «Сфотографировать листы» — камера для многостраничной накладной (все снимки идут как одна накладная, не отдельные).
- «Фото товаров + накладная» — главный сценарий: подряд снимаете накладную и каждый товар (штрихкод + этикетка), пачка идёт одним запросом → Claude сам собирает приёмку с миниатюрой у каждой строки. Там же «Готовые фото» — выбрать уже снятые из галереи, можно смешивать со снимаемыми.
- Загрузка попадает в очередь. Статус-бейдж на карточке:
В очереди → Распознаётся → На проверке | Ошибка. Страница обновляется сама каждые 3 секунды, пока есть распознающиеся. - Открываем карточку «На проверке» → экран проверки:
- Шапка (редактируемая): поставщик, №, дата, итого.
- Строки карточками. Сопоставлено по штрихкоду или по памяти
прошлых накладных → зелёная плашка
→ Название товара. Не сопоставлено → карточка оранжевым, предупреждение сверху. - Общий сканер (поле «Отсканировать…»): скан → +1 к первой несопоставленной строке или новая строка. Удобно когда вы по бумажке проходитесь сканером.
- Для каждой строки можно «Найти товар» (поиск по каталогу) или удалить. Если товара нет — ссылка «Создать товар».
- Сумма строк сравнивается с «Итого» в накладной; если отличается больше чем на 1 — предупреждение.
- Автосохранение: каждая правка (поля шапки, количество, цена,
ручное сопоставление, скан) через ~700 мс сохраняется на сервере.
Индикатор «сопоставлено N/M · сохраняю… / сохранено / не
сохранилось» в нижней панели. Можно закрыть вкладку и открыть
снова — ничего не потеряется. Ручные сопоставления запоминаются
в
InvoiceAlias: на следующей накладной того же поставщика matcher найдёт эти товары автоматически. - Общий сканер: если fuzzy-match по названию неуверенный, появляется панель «<товар> — в какую строку накладной?» с кнопками строк. На этой же странице работает аппаратный сканер целиком — не нужно кликать в поле, серия цифр + Enter подхватывается глобально (если курсор не в текстовом поле, которое читает сам).
- Фото-приёмка: у каждой строки — миниатюра снимка товара, кликабельна для полного размера; пометки «цифры прочитаны неуверенно — отсканируйте» или «штрихкода нет в каталоге».
- «Создать приёмку» → диалог: поставщик — необязательный
(поиск по уже существующим контрагентам, кнопки «создать» нет),
склад, валюта →
POST /applyсоздаёт нашу Supply (Draft). Проведение — обычной кнопкой в приёмке. Если поставщик не выбран, приёмка идёт на служебного «Поставщик не указан» (один на орг, в списке контрагентов — последним, с бейджем, удалить и переименовать нельзя); имя из накладной показывается подсказкой и в комментарий документа не пишется. Это адаптация sklad 06.10 §11: автоподбор контрагента по тексту накладной и автозаведение карточек запрещены — на реальных накладных это плодило мусор. - Готовая накладная (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 |
Конфиг
"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» — пока нет.