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

Акты реализации

Что это

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

Часть актов создаётся автоматически (события открытия/закрытия кассовой смены) — у них есть связь с CashShift и PosTerminal. Ручные акты создаются оператором через UI.

Себестоимость позиции при создании/проведении не задаётся фронтом — бэк подтягивает текущую средневзвешенную себестоимость склада. Фронт передаёт nomenclatureId, quantity, sellingPrice, опционально packagingId и discountPerUnit.

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

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

┌─────────────────────────────────────────────────────────────────────┐
│ Акты реализации 🔄 Обновить 📥 Download │
│ 🔍 Поиск Настройки колонок ⬜ История изменений │
│ Статус ▾ Склад ▾ За период [Открытый ▾] С [...] По [...] │
├─────────────────────────────────────────────────────────────────────┤
│ Дата Тип № Сумма Себест. Склад №касс №смены │
│ 13.11.25 АО 4208 79.90 4493.00 КРИСПИ 7 142 │
│ 13.11.25 АЗ 4209 100.00 60.00 КРИСПИ 7 142 │
├─────────────────────────────────────────────────────────────────────┤
│ ⬛ DRAFT 1 🟢 POSTED 7 🔴 DELETED 0 Кол-во: 8 │
│ Сумма: 749.30₽ Себест.: 51461.00₽ Скидка: 0.00₽ │
└─────────────────────────────────────────────────────────────────────┘
↓ клик «Создать» / клик по строке
┌─────────────────────────────────────────────────────────────────────┐
│ Новый акт реализации ✕ │
├─────────────────────────────────────────────────────────────────────┤
│ № документа [4208] Место списания (склад) [▾] │
│ Счёт выручки [▾] Расходный счёт [▾] │
│ Дата и время [13.11.25 14:30] Комментарий [...] │
│ ─────────────────────────────────────────────────────────────────── │
│ № Код Наимен. Тип Тара Кол-во Прод.цена Сумма Скидка ... │
│ 1 0001 Бургер DISH шт 1.000 79.900 79.90 0.00 │
│ ... │
│ Сумма: 79.90 Скидка всего: 0.00 Себест. всего: 4493.00 │
│ ⬜ С проведением [Отмена] [Сохранить] │
└─────────────────────────────────────────────────────────────────────┘

Базовое

  • Auth. Authorization: Bearer <access_token>. corporationId — из токена. /auth/* для refresh / login / switch-corporation.
  • Время. ISO-8601 UTC. documentDate принимается как YYYY-MM-DD либо ISO.
  • Деньги. Денежные значения возвращаются строками ("79.90"). В Body — number.
  • Оптимистическая блокировка. У SalesAct поля version нет — оптимистической блокировки не предусмотрено. На повторное проведение/двойное удаление бэк возвращает 400.
  • Soft-delete. status: DELETED (как у акта списания, в отличие от инвойсов с отдельным isDeleted).
  • Транзакционность. Все мутирующие операции — в единой БД-транзакции.

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

Префикс — /inventory/sales-acts.

МетодМаршрутКогда вызывается из UI
GET/inventory/sales-actsЗагрузка/обновление таблицы списка
GET/inventory/sales-acts/:idОткрытие карточки акта
GET/inventory/sales-acts/:id/historyТаб «История изменений» в карточке (Wave 6)
POST/inventory/sales-actsКнопка «Сохранить» при создании
PATCH/inventory/sales-acts/:idКнопка «Сохранить» при редактировании черновика
POST/inventory/sales-acts/:id/itemsТочечное добавление одной позиции
DELETE/inventory/sales-acts/:id/items/:itemIdТочечное удаление позиции
PATCH/inventory/sales-acts/:id/postДействие «Провести»
PATCH/inventory/sales-acts/:id/cancel-postДействие «Отменить проведение»
DELETE/inventory/sales-acts/:idДействие «Удалить»
PATCH/inventory/sales-acts/:id/restoreДействие «Восстановить»
POST/inventory/sales-acts/:id/copyДействие «Копировать»
POST/inventory/sales-acts/exportКнопка «Download» (xlsx)
GET/inventory/sales-acts/data-for-actПодгрузка номенклатур + остатков + себестоимости + продажной цены при выборе склада

sales-act.controller.ts


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

Запрос

GET /inventory/sales-acts?
status=DRAFT,POSTED&
showDeleted=false&
dateFrom=2026-04-01&
dateTo=2026-04-30&
subdivisionId=uuid1,uuid2&
onlyOpenPeriod=true&
search=4208&
sortBy=documentDate&
sortOrder=desc&
offset=0&
limit=50

DTO: sales-act-filter.dto.ts.

ПараметрТипОбяз.Описание
statusSalesActStatus[]DRAFT / POSTED / DELETED. Массив или CSV
showDeletedbooltrue — включить DELETED
searchstringПо номеру документа. Макс. 100
dateFrom, dateToYYYY-MM-DDДиапазон по documentDate (включительно)
subdivisionId[]UUID[]Мультивыбор склада (Wave 3)
onlyOpenPeriodboolИсключить заблокированный период (Wave 4)
offset, limitintДефолт 0 / 50
sortByenumWhitelist SALES_ACT_SORT_FIELDS. Дефолт documentDate
sortOrderasc|descДефолт desc

Ответ

{
"items": [
{
"id": "uuid",
"status": "POSTED",
"type": "AUTOMATIC_CLOSED",
"documentNumber": "4208",
"documentDate": "2026-04-13T00:00:00.000Z",
"comment": null,
"warehouseId": "uuid",
"warehouseName": "Кристи Мол",
"revenueAccountId": "uuid",
"revenueAccount": { "id": "uuid", "code": "1.1.1", "name": "Торг. сборы" },
"expenseAccountId": "uuid",
"expenseAccount": { "id": "uuid", "code": "2.1.1", "name": "Себестоимость" },
"paymentMethodId": null,
"posTerminalId": "uuid",
"posTerminalName": "7",
"shiftSessionId": "uuid",
"shiftNumber": 142,
"totalAmount": "79.900",
"totalDiscount": "0.000",
"totalCostPrice": "4493.000",
"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": 8,
"totalAmount": "749.300",
"totalDiscount": "0.000",
"totalCostPrice": "51461.000"
},
"pagination": { "offset": 0, "limit": 50, "returned": 8, "total": 8 }
}

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

ПолеКуда оно идёт в UI
documentDateКолонка «Дата». Date-часть, без TZ-конвертации
typeКолонка «Тип» (AUTOMATIC_OPEN → «АО», AUTOMATIC_CLOSED → «АЗ», MANUAL → «РУЧ»)
documentNumberКолонка «№ документа»
totalAmountКолонка «Сумма, ₽»
totalCostPriceКолонка «Себестоимость, ₽»
totalDiscountКолонка «Сумма скидки, ₽»
commentКолонка «Комментарий» (nullable)
warehouseName (warehouseId)Колонка «Склад»
posTerminalNameКолонка «Номер кассы» (nullable, снапшот на момент создания)
shiftNumberКолонка «Номер смены» (nullable, из связи SalesAct.cashShift, Wave 1)
revenueAccount: {id, code, name}Колонка «Счёт выручки» (Wave 1)
expenseAccount: {id, code, name}Колонка «Расходный счёт» (Wave 1)
derivedКолонка «Доп. информация» — фронт собирает из posTerminalName + shiftNumber (отдельного поля у бэка нет)
statusЦвет строки. deletedAt → стиль зачёркнутой
createdByName, updatedByNameКолонки toggle «История изменений» (Wave 1)
createdAt, updatedAtКолонки toggle

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

Запрос

GET /inventory/sales-acts/:id

Ответ

SalesActDetailResponseDto (sales-act.mapper.ts:183):

{
"id": "uuid",
"status": "POSTED",
"type": "MANUAL",
"documentNumber": "4208",
"documentDate": "2026-04-13T00:00:00.000Z",
"comment": null,
"warehouseId": "uuid",
"revenueAccountId": "uuid",
"expenseAccountId": "uuid",
"paymentMethodId": null,
"posTerminalId": null,
"shiftSessionId": null,
"warehouseName": "Кристи Мол",
"posTerminalName": null,
"totalAmount": "79.900",
"totalDiscount": "0.000",
"totalCostPrice": "4493.000",
"items": [
{
"id": "uuid",
"nomenclatureId": "uuid",
"nomenclatureName": "Бургер",
"nomenclatureType": "DISH",
"nomenclatureSku": "0001",
"measureUnitName": "шт",
"packagingId": null,
"packagingName": null,
"quantity": "1.000",
"sellingPrice": "79.900",
"totalPrice": "79.900",
"discountPerUnit": "0.000",
"totalDiscount": "0.000",
"costPrice": "4493.000",
"totalCost": "4493.000",
"vatRate": "20"
}
],
"header": {
"legalEntityName": "ООО Х",
"subdivisionName": "Кристи Мол",
"posTerminalName": null
},
"postedAt": "2026-04-13T14:30:00.000Z",
"deletedAt": null,
"createdAt": "2026-04-13T14:25:00.000Z",
"updatedAt": "2026-04-13T14:25:00.000Z"
}

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

ПолеКуда оно идёт в UI
nomenclatureSkuКолонка «Код» (nullable)
nomenclatureNameКолонка «Наименование»
nomenclatureTypeКолонка «Тип»
measureUnitNameТултип ед. измерения (nullable)
packagingId, packagingNameКолонка «Тип тары» (nullable, null = базовая) — Wave 7
quantityКолонка «Кол-во»
sellingPriceКолонка «Стоимость ед., ₽» (продажная цена). Wave 7 — хранится в SalesActItem
totalPriceКолонка «Сумма, ₽» — derived = quantity × sellingPrice − totalDiscount
discountPerUnitКолонка «Скидка ₽» (за единицу)
totalDiscountКолонка «Скидка всего, ₽» — derived = quantity × discountPerUnit
costPriceКолонка «Себестоимость ед., ₽» — снапшот с момента проведения
totalCostКолонка «Себестоимость всего, ₽» — derived = quantity × costPrice
vatRateСтавка НДС позиции

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

Запрос

GET /inventory/sales-acts/:id/history

Ответ

Записи журнала изменений (sales_act_audit_logs) по аналогии с накладными и актами списания. Для автоматических актов из кассы (AUTOMATIC_OPEN/AUTOMATIC_CLOSED) исполнитель — системный пользователь, источник действия отражён в поле changes (source: "kassa-receipt" / "cash-shift-closed").

[
{
"id": "uuid",
"salesActId": "uuid",
"action": "CREATED",
"changes": "{\"documentNumber\":\"АР-000001\",\"documentDate\":\"2026-04-13T00:00:00.000Z\",\"type\":\"MANUAL\",\"warehouseId\":\"uuid\",\"itemsCount\":2}",
"performedById": "uuid",
"performedByName": "Абуева Иман",
"createdAt": "2026-04-13T14:25:00.000Z"
},
{
"id": "uuid",
"salesActId": "uuid",
"action": "POSTED",
"changes": null,
"performedById": "uuid",
"performedByName": "Абуева Иман",
"createdAt": "2026-04-13T14:30:00.000Z"
}
]

action"CREATED" \| "UPDATED" \| "POSTED" \| "CANCEL_POSTED" \| "DELETED" \| "RESTORED" \| "ITEM_ADDED" \| "ITEM_REMOVED". Сортировка — по возрастанию createdAt. changes — JSON-строка со сводкой изменений (для UPDATED — diff шапки и позиций, для ITEM_ADDED / ITEM_REMOVED — id позиции, номенклатуры, количества), либо null для action без detail-payload (POSTED, CANCEL_POSTED, DELETED, RESTORED).


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

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

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

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

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

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

POST /inventory/sales-acts
Content-Type: application/json
{
"documentNumber": "4208",
"documentDate": "2026-04-13",
"warehouseId": "uuid",
"revenueAccountId": "uuid",
"expenseAccountId": "uuid",
"paymentMethodId": null,
"posTerminalId": null,
"shiftSessionId": null,
"type": "MANUAL",
"comment": null,
"items": [
{
"nomenclatureId": "uuid",
"packagingId": null,
"quantity": 1,
"sellingPrice": 79.90,
"discountPerUnit": 0
}
],
"postAfterSave": false
}

DTO: create-sales-act.dto.ts.

Поля Body — шапка

Поле UIПоле BodyОбяз.Источник для селекта
Номер документаdocumentNumberЕсли пусто — генерируется
Дата и времяdocumentDateISO date
Место списания (склад)warehouseIdGET /organization/subdivisions. Подтверждено на фронте — селект склада в шапке формы и обязательное warehouse_id в payload
Счёт выручкиrevenueAccountIdGET /accounting/accounts?accountTypeId=<доход>
Расходный счётexpenseAccountIdGET /accounting/accounts?accountTypeId=<расход>
Метод оплатыpaymentMethodIdnullable, ссылка на PaymentMethod
POS-терминалposTerminalIdnullable. Для автоматических актов проставляется бэком
Кассовая сменаshiftSessionIdnullable. Для автоматических актов проставляется бэком
Типtypeenum SalesActType. Дефолт MANUAL (для ручного создания)
Комментарийcommentnullable
⬜ С проведениемpostAfterSaveboolean. Атомарный create + post

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

DTO: create-sales-act-item.dto.ts.

Колонка UIПоле BodyОбяз.Примечание
НаименованиеnomenclatureIdUUID. Подгрузка — data-for-act.items[]
Тип тарыpackagingIdUUID, nullable. null = базовая единица. Wave 7
Кол-воquantity> 0
Стоимость ед., ₽ (продажная)sellingPrice> 0.01
Скидка ₽ (за ед.)discountPerUnit≥ 0, nullable

Чего нет в Body: costPrice, vatRate, totalCost. Бэк подтягивает costPrice из текущего WarehouseStock.costPrice склада при проведении. vatRate хранится на номенклатуре, в DTO позиции не передаётся.

Подсказки для фронта в data-for-act.items[]:

Колонка UI (подсказка)Откуда
Кодdata-for-act.items[].sku
Типdata-for-act.items[].nomenclatureType
Стоимость ед. (предзаполнение)data-for-act.items[].sellingPrice
Себестоимость ед. (предпросмотр)data-for-act.items[].costPrice
Сумма (предпросмотр)clientside quantity × sellingPrice − quantity × discountPerUnit
Себестоимость всего (предпросмотр)clientside quantity × costPrice
Остаток на складеdata-for-act.items[].currentStock

Ответ

201 Created с SalesActDetailResponseDto. При postAfterSave: truestatus: "POSTED", иначе DRAFT.

Ошибки

HTTPТелоКогдаЧто делать в UI
400{message:"Акт продажи должен содержать хотя бы одну товарную позицию"}Пустой items[] при проведенииПодсветить таблицу
400{message:"Недостаточно остатков на складе для номенклатуры \"X\". Доступно: …, требуется: …"}Минус по складу при проведенииТоаст с именем позиции
400{message:"Себестоимость не определена для номенклатуры \"X\" на складе"}WarehouseStock.costPrice = nullТоаст
400{message:"Невозможно изменить склад: акт содержит позиции. Удалите позиции перед сменой склада"}PATCH /:id со сменой warehouseId при непустом items[]Тоаст
400{message:"Невозможно выполнить операцию: дата документа находится в заблокированном периоде"}documentDate ≤ finalBlockingDateТоаст
400стандарт class-validatorНевалидный DTO, sellingPrice < 0.01, плохой UUIDПодсветить поле
401стандартRefresh → login
403стандартЧужая корпорация
404{message:"Акт реализации не найден"}Для PATCH ... / DELETE ...Закрыть форму

Сценарий 5. Обновить черновик

PATCH /inventory/sales-acts/:id
Content-Type: application/json
{ /* UpdateSalesActDto: PartialType(CreateSalesActDto) без postAfterSave */ }

Передавать только изменённые поля. Передача items[] — полная замена набора позиций.

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

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

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

POST /inventory/sales-acts/:id/items
Content-Type: application/json
{
"nomenclatureId": "uuid",
"packagingId": null,
"quantity": 1,
"sellingPrice": 79.90,
"discountPerUnit": 0
}
DELETE /inventory/sales-acts/:id/items/:itemId → 204 No Content

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

HTTPТелоКогда
400{message:"Данная номенклатура уже добавлена в акт реализации"}Дубликат nomenclatureId
404{message:"Позиция не найдена"}DELETE для несуществующей

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

PATCH /inventory/sales-acts/:id/post
PATCH /inventory/sales-acts/:id/cancel-post

Провести: формирует расходный InventoryTransaction, обновляет WarehouseStock, генерирует проводки по revenueAccountId (выручка) и expenseAccountId (себестоимость).

Отменить проведение: восстанавливает остатки склада, откатывает проводки.

Ошибки

HTTPТелоКогда
400{message:"Акт реализации уже проведён или не содержит позиций"}Повторное проведение / items=[]
400{message:"Акт реализации не проведён"}cancel-post для DRAFT
400{message:"Недостаточно остатков на складе для номенклатуры \"X\". …"}При проведении
400{message:"Невозможно выполнить операцию: дата документа находится в заблокированном периоде"}Заблокированный период

Сценарий 8. Удалить / восстановить / копировать

DELETE /inventory/sales-acts/:id → 204 No Content
PATCH /inventory/sales-acts/:id/restore
POST /inventory/sales-acts/:id/copy

DELETE — soft-delete (status: DELETED). Только из DRAFT.

PATCH /:id/restore — снимает DELETED, возвращает в DRAFT.

POST /:id/copy — новый DRAFT-акт со склонированными полями шапки и позициями (с актуальными sellingPrice / costPrice).

Ошибки

HTTPТелоКогда
400{message:"Невозможно скопировать удалённый акт реализации"}:id/copy для DELETED
404{message:"Акт реализации не найден"}Нет в корпорации

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

POST /inventory/sales-acts/export
Content-Type: application/json
{
"status": ["POSTED"],
"dateFrom": "2026-04-01",
"dateTo": "2026-04-30",
"subdivisionId": ["uuid"],
"columns": [
"documentDate",
"documentNumber",
"type",
"totalAmount",
"totalCostPrice",
"totalDiscount",
"warehouseName",
"posTerminalName",
"shiftNumber"
]
}

DTO — SalesActExportDto. Ответ — .xlsx (attachment sales-acts-YYYY-MM-DD.xlsx).


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

SalesActStatus:

statusЦветСемантика
DRAFTсерыйЧерновик
POSTEDзелёныйПроведено
DELETEDкрасный / зачёркнутаяSoft-delete

SalesActType:

typeОткуда
AUTOMATIC_OPENАвто-создание при открытии кассовой смены
AUTOMATIC_CLOSEDАвто-создание при закрытии кассовой смены
MANUALСоздано вручную через UI

Автоматические акты обычно открываются read-only до отмены проведения; UI решает, давать ли редактирование на основе type === "MANUAL".


Селекторы

СелекторЭндпоинтПараметры
Склад / Юрлицо / ПодразделениеGET /organization/tree?search=
Склад (плоский, для формы)GET /organization/subdivisions— — селект «Место списания» в форме
Счёт выручкиGET /accounting/accounts?accountTypeId=<доход>accountTypeId
Расходный счётGET /accounting/accounts?accountTypeId=<расход>accountTypeId
Метод оплатыGET /purchasing/payment-methods
Номенклатура (в строке)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.
  • Колонка «Дата» — date-часть, без TZ-конвертации.
  • createdAt / updatedAt / postedAt / deletedAt — отображать в таймзоне корпорации.

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

  • Persistence настроек колонокlocalStorage или server-side.
  • AUTOMATIC_* редактируемость — UI решает, давать ли редактирование. Бэк не запрещает PATCH для автоматических, но смысл правки руками — открытый вопрос продакта.

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

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

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

  • ✅ Композитное создание/обновление + точечные POST :id/items, DELETE :id/items/:itemId.
  • postAfterSave: true — атомарный create + post.
  • ✅ Серверный фильтр subdivisionId[] (Wave 3), onlyOpenPeriod (Wave 4).
  • ✅ Серверные агрегаты подвала (count, totalAmount, totalDiscount, totalCostPrice) — по всему отфильтрованному набору.
  • revenueAccount + expenseAccount (объекты с code) в list-row (Wave 1).
  • posTerminalName + shiftNumber в list-row (Wave 1).
  • ✅ Audit-поля createdByName / updatedByName (Wave 1).
  • packagingId + packagingName в позиции (Wave 7).
  • sellingPrice + discountPerUnit хранятся в SalesActItem (Wave 7). Derived totalPrice, totalDiscount считаются на чтении.
  • GET /inventory/sales-acts/:id/history (Wave 6) — чтение из таблицы sales_act_audit_logs (LOCALIOFFICE-1070).
  • data-for-act (Wave 5).
  • ✅ Экспорт в Excel.
  • ✅ Селект склада в форме + обязательный warehouseId в Body (подтверждено фронт-кодом locali-office-admin/src/features/createSalesActs/headerActions/index.tsx).

Стилевое замечание

⚠️ Нейминг полей периода: dateFrom/dateTo (как у write-off), в инвойсах — documentDateFrom/documentDateTo. Поведение совпадает.