Остатки строились ОТ таблицы 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>
166 lines
14 KiB
Markdown
166 lines
14 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` — единица измерения (шт/кг/л…).
|
||
|
||
## 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, заглушка подменяется реальной реализацией без правок
|
||
вызывающего кода. Фоновая агрегированная рассылка предупреждений (одно сообщение на склад,
|
||
тихие часы, получатели — Админ+Кладовщики склада) и эскалация — отдельный проход.
|