food-market/docs/mobile-api.md
nns 193ba49ecb fix(stock): «показать нулевые» и фильтр по группе показывают товары без прихода
Остатки строились ОТ таблицы 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>
2026-10-09 09:44:50 +05:00

14 KiB
Raw Blame History

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 по одному источнику (скрывают одно и то же).

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