food-market/docs/mobile-api.md
nns 193ba49ecb fix(stock): «показать нулевые» и фильтр по группе показывают товары без прихода
Остатки строились ОТ таблицы Stocks (JOIN Products), поэтому товары без строки
Stock (ни разу не приходованные) не попадали в список вообще, а includeZero лишь
снимал фильтр Quantity!=0 у существующих строк. То же в отчёте «Остатки на дату»,
в экспорте и в фильтре по группе (iOS получал total 0).

- GetStock/ExportStock: выборка ОТ Products (не-услуги, не-архивные) с остатком
  коррелированным подзапросом (товар без Stock = 0). Склад выбран → строка склада;
  без склада → агрегат «Все склады». Нулевые видны при includeZero; negativeOnly и
  пагинация/сортировка/поиск/экспорт работают и для нулевых. Остаток/ед./штрихкод
  дотягиваются batch-запросом по id страницы (EF8 не переводит подзапросы в SELECT+ORDER BY).
- Отчёт «Остатки на дату»: includeZero добавляет товары без движений (Qty 0);
  фильтр по группе теперь с вложенностью.
- groupId/productGroupId — синонимы с вложенными группами на всех списках остатков
  и в каталоге (List + Export). Раньше catalog?productGroupId молча игнорировался.
- Штрихкод рядом с артикулом в списке/экспорте — различать одноимённые товары
  (4× «BIOBLAS шампунь» с разными артикулами из МойСклада — не удалять).
- Demo (30 894 товара): без флага 12, includeZero → 30 894, <500мс/страница.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 09:44:50 +05:00

166 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# API для мобильного «FM Склад»
Серверные возможности, добавленные для iOS-приложения (стенд `api/*`). Авторизация —
OpenIddict password/refresh grant, `client_id=food-market-web`, Bearer-токен. Все
ответы и ошибки — JSON; бизнес-ошибки отдают `{ error, field?, code? }`.
## 1. Остатки по группе товаров (с вложенными)
`GET /api/inventory/stock?groupId={guid}&storeId=&search=&includeZero=&negativeOnly=&page=&pageSize=&sort=&order=`
- `groupId` (синоним `productGroupId`) — фильтр по группе товаров **включая все
вложенные подгруппы** (по денормализованному `Path`). Комбинируется с `storeId`,
`search`, сортировкой.
- **Выборка идёт ОТ товаров (не-услуги, не-архивные) LEFT JOIN остатки**, поэтому
товар без прихода тоже виден (`quantity 0`). `includeZero=true` → показать нулевые;
по умолчанию (`false`) — только `quantity != 0`; `negativeOnly=true` → только минусы.
- Склад выбран (`storeId`) → остаток этого склада; склад не выбран → агрегат (сумма)
по всем складам, `storeId=00000000-…`, `storeName="Все склады"`.
- `search` ищет по названию, артикулу **и штрихкоду**.
- Ответ: `PagedResult<StockRow>` — `{ items:[{ productId, productName, article, barcode,
unitSymbol, storeId, storeName, quantity, reservedQuantity, available }], total, page, pageSize }`.
`barcode` — основной штрихкод товара (для различения одноимённых товаров), может быть `null`.
## 2. Добавить штрихкод к товару
`POST /api/catalog/products/{id}/barcodes` — право `ProductsEdit`.
Тело: `{ "code": "4870001112223", "kind": "ean13" }`.
`kind` ∈ `ean13|ean8|code128|code39|upca|upce|other` (регистр не важен); если не задан —
тип определяется по длине/цифрам кода.
Ответы:
- `201 Created` → `{ id, productId, code, type, isPrimary }` (первый штрихкод товара
становится primary).
- `200 OK` — тот же код уже у **этого** товара (идемпотентно), тело как у 201.
- `409 Conflict` → `{ error:"Штрихкод уже у товара «…».", code:"barcode-duplicate",
conflictProduct:"…", field:"code" }` — код занят **другим** товаром организации.
- `400` — пустой/слишком длинный код; `404` — товар не найден.
Уникальность проверяется в пределах организации (`(OrganizationId, Code)`).
## 3. Права текущего пользователя
`GET /api/me` теперь возвращает `permissions` — эффективные права (имя флага →
bool) **с учётом роли и простого/расширенного режима**. Мобильное приложение и веб
гейтят UI по одному источнику (скрывают одно и то же).
```json
{
"sub":"…","name":"…","email":"…","roles":["Admin"],"orgId":"…",
"hasLiveOrg":true,"hasActiveEmployee":true,"advancedMode":false,
"permissions":{ "ProductsView":true,"ProductsEdit":true,"StocksView":true,
"DemandsView":false,"TransferEdit":false, … }
}
```
Правила: SuperAdmin — все права без ограничений; Admin — все, но в **простом режиме**
advanced-only скрыты; обычная роль — её флаги (в простом режиме advanced-only тоже
обнуляются). Advanced-only: `DemandsView/Edit/Post`, `TransferEdit`, `LoyaltyManage`,
`PromotionsManage`, `PriceTypesManage` (зеркалит серверный `[RequiresAdvancedMode]`).
Имена флагов — из `RolePermissions`. Веб использует тот же источник (`useCan()`).
## 4. Весовой признак в офлайн-каталоге
`GET /api/pos/v1/sync` (каталог для сканирования/офлайна) в `ProductSyncDto` отдаёт:
- `packaging` (1=штучный, 2=весовой, 3=разливной) и **`isWeighed`** (явный bool `packaging==2`),
- `unitCode` — единица измерения (шт/кг/л…).
## 5. Весовые этикетки (внутримагазинные EAN-13)
Как в МойСкладе: внутримагазинные EAN-13 с префиксом из диапазона 20–29, где зашиты
«код товара» (PLU, N цифр) и величина (вес в граммах → кг, либо цена).
**Настройка организации** — `GET/PUT /api/organization/settings` (PUT — право Admin):
- `weightBarcodeEnabled` (bool, по умолчанию true),
- `weightBarcodeWeightPrefixes` (CSV префиксов веса, null → `20,21,22,23,24`),
- `weightBarcodePricePrefixes` (CSV префиксов цены, null → `25,26,27,28,29`),
- `weightBarcodeCodeDigits` (длина PLU, по умолчанию 5).
Блок применяется, только если в PUT передан `weightBarcodeEnabled` (иначе настройки не трогаются).
**Разбор при поиске** — `GET /api/catalog/products/by-barcode/{value}`:
- Обычный штрихкод → товар (или `{ items:[…] }` при нескольких совпадениях), `404` если нет.
- **Весовая этикетка** (префикс весовой/ценовой) → `{ product:{…}, weighted:true,
valueKind:"WeightKg"|"Price", quantity:1.5, embeddedPrice:null }`. Товар ищется по
встроенному PLU (как короткий штрихкод товара либо как артикул); `quantity` — вес (кг),
`embeddedPrice` — зашитая цена (для ценовых этикеток).
Пример: этикетка `2112345015007` при дефолтных настройках → PLU `12345`, вес `1.500` кг.
Разбор единый для сервера и кассы — `foodmarket.Shared.Barcodes.WeightedBarcode`.
## Инвентаризация: post/unpost (временная совместимость)
С переходом на модель МойСклада инвентаризация сама остатки **не двигает** (см.
`docs/inventory-moysklad-reference.md`). Веб использует: `complete` → `create-enter` /
`create-loss`, затем проводит документы.
Мобильное приложение «FM Склад» исторически вызывает `post`/`unpost` инвентаризации.
Чтобы не ломать установленные приложения, эндпоинты **оставлены как совместимые**:
- `POST /api/inventory/inventories/{id}/post` — **= завершить + создать и ПРОВЕСТИ**
Оприходование (излишки) и Списание (недостача) одним вызовом. Идемпотентно: если
связанные документы уже созданы — не дублирует.
- `POST /api/inventory/inventories/{id}/unpost` — **вернуть в черновик**. Поведение
изменено (08.10): если по инвентаризации есть **проведённые** связанные Оприходование/
Списание — возвращает **409** с текстом
*«По инвентаризации проведены Оприходование О-… и Списание С-…. Сначала отмените
результаты»* и полями `enters`/`losses` (id+номер). Больше **НЕ удаляет** проведённые
документы молча. Если связанные документы только черновики (или их нет) — удаляет
черновики (они не влияли на остатки) и возвращает инвентаризацию в черновик (204).
- `POST /api/inventory/inventories/{id}/cancel-results` — **отменить результаты**: снимает
проведённые связанные Оприходование/Списание с проведения (остатки возвращаются),
**НЕ удаляя** их — они становятся черновиками и остаются в «Связанных документах»,
истории и аудите. Право: `InventoryEdit` + `EnterEdit` (если есть проведённые
Оприходования) и/или `LossEdit` (если есть Списания). 409, если снятие Оприходования
уведёт остаток в минус (часть излишка уже продана) — с полем `lines`. После отмены
результатов инвентаризацию можно вернуть в черновик (`unpost`) или снова провести
документы (`enters/{id}/post`, `losses/{id}/post`).
**Статус: временное.** Рекомендуемый путь для новых версий мобильного приложения —
`complete` + `create-enter`/`create-loss` (как в вебе), а для отмены — `cancel-results`
вместо удаления, чтобы история и аудит сохранялись. `post`/`unpost` будут помечены
deprecated после обновления iOS (iOS обновит Mac-Claude).
## Отрицательные остатки: предупреждения + push (09.10)
Продажа/списание/перемещение/отгрузка/возврат/отмена приёмки **в минус разрешены**
(решение владельца), но создают предупреждение об отрицательном остатке. iOS показывает
счётчик/список и может запустить пересчёт.
- `GET /api/negative-stock/count[?storeId=]` → `{ total, byStore: [{storeId, storeName, count}] }`.
Для баннера/индикатора «N товаров в минусе».
- `GET /api/negative-stock[?storeId=&overdueHours=24]` → список открытых предупреждений:
`{ id, storeId, storeName, productId, productName, quantity(<0), causeDocType, causeDocNumber,
causeUserId, causeAt, firstDetectedAt, lastUpdatedAt, overdue(bool), hint }`.
`hint` — подсказка причины, напр. «возможно, не проведена приёмка П-… от <поставщик>».
- `POST /api/negative-stock/recount` body `{ storeId }` → создаёт **черновик инвентаризации**
по складу ровно с минусовыми товарами (BookQty = текущий отрицательный остаток) →
`{ inventoryId, number, lines }`. Открыть через обычный экран инвентаризации.
- Отчёт остатков: `GET /api/inventory/stock?negativeOnly=true` — только минусы.
### Центр уведомлений (персистентный)
Фоновый джоб кладёт агрегат «N товаров ушли в минус» на склад (дебаунс/тихие часы) и эскалацию
просроченных минусов в таблицу `notifications`. iOS читает тот же фид, что и веб-колокольчик.
- `GET /api/notifications[?unreadOnly=&take=50]` → `{ items:[{ id, type, severity, title, body,
link, dataJson, storeId, read, createdAt }], unread, total }`. `type` = `negative-stock` |
`negative-stock-overdue`; `dataJson` содержит `{ storeId, count, productIds[] }` для действия
«Пересчитать».
- `GET /api/notifications/count` → `{ unread }` — для бейджа.
- `POST /api/notifications/{id}/read` (204) — пометить одно прочитанным; `POST /api/notifications/read-all`
(204) — все.
- Создать инвентаризацию по предупреждениям — `POST /api/negative-stock/recount` `{ storeId }` (см. выше).
### Web Push (VAPID) — браузер/PWA
Для веб-админки и PWA (в т.ч. iOS Safari на домашнем экране). Ключи VAPID генерируются
на сервере и хранятся в platform_settings.
- `GET /api/web-push/public-key` → `{ publicKey }` — для `pushManager.subscribe`.
- `POST /api/web-push/subscribe` body `{ endpoint, keys: { p256dh, auth } }` (204) — идемпотентно.
- `POST /api/web-push/unsubscribe` body `{ endpoint }` (204).
Доставка — из фонового джоба минусов; payload JSON `{title, body, url}`, его разбирает
service worker (обработчики `push`/`notificationclick`).
### Push-токены устройств (заложено; реальный APNs позже)
- `POST /api/me/device-tokens` body `{ platform: "ios"|"android"|"web", token }` — регистрация
токена (идемпотентно по токену). `DELETE /api/me/device-tokens?token=…` — отзыв.
- Реальная отправка push **пока выключена** (нужен платный Apple Developer): в DI включён
`StubPushSender` (пишет в лог). Контракт — `IPushSender.SendAsync(userIds, title, body, data)`;
когда появятся ключи APNs/FCM/VAPID, заглушка подменяется реальной реализацией без правок
вызывающего кода. Фоновая агрегированная рассылка предупреждений (одно сообщение на склад,
тихие часы, получатели — Админ+Кладовщики склада) и эскалация — отдельный проход.