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

Акты списания

Что это

Акт списания — документ списания товаров/заготовок/блюд со склада на расходный счёт (бой, порча, истечение срока). Бухгалтер видит список актов, создаёт новый акт, выбирает склад и счёт списания, добавляет позиции (с фасовкой), сохраняет в черновик или сразу проводит, копирует существующие, удаляет и восстанавливает.

Себестоимость позиции при создании/проведении не задаётся фронтом — бэк подтягивает текущую средневзвешенную себестоимость склада на момент проведения. Фронт передаёт только 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Подгрузка номенклатур + остатков + себестоимости при выборе склада

write-off-act.controller.ts


Сценарий 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=50

DTO: write-off-act-filter.dto.ts.

ПараметрТипОбяз.Описание
statusWriteOffActStatus[]DRAFT / POSTED / DELETED. Массив или CSV
showDeletedbooltrue — добавить DELETED в выборку
searchstringПо номеру документа. Макс. 100
dateFrom, dateToYYYY-MM-DDДиапазон по documentDate (включительно, dateTo парсится как T23:59:59.999Z)
subdivisionId[]UUID[]Мультивыбор склада. До 100 значений (Wave 3)
onlyOpenPeriodboolИсключить документы из закрытого периода корпорации (Wave 4)
offset, limitintДефолт 0 / 50. Максимум — MAX_LIMIT
sortByenumWhitelist в WRITE_OFF_ACT_SORT_FIELDS. Дефолт documentDate
sortOrderasc|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"
}
]

actionWriteOffActAuditAction (CREATE / UPDATE / POST / CANCEL_POST / DELETE / RESTORE). Сортировка — по убыванию createdAt.


Сценарий 4. Создать акт списания

Подгрузка данных для формы

GET /inventory/write-off-acts/data-for-act?warehouseId=<uuid>
ПараметрТипОбяз.Описание
warehouseIdUUIDСклад акта. Должен принадлежать корпорации, иначе 400

Возвращает DataForActResponseDto (Wave 5) — общая структура с актом реализации:

  • items[] — номенклатуры с фасовками, текущим остатком (currentStock) и текущей себестоимостью (costPrice). Только позиции с положительным остатком.

Запрос на создание

POST /inventory/write-off-acts
Content-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
Дата и времяdocumentDateISO date
СкладwarehouseIdGET /organization/subdivisions
На счётaccountIdGET /accounting/accounts?accountTypeId=<uuid> (счёт списания)
Комментарийcommentnullable
⬜ С проведениемpostAfterSaveboolean. true → создать + провести в одной транзакции

Поля Body — строка позиции

DTO: create-write-off-act-item.dto.ts.

Колонка UIПоле BodyОбяз.Примечание
ТоварnomenclatureIdUUID. Подгрузка — data-for-act.items[]
Тип тарыpackagingIdUUID, 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: truestatus: "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/:id
Content-Type: application/json
{
"documentDate": "2026-04-13",
"accountId": "uuid",
"comment": "Исправлено по просьбе менеджера",
"items": [
{ "nomenclatureId": "uuid", "packagingId": "uuid", "quantity": 3 }
]
}

DTO: update-write-off-act.dto.tsPartialType(CreateWriteOffActDto) без postAfterSave. Передавать только изменённые поля.

PATCH /:id полностью замещает позиции при передаче items[]. Альтернатива — точечные операции (Сценарий 6).

Дополнительная ошибка

HTTPТелоКогда
400{message:"Невозможно изменить склад: акт содержит позиции. …"}Смена warehouseId при непустом наборе

Сценарий 6. Точечные операции с позициями

POST /inventory/write-off-acts/:id/items
Content-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 Content
PATCH /inventory/write-off-acts/:id/restore
POST /inventory/write-off-acts/:id/copy

DELETE — 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

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

ПресетdateFromdateTo
Открытый период (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. Поведение совпадает. Унификация — отдельным решением.