Накладные
Что это
Накладная — учётный документ движения товаров/услуг/заготовок между корпорацией и контрагентом. Бухгалтер/менеджер видит список приходных и расходных накладных, создаёт новые (по товарам, услугам и заготовкам — отдельные вкладки на форме), проводит, отменяет проведение, перепроводит расходные с пересчётом цены отгрузки, удаляет и восстанавливает.
Накладная — основной источник входящих остатков склада (приходная) и расхода со склада на контрагента (расходная). Проведение — формирование 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 | Автозаполнение позиций по техкарте (расходная — заготовки) |
Сценарий 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=50DTO: invoice-filter.dto.ts.
| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
type | InvoiceType[] | — | INCOMING / OUTGOING. Массив (?type=INCOMING&type=OUTGOING) или CSV |
status | InvoiceStatus[] | — | DRAFT / POSTED. DELETED не в enum — для удалённых showDeleted |
showDeleted | bool | — | true — включить isDeleted=true |
search | string | — | По номеру документа, внешнему номеру, контрагенту, наименованиям позиций. Макс. 100 символов |
documentDateFrom, documentDateTo | YYYY-MM-DD | — | Диапазон по documentDate (включительно) |
subdivisionId[] | UUID[] | — | Мультивыбор склада. До 100 значений |
legalEntityId[] | UUID[] | — | Мультивыбор юрлица. До 100 значений |
counterpartyId[] | UUID[] | — | Мультивыбор контрагента. До 100 значений |
onlyOpenPeriod | bool | — | Исключить документы из закрытого периода (documentDate ≤ finalBlockingDate). При null finalBlockingDate в корпорации — no-op |
offset, limit | int | — | Дефолт 0 / 50. Максимум limit ограничен MAX_LIMIT (100) |
sortBy | enum | — | Whitelist в INVOICE_SORT_FIELDS (invoice.repository.ts). Дефолт documentDate |
sortOrder | asc|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, sumWithVat | Derived. Бэк пересчитывает |
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" }]action ∈ InvoiceAuditAction (CREATE / UPDATE / POST / CANCEL_POST / REPOST / DELETE / RESTORE). changes — JSON-строка с дельтой, формат для UI читаемый, но не структурированный (на текущей итерации). Сортировка — на стороне сервиса, ожидаемый порядок — по убыванию createdAt.
Сценарий 4. Создать накладную
Подгрузка данных для формы
После выбора склада в форме фронт зовёт:
GET /inventory/invoices/data-for-invoice?type=INCOMING&warehouseId=<uuid>| Параметр | Тип | Обяз. | Описание |
|---|---|---|---|
type | InvoiceType | ✅ | INCOMING / OUTGOING |
warehouseId | UUID | ✅ | Склад накладной |
Возвращает структуру с:
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-dataContent-Type: application/json
{ "nomenclatureId": "uuid" }Возвращает ингредиенты текущей техкарты выбранной номенклатуры, сгруппированные по типу.
Запрос на создание
POST /inventory/invoicesContent-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 |
| Тип | type | ✅ | INCOMING / OUTGOING |
| Дата документа | documentDate | ✅ | ISO date |
| Юр. лицо | legalEntityId | ✅ | GET /organization/legal-entities |
| Контрагент | counterpartyId | ✅ | GET /organization/counterparties |
| Склад | warehouseId | ✅ | GET /organization/subdivisions |
| Счёт-фактура № | invoiceNumber | — | nullable, ≤ 100 символов |
| От (дата СФ) | invoiceDate | — | ISO date, nullable |
| Входящий номер | externalDocumentNumber | — | nullable, ≤ 100 |
| Срок оплаты | dueDate | — | ISO date, nullable |
| Комментарий | comment | — | nullable |
| Стратегия доп. расходов | serviceCostDistribution | — | enum, дефолт NONE |
| ⬜ С проведением | postAfterSave | — | boolean. 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: true — status: "POSTED" в ответе, items[].stockAfter заполнены снапшотом остатков после проведения; при postAfterSave: false — status: "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/:idContent-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 ContentPATCH /inventory/invoices/:id/restore → 200 + InvoiceDetailResponseDtoDELETE — 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/pdfContent-Disposition: attachment; filename="invoice.pdf"- Бинарный PDF по образцу ТОРГ-12: шапка, таблица позиций, итоги, поля подписей.
Сервис: invoice-print.service.ts (PDFKit). Шаблон минимальный, итерируется по правкам дизайна.
Скачивание стандартным паттерном: fetch → blob() → URL.createObjectURL → клик по скрытой <a download>.
Сценарий 9. Скачать Excel
POST /inventory/invoices/exportContent-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/subdivisions | — | name. Для селекта формы |
| Юр. лицо | GET /organization/legal-entities | — | name. Для селекта формы |
| Контрагент | GET /organization/counterparties | types[], search | name. Мультивыбор |
| Номенклатура (в строке) | 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.
| Пресет | documentDateFrom | documentDateTo |
|---|---|---|
| Открытый период (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.