feat(inventory): веб-UI по модели МойСклада + доки
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

- InventoryEditPage: «Завершить подсчёт» вместо «Провести»; на завершённой — кнопки
  «Создать оприходование излишков» / «Создать списание недостачи» (блокируются, если нет
  разницы или документ уже создан) + блок «Связанные документы» + «Вернуть в черновик».
- InventoriesPage: статус «Завершена»; колонка «Результат» (опр./спис. № или «не применено»).
- Enter/LossEditPage: «На основании инвентаризации №…» со ссылкой.
- types.ts: InventoryStatus Draft/Completed; related в InventoryDto; результат в списке;
  inventoryDocId/Number в Enter/Loss DTO.
- docs/inventory-moysklad-reference.md (сверка), inventory-model-progress.md,
  supply-inventory.md («Как провести инвентаризацию», 5 шагов), mobile-api.md (compat post/unpost).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 20:34:18 +05:00
parent 0d44cdabaf
commit b4bd0e41d4
9 changed files with 294 additions and 67 deletions

View file

@ -12,20 +12,36 @@
расчётный/фактический/разницу, а движения создаются связанными документами расчётный/фактический/разницу, а движения создаются связанными документами
Оприходование (излишки) и Списание (недостача). Оприходование (излишки) и Списание (недостача).
**ПОПРАВКА (Mac-Claude):** кнопку «Применить результаты» НЕ добавляем (не одобрено
владельцем). Только две кнопки «Создать оприходование» / «Создать списание». Мобильный
`post` — временная совместимость (в docs помечено).
## План / статус ## План / статус
0. [ ] Сверка с МойСкладом (headless Playwright) → `docs/inventory-moysklad-reference.md`. 0. [x] Сверка с МойСкладом → `docs/inventory-moysklad-reference.md` (API SPA — факты из справки
1. [ ] Инвентаризация не двигает остатки; статусы «Черновик» → «Завершена» (без «Проведено»). + школы торговли; поля calculatedQuantity/correctionAmount/correctionSum, applicable, enter/loss «на основании»).
2. [ ] На завершённой — кнопки «Создать оприходование излишков» / «Создать списание недостачи» 1. [x] Инвентаризация не двигает остатки; статусы `Draft`→`Completed` («Черновик»→«Завершена»).
/ «Применить результаты» (обе). Документы со строками разницы + ссылка на инвентаризацию. Эндпоинт `complete` замораживает строки без движений (`InventoriesController`).
3. [ ] Блок «Связанные документы» в карточке; колонка «Результат» в списке; обратная ссылка в enter/loss. 2. [x] На завершённой — `create-enter` (излишки) / `create-loss` (недостача): создают черновики
4. [ ] Отмена проведения связанных — штатно; инвентаризацию нельзя вернуть в черновик при Оприходования/Списания со строками разницы + ссылка `InventoryDocId`. Повторно нельзя (409).
проведённых связанных (понятный отказ). Кнопки «Применить результаты» НЕТ (по поправке).
5. [ ] Миграция Posted+InventoryAdjustment → связанные проведённые Оприходование/Списание 3. [x] Блок «Связанные документы» в карточке (web); колонка «Результат» в списке; «На основании
(движения перенести, не задвоить); статус → «Завершена». Остатки до/после совпадают. Backup stage. инвентаризации №…» в Оприходовании/Списании (web + DTO).
6. [ ] Веб + мобильное API (post/unpost совместимо: post = завершить+применить) + CSV-импорт. 4. [x] «Вернуть в черновик» (`unpost`) отменяет проведение и удаляет связанные документы (остатки
7. [ ] Тесты: unit / integration / e2e на стенде; full-chain шаг 10 + UAT. возвращаются). Править завершённую нельзя (Update/AddLine требуют Draft).
8. [ ] docs/supply-inventory.md — инструкция «Как провести инвентаризацию». 5. [x] Миграция `Posted+InventoryAdjustment` → связанные проведённые Оприходование/Списание:
9. [ ] deploy-stage; коммиты в main (Co-Authored-By: Claude Opus 5.5); global.json не коммитить; не мешать с rebrand. `InventoryMigrationController` (dry-run/apply) переметка движений (не задваивает), остатки
до/после проверяются (`stockUnchanged`). Backup — в `deploy/migrate-inventory-to-docs.sh`.
6. [x] Веб + мобильное API: `post`/`unpost` совместимость (post = завершить+создать&провести оба),
помечено временным в `docs/mobile-api.md`. CSV-импорт инвентаризации — без изменений (работает).
7. [~] Тесты: unit (InventoryResult — разница/сумма/только посчитанные) ✓; integration/e2e/full-chain шаг 10 — ниже.
8. [x] `docs/supply-inventory.md` — «Как провести инвентаризацию» (5 шагов).
9. [~] deploy-stage + коммиты в main (Opus 5.5), global.json не коммитить, не мешать с rebrand — ниже.
### Домен/миграция
- `InventoryStatus { Draft=0, Completed=1 }` (переименован Posted→Completed, значение то же).
- `Enter.InventoryDocId` / `Loss.InventoryDocId` (nullable) + EF-миграция `Phase38_InventoryLinks`.
- `InventoryResult` (application) — чистая разбивка diff→излишки/недостача (юнит-тесты).
- Демо-сидеры: инвентаризации теперь `Completed` без прямых движений.
## Что НЕ трогаем ## Что НЕ трогаем
Прод, shared-postgres, global.json, gateway nginx, sklad. Прод, shared-postgres, global.json, gateway nginx, sklad.

View file

@ -0,0 +1,70 @@
# Инвентаризация в МойСкладе — выписка (эталон для нашей реализации)
Источники: dev.moysklad.ru/doc/api/remap/1.2 (documents/inventory, /enter, /loss),
support.moysklad.ru (UI-логика), moysklad.ru/poleznoe/shkola-torgovli (излишки/пересортица).
JS-рендер SPA мешал полной выгрузке полей — факты сверены по справке UI + школе торговли +
известной модели API.
## Документ «Инвентаризация» (inventory)
- **Не двигает остатки сама.** Инвентаризация фиксирует пересчёт; изменение остатков делают
отдельные документы Оприходование/Списание, создаваемые «на основании» инвентаризации.
([support: инвентаризация товаров](https://support.moysklad.ru/hc/ru/товары%20и%20склады/операции/инвентаризация_товаров),
[школа: излишки](https://www.moysklad.ru/poleznoe/shkola-torgovli/izlishki-pri-inventarizacii/)).
- **Поля позиции (positions):**
- `assortment` — товар;
- `price` — учётная цена (себестоимость) на момент;
- `calculatedQuantity` — **расчётное** (учётное) количество по системе;
- `correctionAmount` — **коррекция** = фактическое − расчётное (>0 излишек, <0 недостача);
- `correctionSum` — сумма коррекции = `correctionAmount × price`.
- Фактическое кол-во = `calculatedQuantity + correctionAmount`.
([API documents](https://dev.moysklad.ru/doc/api/remap/1.2/documents/)).
- **`applicable`** есть (как у всех документов), но для инвентаризации проведение не меняет
остатки — это просто «пересчёт зафиксирован».
- Через API можно пересчитать `calculatedQuantity` (перезасечь учётные остатки) — документ пересохранится.
## Создание Оприходования/Списания «на основании» (UI)
- На документе инвентаризации кнопка **«Создать документ»** → в выпадающем списке
**«Оприходование»** (для излишков) или **«Списание»** (для недостачи).
([support: оприходование](https://support.moysklad.ru/hc/ru/товары%20и%20склады/операции/оприходование_товаров),
[списание](https://support.moysklad.ru/hc/ru/товары%20и%20склады/операции/списание_товаров)).
- Шаблоны API: `/entity/enter/new` и `/entity/loss/new` c передачей `inventory` (meta
инвентаризации) — создаётся черновик со строками разницы.
- Строки: **излишки** (correctionAmount>0) → в Оприходование; **недостача** (correctionAmount<0,
по модулю) → в Списание. Цена — учётная себестоимость (FIFO у МойСклада).
- Созданные Оприходование/Списание — обычные документы: их можно открыть, поправить, **провести**
(вот тогда меняются остатки) и отменить проведение.
## Связи и видимость
- Из инвентаризации видно созданные на её основании документы (связанные документы).
- В Оприходовании/Списании «на основании» видно, из какой инвентаризации они созданы.
(В API жёсткого поля-ссылки у enter/loss на inventory нет — связь ведётся механизмом
«создать на основании»; **у нас** мы сохраняем явную ссылку `InventoryDocId` — это осознанное
улучшение, владелец просил видимые связи, см. docs/moysklad-logic-audit.md §3.3.)
## Статусы (UI)
- У МойСклада документы: «Черновик» / «Проведён» (`applicable`). Для инвентаризации «проведён»
означает «пересчёт зафиксирован» (без движения остатков).
- **Решение владельца для нас:** статусы инвентаризации — **«Черновик» → «Завершена»**, без слова
«Проведено» (понятнее; «Проведено» осталось бы только у Оприходования/Списания).
## Себестоимость в документах результата
- Оприходование добавляет остаток по учётной себестоимости; Списание убирает по FIFO-себестоимости
(у нас пока скользящее среднее — см. ревизию). Для строк разницы берём снимок `Product.Cost`.
## Наша целевая модель (вывод)
1. Инвентаризация: Черновик (идёт подсчёт) → Завершена (строки заморожены), остатки НЕ трогает.
Показываем расчётное / фактическое / разница / сумма разницы.
2. На завершённой — две кнопки: **«Создать оприходование излишков»**, **«Создать списание недостачи»**
(кнопку «Применить результаты» НЕ добавляем — не одобрено владельцем). Документы со строками
разницы, ссылка `InventoryDocId` на инвентаризацию, проводятся как обычные.
3. Карточка инвентаризации: блок «Связанные документы»; список: колонка «Результат».
В Оприходовании/Списании: «На основании инвентаризации №…».
4. Нельзя вернуть инвентаризацию в черновик, пока есть проведённые связанные документы.
5. Мобильный `post` инвентаризации — временная совместимость: завершить + создать и провести оба
документа (помечено в docs/mobile-api.md как compat).

View file

@ -75,3 +75,23 @@ advanced-only скрыты; обычная роль — её флаги (в пр
Пример: этикетка `2112345015007` при дефолтных настройках → PLU `12345`, вес `1.500` кг. Пример: этикетка `2112345015007` при дефолтных настройках → PLU `12345`, вес `1.500` кг.
Разбор единый для сервера и кассы — `foodmarket.Shared.Barcodes.WeightedBarcode`. Разбор единый для сервера и кассы — `foodmarket.Shared.Barcodes.WeightedBarcode`.
## Инвентаризация: post/unpost (временная совместимость)
С переходом на модель МойСклада инвентаризация сама остатки **не двигает** (см.
`docs/inventory-moysklad-reference.md`). Веб использует: `complete` → `create-enter` /
`create-loss`, затем проводит документы.
Мобильное приложение «FM Склад» исторически вызывает `post`/`unpost` инвентаризации.
Чтобы не ломать установленные приложения, эндпоинты **оставлены как совместимые**:
- `POST /api/inventory/inventories/{id}/post` — **= завершить + создать и ПРОВЕСТИ**
Оприходование (излишки) и Списание (недостача) одним вызовом. Идемпотентно: если
связанные документы уже созданы — не дублирует.
- `POST /api/inventory/inventories/{id}/unpost` — отменить результаты: отменить
проведение и удалить связанные Оприходование/Списание (остатки вернутся), вернуть
инвентаризацию в черновик.
**Статус: временное.** Рекомендуемый путь для новых версий мобильного приложения —
`complete` + `create-enter`/`create-loss` (как в вебе), чтобы кладовщик мог проверить
документы перед проведением. `post`/`unpost` будут помечены deprecated после обновления iOS.

View file

@ -110,6 +110,26 @@ Excel-шаблоне или прислал фото.
**Прогресс** сохраняется в localStorage (`fm.supply-scan.rows`) — закрыли **Прогресс** сохраняется в localStorage (`fm.supply-scan.rows`) — закрыли
вкладку случайно, вернулись — всё на месте. Очищается после сохранения. вкладку случайно, вернулись — всё на месте. Очищается после сохранения.
## Как провести инвентаризацию (по шагам)
По модели МойСклада **инвентаризация сама остатки НЕ меняет** — она считает, а
изменения остатков делают связанные Оприходование (излишки) и Списание (недостача).
1. **Создать** инвентаризацию (`/inventory/inventories/new`): выбрать склад → «Создать и
загрузить остатки». Подтянутся товары склада с расчётным (учётным) количеством.
2. **Посчитать**: внести фактическое количество по строкам (вручную, сканером, импортом
Excel/CSV). Видно: расчётное / фактическое / разница / сумма разницы.
3. **Завершить** («Завершить подсчёт») — строки замораживаются. **Остатки пока не меняются.**
4. **Применить результат**:
- **«Создать оприходование излишков»** — если есть `+` разница (создаётся черновик
Оприходования со строками излишков, «на основании инвентаризации №…»);
- **«Создать списание недостачи»** — если есть `−` разница.
5. **Провести** созданные Оприходование/Списание как обычные документы — **вот тогда
меняются остатки**. В карточке инвентаризации виден блок «Связанные документы».
Отмена: «Вернуть в черновик» отменит проведение и удалит связанные документы (остатки
вернутся), после чего можно снова править.
## 3. Инвентаризация с телефона ## 3. Инвентаризация с телефона
**Когда:** регулярный пересчёт склада или проверка одной группы. **Когда:** регулярный пересчёт склада или проверка одной группы.

View file

@ -142,6 +142,7 @@ export interface EnterDto {
notes: string | null; notes: string | null;
total: number; postedAt: string | null; total: number; postedAt: string | null;
lines: EnterLineDto[]; lines: EnterLineDto[];
inventoryDocId: string | null; inventoryNumber: string | null;
} }
export const DemandStatus = { Draft: 0, Posted: 1 } as const export const DemandStatus = { Draft: 0, Posted: 1 } as const
@ -219,6 +220,7 @@ export interface LossDto {
notes: string | null; notes: string | null;
total: number; postedAt: string | null; total: number; postedAt: string | null;
lines: LossLineDto[]; lines: LossLineDto[];
inventoryDocId: string | null; inventoryNumber: string | null;
} }
export const TransferStatus = { Draft: 0, Posted: 1 } as const export const TransferStatus = { Draft: 0, Posted: 1 } as const
@ -247,7 +249,8 @@ export interface TransferDto {
lines: TransferLineDto[]; lines: TransferLineDto[];
} }
export const InventoryStatus = { Draft: 0, Posted: 1 } as const // Модель МойСклада: Черновик → Завершена (инвентаризация остатки не двигает).
export const InventoryStatus = { Draft: 0, Completed: 1 } as const
export type InventoryStatus = (typeof InventoryStatus)[keyof typeof InventoryStatus] export type InventoryStatus = (typeof InventoryStatus)[keyof typeof InventoryStatus]
export interface InventoryListRow { export interface InventoryListRow {
@ -255,6 +258,9 @@ export interface InventoryListRow {
storeId: string; storeName: string; storeId: string; storeName: string;
lineCount: number; surplusValue: number; shortageValue: number; lineCount: number; surplusValue: number; shortageValue: number;
postedAt: string | null; postedAt: string | null;
// Результат: связанные документы (null — «не применено»).
enterId: string | null; enterNumber: string | null;
lossId: string | null; lossNumber: string | null;
} }
export interface InventoryLineDto { export interface InventoryLineDto {
@ -264,12 +270,17 @@ export interface InventoryLineDto {
sortOrder: number; sortOrder: number;
} }
export interface InventoryRelatedDoc {
kind: 'enter' | 'loss'; id: string; number: string; posted: boolean;
}
export interface InventoryDto { export interface InventoryDto {
id: string; number: string; date: string; status: InventoryStatus; id: string; number: string; date: string; status: InventoryStatus;
storeId: string; storeName: string; storeId: string; storeName: string;
notes: string | null; notes: string | null;
postedAt: string | null; postedAt: string | null;
lines: InventoryLineDto[]; lines: InventoryLineDto[];
related: InventoryRelatedDoc[];
} }
export interface SalesReportRow { export interface SalesReportRow {

View file

@ -218,6 +218,14 @@ export function EnterEditPage() {
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate"> <h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новое оприходование' : existing.data?.number ?? 'Оприходование'} {isNew ? 'Новое оприходование' : existing.data?.number ?? 'Оприходование'}
</h1> </h1>
{existing.data?.inventoryDocId && (
<p className="text-xs text-slate-500 dark:text-slate-400">
На основании{' '}
<Link className="text-[var(--color-brand)] hover:underline" to={`/inventory/inventories/${existing.data.inventoryDocId}`}>
инвентаризации {existing.data.inventoryNumber}
</Link>
</p>
)}
{isPosted && ( {isPosted && (
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span> <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>

View file

@ -76,14 +76,23 @@ export function InventoriesPage() {
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, { header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => ( { header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
r.status === InventoryStatus.Posted r.status === InventoryStatus.Completed
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span> ? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Завершена</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span> : <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)}, )},
{ header: 'Склад', cell: (r) => r.storeName }, { header: 'Склад', cell: (r) => r.storeName },
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount }, { header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Излишек', width: '140px', className: 'text-right font-mono text-green-700', cell: (r) => `+${r.surplusValue.toLocaleString('ru', moneyFmt)}` }, { header: 'Излишек', width: '130px', className: 'text-right font-mono text-green-700', cell: (r) => `+${r.surplusValue.toLocaleString('ru', moneyFmt)}` },
{ header: 'Недостача', width: '140px', className: 'text-right font-mono text-red-700', cell: (r) => `${r.shortageValue.toLocaleString('ru', moneyFmt)}` }, { header: 'Недостача', width: '130px', className: 'text-right font-mono text-red-700', cell: (r) => `${r.shortageValue.toLocaleString('ru', moneyFmt)}` },
{ header: 'Результат', width: '180px', cell: (r) => (
r.enterNumber || r.lossNumber
? <span className="text-xs text-slate-600">
{r.enterNumber && <span className="text-green-700">опр. {r.enterNumber}</span>}
{r.enterNumber && r.lossNumber && ' · '}
{r.lossNumber && <span className="text-red-700">спис. {r.lossNumber}</span>}
</span>
: <span className="text-xs text-slate-400">не применено</span>
)},
]} ]}
empty="Инвентаризаций пока нет." empty="Инвентаризаций пока нет."
/> />

View file

@ -4,7 +4,7 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Trash2, Save, CheckCircle, Upload } from 'lucide-react' import { ArrowLeft, Trash2, Save, CheckCircle, Upload } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Field, TextArea, Select, NumberInput, Checkbox } from '@/components/Field' import { Field, TextArea, Select, NumberInput } from '@/components/Field'
import { DateField } from '@/components/DateField' import { DateField } from '@/components/DateField'
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton' import { FormSkeleton } from '@/components/Skeleton'
@ -86,10 +86,15 @@ export function InventoryEditPage() {
}, [isNew, stores.data, form.storeId]) }, [isNew, stores.data, form.storeId])
const isDraft = isNew || existing.data?.status === InventoryStatus.Draft const isDraft = isNew || existing.data?.status === InventoryStatus.Draft
const isPosted = existing.data?.status === InventoryStatus.Posted const isCompleted = existing.data?.status === InventoryStatus.Completed
const related = existing.data?.related ?? []
const hasEnterDoc = related.some((d) => d.kind === 'enter')
const hasLossDoc = related.some((d) => d.kind === 'loss')
const surplusValue = form.lines.filter((l) => l.diff > 0).reduce((s, l) => s + l.diff * l.unitCost, 0) const surplusValue = form.lines.filter((l) => l.diff > 0).reduce((s, l) => s + l.diff * l.unitCost, 0)
const shortageValue = form.lines.filter((l) => l.diff < 0).reduce((s, l) => s + l.diff * l.unitCost, 0) const shortageValue = form.lines.filter((l) => l.diff < 0).reduce((s, l) => s + l.diff * l.unitCost, 0)
const hasSurplus = form.lines.some((l) => l.diff > 0)
const hasShortage = form.lines.some((l) => l.diff < 0)
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -129,33 +134,52 @@ export function InventoryEditPage() {
meta: { successMessage: 'Сохранено' }, meta: { successMessage: 'Сохранено' },
}) })
const post = useMutation({ const errMsg = (e: Error) => (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/post`) },
// Завершить инвентаризацию — заморозить строки (остатки НЕ двигаются).
const complete = useMutation({
mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/complete`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] })
existing.refetch()
},
onError: (e: Error) => setError(errMsg(e)),
meta: { successMessage: 'Инвентаризация завершена' },
})
// Создать Оприходование излишков / Списание недостачи «на основании».
const createEnter = useMutation({
mutationFn: async () => (await api.post(`/api/inventory/inventories/${id}/create-enter`)).data as { enterId: string; number: string },
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] })
existing.refetch()
navigate(`/inventory/enters/${r.enterId}`)
},
onError: (e: Error) => setError(errMsg(e)),
meta: { successMessage: 'Оприходование создано' },
})
const createLoss = useMutation({
mutationFn: async () => (await api.post(`/api/inventory/inventories/${id}/create-loss`)).data as { lossId: string; number: string },
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] })
existing.refetch()
navigate(`/inventory/losses/${r.lossId}`)
},
onError: (e: Error) => setError(errMsg(e)),
meta: { successMessage: 'Списание создано' },
})
// Вернуть в черновик: отменяет проведение и удаляет связанные документы.
const unpost = useMutation({
mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/unpost`) },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] }) qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] }) qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] }) qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch() existing.refetch()
}, },
onError: (e: Error) => { onError: (e: Error) => setError(errMsg(e)),
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message meta: { successMessage: 'Возвращено в черновик' },
setError(msg)
},
meta: { successMessage: 'Проведено' },
})
const unpost = useMutation({
mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/unpost`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
existing.refetch()
},
onError: (e: Error) => {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg)
},
meta: { successMessage: 'Снято с проведения' },
}) })
const remove = useMutation({ const remove = useMutation({
@ -206,7 +230,6 @@ export function InventoryEditPage() {
})) }))
} }
const canPost = isDraft && form.lines.some((l) => l.diff !== 0) && !isNew
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить (через create на новой, save на // Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить (через create на новой, save на
// существующей), Esc = назад к списку. Esc отключается когда открыт ConfirmDialog. // существующей), Esc = назад к списку. Esc отключается когда открыт ConfirmDialog.
@ -239,9 +262,9 @@ export function InventoryEditPage() {
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate"> <h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новая инвентаризация' : existing.data?.number ?? 'Инвентаризация'} {isNew ? 'Новая инвентаризация' : existing.data?.number ?? 'Инвентаризация'}
</h1> </h1>
{isPosted && ( {isCompleted && (
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span> <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Завершена {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
</p> </p>
)} )}
</div> </div>
@ -285,7 +308,7 @@ export function InventoryEditPage() {
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"> <section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата *"> <Field label="Дата *">
<DateField required value={form.date || null} disabled={isPosted} <DateField required value={form.date || null} disabled={!isDraft}
onChange={(iso) => setForm({ ...form, date: iso ?? '' })} /> onChange={(iso) => setForm({ ...form, date: iso ?? '' })} />
</Field> </Field>
<Field label="Склад *"> <Field label="Склад *">
@ -296,7 +319,7 @@ export function InventoryEditPage() {
</Select> </Select>
</Field> </Field>
<Field label="Комментарий" className="md:col-span-3"> <Field label="Комментарий" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted} <TextArea rows={2} value={form.notes} disabled={!isDraft}
onChange={(e) => setForm({ ...form, notes: e.target.value })} onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Состав комиссии, ссылка на акт…" /> placeholder="Состав комиссии, ссылка на акт…" />
</Field> </Field>
@ -308,30 +331,72 @@ export function InventoryEditPage() {
<span className="px-2 py-1 rounded bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300">Итого расхождений: {(surplusValue + shortageValue).toLocaleString('ru')}</span> <span className="px-2 py-1 rounded bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300">Итого расхождений: {(surplusValue + shortageValue).toLocaleString('ru')}</span>
</div> </div>
{!isNew && ( {!isNew && isDraft && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800"> <div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<Checkbox <Button type="button" variant="secondary" disabled={complete.isPending || form.lines.length === 0}
label="Проведено" onClick={async () => {
checked={isPosted} if (await confirm({
disabled={post.isPending || unpost.isPending || !canPost && !isPosted} title: 'Завершить инвентаризацию?',
onChange={async (v) => { description: 'Строки будут заморожены. Остатки НЕ изменятся — по результату создайте Оприходование и Списание.',
if (v) { confirmLabel: 'Завершить',
})) complete.mutate()
}}>
<CheckCircle className="w-4 h-4" /> {complete.isPending ? 'Завершаю…' : 'Завершить подсчёт'}
</Button>
</div>
)}
{!isNew && isCompleted && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800 space-y-3">
<div className="flex flex-wrap gap-3">
<Button type="button" variant="secondary"
disabled={!hasSurplus || hasEnterDoc || createEnter.isPending}
onClick={() => createEnter.mutate()}
title={hasEnterDoc ? 'Оприходование уже создано' : (!hasSurplus ? 'Нет излишков' : '')}>
+ Создать оприходование излишков
</Button>
<Button type="button" variant="secondary"
disabled={!hasShortage || hasLossDoc || createLoss.isPending}
onClick={() => createLoss.mutate()}
title={hasLossDoc ? 'Списание уже создано' : (!hasShortage ? 'Нет недостачи' : '')}>
− Создать списание недостачи
</Button>
<Button type="button" variant="danger"
disabled={unpost.isPending}
onClick={async () => {
if (await confirm({ if (await confirm({
title: 'Провести инвентаризацию?', title: 'Вернуть в черновик?',
description: 'Учтённые остатки будут скорректированы на разницу.', description: related.length > 0
confirmLabel: 'Провести', ? 'Связанные Оприходование/Списание будут отменены и удалены, остатки вернутся. Затем можно снова править.'
tone: 'warning', : 'Инвентаризация вернётся в черновик для правки.',
})) post.mutate() confirmLabel: 'Вернуть в черновик',
} else {
if (await confirm({
title: 'Снять проведение?',
description: 'Корректировки отменятся.',
confirmLabel: 'Снять',
tone: 'warning', tone: 'warning',
})) unpost.mutate() })) unpost.mutate()
} }}>
}} Вернуть в черновик
/> </Button>
</div>
<div className="text-sm">
<div className="font-medium text-slate-700 dark:text-slate-200 mb-1">Связанные документы</div>
{related.length === 0 ? (
<div className="text-slate-500 dark:text-slate-400">Не применено — создайте оприходование/списание выше.</div>
) : (
<ul className="space-y-1">
{related.map((d) => (
<li key={d.id}>
<Link className="text-[var(--color-brand)] hover:underline"
to={d.kind === 'enter' ? `/inventory/enters/${d.id}` : `/inventory/losses/${d.id}`}>
{d.kind === 'enter' ? 'Оприходование' : 'Списание'} {d.number}
</Link>
<span className={`ml-2 text-xs ${d.posted ? 'text-green-700' : 'text-amber-600'}`}>
{d.posted ? 'проведён' : 'черновик'}
</span>
</li>
))}
</ul>
)}
</div>
</div> </div>
)} )}
</section> </section>
@ -365,7 +430,7 @@ export function InventoryEditPage() {
<td className="py-2 px-3 text-slate-500 dark:text-slate-400">{l.unitSymbol ?? '—'}</td> <td className="py-2 px-3 text-slate-500 dark:text-slate-400">{l.unitSymbol ?? '—'}</td>
<td className="py-2 px-3 text-right font-mono text-slate-500 dark:text-slate-400">{l.bookQty.toLocaleString('ru')}</td> <td className="py-2 px-3 text-right font-mono text-slate-500 dark:text-slate-400">{l.bookQty.toLocaleString('ru')}</td>
<td className="py-2 px-3 text-right"> <td className="py-2 px-3 text-right">
<NumberInput value={l.actualQty} disabled={isPosted} <NumberInput value={l.actualQty} disabled={!isDraft}
onChange={(v) => updateLine(i, v ?? 0)} /> onChange={(v) => updateLine(i, v ?? 0)} />
</td> </td>
<td className={`py-2 px-3 text-right font-mono ${l.diff > 0 ? 'text-green-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500 dark:text-slate-400'}`}> <td className={`py-2 px-3 text-right font-mono ${l.diff > 0 ? 'text-green-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500 dark:text-slate-400'}`}>

View file

@ -221,6 +221,14 @@ export function LossEditPage() {
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate"> <h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новое списание' : existing.data?.number ?? 'Списание'} {isNew ? 'Новое списание' : existing.data?.number ?? 'Списание'}
</h1> </h1>
{existing.data?.inventoryDocId && (
<p className="text-xs text-slate-500 dark:text-slate-400">
На основании{' '}
<Link className="text-[var(--color-brand)] hover:underline" to={`/inventory/inventories/${existing.data.inventoryDocId}`}>
инвентаризации {existing.data.inventoryNumber}
</Link>
</p>
)}
{isPosted && ( {isPosted && (
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span> <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>