# 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` — `{ 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` — только минусы. ### Центр уведомлений (персистентный) Фоновый джоб кладёт агрегат «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, заглушка подменяется реальной реализацией без правок вызывающего кода. Фоновая агрегированная рассылка предупреждений (одно сообщение на склад, тихие часы, получатели — Админ+Кладовщики склада) и эскалация — отдельный проход.