food-market/docs/mobile-api.md
nns 3547e823c3
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
feat(notifications): задел push под iOS — device_tokens + IPushSender (заглушка)
Таблица токенов устройств (device_tokens, Phase40) + POST/DELETE /api/me/device-tokens
для регистрации iOS/Android/Web push-токенов. Контракт IPushSender.SendAsync; в DI
включён StubPushSender (пишет в лог) — реальный APNs/FCM/VAPID подменит заглушку, когда
появятся ключи, без правок вызывающего кода. Описано в docs/mobile-api.md.

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

137 lines
12 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=&page=&pageSize=&sort=&order=`
- `groupId` — фильтр по группе товаров **включая все вложенные подгруппы**
(по денормализованному `Path`). Комбинируется с `storeId`, `search`, сортировкой.
- Ответ: `PagedResult<StockRow>` — `{ items:[{ productId, productName, article, unitSymbol,
storeId, storeName, quantity, reservedQuantity, available }], total, page, pageSize }`.
## 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` — только минусы.
### 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, заглушка подменяется реальной реализацией без правок
вызывающего кода. Фоновая агрегированная рассылка предупреждений (одно сообщение на склад,
тихие часы, получатели — Админ+Кладовщики склада) и эскалация — отдельный проход.