Перейти к содержимому

Инвентаризация

Что это

Инвентаризация — документ пересчёта остатков на складе. Бухгалтер запускает 3-шаговый мастер: на шаге 1 пересчитывает блюда и заготовки, на шаге 2 — приобретённые товары, на шаге 3 указывает счета для оприходования излишков / списания недостач и проводит документ.

Дополнительные сценарии: автозаполнение позиций со склада (addItemsFromStock), точечный пересчёт позиции (recount с версионированием через isActive + previousVersionId), копирование, soft-delete и восстановление.

При проведении формируются корректирующие InventoryTransaction’ы (излишек / недостача) и обновляется WarehouseStock. Бухгалтерских проводок офис при этом НЕ делает (LOCALIOFFICE-1116, товарный GL вне MVP): счета излишков / недостач (surplusAccountId / shortageAccountId) — разметка документа для будущей выгрузки, а не источник проводок.

Связанная веха: LOCALIOFFICE-966 — Inventory: аудит бизнес-логики.

Картина пользователя

┌─────────────────────────────────────────────────────────────────────┐
│ Инвентаризации 🔄 Обновить 📥 Download │
│ 🔍 Поиск Настройки колонок ⬜ История изменений │
│ Статус ▾ За период [Открытый ▾] С [...] По [...] │
├─────────────────────────────────────────────────────────────────────┤
│ Учётная дата № док. Склад Излишки Недостача Сумма │
│ 13.11.25 03624362 КРИСПИ 0.000 0.000 1.000 │
├─────────────────────────────────────────────────────────────────────┤
│ ⬛ DRAFT 0 🟢 POSTED 8 Сумма излишков: 0 Сумма недостач: 0 │
└─────────────────────────────────────────────────────────────────────┘
↓ «Создать»
Шаг 1. Блюда / заготовки ─────► POST /inventory/inventories
(DRAFT, items[ type ∈ DISH/SEMI_FINISHED ])
или POST /save
Шаг 2. Приобретённые товары ─────► PATCH /:id/save (полная замена items[])
Альтернативы:
POST /:id/items/from-stock (автозаполнение)
PATCH /:id/items/:itemId/recount
DELETE /:id/items/:itemId
Шаг 3. Завершение ─────► PATCH /:id/save с surplusAccountId,
shortageAccountId и postAfterSave: true
→ атомарный update + post

Базовое

  • Auth. Authorization: Bearer <access_token>. corporationId — из токена. /auth/* для refresh / login / switch-corporation.
  • Время. ISO-8601 UTC. documentDate — учётная дата (на которую считаем остатки).
  • Деньги и количества. Возвращаются строками ("265.680"). В Body — number.
  • Оптимистическая блокировка. У Inventory поле version: number в каждом ответе (стартует с 1, инкрементится сервером после каждой мутации). На все mutate-эндпоинты (PATCH / DELETE / POST items*) фронт обязан передавать актуальную версию — через заголовок If-Match: <version> (приоритет) либо поле version в теле запроса. При расхождении сервер вернёт 409 STALE_VERSION — фронт должен перезагрузить документ через GET /:id, перерисовать форму с новой версией и попросить пользователя пересохранить. Подробнее ниже в разделе «Оптимистическая блокировка».
  • Версионирование позиций (другое). При recount старая позиция помечается isActive=false и связывается со «свежей» через previousVersionId. UI рендерит только isActive=true позиции; история пересчётов остаётся в БД. Не путать с Inventory.version — это разные сущности.
  • Soft-delete. Через isDeleted: boolean (как у инвойсов, в отличие от write-off / sales-act со статусом DELETED). ?showDeleted=true — включить удалённые в выборку.
  • Транзакционность. Все мутирующие операции — в единой БД-транзакции.

Оптимистическая блокировка

Inventory.version: number — целое число, стартует с 1. У каждой инвентаризации в DTO ответа есть это поле. Сервер инкрементит его при каждой mutate-операции. Фронт хранит последнюю полученную версию и отдаёт её обратно при следующем mutate.

Передать версию можно одним из двух способов — оба эквивалентны, сервер сначала смотрит заголовок:

Способ 1 — заголовок If-Match (предпочтительно):

If-Match: 3

или в стиле RFC 7232 с кавычками:

If-Match: "3"

Способ 2 — поле version в теле запроса:

{
"version": 3,
"documentDate": "2026-02-20"
}

Применяется, когда удобнее запихнуть всё в JSON. Для эндпоинтов без body (POST /:id/items/from-stock, PATCH /:id/post, PATCH /:id/cancel-post, DELETE /:id, PATCH /:id/restore, DELETE /:id/items/:itemId) фронт обязан слать If-Match либо отправить { "version": N } в body.

Жизненный цикл версии

1. GET /inventory/inventories/:id → { ..., "version": 3 }
2. UI открывает форму, запоминает → version=3
3. PATCH /inventory/inventories/:id → If-Match: 3
→ 200 OK { ..., "version": 4 }
4. UI обновляет своё состояние → version=4 (для следующего PATCH)

Что делать при 409 STALE_VERSION

Кто-то (или другая вкладка) уже изменил эту инвентаризацию. Действия фронта:

  1. Тоаст «Запись изменена другим пользователем».
  2. Перезагрузить запись: GET /inventory/inventories/:id → перерисовать форму с новой version.
  3. Пользователь смотрит изменения и пересохраняет.

Не пытаться угадать новую версию и повторить PATCH автоматически — можно затереть чужую правку.

Если на старте вообще не передать ни заголовок, ни поле — сервер выполнит операцию без проверки версии (поле expectedVersion опционально). Так делать не надо — в инвентаризации параллельные правки реальны (длинный 3-шаговый мастер, две вкладки).

Различение 409 по причинам

В инвентаризации сейчас только один источник 409StaleVersionException. Тело ответа:

{
"statusCode": 409,
"code": "STALE_VERSION",
"message": "Версия записи устарела"
}

Поле body.code === "STALE_VERSION" — единственный надёжный признак конфликта версий. Заблокированный период бухгалтерии бросает 400 (не 409).

Эндпоинты раздела

Префикс — /inventory/inventories.

МетодМаршрутКогда вызывается из UI
GET/inventory/inventoriesЗагрузка/обновление таблицы списка
GET/inventory/inventories/:idОткрытие карточки инвентаризации
GET/inventory/inventories/:id/historyТаб «История изменений»
POST/inventory/inventoriesСоздать (с опциональным набором позиций)
POST/inventory/inventories/saveКомпозитное создание целиком (DRAFT с массивом позиций одним запросом)
PATCH/inventory/inventories/:id/saveКомпозитная полная замена черновика (используется на шагах мастера)
PATCH/inventory/inventories/:idТочечное обновление черновика
POST/inventory/inventories/:id/itemsДобавить одну позицию
POST/inventory/inventories/:id/items/from-stockАвтозаполнение позиций из текущего WarehouseStock
GET/inventory/inventories/:id/items/search-nomenclatureПоиск номенклатуры для inline-селекта строки (хиты + снимок bookQuantity/costPrice склада + флаг alreadyInInventory)
PATCH/inventory/inventories/:id/items/:itemId/recountПересчитать одну позицию (обновить factualQuantity)
PATCH/inventory/inventories/:id/items/:itemId/replace-nomenclatureСменить номенклатуру в активной строке: старая → isActive=false, новая со свежим снимком bookQuantity/costPrice + previousVersionId
DELETE/inventory/inventories/:id/items/:itemIdУдалить позицию (isActive=false)
PATCH/inventory/inventories/:id/postПровести
PATCH/inventory/inventories/:id/cancel-postОтменить проведение
DELETE/inventory/inventories/:idSoft-delete
PATCH/inventory/inventories/:id/restoreВосстановить удалённую
POST/inventory/inventories/exportКнопка «Download» (xlsx)

inventory.controller.ts


Сценарий 1. Загрузить таблицу списка

Запрос

GET /inventory/inventories?
status=DRAFT,POSTED&
showDeleted=false&
documentDateFrom=2026-04-01&
documentDateTo=2026-04-30&
search=03624&
sortBy=documentDate&
sortOrder=desc&
offset=0&
limit=50

DTO: inventory-filter.dto.ts.

ПараметрТипОбяз.Описание
statusInventoryStatus[]DRAFT / POSTED. DELETED не в enum — для удалённых showDeleted
showDeletedbooltrue — включить isDeleted=true
searchstringПо номеру документа и наименованию склада. Макс. 100
documentDateFrom, documentDateToYYYY-MM-DDДиапазон по учётной дате
subdivisionId[]UUID[]Мультивыбор склада (Subdivision.id). До 100 значений
onlyOpenPeriodboolИсключить документы из закрытого периода корпорации (documentDate ≤ finalBlockingDate). При null finalBlockingDate — no-op
offset, limitintДефолт 0 / 50
sortByenumWhitelist INVENTORY_SORT_FIELDS (documentDate/documentNumber/status/surplusAmount/shortageAmount/totalAmount/warehouseName/createdAt/updatedAt/createdByName/updatedByName). Дефолт documentDate
sortOrderasc|descДефолт desc

Ответ

{
"items": [
{
"id": "uuid",
"status": "POSTED",
"documentNumber": "03624362",
"documentDate": "2026-04-13T00:00:00.000Z",
"comment": null,
"warehouseId": "uuid",
"warehouseName": "Кристи Мол",
"surplusAmount": "0.000",
"shortageAmount": "0.000",
"totalAmount": "1.000",
"surplusAccountId": "uuid",
"shortageAccountId": "uuid",
"isDeleted": false,
"deletedAt": null,
"createdAt": "2026-04-13T14:25:00.000Z",
"createdByName": "Абуева Иман",
"updatedAt": "2026-04-13T14:25:00.000Z",
"updatedByName": null,
"version": 3,
},
],
"aggregates": {
"count": 8,
"totalSurplusAmount": "0.000",
"totalShortageAmount": "0.000",
"totalAmount": "8.000",
},
"pagination": { "offset": 0, "limit": 50, "returned": 8, "total": 8 },
}

Что фронту делать с полями строки

ПолеКуда оно идёт в UI
documentDateКолонка «Учётная дата»
documentNumberКолонка «№ документа»
warehouseName (warehouseId)Колонка «Склад»
statusЦвет строки
surplusAmountКолонка «Сумма излишков, ₽»
shortageAmountКолонка «Сумма недостачи, ₽»
totalAmountКолонка «Сумма, ₽» (= surplusAmount − shortageAmount)
commentКолонка «Комментарий» (nullable)
isDeleted, deletedAtСтиль зачёркнутой строки при showDeleted=true
createdByNameКолонка «Кто создал» (ФИО либо login — нейминг унифицирован с инвойсами / актами)
updatedByNameКолонка «Кто изменил» (см. выше)
createdAt, updatedAtКолонки toggle «История изменений»
versionЦелое число для оптимистической блокировки. Фронт хранит и шлёт обратно в If-Match (см. «Оптимистическая блокировка»)

Сценарий 2. Открыть карточку инвентаризации

Запрос

GET /inventory/inventories/:id

Ответ

{
"id": "uuid",
"status": "POSTED",
"documentNumber": "03624362",
"documentDate": "2026-04-13T00:00:00.000Z",
"comment": null,
"warehouseId": "uuid",
"surplusAccountId": "uuid",
"shortageAccountId": "uuid",
"surplusAmount": "265.680",
"shortageAmount": "265.680",
"totalAmount": "0.000",
"isDeleted": false,
"version": 4,
"items": [
{
"id": "uuid",
"nomenclatureId": "uuid",
"nomenclatureName": "Донат Шоколадный",
"nomenclatureType": "DISH",
"nomenclatureSku": "0102",
"measureUnitName": "шт",
"productGroupId": "uuid",
"productGroupName": "Группа пбулочка",
"bookQuantity": "33",
"factualQuantity": "26",
"quantityDifference": "-7",
"costPrice": "39.71",
"amountDifference": "-277.97",
"comment": null,
"isActive": true,
"previousVersionId": null,
"isWithinNorm": false,
"deviationPercent": "21.21",
"tolerancePercent": "5.00",
},
],
"createdAt": "2026-04-13T14:25:00.000Z",
"updatedAt": "2026-04-13T14:25:00.000Z",
}

Что фронту делать с полями позиции

ПолеКуда оно идёт в UI
nomenclatureSkuКолонка «Код» (nullable)
nomenclatureNameКолонка «Блюдо/заготовка/товар»
nomenclatureTypeКуда раскладывать позицию по шагам: DISH / SEMI_FINISHED → Шаг 1, GOODS → Шаг 2
measureUnitNameКолонка «Ед. изм.» (nullable)
productGroupId, productGroupNameДля группировки на Шаге 3 (заголовки групп типа «Группа пбулочка»)
bookQuantityКолонка «Книжное кол-во» — учётный остаток на момент создания позиции
factualQuantityКолонка «Факт. кол-во» — что ввёл оператор
quantityDifferenceКолонка «Разница кол-во» (derived factualQuantity − bookQuantity)
costPriceКолонка «Себест., ₽» — себестоимость за ед. на момент создания позиции
amountDifferenceКолонка «Разница сумма, ₽» (derived quantityDifference × costPrice)
commentКолонка «Комментарий» позиции (nullable)
isActiveФронт рендерит только isActive=true. Деактивированные позиции (результат пересчёта recount) — история, в таблицу не выводятся
previousVersionIdЕсли не null — ссылка на предыдущую (деактивированную) версию позиции, появившуюся при пересчёте
isWithinNormБейдж / подсветка строки: true → «в норме» (нейтрально), false → «сверх нормы» (акцент). Нулевое расхождение всегда true
deviationPercentКолонка «Отклонение, %» — |разница| / книжное × 100. При null (книжный остаток 0, процент не считается) показывать прочерк, не «0 %»
tolerancePercentПрименённая норма (номенклатура либо дефолт корпорации), string | null. Для тултипа «норма N %»; null — норма не задана

Сценарий 3. История изменений

Запрос

GET /inventory/inventories/:id/history

Ответ

[
{
"id": "uuid",
"inventoryId": "uuid",
"action": "CREATE",
"changes": null,
"performedById": "uuid",
"performedByName": "Абуева Иман",
"createdAt": "2026-04-13T14:25:00.000Z",
},
]

actionInventoryAuditAction. Сортировка — по убыванию createdAt.

Аудит-лог здесь отдельная таблица (в отличие от sales-act), поэтому performedByName — реально name из связи (не login).


Сценарий 4. 3-шаговый мастер создания

Мастер — клиентский state. Бэк не различает шаги: на каждом «Далее» фронт делает либо POST, либо PATCH /:id/save с актуальным набором позиций. На шаге 3 — добавляются счета и (опционально) флаг проведения.

Шаг 0. Подгрузка справочников

Параллельно при открытии мастера:

GET /organization/subdivisions ─ селект «Склад»
GET /menu-management/nomenclatures?type=DISH,SEMI_FINISHED ─ для Шага 1
GET /menu-management/nomenclatures?type=GOODS ─ для Шага 2
GET /accounting/accounts?accountTypeId=<излишек> ─ для Шага 3
GET /accounting/accounts?accountTypeId=<недостача> ─ для Шага 3

Шаг 1. Подсчёт приготовленных блюд и заготовок

UI-state: фронт собирает строки только для номенклатур с type ∈ {DISH, SEMI_FINISHED}. Кнопка «Далее» → создание DRAFT.

POST /inventory/inventories/save
Content-Type: application/json
{
"documentNumber": "INV-2024-001",
"documentDate": "2026-04-13",
"warehouseId": "uuid",
"comment": null,
"items": [
{ "nomenclatureId": "uuid", "factualQuantity": 12.5 }
]
}

DTO: save-inventory.dto.ts.

Поле UIПоле BodyОбяз.Примечание
Номер документаdocumentNumberЕсли пусто — генерируется
ДатаdocumentDateISO date / YYYY-MM-DD
СкладwarehouseIdGET /organization/subdivisions. Фиксируется на Шаге 1
Комментарийcommentnullable
Позиции (один из шагов 1/2)items[]nomenclatureId, factualQuantity (≥ 0), опциональный comment по позиции

Ответ — 201 Created с InventoryDetailResponseDto (status: "DRAFT").

Шаг 2. Подсчёт приобретённых товаров

UI-state: фильтр type === GOODS. Фронт хранит позиции из шага 1, добавляет новые и зовёт:

PATCH /inventory/inventories/:id/save
Content-Type: application/json
{
"documentDate": "2026-04-13",
"warehouseId": "uuid",
"items": [
/* позиции шага 1 + новые позиции шага 2, полный набор */
]
}

Полная замена позиций — бэк перезаписывает массив.

Альтернативы на шаге 2 (если фронт не хочет ставить весь массив):

  • POST /:id/items — добавить одну позицию (create-inventory-item.dto.ts: nomenclatureId, factualQuantity, опциональный comment)
  • POST /:id/items/from-stock — автозаполнение позиций со склада из текущего WarehouseStock (фронт может одной кнопкой стянуть все номенклатуры с остатком, дальше оператор только корректирует factualQuantity)
  • GET /:id/items/search-nomenclature — поиск номенклатуры для inline-селекта строки (autocomplete), см. секцию ниже
  • PATCH /:id/items/:itemId/recount — пересчитать одну позицию (RecountInventoryItemDto). Создаёт новую версию позиции, старую помечает isActive=false
  • PATCH /:id/items/:itemId/replace-nomenclature — сменить номенклатуру в строке (inline-селект «Блюдо/заготовка»), см. секцию ниже
  • DELETE /:id/items/:itemId — удалить позицию (физически isActive=false)

GET /:id/items/search-nomenclature — поиск для inline-селекта

Питает autocomplete-селект «Блюдо/заготовка/товар» в строке Шага 2: фронт делает debounced GET на каждый ввод и получает хиты вместе с готовым снимком книжного остатка и себестоимости для склада инвентаризации + флагом «уже в этом документе».

Query params (search-inventory-nomenclature.dto.ts):

ПолеТипДефолтОписание
searchstring ≤200Подстрока по name/sku, регистронезависимо
typesNomenclatureType[]Фильтр по типу. Передаётся повторами (?types=GOODS&types=DISH) или CSV (?types=GOODS,DISH). Без параметра — все типы
limitint 1..10020Размер выборки

Ответ InventoryNomenclatureSearchResponseDto:

{
items: Array<{
nomenclatureId: string;
name: string;
sku: string | null;
type: "DISH" | "GOODS" | "SEMI_FINISHED" | "MODIFIER" | "SERVICE";
measureUnitName: string | null;
bookQuantity: string; // Decimal-строка; "0" если на складе нет записи
costPrice: string; // Decimal-строка; "0" если на складе нет записи
alreadyInInventory: boolean; // true — уже добавлена активной строкой в этот документ
}>;
}

Сортировка — name ASC. Поиск ограничен номенклатурой своей корпорации, soft-deleted позиции исключены. Снимок остатков и список активных строк документа берутся согласованно — alreadyInInventory не разъезжается с фактическим состоянием при параллельных правках.

UI:

  1. Юзер открывает inline-селект → debounced GET с search, types, limit=20.
  2. Рендерить hits: name (sku) + бейдж типа + строка-превью «Книжное: X, Себест.: Y».
  3. Позиции с alreadyInInventory=true — дизейблить или серый цвет.
  4. По выбору в пустой строке (новая позиция) → POST /:id/items.
  5. По выбору в существующей строке (смена позиции) → PATCH /:id/items/:itemId/replace-nomenclature, см. ниже.

PATCH /:id/items/:itemId/replace-nomenclature — смена номенклатуры в строке

Используется, когда юзер на Шаге 2 в уже сохранённой строке выбрал в inline-селекте другую позицию (например, «Донат Шоколадный» → «Дип-пот Соус Сырный»). Не путать с recount (тот же nomenclatureId, переснимаем книжный остаток).

Body (replace-inventory-item-nomenclature.dto.ts):

ПолеТипОбяз.Описание
nomenclatureIdUUIDдаНовая номенклатура
factualQuantitynumber ≥0нетНовый факт. Если не передан — bookQuantity нового снимка
commentstringнетКомментарий к строке. Если не передан — наследуется со старой версии (iiko-семантика). Чтобы очистить — ""
versionint ≥1нетАльтернатива If-Match

Что делает бэк (в одной транзакции):

  1. FOR UPDATE инвентаризации, проверки DRAFT / corporationId / version / период.
  2. Перечитывает item под lock’ом, проверяет isActive=true.
  3. Если dto.nomenclatureId === item.nomenclatureId400 «Та же номенклатура — используйте /recount».
  4. Cross-tenant guard на новую номенклатуру.
  5. Если новая номенклатура уже среди активных строк документа → 400 «Данная номенклатура уже добавлена».
  6. FOR UPDATE стока на (новая номенклатура, склад инвентаризации). Если нет записи → bookQuantity=0, costPrice=0.
  7. Старая позиция → isActive=false.
  8. Создаётся новая:
    • nomenclatureId — из DTO
    • bookQuantity / costPrice — свежий снимок склада
    • factualQuantity = dto.factualQuantity ?? bookQuantity
    • quantityDifference = factual − book
    • amountDifference = diff × costPrice (2 знака)
    • comment = dto.comment ?? old.comment ?? null
    • previousVersionId = old.id
    • isActive = true
  9. Пересчёт агрегатов документа (surplusAmount / shortageAmount / totalAmount).
  10. Audit UPDATED с { op: "REPLACE_NOMENCLATURE", previousItemId, newItemId, fromNomenclatureId, toNomenclatureId, factualQuantity }.

Ответ: InventoryItemResponseDto новой активной строки (тот же тип что у recount). Чтобы получить весь документ с агрегатами и versionGET /:id отдельным запросом.

Возможные ошибки:

HTTPПричинаДействие UI
400«Позиция уже деактивирована»Рефетч документа
400«Та же номенклатура — используйте /recount»Под капотом переключиться на /recount
400«Данная номенклатура уже добавлена»Подсветить конфликтующую активную строку, не применять смену
400«Период закрыт»Toast «обратись к админу»
404Не найдено / cross-tenantRedirect / тоаст «нет доступа»
409STALE_VERSIONРефетч + повтор по подтверждению юзера

Почему не DELETE + POST: 2 round-trip, между ними меняется version (409), теряется comment, нарушается порядок строк. PATCH /:id/save со всем массивом — атомарная альтернатива, но перетирает чужие правки.

Шаг 3. Завершение

UI-state: UI отображает агрегаты (surplusAmount, shortageAmount, totalAmount — уже посчитаны бэком после Шага 2), позиции сгруппированы по productGroupName. На шаге выбираются счета и финал.

PATCH /inventory/inventories/:id/save
Content-Type: application/json
{
"documentDate": "2026-04-13",
"warehouseId": "uuid",
"surplusAccountId": "uuid",
"shortageAccountId": "uuid",
"items": [ /* итоговый набор позиций */ ],
"postAfterSave": true
}
ПолеНазначение
surplusAccountIdUUID счёта оприходования излишков (nullable, обязателен при наличии излишков). Разметка документа для будущей выгрузки — проводок офис по нему не делает (1116)
shortageAccountIdUUID счёта списания недостач (nullable, обязателен при наличии недостач). Разметка документа для будущей выгрузки — проводок офис по нему не делает (1116)
postAfterSavetrue → атомарный update + post; при ошибке проведения откат, статус остаётся DRAFT

Ошибки на шагах мастера

HTTPТелоКогдаГде (шаг)
400{message:"В инвентаризации не может быть двух позиций с одной номенклатурой"}Дубликат nomenclatureId в items[]Любой шаг с items[]
400{message:"Данная номенклатура уже добавлена в инвентаризацию"}POST /:id/items для уже существующейШаг 2
400{message:"На складе нет позиций с остатками для добавления"}POST /:id/items/from-stock — пустой складШаг 2
400{message:"Все позиции со склада уже добавлены в инвентаризацию"}POST /:id/items/from-stock — всё уже добавленоШаг 2
400{message:"Позиция уже деактивирована"}recount для isActive=false позицииШаг 2
400{message:"Номенклатура деактивирована в данной инвентаризации другим процессом"}Гонка двух пересчётов одной позицииШаг 2
400{message:"Невозможно провести инвентаризацию: все позиции имеют нулевое расхождение"}Все quantityDifference = 0 при postШаг 3 / отдельный post
400{message:"Необходимо указать счёт для оприходования излишков"}surplusAmount > 0 без surplusAccountIdШаг 3
400{message:"Необходимо указать счёт для списания недостач"}shortageAmount > 0 без shortageAccountIdШаг 3
400{message:"Остаток для номенклатуры X не может быть отрицательным …"}При проведении баланс уходит в минусШаг 3
400{message:"Невозможно отменить проведение: отсутствует складской остаток для номенклатуры X"}cancel-post — позиция уже расписанаПосле проведения
400{message:"Остаток для номенклатуры X не может быть отрицательным при отмене проведения"}cancel-post — после восстановления баланс ушёл в минусПосле проведения
400{message:"Невозможно выполнить операцию: дата документа находится в заблокированном периоде"}documentDate ≤ finalBlockingDateЛюбая мутация
401стандартRefresh → login
403стандартЧужая корпорация
404{message:"Инвентаризация не найдена"}Для PATCH ... / DELETE ...
409{statusCode:409, code:"STALE_VERSION", message:"Версия записи устарела"}Несовпадение If-Match / body.version с актуальной version в БДGET /:id → перерисовать с новой версией → попросить пересохранить. Различать по body.code === "STALE_VERSION"

Сценарий 5. Точечный пересчёт позиции

PATCH /inventory/inventories/:id/items/:itemId/recount
Content-Type: application/json
{ "factualQuantity": 13 }

DTO: recount-inventory-item.dto.ts.

Создаёт новую версию позиции:

  • старая помечается isActive=false;
  • новая позиция получает previousVersionId со ссылкой на старую;
  • quantityDifference и amountDifference пересчитываются;
  • агрегаты инвентаризации (surplusAmount / shortageAmount / totalAmount) обновляются.

UI отображает только isActive=true позиции. История пересчётов сохраняется в БД и не показывается, но доступна программно для аналитики.


Сценарий 6. Провести / отменить проведение / удалить / восстановить

PATCH /inventory/inventories/:id/post
PATCH /inventory/inventories/:id/cancel-post
DELETE /inventory/inventories/:id → 204 No Content
PATCH /inventory/inventories/:id/restore

post: формирует InventoryTransaction’ы (положительные для излишков, отрицательные для недостач) и обновляет WarehouseStock. Бухгалтерских проводок не формирует (LOCALIOFFICE-1116). Все позиции с quantityDifference != 0 участвуют; с нулевым расхождением — нет.

cancel-post: восстанавливает остатки склада (откат InventoryTransaction’ов и WarehouseStock).

DELETE: soft-delete (isDeleted=true, deletedAt=now). Только из DRAFT. Для POSTED — сначала cancel-post.

PATCH /:id/restore: снимает isDeleted.

Ошибки

HTTPТелоКогда
400{message:"Инвентаризация уже проведена или не содержит позиций"}Повторное проведение / пустой items[]
400{message:"Инвентаризация не проведена"}cancel-post для DRAFT
400{message:"Невозможно провести инвентаризацию: все позиции имеют нулевое расхождение"}Все quantityDifference = 0
404{message:"Инвентаризация не найдена"}Нет в корпорации
409{statusCode:409, code:"STALE_VERSION"}Версия устарела

Сценарий 7. Скачать Excel

POST /inventory/inventories/export
Content-Type: application/json
{
"status": ["POSTED"],
"documentDateFrom": "2026-04-01",
"documentDateTo": "2026-04-30",
"columns": [
"documentDate",
"documentNumber",
"warehouseName",
"surplusAmount",
"shortageAmount",
"totalAmount"
]
}

DTO — InventoryExportDto. Ответ — .xlsx (attachment inventories.xlsx).


Цвета и статусы

statusЦветСемантика
DRAFTсерыйЧерновик. Склад не затронут
POSTEDзелёныйПроведено. Сформированы корректирующие InventoryTransaction’ы, обновлён WarehouseStock. Проводок офис не делает (LOCALIOFFICE-1116)

isDeleted = true отображается отдельно (зачёркнутая строка), в выборку при showDeleted=true.

Чипы в подвале — клиентский расчёт; бэк отдаёт только aggregates.{count, totalSurplusAmount, totalShortageAmount, totalAmount}.


Селекторы

СелекторЭндпоинтПараметры
СкладGET /organization/subdivisions
Номенклатуры (Шаг 1)GET /menu-management/nomenclatures?type=DISH,SEMI_FINISHED
Номенклатуры (Шаг 2)GET /menu-management/nomenclatures?type=GOODS
Счёт излишковGET /accounting/accounts?accountTypeId=<излишек>accountTypeId
Счёт недостачGET /accounting/accounts?accountTypeId=<недостача>accountTypeId

Пресеты периода

ПресетdocumentDateFromdocumentDateTo
Открытый период (default)начало месяцане передавать
Текущая неделяпн 00:00вс 23:59:59
Текущий месяц1-е 00:00последний день 23:59:59
Произвольный«С»«По»

Часовой пояс и формат времени

  • API: ISO-8601 UTC. documentDate принимается как YYYY-MM-DD.
  • documentDate — учётная дата. Брать только date-часть, без TZ-конвертации.
  • createdAt / updatedAt / deletedAt — отображать в таймзоне корпорации.

Открытые вопросы

  • Persistence настроек колонокlocalStorage или server-side.
  • Шаг 3 без излишков/недостач — если в инвентаризации всё совпадает, нужно ли разрешать post (quantityDifference = 0)? Сейчас бэк отдаёт 400 "все позиции имеют нулевое расхождение". Решение продакта: либо разрешать (для подтверждения), либо оставить как есть.

Известные расхождения и план доработок

ЧтоЗадача
Все 8 волн правок по аудиту inventory закрытыLOCALIOFFICE-966
Доводка инвентаризации до единого стандарта inventory-модуляLOCALIOFFICE-966

Что работает уже сейчас и менять не нужно

  • ✅ Композитное сохранение целиком одним запросом (POST /save, PATCH /:id/save).
  • ✅ Точечные операции с позициями (addItem, addItemsFromStock, recount, removeItem).
  • ✅ Версионирование позиций при recount через isActive + previousVersionId.
  • ✅ Серверный расчёт bookQuantity, quantityDifference, amountDifference, surplusAmount, shortageAmount, totalAmount.
  • postAfterSave: true — атомарный save + post. При падении проведения транзакция откатывается.
  • ✅ Серверные агрегаты подвала (count, totalSurplusAmount, totalShortageAmount, totalAmount) — по всему отфильтрованному набору.
  • ✅ Soft-delete + restore.
  • ✅ Экспорт в Excel.
  • ✅ История изменений :id/history — отдельная таблица inventory_audit_log, performedByName приходит как name (в отличие от list-row).
  • ✅ Автозаполнение позиций из WarehouseStock через /items/from-stock — снимает необходимость заводить руками каждую номенклатуру.
  • Оптимистическая блокировка на всех mutate-эндпоинтах (@OptimisticLock() + @OptimisticVersion()) — поле version в DTO ответа, заголовок If-Match / body.version в запросе, 409 STALE_VERSION при расхождении. Паттерн как в staffing.
  • ✅ Audit-нейминг унифицирован: createdByName / updatedByName (ФИО либо login через formatUserName).
  • ✅ Фильтры списка: subdivisionId[] (мультивыбор склада) и onlyOpenPeriod (исключить заблокированный период).
  • ✅ Сортировка по полям createdByName / updatedByName через Employee.lastName в join’е.
  • header.conception убран из SalesActHeaderInfo (был known-gap MVP с null-заглушкой). Этот пункт касается смежного модуля sales-acts, фиксируется здесь для полноты.

Нормы отклонений (LOCALIOFFICE-1117)

Цель: отделять значимые расхождения от шума (погрешность весов, округления). Задаётся допустимый процент отклонения; позиция помечается «в норме / сверх нормы»; в документе можно отобрать только значимые расхождения.

Процент задаётся на двух уровнях: дефолт на корпорации, переопределение на номенклатуре (приоритет у номенклатуры). NULL на обоих уровнях — норма не задана, любое расхождение значимое.

Что нового в API (аддитивно, ничего не ломает)

Позиция в ответе GET /inventory/inventories/:id получила три поля:

  • isWithinNorm (boolean) — укладывается ли расхождение в норму. Нулевое расхождение всегда в норме; нет нормы или книжный остаток 0 — значимое.
  • deviationPercent (string | null) — процент отклонения |разница| / книжное * 100. NULL, когда книжный остаток 0 (процент посчитать нельзя): фронт показывает прочерк.
  • tolerancePercent (string | null) — применённая норма (номенклатура либо дефолт корпорации). NULL — норма не задана.

Новый query на том же эндпоинте: ?onlySignificant=true — оставляет в items только позиции вне нормы (по аналогии с onlyRecounted). Комбинируется с onlyRecounted и types через И. Дефолт false — поведение как раньше.

Задание нормы:

  • Корпорация: поле inventoryTolerancePercent (number 0..100 на вход, string | null в ответе) в PATCH /organization/corporation.
  • Номенклатура: поле inventoryTolerancePercent (number 0..100 на вход, string | null в ответе) в create/update номенклатуры.

Что делать фронту

  • Настройки корпорации: поле «допустимый % отклонения (норма по умолчанию)».
  • Карточка номенклатуры: поле «% отклонения (норма)» — переопределение дефолта корпорации.
  • Документ инвентаризации (таблица позиций): отображение полей isWithinNorm / deviationPercent / tolerancePercent описано в едином display-гайде — таблице «Что фронту делать с полями позиции». Тоггл «только значимые» дёргает ?onlySignificant=true.

Нюансы

  • deviationPercent = null при книжном остатке 0 — обработать (прочерк, не «0%»).
  • Шапочные итоги (surplusAmount / shortageAmount / totalAmount) при onlySignificant НЕ пересчитываются — отражают все активные позиции (как и при onlyRecounted).
  • Одиночные операции над позицией (add-item, recount, replace) тоже возвращают isWithinNorm с учётом дефолта корпорации.
  • Проводок по результату инвентаризации офис не делает (LOCALIOFFICE-1116, товарный GL вне MVP), поэтому трактовка «в пределах нормы это естественная убыль, сверх это недостача» в проводках неприменима. Норма — про подсветку и фильтр в документе.