food-market/docs/mobile-api.md
nns d465d82248
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
docs(weighted): unitShort в sync + обновлён mobile-api и прогресс
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:18:33 +05:00

171 lines
15 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` — код ОКЕИ единицы измерения («796»=шт, «166»=кг, «112»=л…),
- **`unitShort`** — короткое имя для отображения («шт»/«кг»/«л»), выведено из `unitCode`/названия,
чтобы касса/моб. не держали свою таблицу ОКЕИ. То же поле есть в строках возврата
(`PosReturnLineDto.unitShort`). Маппинг — `foodmarket.Shared.Units.UnitShort`.
## 5. Поиск по штрихкоду и весовые этикетки
**Реальный сценарий (09.10): весов с печатью этикеток нет.** Штрихкоды весовых товаров —
обычные коды в карточке (у владельца короткие «27xxxxx»). Поэтому:
**Поиск** — `GET /api/catalog/products/by-barcode/{value}`:
- **Сначала точное совпадение штрихкода** карточки (включая короткие «27xxxxx»):
1 → объект товара, несколько → `{ items:[…] }`.
- **Поиск по штрихкоду НИКОГДА не матчит по артикулу** (раньше весовой PLU матчился
по артикулу — товар с артикулом «12345» ошибочно находился; исправлено).
- Разбор весовой этикетки — **только если включён в организации И точного штрихкода нет**.
Тогда встроенный PLU ищется как ШТРИХКОД (не артикул) → `{ product:{…}, weighted:true,
valueKind:"WeightKg"|"Price", quantity:1.5, embeddedPrice:null }`.
- Нет совпадений → `404`.
**Настройка организации** — `GET/PUT /api/organization/settings` (PUT — право Admin):
- `weightBarcodeEnabled` (bool, **по умолчанию FALSE**) — разбор этикеток;
- `weightBarcodeWeightPrefixes` / `weightBarcodePricePrefixes` (CSV, null → `20..24`/`25..29`);
- `weightBarcodeCodeDigits` (длина PLU, по умолчанию 5);
- **`weightedCodeFormat`** («short»|«ean13», default «short») — формат кода, который
генерируется для нового ВЕСОВОГО товара; **`weightedCodePrefix`** (default 27),
**`weightedCodeDigits`** (default 5) → код вида «27xxxxx», следующий свободный номер.
Разбор единый для сервера и кассы — `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, заглушка подменяется реальной реализацией без правок
вызывающего кода. Фоновая агрегированная рассылка предупреждений (одно сообщение на склад,
тихие часы, получатели — Админ+Кладовщики склада) и эскалация — отдельный проход.