Акты списания
Что это
Акт списания — документ списания товаров/заготовок/блюд со склада на расходный счёт (бой, порча, истечение срока). Бухгалтер видит список актов, создаёт новый акт, выбирает склад и счёт списания, добавляет позиции (с фасовкой), сохраняет в черновик или сразу проводит, копирует существующие, удаляет и восстанавливает.
Себестоимость позиции при создании/проведении не задаётся фронтом — бэк подтягивает текущую средневзвешенную себестоимость склада на момент проведения. Фронт передаёт только nomenclatureId, quantity и опционально packagingId.
Связанная веха: LOCALIOFFICE-966 — Inventory: аудит бизнес-логики.
Картина пользователя
┌─────────────────────────────────────────────────────────────────────┐│ Акты списания 🔄 Обновить 📥 Download ││ 🔍 Поиск Настройки колонок ⬜ История изменений ││ Статус ▾ Склад ▾ За период [Открытый ▾] С [...] По [...] │├─────────────────────────────────────────────────────────────────────┤│ Дата № док. Товары Сумма Склад Счёт ││ 13.11.25 WO-203 Мука пшеничная 300₽ КРИСПИ 20.1 ││ 13.11.25 WO-204 Чизбургер, +3 поз. 580₽ КРИСПИ 20.1 │├─────────────────────────────────────────────────────────────────────┤│ ⬛ DRAFT 1 🟢 POSTED 11 🔴 DELETED 0 Кол-во: 12 Сумма: 4580₽ │└─────────────────────────────────────────────────────────────────────┘
↓ клик «Создать» / клик по строке
┌─────────────────────────────────────────────────────────────────────┐│ Новый акт списания ✕ │├─────────────────────────────────────────────────────────────────────┤│ № документа [WO-203] Склад [▾] На счёт [▾] ││ Дата и время [13.11.25 14:30] Комментарий [...] ││ ─────────────────────────────────────────────────────────────────── ││ № Код Товар Тип Тип тары Кол-во Себест. Остаток ││ 1 M-001 Мука пшеничная GOODS Кг 2.000 150.00 10.500 ││ 2 ... ││ Себестоимость: 300.00 ₽ ││ ⬜ С проведением [Отмена] [Сохранить] │└─────────────────────────────────────────────────────────────────────┘Базовое
- Auth.
Authorization: Bearer <access_token>. corporationId — из токена. Refresh / login / switch-corporation —/auth/*. - Время. ISO-8601 UTC.
documentDateпринимается какYYYY-MM-DDлибо ISO. - Деньги. Денежные значения возвращаются строками (
"150.00"). В Body —number. - Оптимистическая блокировка. У
WriteOffActполяversionнет — оптимистической блокировки не предусмотрено. На повторное удаление/двойное проведение бэк возвращает400с понятным сообщением. - Soft-delete.
status: DELETED(в отличие от инвойсов, где soft-delete — отдельный флагisDeleted). В выборку попадает при?showDeleted=true. Restore — отдельный эндпоинт. - Транзакционность. Все мутирующие операции — в единой БД-транзакции. При ошибке шага — откат целиком.
Эндпоинты раздела
Префикс — /inventory/write-off-acts.
| Метод | Маршрут | Когда вызывается из UI |
|---|---|---|
GET | /inventory/write-off-acts | Загрузка/обновление таблицы списка |
GET | /inventory/write-off-acts/:id | Открытие карточки акта |
GET | /inventory/write-off-acts/:id/history | Таб «История изменений» в карточке |
POST | /inventory/write-off-acts | Кнопка «Сохранить» при создании |
PATCH | /inventory/write-off-acts/:id | Кнопка «Сохранить» при редактировании черновика |
POST | /inventory/write-off-acts/:id/items | Точечное добавление одной позиции |
DELETE | /inventory/write-off-acts/:id/items/:itemId | Точечное удаление позиции |
PATCH | /inventory/write-off-acts/:id/post | Действие «Провести» |
PATCH | /inventory/write-off-acts/:id/cancel-post | Действие «Отменить проведение» |
DELETE | /inventory/write-off-acts/:id | Действие «Удалить» |
PATCH | /inventory/write-off-acts/:id/restore | Действие «Восстановить» |
POST | /inventory/write-off-acts/:id/copy | Действие «Копировать» |
GET | /inventory/write-off-acts/data-for-act | Подгрузка номенклатур + остатков + себестоимости при выборе склада |
Сценарий 1. Загрузить таблицу списка
Запрос
GET /inventory/write-off-acts? status=DRAFT,POSTED& showDeleted=false& dateFrom=2026-04-01& dateTo=2026-04-30& subdivisionId=uuid1,uuid2& onlyOpenPeriod=true& search=WO-203& sortBy=documentDate& sortOrder=desc& offset=0& limit=50DTO: write-off-act-filter.dto.ts.
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
status | WriteOffActStatus[] | — | DRAFT / POSTED / DELETED. Массив или CSV |
showDeleted | bool | — | true — добавить DELETED в выборку |
search | string | — | По номеру документа. Макс. 100 |
dateFrom, dateTo | YYYY-MM-DD | — | Диапазон по documentDate (включительно, dateTo парсится как T23:59:59.999Z) |
subdivisionId[] | UUID[] | — | Мультивыбор склада. До 100 значений (Wave 3) |
onlyOpenPeriod | bool | — | Исключить документы из закрытого периода корпорации (Wave 4) |
offset, limit | int | — | Дефолт 0 / 50. Максимум — MAX_LIMIT |
sortBy | enum | — | Whitelist в WRITE_OFF_ACT_SORT_FIELDS. Дефолт documentDate |
sortOrder | asc|desc | — | Дефолт desc |
Нейминг периода:
dateFrom/dateTo, неdocumentDateFrom/documentDateToкак у инвойсов. Поведение одно (parseDateRangeFilter), ключи разные.
Ответ
{ "items": [ { "id": "uuid", "status": "POSTED", "documentNumber": "WO-203", "documentDate": "2026-04-13T00:00:00.000Z", "comment": null, "totalCost": "300.00", "warehouseId": "uuid", "warehouseName": "Кристи Мол", "accountId": "uuid", "account": { "id": "uuid", "code": "20.1", "name": "Списание брака" }, "items": [ { "id": "uuid", "name": "Мука пшеничная", "type": "GOODS" } ], "itemsSummary": "Мука пшеничная", "postedAt": "2026-04-13T14:30:00.000Z", "deletedAt": null, "createdAt": "2026-04-13T14:25:00.000Z", "createdByName": "Абуева Иман", "updatedAt": "2026-04-13T14:25:00.000Z", "updatedByName": null } ], "aggregates": { "count": 12, "totalCost": "4580.00" }, "pagination": { "offset": 0, "limit": 50, "returned": 12, "total": 12 }}Что фронту делать с полями строки
| Поле | Куда оно идёт в UI |
|---|---|
documentDate | Колонка «Дата». Брать только date-часть, не конвертировать в TZ |
documentNumber | Колонка «№ документа» |
items[] | Объектный массив {id, name, type} — иконка по type и тултип с полным перечнем |
itemsSummary | Колонка «Товары» — готовая сводка вида «Мука пшеничная, +3» (Wave 1) |
totalCost | Колонка «Сумма, ₽» |
warehouseName | Колонка «Склад» |
account: {id, code, name} | Колонка «Счёт списания». code отображается рядом с name (Wave 1) |
comment | Колонка «Комментарий» (nullable) |
status | Цвет строки (см. секцию ниже) |
deletedAt | Стиль строки (зачёркнутая) при showDeleted=true |
createdByName, updatedByName | Колонки toggle «История изменений» (Wave 1) |
createdAt, updatedAt | Колонки toggle |
Сценарий 2. Открыть карточку акта
Запрос
GET /inventory/write-off-acts/:idОтвет
WriteOffActDetailResponseDto (write-off-act.mapper.ts:128):
{ "id": "uuid", "status": "POSTED", "documentNumber": "WO-203", "documentDate": "2026-04-13T00:00:00.000Z", "comment": null, "totalCost": "300.00", "warehouseId": "uuid", "accountId": "uuid", "items": [ { "id": "uuid", "nomenclatureId": "uuid", "nomenclatureName": "Мука пшеничная", "nomenclatureType": "GOODS", "nomenclatureSku": "M-001", "measureUnitName": "кг", "packagingId": "uuid", "packagingName": "Кг", "quantity": "2.000", "costPrice": "150.00", "totalCost": "300.00" } ], "header": { "legalEntityName": "ООО Х", "warehouse": { "name": "Кристи Мол", "address": "..." }, "account": { "code": "20.1", "name": "Списание брака" } }, "postedAt": "2026-04-13T14:30:00.000Z", "deletedAt": null, "createdAt": "2026-04-13T14:25:00.000Z", "updatedAt": "2026-04-13T14:25:00.000Z"}header — снапшот для печати/отображения шапки карточки (юрлицо + склад с адресом + счёт). Не дублируется live-запросом.
Что фронту делать с полями позиции
| Поле | Куда оно идёт в UI |
|---|---|
nomenclatureSku | Колонка «Код товара» (nullable) |
nomenclatureName | Колонка «Товар» |
nomenclatureType | Колонка «Тип» (GOODS / DISH / SEMI_FINISHED / …) |
measureUnitName | Тултип ед. измерения (nullable) |
packagingId, packagingName | Колонка «Тип тары». packagingName = packaging.name ?? packaging.measurementUnit.shortName ?? null |
quantity | Колонка «Количество» |
costPrice | Колонка «Себестоимость ед., ₽» — подставлена бэком на момент проведения |
totalCost | Колонка «Себестоимость всего, ₽» (= quantity × costPrice) |
Сценарий 3. История изменений акта
Запрос
GET /inventory/write-off-acts/:id/historyОтвет
[ { "id": "uuid", "writeOffActId": "uuid", "action": "CREATE", "changes": null, "performedById": "uuid", "performedByName": "Абуева Иман", "createdAt": "2026-04-13T14:25:00.000Z" }, { "id": "uuid", "writeOffActId": "uuid", "action": "POST", "changes": "{\"status\":\"DRAFT→POSTED\"}", "performedById": "uuid", "performedByName": "Абуева Иман", "createdAt": "2026-04-13T14:30:00.000Z" }]action ∈ WriteOffActAuditAction (CREATE / UPDATE / POST / CANCEL_POST / DELETE / RESTORE). Сортировка — по убыванию createdAt.
Сценарий 4. Создать акт списания
Подгрузка данных для формы
GET /inventory/write-off-acts/data-for-act?warehouseId=<uuid>| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
warehouseId | UUID | ✅ | Склад акта. Должен принадлежать корпорации, иначе 400 |
Возвращает DataForActResponseDto (Wave 5) — общая структура с актом реализации:
items[]— номенклатуры с фасовками, текущим остатком (currentStock) и текущей себестоимостью (costPrice). Только позиции с положительным остатком.
Запрос на создание
POST /inventory/write-off-actsContent-Type: application/json
{ "documentNumber": "WO-203", "documentDate": "2026-04-13", "warehouseId": "uuid", "accountId": "uuid", "comment": null, "items": [ { "nomenclatureId": "uuid", "packagingId": "uuid", "quantity": 2 } ], "postAfterSave": false}DTO: create-write-off-act.dto.ts.
Поля Body — шапка
| Поле UI | Поле Body | Обяз. | Источник для селекта |
|---|---|---|---|
| Номер документа | documentNumber | — | Если пусто — генерируется DocumentNumberGenerator |
| Дата и время | documentDate | ✅ | ISO date |
| Склад | warehouseId | ✅ | GET /organization/subdivisions |
| На счёт | accountId | ✅ | GET /accounting/accounts?accountTypeId=<uuid> (счёт списания) |
| Комментарий | comment | — | nullable |
| ⬜ С проведением | postAfterSave | — | boolean. true → создать + провести в одной транзакции |
Поля Body — строка позиции
DTO: create-write-off-act-item.dto.ts.
| Колонка UI | Поле Body | Обяз. | Примечание |
|---|---|---|---|
| Товар | nomenclatureId | ✅ | UUID. Подгрузка — data-for-act.items[] |
| Тип тары | packagingId | — | UUID, nullable. null = базовая единица. Wave 7 — поле появилось в схеме |
| Количество | quantity | ✅ | > 0 |
Чего нет в Body: costPrice, comment по позиции — бэк берёт costPrice из текущего WarehouseStock.costPrice на момент проведения, комментарий по позиции в WriteOffActItem не предусмотрен (только общий comment на акт).
Подсказки для фронта в data-for-act.items[]:
| Колонка UI (подсказка) | Откуда |
|---|---|
| Код товара | data-for-act.items[].sku |
| Тип | data-for-act.items[].nomenclatureType |
| Себестоимость ед., ₽ (предпросмотр) | data-for-act.items[].costPrice |
| Себестоимость всего, ₽ (предпросмотр) | clientside quantity × costPrice |
| Остаток на складе | data-for-act.items[].currentStock |
Ответ
201 Created с WriteOffActDetailResponseDto. Если postAfterSave: true — status: "POSTED", иначе DRAFT.
Ошибки
| HTTP | Тело | Когда | Что делать в UI |
|---|---|---|---|
400 | {message:"Акт списания должен содержать хотя бы одну товарную позицию (не услугу)"} | Все позиции — услуги, при проведении | Подсветить вкладку |
400 | {message:"Недостаточно остатков на складе для номенклатуры \"X\". Доступно: …, требуется: …"} | Минус по складу при проведении | Тоаст с именем позиции |
400 | {message:"Себестоимость не определена для номенклатуры \"X\" на складе"} | На складе позиция есть, но costPrice = null (нетипично) | Тоаст — позиция не списывается без себестоимости |
400 | {message:"Невозможно изменить склад: акт содержит позиции. Удалите позиции перед сменой склада"} | PATCH /:id со сменой warehouseId при непустом items[] | Тоаст |
400 | {message:"Невозможно выполнить операцию: дата документа находится в заблокированном периоде"} | documentDate ≤ finalBlockingDate | Тоаст «Период заблокирован» |
400 | стандарт class-validator | Невалидные DTO | Подсветить поле |
401 | стандарт | Токен истёк | Refresh → login |
403 | стандарт | Чужая корпорация | Тоаст «Нет прав» |
404 | {message:"Акт списания не найден"} | Только для PATCH ... / DELETE ... | Закрыть форму, обновить список |
Сценарий 5. Обновить черновик
PATCH /inventory/write-off-acts/:idContent-Type: application/json
{ "documentDate": "2026-04-13", "accountId": "uuid", "comment": "Исправлено по просьбе менеджера", "items": [ { "nomenclatureId": "uuid", "packagingId": "uuid", "quantity": 3 } ]}DTO: update-write-off-act.dto.ts — PartialType(CreateWriteOffActDto) без postAfterSave. Передавать только изменённые поля.
PATCH /:id полностью замещает позиции при передаче items[]. Альтернатива — точечные операции (Сценарий 6).
Дополнительная ошибка
| HTTP | Тело | Когда |
|---|---|---|
400 | {message:"Невозможно изменить склад: акт содержит позиции. …"} | Смена warehouseId при непустом наборе |
Сценарий 6. Точечные операции с позициями
POST /inventory/write-off-acts/:id/itemsContent-Type: application/json
{ "nomenclatureId": "uuid", "packagingId": "uuid", "quantity": 1.5 }Добавить одну позицию в существующий DRAFT-акт. Ответ — обновлённый WriteOffActDetailResponseDto.
DELETE /inventory/write-off-acts/:id/items/:itemId → 204 No ContentУдалить одну позицию.
Дополнительные ошибки
| HTTP | Тело | Когда |
|---|---|---|
400 | {message:"Данная номенклатура уже добавлена в акт списания"} | Дубликат nomenclatureId |
400 | {message:"Невозможно добавить номенклатуру в акт списания: нет остатков на складе"} | WarehouseStock.quantity ≤ 0 |
400 | {message:"Недостаточно остатков на складе. Доступно: …, запрошено: …"} | quantity > stock.quantity |
400 | {message:"Себестоимость не определена для номенклатуры (id: …) на складе"} | WarehouseStock.costPrice = null |
404 | {message:"Позиция не найдена"} | DELETE /:id/items/:itemId для несуществующей |
Сценарий 7. Провести / отменить проведение
PATCH /inventory/write-off-acts/:id/postПровести черновик. Формирует расходный InventoryTransaction, обновляет WarehouseStock, генерирует проводку по accountId на сумму totalCost.
PATCH /inventory/write-off-acts/:id/cancel-postОткатить проведение. Восстанавливает остатки склада.
Ошибки
| HTTP | Тело | Когда |
|---|---|---|
400 | {message:"Акт списания уже проведён или не содержит позиций"} | Повторное проведение / items=[] |
400 | {message:"Акт списания не проведён"} | cancel-post для DRAFT |
400 | {message:"Невозможно выполнить операцию: дата документа находится в заблокированном периоде"} | Заблокированный период |
Сценарий 8. Удалить / восстановить / копировать
DELETE /inventory/write-off-acts/:id → 204 No ContentPATCH /inventory/write-off-acts/:id/restorePOST /inventory/write-off-acts/:id/copyDELETE — soft-delete (status: DELETED, deletedAt = now). Допускается только из DRAFT. Для POSTED — сначала cancel-post.
PATCH /:id/restore — снимает статус DELETED, возвращает в DRAFT.
POST /:id/copy — создаёт новый DRAFT-акт со склонированными полями шапки и позициями (с актуальной себестоимостью на момент копирования). Удалённый акт скопировать нельзя.
Ошибки
| HTTP | Тело | Когда |
|---|---|---|
400 | {message:"Невозможно скопировать удалённый акт списания"} | POST /:id/copy для DELETED |
404 | {message:"Акт списания не найден"} | Нет в корпорации |
Цвета и статусы
status | Цвет | Семантика |
|---|---|---|
DRAFT | серый | Черновик. Склад не затронут |
POSTED | зелёный | Проведено. Сформирован InventoryTransaction, обновлён WarehouseStock, есть проводка по счёту списания |
DELETED | красный / зачёркнутая | Soft-delete. В выборку при showDeleted=true |
Чипы в подвале — клиентский расчёт по items.status / deletedAt. Бэк отдаёт только aggregates.{count, totalCost}.
Селекторы
| Селектор | Эндпоинт | Параметры |
|---|---|---|
| Склад / Юрлицо / Подразделение | GET /organization/tree | ?search= |
| Склад (плоский) | GET /organization/subdivisions | — |
| На счёт (списания) | GET /accounting/accounts?accountTypeId=<uuid> | accountTypeId — UUID типа счёта (Wave 3) |
| Типы счетов | GET /accounting/account-types | для предзаполнения accountTypeId |
| Номенклатура (в строке) | data-for-act.items[] или GET /menu-management/nomenclatures/search | — |
Пресеты периода
| Пресет | dateFrom | dateTo |
|---|---|---|
| Открытый период (default) | начало текущего месяца | не передавать |
| Текущая неделя | пн 00:00 | вс 23:59:59 |
| Текущий месяц | 1-е 00:00 | последний день 23:59:59 |
| Произвольный | «С» | «По» |
onlyOpenPeriod=true — отдельный флаг, отсекает по finalBlockingDate.
Часовой пояс и формат времени
- API: ISO-8601 UTC.
documentDateпринимается какYYYY-MM-DD. - Колонка «Дата» в UI — date-часть
documentDate, без TZ-конвертации. createdAt/updatedAt/postedAt/deletedAt— отображать в таймзоне корпорации (CorporationResponseDto.timezone).
Открытые вопросы
- Persistence настроек колонок —
localStorageили server-side. - Формат
changesв истории — JSON-строка, не структурированная. UI решает: показывать как есть или парсить. - Сортировка по нестатическим колонкам — только whitelist
WRITE_OFF_ACT_SORT_FIELDS; стрелочки на остальных — либо убрать, либо client-side в пределах страницы.
Известные расхождения и план доработок
| Что | Задача |
|---|---|
| Все 8 волн правок по аудиту inventory закрыты | LOCALIOFFICE-966 |
Что работает уже сейчас и менять не нужно
- ✅ Композитное создание/обновление + точечные
POST :id/items,DELETE :id/items/:itemId. - ✅
postAfterSave: true— атомарныйcreate + post. - ✅ Серверный фильтр
subdivisionId[](Wave 3),onlyOpenPeriod(Wave 4). - ✅ Серверные агрегаты подвала (
count,totalCost) — по всему отфильтрованному набору. - ✅
account: {id, code, name}в list-row (Wave 1). - ✅
itemsSummary: stringв list-row (Wave 1). - ✅ Audit-поля
createdByName/updatedByName(Wave 1 — нейминг унифицирован). - ✅
packagingId+packagingNameв позиции (Wave 7). - ✅
data-for-act(Wave 5). - ✅ Soft-delete + restore + копирование.
- ✅
headerв detail-ответе — снапшот юрлица/склада/счёта.
Стилевое замечание
⚠️ Нейминг полей периода: dateFrom/dateTo (как у sales-acts), в инвойсах — documentDateFrom/documentDateTo. Поведение совпадает. Унификация — отдельным решением.