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

Накладные

Что это

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

Накладная — основной источник входящих остатков склада (приходная) и расхода со склада на контрагента (расходная). Проведение — формирование InventoryTransaction’ов и обновление WarehouseStock. Все операции транзакционны: либо отрабатывает всё, либо ничего не меняется.

Связанная веха: LOCALIOFFICE-966 — Inventory: аудит бизнес-логики. Контракт зафиксирован после прохода 8 волн правок (мапперы, серверные агрегаты, фильтры, onlyOpenPeriod, data-for-invoice под INCOMING, packagingId, sellingPrice в актах, печать).

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

┌─────────────────────────────────────────────────────────────────────┐
│ Накладные 🔄 Обновить 📥 Download │
│ 🔍 Поиск Настройки колонок ⬜ История изменений │
│ Тип ▾ Статус ▾ Контрагент ▾ Юр.лицо ▾ Склад ▾ │
│ За период [Открытый период ▾] С [DD.MM.YYYY] По [DD.MM.YYYY] │
├─────────────────────────────────────────────────────────────────────┤
│ Тип Дата Поставщик/Покуп. № Сумма Юр.лицо Склад │
│ ПР 13.11.25 Eurasia Mining 2148 1200 ООО Х КРИСПИ │
│ РС 13.11.25 ИП Иванов 0103 500 ООО Х КРИСПИ │
│ ... │
├─────────────────────────────────────────────────────────────────────┤
│ ⬛ DRAFT 2 🟢 POSTED 5 🔴 DELETED 0 Кол-во: 7 Сумма: 8400₽ │
└─────────────────────────────────────────────────────────────────────┘
↓ клик «Создать» / клик по строке
┌─────────────────────────────────────────────────────────────────────┐
│ Новая приходная накладная Действия ▾ [Скачать PDF] ✕ │
├─────────────────────────────────────────────────────────────────────┤
│ № документа [...] Юр.лицо [▾] Контрагент [▾] Склад [▾] │
│ Дата [DD.MM.YYYY] Счёт-фактура № [...] От [DD.MM.YYYY] │
│ Входящий № [...] Срок оплаты [DD.MM.YYYY] Комментарий [...] │
│ ─────────────────────────────────────────────────────────────────── │
│ [Товары] [Услуги] [Заготовки] ← вкладки │
│ ┌─ Товары ────────────────────────────────────────────────────────┐ │
│ │ № Код Наимен. Упак. Кол-во Всего ед. Факт ед. Цена … │ │
│ │ 1 M-001 Мука... мешок 5 50 50 20.00… │ │
│ │ ... │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ Общая сумма: 1200.00 ₽ В т.ч. НДС: 200.00 ₽ │
│ ⬜ С проведением ⬜ Перепроводить с пересчётом │
│ [Отмена] [Сохранить] │
└─────────────────────────────────────────────────────────────────────┘

Базовое

  • Auth. Все запросы — Authorization: Bearer <access_token>. ID корпорации берётся из токена. Получение/обновление — POST /auth/login, POST /auth/refresh, POST /auth/switch-corporation, POST /auth/logout. На 401 — попытка refresh, при повторном 401 — редирект на login.
  • Время. Все даты/времена в API — ISO-8601 в UTC. documentDate принимается также в укороченном формате YYYY-MM-DD (бэк парсит как T00:00:00Z).
  • Деньги. Все денежные значения возвращаются строками ("1200.00") для сохранения точности. Парсить в Decimal-библиотеку фронта. В Body передавать как number (см. DTO).
  • Оптимистическая блокировка. У Invoice поля version нет — оптимистической блокировки на уровне накладной не предусмотрено. Конфликты параллельной правки не считаются реальными для бизнес-сценария (одна накладная редактируется одним пользователем). При попытке отменить проведение или удалить уже-удалённую накладную сервер вернёт 400 с понятным сообщением — фронту достаточно перезагрузить состояние через GET /:id.
  • Soft-delete. Накладные не удаляются физически — выставляется isDeleted = true и deletedAt. Из выборки убираются по дефолту, возвращаются через ?showDeleted=true. Restore — отдельный эндпоинт.
  • Транзакционность. Все мутирующие операции (POST, PUT, :id/post, :id/cancel-post, :id/repost, DELETE, :id/restore) выполняются в единой БД-транзакции. При падении любой шага — откат целиком.

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

Префикс — /inventory/invoices.

МетодМаршрутКогда вызывается из UI
GET/inventory/invoicesЗагрузка/обновление таблицы списка
GET/inventory/invoices/:idОткрытие карточки накладной
GET/inventory/invoices/:id/historyОткрытие таба «История изменений» в карточке
GET/inventory/invoices/:id/pdfДействия → «Скачать PDF»
POST/inventory/invoicesКнопка «Сохранить» при создании
PUT/inventory/invoices/:idКнопка «Сохранить» при редактировании черновика
PATCH/inventory/invoices/:id/postДействие «Провести» (если уже создано)
PATCH/inventory/invoices/:id/cancel-postДействие «Отменить проведение»
PATCH/inventory/invoices/:id/repostЧекбокс «Перепроводить с пересчётом цены отгрузки» (после сохранения)
DELETE/inventory/invoices/:idДействие «Удалить»
PATCH/inventory/invoices/:id/restoreДействие «Восстановить» (для удалённых)
POST/inventory/invoices/exportКнопка «Download» (xlsx)
GET/inventory/invoices/data-for-invoiceПодгрузка номенклатур + остатков + цен при выборе склада в форме
GET/inventory/invoices/last-priceПодсветка дефолтной цены для одной номенклатуры (после выбора в строке)
POST/inventory/invoices/invoice-completion-dataАвтозаполнение позиций по техкарте (расходная — заготовки)

invoice.controller.ts


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

Запрос

GET /inventory/invoices?
type=INCOMING,OUTGOING&
status=DRAFT,POSTED&
documentDateFrom=2026-04-01&
documentDateTo=2026-04-30&
subdivisionId=uuid1,uuid2&
legalEntityId=uuid3&
counterpartyId=uuid4&
onlyOpenPeriod=true&
showDeleted=false&
search=2148&
sortBy=documentDate&
sortOrder=desc&
offset=0&
limit=50

DTO: invoice-filter.dto.ts.

ПараметрТипОбяз.Описание
typeInvoiceType[]INCOMING / OUTGOING. Массив (?type=INCOMING&type=OUTGOING) или CSV
statusInvoiceStatus[]DRAFT / POSTED. DELETED не в enum — для удалённых showDeleted
showDeletedbooltrue — включить isDeleted=true
searchstringПо номеру документа, внешнему номеру, контрагенту, наименованиям позиций. Макс. 100 символов
documentDateFrom, documentDateToYYYY-MM-DDДиапазон по documentDate (включительно)
subdivisionId[]UUID[]Мультивыбор склада. До 100 значений
legalEntityId[]UUID[]Мультивыбор юрлица. До 100 значений
counterpartyId[]UUID[]Мультивыбор контрагента. До 100 значений
onlyOpenPeriodboolИсключить документы из закрытого периода (documentDate ≤ finalBlockingDate). При null finalBlockingDate в корпорации — no-op
offset, limitintДефолт 0 / 50. Максимум limit ограничен MAX_LIMIT (100)
sortByenumWhitelist в INVOICE_SORT_FIELDS (invoice.repository.ts). Дефолт documentDate
sortOrderasc|descДефолт desc

Ответ

{
"items": [
{
"id": "uuid",
"type": "INCOMING",
"status": "POSTED",
"documentDate": "2026-04-13T00:00:00.000Z",
"documentNumber": "2148",
"counterpartyName": "Eurasia Mining Company",
"counterpartyId": "uuid",
"itemNames": "Мука пшеничная, Курганская...",
"legalEntityName": "ООО Х",
"legalEntityId": "uuid",
"warehouseName": "Кристи Мол",
"warehouseId": "uuid",
"externalDocumentNumber": "2147",
"invoiceNumber": null,
"invoiceDate": null,
"comment": null,
"totalWithoutVat": "1000.00",
"totalVat": "200.00",
"totalWithVat": "1200.00",
"dueDate": null,
"paidAmount": "0.00",
"paymentStatus": "UNPAID",
"hasQuantityDiscrepancies": false,
"isDeleted": false,
"deletedAt": null,
"createdAt": "2026-04-13T10:00:00.000Z",
"createdByName": "Абуева Иман",
"updatedAt": "2026-04-13T10:00:00.000Z",
"updatedByName": null,
"serviceCostDistribution": "NONE"
}
],
"aggregates": {
"count": 7,
"draftCount": 2,
"postedCount": 5,
"deletedCount": 0,
"totalWithVat": "8400.00",
"totalVat": "1400.00",
"totalCostWithVat": "8400.00",
"totalCostWithoutVat": "7000.00",
"totalDiscount": "0.00"
},
"pagination": { "offset": 0, "limit": 50, "returned": 7, "total": 7 }
}

aggregates считается по всему отфильтрованному набору, не по странице — итоги в подвале корректны при бесконечном скролле.

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

ПолеКуда оно идёт в UI
typeКолонка «Тип» (INCOMING → «Приходная», OUTGOING → «Расходная»)
documentDateКолонка «Дата». Брать только date-часть (YYYY-MM-DD), не конвертировать в локальный TZ — иначе документ за 31 число «уедет» в 1 число соседнего месяца
counterpartyNameКолонка «Поставщик/Покупатель». counterpartyId — для drill-down в карточку контрагента
documentNumberКолонка «№ док.»
itemNamesКолонка «Доп. информация». Первые 3 позиции через запятую (ITEM_NAMES_PREVIEW_LIMIT = 3); если позиций больше — оборвать с многоточием на стороне фронта
totalWithVatКолонка «Сумма, ₽» (с НДС). Парсить как Decimal
totalVat, totalWithoutVatКолонки «Сумма НДС, ₽» / «Сумма без НДС, ₽»
legalEntityNameКолонка «Юр. лицо»
commentКолонка «Комментарий» (nullable)
warehouseNameКолонка «Склад»
externalDocumentNumberКолонка «Входящий номер» (nullable)
hasQuantityDiscrepanciesИконка-предупреждение в колонке «Расхождения по кол-ву». Бэк считает по items.some(i => i.actualQuantity != null && !i.actualQuantity.eq(i.totalQuantity))
paymentStatusОкраска правой части строки (если в UI есть колонка статуса оплаты). См. «Приём платежей»
createdByName, updatedByNameКолонки «Кто создал/изменил». Показываются при toggle «История изменений». ФИО либо login (User.name приоритетнее)
createdAt, updatedAtКолонки «Дата создания/изменения». Показываются при toggle
isDeleted, deletedAtСтиль строки (зачёркнутая / красная) при showDeleted=true

Toggle «История изменений» — клиентский, разворачивает 4 колонки уже из имеющегося ответа, доп. запроса не делает.


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

Запрос

GET /inventory/invoices/:id

Возвращает InvoiceDetailResponseDto (invoice.mapper.ts:204) — все поля списка плюс items сгруппированы по типу номенклатуры и totalDiscount.

Ответ

{
"id": "uuid",
"type": "INCOMING",
"status": "POSTED",
"documentDate": "2026-04-13T00:00:00.000Z",
"documentNumber": "2148",
"counterpartyId": "uuid",
"legalEntityId": "uuid",
"warehouseId": "uuid",
"externalDocumentNumber": "2147",
"invoiceNumber": "СФ-001",
"invoiceDate": "2026-04-13T00:00:00.000Z",
"dueDate": "2026-04-20T00:00:00.000Z",
"comment": null,
"serviceCostDistribution": "NONE",
"totalWithoutVat": "1000.00",
"totalVat": "200.00",
"totalWithVat": "1200.00",
"totalDiscount": "0.00",
"paidAmount": "0.00",
"paymentStatus": "UNPAID",
"isDeleted": false,
"items": {
"GOODS": [
{
"id": "uuid",
"nomenclatureId": "uuid",
"nomenclatureName": "Мука пшеничная",
"nomenclatureType": "GOODS",
"nomenclatureSku": "M-001",
"packagingUnitId": "uuid",
"packagingUnitName": "Мешок 50 кг",
"packagingUnitShortName": "меш",
"packageQuantity": "5",
"totalQuantity": "50.000",
"actualQuantity": "50.000",
"priceWithoutVat": "20.00",
"vatRate": "20",
"sumWithoutVat": "1000.00",
"vatSum": "200.00",
"sumWithVat": "1200.00",
"additionalCosts": "0.00",
"discount": null,
"writeOffCoefficient": null,
"costPerUnit": "20.00",
"totalCost": "1000.00",
"stockBefore": null,
"stockAfter": "50.000"
}
],
"SEMI_FINISHED": [],
"SERVICE": []
},
"createdAt": "2026-04-13T10:00:00.000Z",
"updatedAt": "2026-04-13T10:00:00.000Z"
}

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

ПолеКуда оно идёт в UI
nomenclatureSkuКолонка «Код» (nullable)
nomenclatureNameКолонка «Наименование»
nomenclatureTypeКуда раскладывать позицию по вкладкам: GOODS → Товары, SEMI_FINISHED → Заготовки, SERVICE → Услуги. Бэк уже сгруппировал в items.{GOODS, SEMI_FINISHED, SERVICE} — фронту достаточно рендерить три массива в три вкладки
packagingUnitName / packagingUnitShortNameКолонка «Упаковка»
packageQuantityКолонка «Кол-во упаковок»
totalQuantityКолонка «Всего ед.» (в базовых единицах)
actualQuantityКолонка «Фактическое кол-во ед.». Видна только для INCOMING + GOODS/SEMI_FINISHED. null для услуг и расходных
priceWithoutVatКолонка «Цена за ед. без НДС»
vatRateКолонка «Ставка НДС, %». Хардкод набор 0/10/20 на фронте
sumWithoutVat, vatSum, sumWithVatDerived. Бэк пересчитывает
additionalCostsРаспределяется бэком по serviceCostDistribution. Фронт значение по позиции не передаёт
discount, writeOffCoefficientТолько для SEMI_FINISHED и SERVICE (nullable)
costPerUnit, totalCostДля приходных услуг/заготовок задаётся пользователем; для расходных — проставляется бэком при проведении
stockBeforeЗаполнено только для DRAFT — снапшот текущего WarehouseStock.balance. На форме редактирования черновика подсвечивает остаток до проведения
stockAfterЗаполнено только для POSTED — снапшот остатка после проведения. На карточке проведённой накладной

В detail-ответе не приходят counterpartyName / legalEntityName / warehouseName / createdByName (только ID-шники). Если карточке нужны имена — тянуть отдельным запросом по соответствующим селекторам.


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

Запрос

GET /inventory/invoices/:id/history

Ответ

[
{
"id": "uuid",
"invoiceId": "uuid",
"action": "CREATE",
"changes": null,
"performedById": "uuid",
"performedByName": "Абуева Иман",
"createdAt": "2026-04-13T10:00:00.000Z"
},
{
"id": "uuid",
"invoiceId": "uuid",
"action": "POST",
"changes": "{\"status\":\"DRAFT→POSTED\"}",
"performedById": "uuid",
"performedByName": "Абуева Иман",
"createdAt": "2026-04-13T10:30:00.000Z"
}
]

actionInvoiceAuditAction (CREATE / UPDATE / POST / CANCEL_POST / REPOST / DELETE / RESTORE). changes — JSON-строка с дельтой, формат для UI читаемый, но не структурированный (на текущей итерации). Сортировка — на стороне сервиса, ожидаемый порядок — по убыванию createdAt.


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

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

После выбора склада в форме фронт зовёт:

GET /inventory/invoices/data-for-invoice?type=INCOMING&warehouseId=<uuid>
ПараметрТипОбяз.Описание
typeInvoiceTypeINCOMING / OUTGOING
warehouseIdUUIDСклад накладной

Возвращает структуру с:

  • items[] — номенклатуры с фасовками, текущим остатком (currentStock) и текущей себестоимостью (costPrice). Для INCOMING тянутся все доступные позиции; для OUTGOING — только с положительным остатком на складе. Wave 5 — stocks теперь возвращается и для INCOMING;
  • lastPrices — последняя цена прихода (для INCOMING) либо отгрузки (для OUTGOING) по каждой номенклатуре. Подставляется в priceWithoutVat по умолчанию.

Отдельный точечный запрос для одной номенклатуры:

GET /inventory/invoices/last-price?nomenclatureId=<uuid>&warehouseId=<uuid>

Возвращает LastIncomingPriceResponseDto (priceWithoutVat, vatRate, packagingUnitId, documentDate). Используется, когда фронт меняет номенклатуру в одной строке и хочет обновить только её дефолтную цену.

Автозаполнение позиций по техкарте (для расходных «Заготовки»):

POST /inventory/invoices/invoice-completion-data
Content-Type: application/json
{ "nomenclatureId": "uuid" }

Возвращает ингредиенты текущей техкарты выбранной номенклатуры, сгруппированные по типу.

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

POST /inventory/invoices
Content-Type: application/json
{
"documentNumber": "2148",
"documentDate": "2026-04-13",
"type": "INCOMING",
"legalEntityId": "uuid",
"counterpartyId": "uuid",
"warehouseId": "uuid",
"externalDocumentNumber": "2147",
"invoiceNumber": null,
"invoiceDate": null,
"dueDate": null,
"comment": null,
"serviceCostDistribution": "NONE",
"items": [
{
"nomenclatureId": "uuid",
"packagingUnitId": "uuid",
"packageQuantity": 5,
"totalQuantity": 50,
"actualQuantity": 50,
"priceWithoutVat": 20.00,
"vatRate": 20
}
],
"postAfterSave": false
}

DTO: save-invoice.dto.ts.

Поля Body — шапка

Поле UIПоле BodyОбяз.Источник для селекта
Номер документаdocumentNumberЕсли пусто — генерируется DocumentNumberGenerator
ТипtypeINCOMING / OUTGOING
Дата документаdocumentDateISO date
Юр. лицоlegalEntityIdGET /organization/legal-entities
КонтрагентcounterpartyIdGET /organization/counterparties
СкладwarehouseIdGET /organization/subdivisions
Счёт-фактура №invoiceNumbernullable, ≤ 100 символов
От (дата СФ)invoiceDateISO date, nullable
Входящий номерexternalDocumentNumbernullable, ≤ 100
Срок оплатыdueDateISO date, nullable
Комментарийcommentnullable
Стратегия доп. расходовserviceCostDistributionenum, дефолт NONE
⬜ С проведениемpostAfterSaveboolean. true → создать + провести в одной транзакции

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

DTO: SaveInvoiceItemDto (save-invoice.dto.ts:21). Один набор полей для трёх вкладок (Товары / Услуги / Заготовки) — UI скрывает колонки в зависимости от nomenclature.type.

Колонка UIПоле BodyПрименимо к
НаименованиеnomenclatureIdвсе
УпаковкаpackagingUnitIdвсе. UUID единицы измерения упаковки
Кол-во упаковокpackageQuantityвсе. > 0
Всего ед.totalQuantityвсе. > 0 (в базовых единицах)
Фактическое кол-во ед.actualQuantityтолько INCOMING + GOODS/SEMI_FINISHED. ≥ 0. nullable
Цена за ед. без НДСpriceWithoutVatвсе. ≥ 0
Ставка НДС, %vatRateвсе. Дефолт 0. Хардкод 0/10/20 на фронте
Скидка за ед.discountтолько SEMI_FINISHED и SERVICE. ≥ 0
Коэф. списанияwriteOffCoefficientтолько SEMI_FINISHED и SERVICE. ≥ 0
Себестоимость за ед.costPerUnitприходные услуги/заготовки — задаёт пользователь. Для расходных не передаётся — бэк подставляет текущую себестоимость склада при проведении

nomenclatureSku и nomenclatureName в Body не идут — бэк подтягивает их из связи по nomenclatureId.

Ответ

201 Created с тем же InvoiceDetailResponseDto, что и в Сценарии 2.

При postAfterSave: truestatus: "POSTED" в ответе, items[].stockAfter заполнены снапшотом остатков после проведения; при postAfterSave: falsestatus: "DRAFT", items[].stockBefore заполнены текущими остатками до проведения.

Ошибки

HTTPТелоКогдаЧто делать в UI
400{statusCode:400, message:"В накладной не может быть двух позиций с одной номенклатурой"}Дубликаты позиций по nomenclatureIdПодсветить повторяющиеся строки
400{statusCode:400, message:"Количество позиции \"X\" должно быть положительным"}actualQuantity ≤ 0 при проведении приходнойПодсветить ячейку
400{statusCode:400, message:"Недостаточно остатков на складе для номенклатуры \"X\". Доступно: …, требуется: …"}Минус по складу при проведении расходнойТоаст с именем позиции
400{statusCode:400, message:"Невозможно выполнить операцию: дата документа находится в заблокированном периоде"}documentDate ≤ finalBlockingDate корпорацииТоаст. Дата блокировки в message не вставляется, фронт сообщает «Период заблокирован»
400{statusCode:400, message:"Не удалось определить тип номенклатуры …"}Невалидный nomenclatureId (нет в корпорации)Тоаст
400{statusCode:400, message:"Для расходных накладных фактическое количество не отличается от общего"}actualQuantity передан в расходнойНе отправлять actualQuantity для OUTGOING
400стандарт class-validatorНевалидные данные DTO (пустой items, отрицательные значения, плохой UUID)Подсветить поле
401стандартный 401Токен истёкПопытка /auth/refresh; повторный 401 → login
403стандартный 403Чужая корпорация (контрагент / юрлицо / склад)Тоаст «Нет прав»
404{statusCode:404, message:"Накладная с id … не найдена"}Только для PUT /:id / PATCH ... / DELETE ...Закрыть форму, обновить список

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

PUT /inventory/invoices/:id
Content-Type: application/json
{ /* тот же SaveInvoiceDto, как в Сценарии 4 */ }

Полная замена полей шапки и набора позиций. Допускается только в DRAFT. Опционально с postAfterSave: true — обновить + провести в одной транзакции.

Ответ — InvoiceDetailResponseDto. Те же ошибки, что и в POST, плюс 400 "Накладная уже удалена" если попытка обновить soft-deleted.


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

PATCH /inventory/invoices/:id/post

Провести черновик. Тело пустое. Сервис формирует InventoryTransaction’ы, обновляет WarehouseStock, генерирует проводки по плану счетов. Транзакционно: при ошибке шага ничего не меняется.

PATCH /inventory/invoices/:id/cancel-post

Откатить проведение. Восстанавливает остатки складов до состояния перед проведением. Может вернуть 400, если после восстановления остаток ушёл бы в минус (другой документ уже забрал склад).

PATCH /inventory/invoices/:id/repost

Только для проведённых расходных. Пересчитывает costPerUnit позиций по текущей средневзвешенной себестоимости склада и перепроводит. Используется чекбоксом «Перепроводить с пересчётом цены отгрузки» — клиент после PUT :id вызывает PATCH :id/repost.

Ошибки

HTTPТелоКогда
400{message:"Недостаточно остатков на складе для номенклатуры \"X\". Доступно: …, требуется: …"}При post расходной — нет позиций на складе
400{message:"Невозможно отменить проведение: не найден складской остаток для номенклатуры \"X\""}При cancel-post — позиция была списана и удалена со склада
400{message:"Невозможно отменить проведение: остаток номенклатуры \"X\" стал бы отрицательным. …"}При cancel-post приходной — товар уже расписан другими документами
400{message:"Невозможно восстановить себестоимость номенклатуры \"X\": …"}При cancel-post — потеряна история себестоимости (батч удалён)
400{message:"Невозможно выполнить операцию: дата документа находится в заблокированном периоде"}Дата накладной попала в закрытый период
404{message:"Накладная с id … не найдена"}Удалённая или из чужой корпорации

Сценарий 7. Удалить и восстановить

DELETE /inventory/invoices/:id → 204 No Content
PATCH /inventory/invoices/:id/restore → 200 + InvoiceDetailResponseDto

DELETE — soft-delete (isDeleted = true, deletedAt = now). Допускается только в DRAFT. Для проведённой — сначала cancel-post, потом DELETE.

PATCH /:id/restore снимает флаг. Удалённая накладная видна в списке только при showDeleted=true или явном GET /:id.

Ошибки

HTTPТелоКогда
400{message:"Накладная уже удалена"}Повторный DELETE
400{message:"Невозможно удалить проведённую накладную. Сначала отмените проведение"}DELETE для POSTED
404{message:"Накладная с id … не найдена"}Нет в корпорации

Сценарий 8. Скачать PDF

GET /inventory/invoices/:id/pdf

Возвращает:

  • Content-Type: application/pdf
  • Content-Disposition: attachment; filename="invoice.pdf"
  • Бинарный PDF по образцу ТОРГ-12: шапка, таблица позиций, итоги, поля подписей.

Сервис: invoice-print.service.ts (PDFKit). Шаблон минимальный, итерируется по правкам дизайна.

Скачивание стандартным паттерном: fetchblob()URL.createObjectURL → клик по скрытой <a download>.


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

POST /inventory/invoices/export
Content-Type: application/json
{
"type": ["INCOMING"],
"status": ["POSTED"],
"documentDateFrom": "2026-04-01",
"documentDateTo": "2026-04-30",
"subdivisionId": ["uuid"],
"columns": [
"documentDate",
"documentNumber",
"type",
"counterpartyName",
"legalEntityName",
"warehouseName",
"totalWithoutVat",
"totalVat",
"totalWithVat"
]
}

DTO — InvoiceExportDto (invoice-export.dto.ts). Принимает тот же набор фильтров, что GET /inventory/invoices, плюс массив выбранных колонок.

Ответ — .xlsx (attachment invoices.xlsx).


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

Статусы накладной

statusЦвет на UIСемантика
DRAFTсерыйЧерновик. Склад не затронут
POSTEDзелёныйПроведено. Сформированы InventoryTransaction’ы, обновлён WarehouseStock, есть проводки
(флаг) isDeleted = trueкрасный / зачёркнутая строкаSoft-delete. В списке видно только при showDeleted=true

paymentStatus (PaymentStatus) — окраска отдельной зоны строки или иконка. Семантика в «Приём платежей».

Чипы в подвале

aggregates.{draftCount, postedCount, deletedCount} дают разбивку для легенды статусов; aggregates.{totalWithVat, totalCostWithoutVat, totalDiscount} — финансовые итоги. Фронт рендерит чипы по тем же цветам, что и строки.


Селекторы (откуда подгружать значения dropdown’ов)

Все запросы возвращают полный объект — фронт показывает в dropdown’е name, в Body/Query кладёт id.

СелекторЭндпоинтПараметрыЧто показать
Склад / Юрлицо / ПодразделениеGET /organization/tree?search=Иерархическое дерево. Узел → subdivisionId[] / legalEntityId[]
Склад (плоский)GET /organization/subdivisionsname. Для селекта формы
Юр. лицоGET /organization/legal-entitiesname. Для селекта формы
КонтрагентGET /organization/counterpartiestypes[], searchname. Мультивыбор
Номенклатура (в строке)data-for-invoice.items[] или GET /menu-management/nomenclatures/search?type=...по типу вкладкиname + sku
Упаковкаdata-for-invoice.items[].packagings или GET /menu-management/nomenclatures/:id/packagingsПо выбранной номенклатуре
Ставка НДСhardcode [0, 10, 20]без бэка

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

Dropdown «За период» — чистый фронт. Бэк знает только documentDateFrom / documentDateTo.

ПресетdocumentDateFromdocumentDateTo
Открытый период (default)начало текущего месяцане передавать → бэк интерпретирует как «до сейчас»
Текущая неделяпонедельник 00:00воскресенье 23:59:59
Текущий месяц1-е число 00:00последний день 23:59:59
Произвольныйполе «С»поле «По»

Чекбокс «Открытый период» — отдельный параметр onlyOpenPeriod=true — фильтрует уже по finalBlockingDate корпорации; работает совместно с диапазоном дат.


Часовой пояс и формат времени

  • Все даты в API — ISO-8601 UTC (...T...Z). На приём допускается также YYYY-MM-DD (бэк парсит как T00:00:00Z).
  • Поле documentDate — это дата документа (без времени). Брать только date-часть, не конвертировать в локальный TZ — иначе документ за последний день месяца уедет в первый день соседнего при работе в восточных таймзонах.
  • Поля createdAt, updatedAt, deletedAt, invoiceDate, dueDate — ISO timestamp. Для отображения в UI конвертировать в таймзону корпорации (CorporationResponseDto.timezone, например Europe/Moscow).
  • Поле finalBlockingDate корпорации (для onlyOpenPeriod) — нативная дата. Запрос GET /organization/corporation/blocking-dates (см. Wave 4).

Настройки колонок и режим отображения

  • «Настройки колонок» — чистый фронт. Бэк всегда возвращает полный объект.
  • Toggle «История изменений» — клиентский, разворачивает 4 audit-колонки (createdByName, updatedByName, createdAt, updatedAt) уже из ответа. Доп. запроса не делает.
  • Toggle «Автообновление» — клиентский поллинг существующего list-эндпоинта по таймеру (бэка не касается).
  • Persistence настроек колонок — на стороне фронта (localStorage или серверные user preferences — на усмотрение команды). При экспорте в Excel этот же набор передаётся в columns[] запроса /export.

Открытые вопросы (уточнить с дизайнером / PM)

  • Persistence «Настроек колонок»localStorage или серверные user preferences.
  • Визуальный паттерн changes в истории — сейчас бэк отдаёт JSON-строку с дельтой без структурированного формата. UI решает: показывать как есть или парсить.
  • Сортировка по нестатическим колонкам — сервер поддерживает только whitelist в INVOICE_SORT_FIELDS. Стрелочки на остальных колонках — либо убрать, либо делать client-side в пределах загруженной страницы.

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

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

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

  • ✅ Композитное создание/обновление с массивом позиций в одном запросе. Транзакционно, с пересчётом итогов на бэке.
  • postAfterSave: true — атомарный save + post. При падении проведения вся транзакция откатывается, черновик не создаётся.
  • ✅ Серверные фильтры subdivisionId[], legalEntityId[], counterpartyId[], onlyOpenPeriod (Wave 3-4). Принимают массив или CSV.
  • ✅ Серверные агрегаты подвала (count, totalWithVat, totalVat, totalCostWithoutVat, totalCostWithVat, totalDiscount, разбивка по статусам) — по всему отфильтрованному набору, не по странице.
  • data-for-invoice отдаёт stocks и для INCOMING (Wave 5). Колонка «Остаток до прихода» наполняется на форме приходной.
  • hasQuantityDiscrepancies: boolean в list-row — иконка-предупреждение (Wave 1).
  • ✅ Audit-поля createdByName, updatedByName (Wave 1).
  • totalDiscount в InvoiceDetailResponseDto (Wave 1).
  • stockBefore / stockAfter в позициях — снапшоты остатка для DRAFT и POSTED.
  • ✅ Группировка позиций detail-ответа по nomenclature.type — готово к вкладкам.
  • :id/pdf по образцу ТОРГ-12 (Wave 8).
  • :id/repost — пересчёт costPerUnit по текущей себестоимости склада.
  • ✅ Soft-delete + restore.