Инвентаризация
Что это
Инвентаризация — документ пересчёта остатков на складе. Бухгалтер запускает 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=33. PATCH /inventory/inventories/:id → If-Match: 3 → 200 OK { ..., "version": 4 }4. UI обновляет своё состояние → version=4 (для следующего PATCH)Что делать при 409 STALE_VERSION
Кто-то (или другая вкладка) уже изменил эту инвентаризацию. Действия фронта:
- Тоаст «Запись изменена другим пользователем».
- Перезагрузить запись:
GET /inventory/inventories/:id→ перерисовать форму с новойversion. - Пользователь смотрит изменения и пересохраняет.
Не пытаться угадать новую версию и повторить PATCH автоматически — можно затереть чужую правку.
Если на старте вообще не передать ни заголовок, ни поле — сервер выполнит операцию без проверки версии (поле expectedVersion опционально). Так делать не надо — в инвентаризации параллельные правки реальны (длинный 3-шаговый мастер, две вкладки).
Различение 409 по причинам
В инвентаризации сейчас только один источник 409 — StaleVersionException. Тело ответа:
{ "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/:id | Soft-delete |
PATCH | /inventory/inventories/:id/restore | Восстановить удалённую |
POST | /inventory/inventories/export | Кнопка «Download» (xlsx) |
Сценарий 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=50DTO: inventory-filter.dto.ts.
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
status | InventoryStatus[] | — | DRAFT / POSTED. DELETED не в enum — для удалённых showDeleted |
showDeleted | bool | — | true — включить isDeleted=true |
search | string | — | По номеру документа и наименованию склада. Макс. 100 |
documentDateFrom, documentDateTo | YYYY-MM-DD | — | Диапазон по учётной дате |
subdivisionId[] | UUID[] | — | Мультивыбор склада (Subdivision.id). До 100 значений |
onlyOpenPeriod | bool | — | Исключить документы из закрытого периода корпорации (documentDate ≤ finalBlockingDate). При null finalBlockingDate — no-op |
offset, limit | int | — | Дефолт 0 / 50 |
sortBy | enum | — | Whitelist INVENTORY_SORT_FIELDS (documentDate/documentNumber/status/surplusAmount/shortageAmount/totalAmount/warehouseName/createdAt/updatedAt/createdByName/updatedByName). Дефолт documentDate |
sortOrder | asc|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", },]action ∈ InventoryAuditAction. Сортировка — по убыванию 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 ─ для Шага 1GET /menu-management/nomenclatures?type=GOODS ─ для Шага 2GET /accounting/accounts?accountTypeId=<излишек> ─ для Шага 3GET /accounting/accounts?accountTypeId=<недостача> ─ для Шага 3Шаг 1. Подсчёт приготовленных блюд и заготовок
UI-state: фронт собирает строки только для номенклатур с type ∈ {DISH, SEMI_FINISHED}. Кнопка «Далее» → создание DRAFT.
POST /inventory/inventories/saveContent-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 | — | Если пусто — генерируется |
| Дата | documentDate | ✅ | ISO date / YYYY-MM-DD |
| Склад | warehouseId | ✅ | GET /organization/subdivisions. Фиксируется на Шаге 1 |
| Комментарий | comment | — | nullable |
| Позиции (один из шагов 1/2) | items[] | ✅ | nomenclatureId, factualQuantity (≥ 0), опциональный comment по позиции |
Ответ — 201 Created с InventoryDetailResponseDto (status: "DRAFT").
Шаг 2. Подсчёт приобретённых товаров
UI-state: фильтр type === GOODS. Фронт хранит позиции из шага 1, добавляет новые и зовёт:
PATCH /inventory/inventories/:id/saveContent-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=falsePATCH /: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):
| Поле | Тип | Дефолт | Описание |
|---|---|---|---|
search | string ≤200 | — | Подстрока по name/sku, регистронезависимо |
types | NomenclatureType[] | — | Фильтр по типу. Передаётся повторами (?types=GOODS&types=DISH) или CSV (?types=GOODS,DISH). Без параметра — все типы |
limit | int 1..100 | 20 | Размер выборки |
Ответ 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:
- Юзер открывает inline-селект → debounced GET с
search,types,limit=20. - Рендерить hits:
name (sku)+ бейдж типа + строка-превью «Книжное: X, Себест.: Y». - Позиции с
alreadyInInventory=true— дизейблить или серый цвет. - По выбору в пустой строке (новая позиция) →
POST /:id/items. - По выбору в существующей строке (смена позиции) →
PATCH /:id/items/:itemId/replace-nomenclature, см. ниже.
PATCH /:id/items/:itemId/replace-nomenclature — смена номенклатуры в строке
Используется, когда юзер на Шаге 2 в уже сохранённой строке выбрал в inline-селекте другую позицию (например, «Донат Шоколадный» → «Дип-пот Соус Сырный»). Не путать с recount (тот же nomenclatureId, переснимаем книжный остаток).
Body (replace-inventory-item-nomenclature.dto.ts):
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
nomenclatureId | UUID | да | Новая номенклатура |
factualQuantity | number ≥0 | нет | Новый факт. Если не передан — bookQuantity нового снимка |
comment | string | нет | Комментарий к строке. Если не передан — наследуется со старой версии (iiko-семантика). Чтобы очистить — "" |
version | int ≥1 | нет | Альтернатива If-Match |
Что делает бэк (в одной транзакции):
- FOR UPDATE инвентаризации, проверки
DRAFT/corporationId/version/ период. - Перечитывает item под lock’ом, проверяет
isActive=true. - Если
dto.nomenclatureId === item.nomenclatureId→400 «Та же номенклатура — используйте /recount». - Cross-tenant guard на новую номенклатуру.
- Если новая номенклатура уже среди активных строк документа →
400 «Данная номенклатура уже добавлена». - FOR UPDATE стока на (новая номенклатура, склад инвентаризации). Если нет записи →
bookQuantity=0,costPrice=0. - Старая позиция →
isActive=false. - Создаётся новая:
nomenclatureId— из DTObookQuantity/costPrice— свежий снимок складаfactualQuantity=dto.factualQuantity ?? bookQuantityquantityDifference=factual − bookamountDifference=diff × costPrice(2 знака)comment=dto.comment ?? old.comment ?? nullpreviousVersionId=old.idisActive = true
- Пересчёт агрегатов документа (
surplusAmount/shortageAmount/totalAmount). - Audit
UPDATEDс{ op: "REPLACE_NOMENCLATURE", previousItemId, newItemId, fromNomenclatureId, toNomenclatureId, factualQuantity }.
Ответ: InventoryItemResponseDto новой активной строки (тот же тип что у recount). Чтобы получить весь документ с агрегатами и version — GET /:id отдельным запросом.
Возможные ошибки:
| HTTP | Причина | Действие UI |
|---|---|---|
| 400 | «Позиция уже деактивирована» | Рефетч документа |
| 400 | «Та же номенклатура — используйте /recount» | Под капотом переключиться на /recount |
| 400 | «Данная номенклатура уже добавлена» | Подсветить конфликтующую активную строку, не применять смену |
| 400 | «Период закрыт» | Toast «обратись к админу» |
| 404 | Не найдено / cross-tenant | Redirect / тоаст «нет доступа» |
| 409 | STALE_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/saveContent-Type: application/json
{ "documentDate": "2026-04-13", "warehouseId": "uuid", "surplusAccountId": "uuid", "shortageAccountId": "uuid", "items": [ /* итоговый набор позиций */ ], "postAfterSave": true}| Поле | Назначение |
|---|---|
surplusAccountId | UUID счёта оприходования излишков (nullable, обязателен при наличии излишков). Разметка документа для будущей выгрузки — проводок офис по нему не делает (1116) |
shortageAccountId | UUID счёта списания недостач (nullable, обязателен при наличии недостач). Разметка документа для будущей выгрузки — проводок офис по нему не делает (1116) |
postAfterSave | true → атомарный 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/recountContent-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/postPATCH /inventory/inventories/:id/cancel-postDELETE /inventory/inventories/:id → 204 No ContentPATCH /inventory/inventories/:id/restorepost: формирует 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/exportContent-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 |
Пресеты периода
| Пресет | documentDateFrom | documentDateTo |
|---|---|---|
| Открытый период (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), поэтому трактовка «в пределах нормы это естественная убыль, сверх это недостача» в проводках неприменима. Норма — про подсветку и фильтр в документе.