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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
15 KiB
Markdown
171 lines
15 KiB
Markdown
# 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, заглушка подменяется реальной реализацией без правок
|
||
вызывающего кода. Фоновая агрегированная рассылка предупреждений (одно сообщение на склад,
|
||
тихие часы, получатели — Админ+Кладовщики склада) и эскалация — отдельный проход.
|