Акты реализации
Что это
Акт реализации — документ продажи (реализации) товаров/блюд/заготовок со склада с разнесением выручки и себестоимости по соответствующим счетам. Бухгалтер видит список актов, создаёт новый акт, выбирает склад, счёт выручки и расходный счёт, добавляет позиции, сохраняет в черновик или сразу проводит, копирует, удаляет, восстанавливает, открывает историю аудит-событий и экспортирует выборку в 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 | Подгрузка номенклатур + остатков + себестоимости + продажной цены при выборе склада |
Сценарий 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=50DTO: sales-act-filter.dto.ts.
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
status | SalesActStatus[] | — | DRAFT / POSTED / DELETED. Массив или CSV |
showDeleted | bool | — | true — включить DELETED |
search | string | — | По номеру документа. Макс. 100 |
dateFrom, dateTo | YYYY-MM-DD | — | Диапазон по documentDate (включительно) |
subdivisionId[] | UUID[] | — | Мультивыбор склада (Wave 3) |
onlyOpenPeriod | bool | — | Исключить заблокированный период (Wave 4) |
offset, limit | int | — | Дефолт 0 / 50 |
sortBy | enum | — | Whitelist SALES_ACT_SORT_FIELDS. Дефолт documentDate |
sortOrder | asc|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>| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
warehouseId | UUID | ✅ | Склад акта. Должен принадлежать корпорации |
Возвращает DataForActResponseDto (Wave 5) — общая структура с актом списания:
items[]— номенклатуры с фасовками, текущим остатком (currentStock), текущей себестоимостью (costPrice) и (для sales-act) текущей продажной ценой из прайс-листа.
Запрос на создание
POST /inventory/sales-actsContent-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 | — | Если пусто — генерируется |
| Дата и время | documentDate | ✅ | ISO date |
| Место списания (склад) | warehouseId | ✅ | GET /organization/subdivisions. Подтверждено на фронте — селект склада в шапке формы и обязательное warehouse_id в payload |
| Счёт выручки | revenueAccountId | ✅ | GET /accounting/accounts?accountTypeId=<доход> |
| Расходный счёт | expenseAccountId | ✅ | GET /accounting/accounts?accountTypeId=<расход> |
| Метод оплаты | paymentMethodId | — | nullable, ссылка на PaymentMethod |
| POS-терминал | posTerminalId | — | nullable. Для автоматических актов проставляется бэком |
| Кассовая смена | shiftSessionId | — | nullable. Для автоматических актов проставляется бэком |
| Тип | type | — | enum SalesActType. Дефолт MANUAL (для ручного создания) |
| Комментарий | comment | — | nullable |
| ⬜ С проведением | postAfterSave | — | boolean. Атомарный create + post |
Поля Body — строка позиции
DTO: create-sales-act-item.dto.ts.
| Колонка UI | Поле Body | Обяз. | Примечание |
|---|---|---|---|
| Наименование | nomenclatureId | ✅ | UUID. Подгрузка — data-for-act.items[] |
| Тип тары | packagingId | — | UUID, 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: true → status: "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/:idContent-Type: application/json
{ /* UpdateSalesActDto: PartialType(CreateSalesActDto) без postAfterSave */ }Передавать только изменённые поля. Передача items[] — полная замена набора позиций.
Дополнительная ошибка
| HTTP | Тело | Когда |
|---|---|---|
400 | {message:"Невозможно изменить склад: акт содержит позиции. …"} | Смена warehouseId при непустом наборе |
Сценарий 6. Точечные операции с позициями
POST /inventory/sales-acts/:id/itemsContent-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/postPATCH /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 ContentPATCH /inventory/sales-acts/:id/restorePOST /inventory/sales-acts/:id/copyDELETE — 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/exportContent-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 | — |
Пресеты периода
| Пресет | 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. - Колонка «Дата» — 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). DerivedtotalPrice,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. Поведение совпадает.