Compare commits

..

2 commits

Author SHA1 Message Date
nns b4bd0e41d4 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>
2026-10-08 20:34:18 +05:00
nns 0d44cdabaf feat(inventory): модель МойСклада — инвентаризация не двигает остатки
Замечание владельца: инвентаризация стояла «Проведено» без оприходования/списания.
Переделано по МойСкладу (docs/inventory-moysklad-reference.md):

- InventoryStatus Draft→Completed (было Posted; значение 1 то же). Инвентаризация
  остатки НЕ двигает: endpoint complete замораживает строки без движений.
- create-enter (излишки diff>0) / create-loss (недостача diff<0) — создают черновики
  Оприходования/Списания со строками разницы + ссылка InventoryDocId. Повторно нельзя (409).
- Кнопки «Применить результаты» НЕТ (поправка Mac-Claude — не одобрено владельцем).
- unpost («вернуть в черновик») отменяет проведение и удаляет связанные документы.
- post/unpost — временная совместимость для мобильного «FM Склад» (post = завершить +
  создать и провести оба документа); помечено в docs/mobile-api.md.
- Enter/Loss: поле InventoryDocId (+ миграция Phase38) + в DTO «на основании инвентаризации №…».
- InventoryResult (application) — чистая разбивка diff→излишки/недостача (unit-тесты:
  разница/сумма/только посчитанные строки/округление).
- InventoryMigrationController (dry-run/apply): старые Posted+InventoryAdjustment →
  связанные проведённые документы переметкой движений (не задваивает), с проверкой
  неизменности остатков. Скрипт deploy/migrate-inventory-to-docs.sh (backup + запуск).
- Демо-сидеры: инвентаризации теперь Completed без прямых движений.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:34:05 +05:00
23 changed files with 940 additions and 194 deletions

View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Миграция старой модели инвентаризации (Posted + InventoryAdjustment-движения)
# в модель МойСклада: связанные ПРОВЕДЁННЫЕ Оприходование/Списание. Движения не
# задваиваются (переметка), остатки остаются неизменными (проверяется в отчёте).
#
# ПОРЯДОК: 1) backup БД 2) dry-run (отчёт) 3) --apply (реально, под каждую орг).
#
# Запуск (стенд):
# TOKEN=<admin-token-организации> BASE=https://test.admin.food-market.kz \
# bash deploy/migrate-inventory-to-docs.sh # dry-run
# ... same ... --apply # применить
#
# Backup стенд-БД (на prod-vm, где живёт food-market-stage-postgres):
# docker exec food-market-stage-postgres pg_dump -U food_market food_market \
# | gzip > ~/stage-backup-inventory-$(date +%Y%m%d-%H%M%S).sql.gz
set -euo pipefail
BASE="${BASE:-https://test.admin.food-market.kz}"
APPLY=0
[[ "${1:-}" == "--apply" ]] && APPLY=1
if [[ -z "${TOKEN:-}" ]]; then
echo "✗ Нужен TOKEN (admin-токен организации). Миграция выполняется под текущую орг." >&2
echo " Для нескольких организаций — запустить с токеном каждой." >&2
exit 1
fi
if [[ "$APPLY" -eq 1 ]]; then
echo "⚠ ПЕРЕД --apply сделайте backup стенд-БД (см. шапку скрипта)."
echo " Миграция переметит движения inventory → enter/loss и создаст проведённые документы."
echo " Остатки по товарам остаются прежними (проверяется stockUnchanged в отчёте)."
fi
URL="$BASE/api/admin/inventory-migration/run?apply=$([[ $APPLY -eq 1 ]] && echo true || echo false)"
echo "→ POST $URL"
resp=$(curl -sk -m 60 -X POST "$URL" -H "Authorization: Bearer $TOKEN" -H "Content-Length: 0")
echo "$resp" | python3 -m json.tool 2>/dev/null || echo "$resp"
# Короткий итог
echo "$resp" | python3 - <<'PY' 2>/dev/null || true
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(0)
print(f"\nИтог: applied={d.get('applied')} candidates={d.get('candidates')} "
f"migrated={d.get('migrated')} stockUnchanged={d.get('stockUnchanged')} "
f"stockDiffRows={d.get('stockDiffRows')}")
if d.get('applied') and not d.get('stockUnchanged'):
print("✗ ВНИМАНИЕ: остатки изменились — проверьте вручную и восстановите из backup!")
PY

View file

@ -0,0 +1,50 @@
# Инвентаризация по модели МойСклада — прогресс
Замечание владельца (08.10.2026): «инвентаризация стоит в состоянии „проведено“,
но без оприходования и списания — логика непонятная».
**Сейчас (неправильно):** `InventoriesController.Post` сам применяет движения
`MovementType.InventoryAdjustment` к остаткам и ставит `Status=Posted` — без создания
Оприходования/Списания. Владелец видит «Проведено» без связанных документов; в отчётах
непонятно, откуда изменения.
**Как должно быть (по МойСкладу):** инвентаризация НЕ двигает остатки; она фиксирует
расчётный/фактический/разницу, а движения создаются связанными документами
Оприходование (излишки) и Списание (недостача).
**ПОПРАВКА (Mac-Claude):** кнопку «Применить результаты» НЕ добавляем (не одобрено
владельцем). Только две кнопки «Создать оприходование» / «Создать списание». Мобильный
`post` — временная совместимость (в docs помечено).
## План / статус
0. [x] Сверка с МойСкладом → `docs/inventory-moysklad-reference.md` (API SPA — факты из справки
+ школы торговли; поля calculatedQuantity/correctionAmount/correctionSum, applicable, enter/loss «на основании»).
1. [x] Инвентаризация не двигает остатки; статусы `Draft`→`Completed` («Черновик»→«Завершена»).
Эндпоинт `complete` замораживает строки без движений (`InventoriesController`).
2. [x] На завершённой — `create-enter` (излишки) / `create-loss` (недостача): создают черновики
Оприходования/Списания со строками разницы + ссылка `InventoryDocId`. Повторно нельзя (409).
Кнопки «Применить результаты» НЕТ (по поправке).
3. [x] Блок «Связанные документы» в карточке (web); колонка «Результат» в списке; «На основании
инвентаризации №…» в Оприходовании/Списании (web + DTO).
4. [x] «Вернуть в черновик» (`unpost`) отменяет проведение и удаляет связанные документы (остатки
возвращаются). Править завершённую нельзя (Update/AddLine требуют Draft).
5. [x] Миграция `Posted+InventoryAdjustment` → связанные проведённые Оприходование/Списание:
`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.
## Журнал
- 08.10 — старт: прочитано замечание, составлен план. Следующий шаг — сверка с МойСкладом.

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

@ -3,6 +3,7 @@
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Application.Inventory; using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory; using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization; using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -11,11 +12,13 @@
namespace foodmarket.Api.Controllers.Inventory; namespace foodmarket.Api.Controllers.Inventory;
/// <summary>Инвентаризация (пересчёт). Создание подгружает текущие остатки /// <summary>Инвентаризация (пересчёт) по модели МойСклада. Создание подгружает
/// склада в <c>bookQty</c>; пользователь вносит фактические количества; /// текущие остатки склада в <c>bookQty</c>; пользователь вносит фактические
/// при Post создаются корректирующие движения <see cref="MovementType.InventoryAdjustment"/> /// количества. «Завершение» (<c>complete</c>) замораживает строки (Completed) и
/// на <c>diff = actual - book</c> (положительный — приход, отрицательный /// НЕ двигает остатки. По результату создаются связанные Оприходование
/// — списание).</summary> /// (<c>create-enter</c>, излишки diff&gt;0) и Списание (<c>create-loss</c>, недостача
/// diff&lt;0) — их проводят как обычные документы (вот тогда меняются остатки).
/// <c>post</c>/<c>unpost</c> — временная совместимость для мобильного «FM Склад».</summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("api/inventory/inventories")] [Route("api/inventory/inventories")]
@ -35,19 +38,26 @@ public record InventoryListRow(
Guid StoreId, string StoreName, Guid StoreId, string StoreName,
int LineCount, int LineCount,
decimal SurplusValue, decimal ShortageValue, decimal SurplusValue, decimal ShortageValue,
DateTime? PostedAt); DateTime? PostedAt,
// Результат: связанные документы (null — «не применено»).
Guid? EnterId, string? EnterNumber, Guid? LossId, string? LossNumber);
public record InventoryLineDto( public record InventoryLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol, Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal BookQty, decimal ActualQty, decimal Diff, decimal UnitCost, decimal BookQty, decimal ActualQty, decimal Diff, decimal UnitCost,
int SortOrder); int SortOrder);
/// <summary>Связанный документ результата: «enter» (оприходование) / «loss» (списание).
/// <c>Posted</c> — проведён ли (двигал ли остатки).</summary>
public record RelatedDocDto(string Kind, Guid Id, string Number, bool Posted);
public record InventoryDto( public record InventoryDto(
Guid Id, string Number, DateTime Date, InventoryStatus Status, Guid Id, string Number, DateTime Date, InventoryStatus Status,
Guid StoreId, string StoreName, Guid StoreId, string StoreName,
string? Notes, string? Notes,
DateTime? PostedAt, DateTime? PostedAt,
IReadOnlyList<InventoryLineDto> Lines); IReadOnlyList<InventoryLineDto> Lines,
IReadOnlyList<RelatedDocDto> Related);
public record InventoryLineInput( public record InventoryLineInput(
Guid ProductId, Guid ProductId,
@ -239,7 +249,11 @@ await using (var fs = System.IO.File.Create(tmp))
x.i.Lines.Count, x.i.Lines.Count,
x.i.Lines.Where(l => l.Diff > 0).Sum(l => l.Diff * l.UnitCost), x.i.Lines.Where(l => l.Diff > 0).Sum(l => l.Diff * l.UnitCost),
x.i.Lines.Where(l => l.Diff < 0).Sum(l => l.Diff * l.UnitCost), x.i.Lines.Where(l => l.Diff < 0).Sum(l => l.Diff * l.UnitCost),
x.i.PostedAt)) x.i.PostedAt,
_db.Enters.Where(e => e.InventoryDocId == x.i.Id).Select(e => (Guid?)e.Id).FirstOrDefault(),
_db.Enters.Where(e => e.InventoryDocId == x.i.Id).Select(e => e.Number).FirstOrDefault(),
_db.Losses.Where(l => l.InventoryDocId == x.i.Id).Select(l => (Guid?)l.Id).FirstOrDefault(),
_db.Losses.Where(l => l.InventoryDocId == x.i.Id).Select(l => l.Number).FirstOrDefault()))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<InventoryListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<InventoryListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
@ -477,126 +491,204 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
return NoContent(); return NoContent();
} }
// ── Завершение + результирующие документы (модель МойСклада) ─────────
// Инвентаризация НЕ двигает остатки. Завершаем (Completed), затем по
// результату создаём связанные Оприходование (излишки) и Списание (недостача).
/// <summary>Завершить инвентаризацию: заморозить строки (Status=Completed).
/// Остатки НЕ двигаются. После — кнопки «Создать оприходование/списание».</summary>
[HttpPost("{id:guid}/complete"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Complete(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status == InventoryStatus.Completed) return Conflict(new { error = "Инвентаризация уже завершена." });
if (doc.Lines.Count == 0) return BadRequest(new { error = "Нельзя завершить инвентаризацию без строк." });
doc.Status = InventoryStatus.Completed;
doc.PostedAt = DateTime.UtcNow;
try { await _db.SaveChangesAsync(ct); }
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите." }); }
return NoContent();
}
/// <summary>Создать Оприходование излишков (diff&gt;0) «на основании» инвентаризации —
/// черновик, проводится отдельно как обычный документ. Повторно нельзя.</summary>
[HttpPost("{id:guid}/create-enter"), RequiresPermission("EnterEdit")]
public Task<IActionResult> CreateEnter(Guid id, CancellationToken ct) => CreateResultDocAsync(id, surplus: true, ct);
/// <summary>Создать Списание недостачи (diff&lt;0) «на основании» инвентаризации.</summary>
[HttpPost("{id:guid}/create-loss"), RequiresPermission("LossEdit")]
public Task<IActionResult> CreateLoss(Guid id, CancellationToken ct) => CreateResultDocAsync(id, surplus: false, ct);
private async Task<IActionResult> CreateResultDocAsync(Guid id, bool surplus, CancellationToken ct)
{
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status != InventoryStatus.Completed)
return Conflict(new { error = "Сначала завершите инвентаризацию." });
var split = InventoryResult.Build(doc.Lines.Select(l => (l.ProductId, l.Diff, l.UnitCost)));
if (surplus)
{
if (!split.HasSurplus) return BadRequest(new { error = "Нет излишков — оприходовать нечего." });
var ex = await _db.Enters.Where(e => e.InventoryDocId == id).Select(e => new { e.Id, e.Number }).FirstOrDefaultAsync(ct);
if (ex is not null) return Conflict(new { error = $"Оприходование {ex.Number} уже создано по этой инвентаризации.", enterId = ex.Id, number = ex.Number });
var enter = await BuildEnterAsync(doc, split.Surplus, post: false, ct);
await _db.SaveChangesAsync(ct);
return Ok(new { enterId = enter.Id, number = enter.Number });
}
else
{
if (!split.HasShortage) return BadRequest(new { error = "Нет недостачи — списывать нечего." });
var ex = await _db.Losses.Where(l => l.InventoryDocId == id).Select(l => new { l.Id, l.Number }).FirstOrDefaultAsync(ct);
if (ex is not null) return Conflict(new { error = $"Списание {ex.Number} уже создано по этой инвентаризации.", lossId = ex.Id, number = ex.Number });
var loss = await BuildLossAsync(doc, split.Shortage, post: false, ct);
await _db.SaveChangesAsync(ct);
return Ok(new { lossId = loss.Id, number = loss.Number });
}
}
/// <summary>Строит Оприходование из строк излишков. Если post — сразу проводит
/// (движения Enter, +остаток). Не сохраняет (SaveChanges делает вызывающий).</summary>
private async Task<Enter> BuildEnterAsync(InventoryDoc doc, IReadOnlyList<InventoryResult.ResultLine> lines, bool post, CancellationToken ct)
{
var currencyId = await DefaultCurrencyIdAsync(ct);
var enter = new Enter
{
Number = await GenerateDocNumberAsync<Enter>("О-", doc.Date, ct),
Date = doc.Date,
Status = EnterStatus.Draft,
StoreId = doc.StoreId,
CurrencyId = currencyId,
InventoryDocId = doc.Id,
Notes = $"Излишки по инвентаризации {doc.Number}",
Total = lines.Sum(l => l.LineTotal),
Lines = lines.Select(l => new EnterLine { ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, LineTotal = l.LineTotal }).ToList(),
};
_db.Enters.Add(enter);
if (post)
{
foreach (var l in enter.Lines)
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, enter.StoreId, l.Quantity,
MovementType.Enter, "enter", enter.Id, enter.Number, l.UnitCost, enter.Date), ct);
enter.Status = EnterStatus.Posted;
enter.PostedAt = DateTime.UtcNow;
}
return enter;
}
/// <summary>Строит Списание из строк недостачи. Если post — проводит (WriteOff, −остаток).</summary>
private async Task<Loss> BuildLossAsync(InventoryDoc doc, IReadOnlyList<InventoryResult.ResultLine> lines, bool post, CancellationToken ct)
{
var currencyId = await DefaultCurrencyIdAsync(ct);
var loss = new Loss
{
Number = await GenerateDocNumberAsync<Loss>("С-", doc.Date, ct),
Date = doc.Date,
Status = LossStatus.Draft,
StoreId = doc.StoreId,
CurrencyId = currencyId,
Reason = LossReason.Shortage,
InventoryDocId = doc.Id,
Notes = $"Недостача по инвентаризации {doc.Number}",
Total = lines.Sum(l => l.LineTotal),
Lines = lines.Select(l => new LossLine { ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, LineTotal = l.LineTotal }).ToList(),
};
_db.Losses.Add(loss);
if (post)
{
foreach (var l in loss.Lines)
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, loss.StoreId, -l.Quantity,
MovementType.WriteOff, "loss", loss.Id, loss.Number, l.UnitCost, loss.Date, Notes: $"reason={loss.Reason}"), ct);
loss.Status = LossStatus.Posted;
loss.PostedAt = DateTime.UtcNow;
}
return loss;
}
/// <summary>МОБИЛЬНАЯ СОВМЕСТИМОСТЬ (временная, см. docs/mobile-api.md): iOS
/// «FM Склад» вызывает post. post = завершить + создать и ПРОВЕСТИ Оприходование
/// (излишки) и Списание (недостача). Веб использует complete + create-enter/create-loss.</summary>
[HttpPost("{id:guid}/post"), RequiresPermission("InventoryEdit")] [HttpPost("{id:guid}/post"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct) public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{ {
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct); var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound(); if (doc is null) return NotFound();
if (doc.Status == InventoryStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (doc.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); if (doc.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
var withDiff = doc.Lines.Where(l => l.Diff != 0m).ToList(); await using var tx = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
if (withDiff.Count == 0) if (doc.Status != InventoryStatus.Completed) { doc.Status = InventoryStatus.Completed; doc.PostedAt = DateTime.UtcNow; }
return BadRequest(new { error = "Нет расхождений учётного и фактического количества — нечего проводить." });
await using var tx = await _db.Database.BeginTransactionAsync( var split = InventoryResult.Build(doc.Lines.Select(l => (l.ProductId, l.Diff, l.UnitCost)));
System.Data.IsolationLevel.Serializable, ct); if (split.HasSurplus && !await _db.Enters.AnyAsync(e => e.InventoryDocId == id, ct))
var now = DateTime.UtcNow; await BuildEnterAsync(doc, split.Surplus, post: true, ct);
if (split.HasShortage && !await _db.Losses.AnyAsync(l => l.InventoryDocId == id, ct))
foreach (var line in withDiff) await BuildLossAsync(doc, split.Shortage, post: true, ct);
{ try { await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); }
await _stock.ApplyMovementAsync(new StockMovementDraft( catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ проводится параллельно. Повторите попытку." }); }
ProductId: line.ProductId,
StoreId: doc.StoreId,
Quantity: line.Diff, // positive: surplus; negative: shortage
Type: MovementType.InventoryAdjustment,
DocumentType: "inventory",
DocumentId: doc.Id,
DocumentNumber: doc.Number,
UnitCost: line.UnitCost,
OccurredAt: doc.Date,
Notes: line.Diff > 0 ? "surplus" : "shortage"), ct);
}
doc.Status = InventoryStatus.Posted;
doc.PostedAt = now;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
return NoContent(); return NoContent();
} }
/// <summary>Отменить результаты: отменить проведение и удалить связанные
/// Оприходование/Списание (вернуть остатки), затем вернуть инвентаризацию в
/// черновик. Веб предупреждает пользователя об удалении связанных документов.
/// Мобильный /unpost (совместимость) — эта же логика.</summary>
[HttpPost("{id:guid}/unpost"), RequiresPermission("InventoryEdit")] [HttpPost("{id:guid}/unpost"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct) public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{ {
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct); var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound(); if (doc is null) return NotFound();
if (doc.Status != InventoryStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: для каждой строки c diff != 0 — обратное движение на -diff. await using var tx = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
// Защита от ухода в минус: если diff был положительный (излишек), при unpost var enters = await _db.Enters.Include(e => e.Lines).Where(e => e.InventoryDocId == id).ToListAsync(ct);
// мы списываем (-diff = -surplus) — стоит проверить что эта величина в наличии. foreach (var e in enters)
var positive = doc.Lines.Where(l => l.Diff > 0)
.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Diff) }).ToList();
var productIds = positive.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == doc.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in positive)
{ {
stocks.TryGetValue(r.ProductId, out var available); if (e.Status == EnterStatus.Posted)
if (available < r.Quantity) foreach (var l in e.Lines)
{ await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, e.StoreId, -l.Quantity,
var name = await _db.Products.Where(p => p.Id == r.ProductId) MovementType.Enter, "enter-reversal", e.Id, e.Number, l.UnitCost, DateTime.UtcNow,
.Select(p => p.Name).FirstOrDefaultAsync(ct); Notes: $"Отмена {e.Number} (инвентаризация {doc.Number})"), ct);
conflicts.Add(new _db.Enters.Remove(e);
{
productId = r.ProductId,
productName = name,
reverseQty = r.Quantity,
available,
});
}
} }
if (conflicts.Count > 0) var losses = await _db.Losses.Include(l => l.Lines).Where(l => l.InventoryDocId == id).ToListAsync(ct);
foreach (var ls in losses)
{ {
return Conflict(new if (ls.Status == LossStatus.Posted)
{ foreach (var l in ls.Lines)
error = "Нельзя отменить проведение: остаток уйдёт в минус (излишек уже расходован).", await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, ls.StoreId, l.Quantity,
lines = conflicts, MovementType.WriteOff, "loss-reversal", ls.Id, ls.Number, l.UnitCost, DateTime.UtcNow,
}); Notes: $"Отмена {ls.Number} (инвентаризация {doc.Number})"), ct);
} _db.Losses.Remove(ls);
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
foreach (var line in doc.Lines.Where(l => l.Diff != 0m))
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: doc.StoreId,
Quantity: -line.Diff,
Type: MovementType.InventoryAdjustment,
DocumentType: "inventory-reversal",
DocumentId: doc.Id,
DocumentNumber: doc.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {doc.Number}"), ct);
} }
doc.Status = InventoryStatus.Draft; doc.Status = InventoryStatus.Draft;
doc.PostedAt = null; doc.PostedAt = null;
try try { await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); }
{ catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." }); }
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." });
}
return NoContent(); return NoContent();
} }
private async Task<Guid> DefaultCurrencyIdAsync(CancellationToken ct)
{
var orgCur = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct);
if (orgCur is { } c) return c;
return await _db.Currencies.Where(x => x.Code == "KZT").Select(x => x.Id).FirstOrDefaultAsync(ct);
}
/// <summary>Генерирует номер документа Оприходования/Списания по образцу их
/// контроллеров: «&lt;prefix&gt;&lt;год&gt;-NNNNNN».</summary>
private async Task<string> GenerateDocNumberAsync<T>(string prefix, DateTime date, CancellationToken ct) where T : class
{
var pfx = $"{prefix}{date.Year}-";
var last = typeof(T) == typeof(Enter)
? await _db.Enters.Where(e => e.Number.StartsWith(pfx)).OrderByDescending(e => e.Number).Select(e => e.Number).FirstOrDefaultAsync(ct)
: await _db.Losses.Where(l => l.Number.StartsWith(pfx)).OrderByDescending(l => l.Number).Select(l => l.Number).FirstOrDefaultAsync(ct);
var seq = 1;
if (last is not null && int.TryParse(last[pfx.Length..], out var n)) seq = n + 1;
return $"{pfx}{seq:D6}";
}
private static bool IsSerializationConflict(Exception ex) private static bool IsSerializationConflict(Exception ex)
{ {
for (Exception? e = ex; e is not null; e = e.InnerException) for (Exception? e = ex; e is not null; e = e.InnerException)
@ -641,10 +733,16 @@ orderby l.SortOrder
l.BookQty, l.ActualQty, l.Diff, l.UnitCost, l.SortOrder)) l.BookQty, l.ActualQty, l.Diff, l.UnitCost, l.SortOrder))
.ToListAsync(ct); .ToListAsync(ct);
var related = new List<RelatedDocDto>();
related.AddRange(await _db.Enters.AsNoTracking().Where(e => e.InventoryDocId == id)
.Select(e => new RelatedDocDto("enter", e.Id, e.Number, e.Status == EnterStatus.Posted)).ToListAsync(ct));
related.AddRange(await _db.Losses.AsNoTracking().Where(l => l.InventoryDocId == id)
.Select(l => new RelatedDocDto("loss", l.Id, l.Number, l.Status == LossStatus.Posted)).ToListAsync(ct));
return new InventoryDto( return new InventoryDto(
row.i.Id, row.i.Number, row.i.Date, row.i.Status, row.i.Id, row.i.Number, row.i.Date, row.i.Status,
row.st.Id, row.st.Name, row.st.Id, row.st.Name,
row.i.Notes, row.i.PostedAt, row.i.Notes, row.i.PostedAt,
lines); lines, related);
} }
} }

View file

@ -0,0 +1,170 @@
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Inventory;
/// <summary>Разовая миграция старой модели инвентаризации в модель МойСклада:
/// инвентаризации, которые раньше сами двигали остатки движениями
/// <c>MovementType.InventoryAdjustment</c>, преобразуются в связанные ПРОВЕДЁННЫЕ
/// Оприходование (излишки) и Списание (недостача). Движения НЕ задваиваются —
/// существующие InventoryAdjustment-движения ПЕРЕМЕЧАЮТСЯ на новые документы
/// (Type→Enter/WriteOff, DocumentType→enter/loss, DocumentId/Number→новый док).
/// Остатки по товарам остаются неизменными (таблица stocks не трогается).
///
/// Выполняется для текущей организации (для прод — запускать под каждой орг).
/// Dry-run по умолчанию (apply=false) — только отчёт. См. deploy/migrate-inventory-to-docs.sh.</summary>
[ApiController]
[Authorize(Roles = "SuperAdmin,Admin")]
[Route("api/admin/inventory-migration")]
public class InventoryMigrationController : ControllerBase
{
private readonly AppDbContext _db;
public InventoryMigrationController(AppDbContext db) => _db = db;
public record MigRow(Guid InventoryId, string InventoryNumber, int SurplusLines, int ShortageLines,
decimal SurplusValue, decimal ShortageValue, string? EnterNumber, string? LossNumber);
public record MigReport(bool Applied, int Candidates, int Migrated, bool StockUnchanged,
int StockDiffRows, IReadOnlyList<MigRow> Rows);
[HttpPost("run")]
public async Task<ActionResult<MigReport>> Run([FromQuery] bool apply = false, CancellationToken ct = default)
{
// Кандидаты: Completed-инвентаризации с «старыми» движениями inventory и без связанных документов.
var candidateIds = await _db.InventoryDocs
.Where(i => i.Status == InventoryStatus.Completed)
.Where(i => _db.StockMovements.Any(m => m.DocumentType == "inventory" && m.DocumentId == i.Id))
.Where(i => !_db.Enters.Any(e => e.InventoryDocId == i.Id) && !_db.Losses.Any(l => l.InventoryDocId == i.Id))
.OrderBy(i => i.Date)
.Select(i => i.Id)
.ToListAsync(ct);
// Снимок остатков ДО (для проверки неизменности).
var before = await _db.Stocks.AsNoTracking()
.Select(s => new { s.ProductId, s.StoreId, s.Quantity })
.ToDictionaryAsync(s => (s.ProductId, s.StoreId), s => s.Quantity, ct);
var currencyId = await DefaultCurrencyIdAsync(ct);
var rows = new List<MigRow>();
int migrated = 0;
// Счётчики номеров (чтобы в одном прогоне не было коллизий).
int enterSeq = await MaxSeqAsync(isEnter: true, ct);
int lossSeq = await MaxSeqAsync(isEnter: false, ct);
await using var tx = apply ? await _db.Database.BeginTransactionAsync(ct) : null;
foreach (var invId in candidateIds)
{
var inv = await _db.InventoryDocs.FirstAsync(i => i.Id == invId, ct);
var movements = await _db.StockMovements
.Where(m => m.DocumentType == "inventory" && m.DocumentId == invId)
.ToListAsync(ct);
var surplus = movements.Where(m => m.Quantity > 0m).ToList();
var shortage = movements.Where(m => m.Quantity < 0m).ToList();
var surplusVal = surplus.Sum(m => m.Quantity * (m.UnitCost ?? 0m));
var shortageVal = shortage.Sum(m => m.Quantity * (m.UnitCost ?? 0m));
string? enterNumber = null, lossNumber = null;
if (apply)
{
if (surplus.Count > 0)
{
enterNumber = $"О-{inv.Date.Year}-{++enterSeq:D6}";
var enter = new Enter
{
OrganizationId = inv.OrganizationId,
Number = enterNumber, Date = inv.Date, Status = EnterStatus.Posted,
StoreId = inv.StoreId, CurrencyId = currencyId, InventoryDocId = inv.Id,
Notes = $"Излишки по инвентаризации {inv.Number} (миграция)",
PostedAt = inv.PostedAt ?? inv.Date,
Total = surplus.Sum(m => m.Quantity * (m.UnitCost ?? 0m)),
Lines = surplus.Select((m, idx) => new EnterLine
{
OrganizationId = inv.OrganizationId, ProductId = m.ProductId,
Quantity = m.Quantity, UnitCost = m.UnitCost ?? 0m,
LineTotal = m.Quantity * (m.UnitCost ?? 0m), SortOrder = idx,
}).ToList(),
};
_db.Enters.Add(enter);
// ПЕРЕМЕТИТЬ движения (не задваивать): теперь принадлежат оприходованию.
foreach (var m in surplus)
{
m.Type = MovementType.Enter;
m.DocumentType = "enter"; m.DocumentId = enter.Id; m.DocumentNumber = enter.Number;
}
}
if (shortage.Count > 0)
{
lossNumber = $"С-{inv.Date.Year}-{++lossSeq:D6}";
var loss = new Loss
{
OrganizationId = inv.OrganizationId,
Number = lossNumber, Date = inv.Date, Status = LossStatus.Posted,
StoreId = inv.StoreId, CurrencyId = currencyId, Reason = LossReason.Shortage,
InventoryDocId = inv.Id, Notes = $"Недостача по инвентаризации {inv.Number} (миграция)",
PostedAt = inv.PostedAt ?? inv.Date,
Total = shortage.Sum(m => -m.Quantity * (m.UnitCost ?? 0m)),
Lines = shortage.Select((m, idx) => new LossLine
{
OrganizationId = inv.OrganizationId, ProductId = m.ProductId,
Quantity = -m.Quantity, UnitCost = m.UnitCost ?? 0m,
LineTotal = -m.Quantity * (m.UnitCost ?? 0m), SortOrder = idx,
}).ToList(),
};
_db.Losses.Add(loss);
foreach (var m in shortage)
{
m.Type = MovementType.WriteOff;
m.DocumentType = "loss"; m.DocumentId = loss.Id; m.DocumentNumber = loss.Number;
}
}
migrated++;
}
rows.Add(new MigRow(invId, inv.Number, surplus.Count, shortage.Count, surplusVal, shortageVal, enterNumber, lossNumber));
}
if (apply)
{
await _db.SaveChangesAsync(ct);
await tx!.CommitAsync(ct);
}
// Снимок остатков ПОСЛЕ — должен совпасть (мы движения только переметили).
var after = await _db.Stocks.AsNoTracking()
.Select(s => new { s.ProductId, s.StoreId, s.Quantity })
.ToDictionaryAsync(s => (s.ProductId, s.StoreId), s => s.Quantity, ct);
int diff = 0;
foreach (var kv in after)
if (!before.TryGetValue(kv.Key, out var b) || b != kv.Value) diff++;
foreach (var kv in before)
if (!after.ContainsKey(kv.Key)) diff++;
return Ok(new MigReport(apply, candidateIds.Count, migrated, diff == 0, diff, rows));
}
private async Task<int> MaxSeqAsync(bool isEnter, CancellationToken ct)
{
// Берём максимальный хвост номера среди всех «О-»/«С-» документов (по всем годам) грубо:
// новые номера генерим от (max+1). Для миграции достаточно монотонности.
var numbers = isEnter
? await _db.Enters.Select(e => e.Number).ToListAsync(ct)
: await _db.Losses.Select(l => l.Number).ToListAsync(ct);
int max = 0;
foreach (var n in numbers)
{
var dash = n.LastIndexOf('-');
if (dash >= 0 && int.TryParse(n[(dash + 1)..], out var s) && s > max) max = s;
}
return max;
}
private async Task<Guid> DefaultCurrencyIdAsync(CancellationToken ct)
{
var orgCur = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct);
if (orgCur is { } c) return c;
return await _db.Currencies.Where(x => x.Code == "KZT").Select(x => x.Id).FirstOrDefaultAsync(ct);
}
}

View file

@ -48,7 +48,9 @@ public record LossDto(
Guid CurrencyId, string CurrencyCode, Guid CurrencyId, string CurrencyCode,
string? Notes, string? Notes,
decimal Total, DateTime? PostedAt, decimal Total, DateTime? PostedAt,
IReadOnlyList<LossLineDto> Lines); IReadOnlyList<LossLineDto> Lines,
// «На основании инвентаризации №…» (null — обычное списание).
Guid? InventoryDocId, string? InventoryNumber);
public record LossLineInput( public record LossLineInput(
Guid ProductId, Guid ProductId,
@ -387,12 +389,16 @@ orderby l.SortOrder
.Select(s => (decimal?)s.Quantity).FirstOrDefault())) .Select(s => (decimal?)s.Quantity).FirstOrDefault()))
.ToListAsync(ct); .ToListAsync(ct);
string? invNumber = row.l.InventoryDocId is { } invId
? await _db.InventoryDocs.Where(i => i.Id == invId).Select(i => i.Number).FirstOrDefaultAsync(ct)
: null;
return new LossDto( return new LossDto(
row.l.Id, row.l.Number, row.l.Date, row.l.Status, row.l.Reason, row.l.Id, row.l.Number, row.l.Date, row.l.Status, row.l.Reason,
row.st.Id, row.st.Name, row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code, row.cu.Id, row.cu.Code,
row.l.Notes, row.l.Notes,
row.l.Total, row.l.PostedAt, row.l.Total, row.l.PostedAt,
lines); lines,
row.l.InventoryDocId, invNumber);
} }
} }

View file

@ -48,7 +48,9 @@ public record EnterDto(
Guid CurrencyId, string CurrencyCode, Guid CurrencyId, string CurrencyCode,
string? Notes, string? Notes,
decimal Total, DateTime? PostedAt, decimal Total, DateTime? PostedAt,
IReadOnlyList<EnterLineDto> Lines); IReadOnlyList<EnterLineDto> Lines,
// «На основании инвентаризации №…» (null — обычное оприходование).
Guid? InventoryDocId, string? InventoryNumber);
public record EnterLineInput( public record EnterLineInput(
Guid ProductId, Guid ProductId,
@ -391,12 +393,16 @@ orderby l.SortOrder
l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder)) l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder))
.ToListAsync(ct); .ToListAsync(ct);
string? invNumber = row.e.InventoryDocId is { } invId
? await _db.InventoryDocs.Where(i => i.Id == invId).Select(i => i.Number).FirstOrDefaultAsync(ct)
: null;
return new EnterDto( return new EnterDto(
row.e.Id, row.e.Number, row.e.Date, row.e.Status, row.e.Id, row.e.Number, row.e.Date, row.e.Status,
row.st.Id, row.st.Name, row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code, row.cu.Id, row.cu.Code,
row.e.Notes, row.e.Notes,
row.e.Total, row.e.PostedAt, row.e.Total, row.e.PostedAt,
lines); lines,
row.e.InventoryDocId, invNumber);
} }
} }

View file

@ -368,13 +368,16 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
UnitCost: transferProd.Cost, OccurredAt: transfer.Date), ct); UnitCost: transferProd.Cost, OccurredAt: transfer.Date), ct);
_db.Transfers.Add(transfer); _db.Transfers.Add(transfer);
// ── 1 инвентаризация (Posted) с небольшим diff ────────────────────── // ── 1 инвентаризация (Completed) с небольшим diff ───────────────────
// По модели МойСклада инвентаризация остатки НЕ двигает; результаты
// применяются связанными Оприходованием/Списанием (кнопки в карточке).
// Демо оставляем в статусе «Завершена, не применено».
var invProds = products.OrderBy(_ => _rng.Next()).Take(5).ToList(); var invProds = products.OrderBy(_ => _rng.Next()).Take(5).ToList();
var inv = new InventoryDoc var inv = new InventoryDoc
{ {
OrganizationId = orgId, OrganizationId = orgId,
Number = "ИНВ-DEMO-001", Number = "ИНВ-DEMO-001",
Date = now.AddDays(-1), Status = InventoryStatus.Posted, PostedAt = now.AddDays(-1), Date = now.AddDays(-1), Status = InventoryStatus.Completed, PostedAt = now.AddDays(-1),
StoreId = mainStore.Id, Notes = "DEMO", StoreId = mainStore.Id, Notes = "DEMO",
}; };
int iOrder = 0; int iOrder = 0;
@ -383,7 +386,6 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
var book = await _db.Stocks var book = await _db.Stocks
.Where(s => s.StoreId == mainStore.Id && s.ProductId == p.Id) .Where(s => s.StoreId == mainStore.Id && s.ProductId == p.Id)
.Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m; .Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m;
// Для демо: +/- 1 от книги (можно случайно)
var diff = _rng.Next(-1, 2); var diff = _rng.Next(-1, 2);
var actual = Math.Max(0m, book + diff); var actual = Math.Max(0m, book + diff);
inv.Lines.Add(new InventoryLine inv.Lines.Add(new InventoryLine
@ -393,15 +395,6 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
ProductId = p.Id, BookQty = book, ActualQty = actual, Diff = actual - book, ProductId = p.Id, BookQty = book, ActualQty = actual, Diff = actual - book,
UnitCost = p.Cost, SortOrder = iOrder++, UnitCost = p.Cost, SortOrder = iOrder++,
}); });
if (actual - book != 0m)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: p.Id, StoreId: mainStore.Id, Quantity: actual - book,
Type: MovementType.InventoryAdjustment, DocumentType: "inventory",
DocumentId: inv.Id, DocumentNumber: inv.Number,
UnitCost: p.Cost, OccurredAt: inv.Date,
Notes: actual > book ? "surplus" : "shortage"), ct);
}
} }
_db.InventoryDocs.Add(inv); _db.InventoryDocs.Add(inv);

View file

@ -464,7 +464,9 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
{ {
OrganizationId = orgId, OrganizationId = orgId,
Number = $"ИНВ-Y1-{i + 1:D3}", Number = $"ИНВ-Y1-{i + 1:D3}",
Date = date, Status = InventoryStatus.Posted, PostedAt = date, // Модель МойСклада: инвентаризация остатки не двигает (Completed,
// результаты применяются связанными Оприходованием/Списанием).
Date = date, Status = InventoryStatus.Completed, PostedAt = date,
StoreId = mainStore.Id, Notes = "Y1", StoreId = mainStore.Id, Notes = "Y1",
}; };
int order = 0; int order = 0;
@ -480,18 +482,6 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
BookQty = book, ActualQty = actual, Diff = actual - book, BookQty = book, ActualQty = actual, Diff = actual - book,
UnitCost = p.Cost, SortOrder = order++, UnitCost = p.Cost, SortOrder = order++,
}); });
if (actual - book != 0)
{
movements.Add(new StockMovement
{
OrganizationId = orgId, ProductId = p.Id, StoreId = mainStore.Id,
Quantity = actual - book, UnitCost = p.Cost,
Type = MovementType.InventoryAdjustment,
DocumentType = "inventory", DocumentId = inv.Id, DocumentNumber = inv.Number,
OccurredAt = date,
Notes = actual > book ? "surplus" : "shortage",
});
}
} }
_db.InventoryDocs.Add(inv); _db.InventoryDocs.Add(inv);
inventories.Add(inv); inventories.Add(inv);

View file

@ -0,0 +1,42 @@
namespace foodmarket.Application.Inventory;
/// <summary>Чистая логика «разбить результат инвентаризации на строки
/// Оприходования (излишки) и Списания (недостача)». Вынесено для юнит-тестов.
///
/// Правила (по модели МойСклада):
/// - diff = факт − расчётное; строки с diff == 0 НЕ участвуют (непосчитанные/совпавшие
/// не трогаются);
/// - diff &gt; 0 → излишек → строка Оприходования, количество = diff;
/// - diff &lt; 0 → недостача → строка Списания, количество = |diff|;
/// - сумма строки = количество × UnitCost (снимок себестоимости).
/// Сама инвентаризация остатки НЕ двигает.</summary>
public static class InventoryResult
{
public readonly record struct ResultLine(Guid ProductId, decimal Quantity, decimal UnitCost)
{
public decimal LineTotal => decimal.Round(Quantity * UnitCost, 2);
}
public readonly record struct Split(IReadOnlyList<ResultLine> Surplus, IReadOnlyList<ResultLine> Shortage)
{
public bool HasSurplus => Surplus.Count > 0;
public bool HasShortage => Shortage.Count > 0;
public decimal SurplusTotal => Surplus.Sum(l => l.LineTotal);
public decimal ShortageTotal => Shortage.Sum(l => l.LineTotal);
}
/// <summary>Разбивает строки инвентаризации (productId, diff, unitCost) на излишки/недостачи.
/// diff==0 пропускаются. Количество в Списании положительное (|diff|).</summary>
public static Split Build(IEnumerable<(Guid ProductId, decimal Diff, decimal UnitCost)> lines)
{
var surplus = new List<ResultLine>();
var shortage = new List<ResultLine>();
foreach (var l in lines)
{
if (l.Diff > 0m) surplus.Add(new ResultLine(l.ProductId, l.Diff, l.UnitCost));
else if (l.Diff < 0m) shortage.Add(new ResultLine(l.ProductId, -l.Diff, l.UnitCost));
// diff == 0 — пропускаем
}
return new Split(surplus, shortage);
}
}

View file

@ -5,16 +5,21 @@ namespace foodmarket.Domain.Inventory;
public enum InventoryStatus public enum InventoryStatus
{ {
/// <summary>Идёт подсчёт — строки можно править.</summary>
Draft = 0, Draft = 0,
Posted = 1, /// <summary>Подсчёт закончен, строки заморожены. По модели МойСклада
/// инвентаризация НЕ двигает остатки — изменения остатков делают связанные
/// Оприходование (излишки) и Списание (недостача), создаваемые «на основании».
/// Прежнее значение называлось Posted (двигало остатки напрямую) — после
/// миграции 20261009 переименовано в Completed, движения перенесены в документы.</summary>
Completed = 1,
} }
/// <summary>Документ инвентаризации (пересчёта). При создании контроллер /// <summary>Документ инвентаризации (пересчёта). При создании контроллер
/// заполняет <c>bookQty</c> по текущему <see cref="Stock"/> склада. После /// заполняет <c>bookQty</c> по текущему <see cref="Stock"/> склада. Пользователь
/// внесения фактических количеств (<c>actualQty</c>) при Post создаются /// вносит фактические количества (<c>actualQty</c>); документ завершается
/// корректирующие движения <see cref="MovementType.InventoryAdjustment"/> /// (Completed) БЕЗ движения остатков. По результату создаются связанные
/// на <c>diff = actual - book</c>: положительные приходят (излишек), /// Оприходование/Списание на <c>diff = actual - book</c> (излишек/недостача).
/// отрицательные списываются (недостача).
/// ///
/// Назван <c>InventoryDoc</c> чтобы не конфликтовать с .NET-неймспейсом /// Назван <c>InventoryDoc</c> чтобы не конфликтовать с .NET-неймспейсом
/// <c>System.Collections.Specialized.Inventory</c> и не путаться с самой /// <c>System.Collections.Specialized.Inventory</c> и не путаться с самой

View file

@ -48,6 +48,10 @@ public class Loss : TenantEntity
/// <summary>Сумма по строкам = Σ Quantity·UnitCost. Балансовая стоимость списания.</summary> /// <summary>Сумма по строкам = Σ Quantity·UnitCost. Балансовая стоимость списания.</summary>
public decimal Total { get; set; } public decimal Total { get; set; }
/// <summary>Если документ создан «на основании инвентаризации» (недостача) —
/// ссылка на неё. Иначе null (обычное списание брака/просрочки).</summary>
public Guid? InventoryDocId { get; set; }
public DateTime? PostedAt { get; set; } public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; } public Guid? PostedByUserId { get; set; }

View file

@ -36,6 +36,10 @@ public class Enter : TenantEntity
/// <summary>Сумма по строкам = Σ Quantity·UnitCost.</summary> /// <summary>Сумма по строкам = Σ Quantity·UnitCost.</summary>
public decimal Total { get; set; } public decimal Total { get; set; }
/// <summary>Если документ создан «на основании инвентаризации» (излишки) —
/// ссылка на неё. Иначе null (обычное оприходование начальных остатков).</summary>
public Guid? InventoryDocId { get; set; }
public DateTime? PostedAt { get; set; } public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; } public Guid? PostedByUserId { get; set; }

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase38 — связь Оприходования/Списания с инвентаризацией (модель
/// МойСклада: инвентаризация не двигает остатки; излишки/недостача оформляются
/// связанными документами). Добавляет nullable <c>InventoryDocId</c> в
/// <c>enters</c> и <c>losses</c> + индексы для выборки «связанные документы».
/// Статус инвентаризации Posted(1) переосмыслен как Completed(1) — числовое
/// значение не меняется; перенос движений делает отдельный скрипт миграции данных
/// (deploy/migrate-inventory-to-docs.sql / .sh). Идемпотентно.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261009000000_Phase38_InventoryLinks")]
public partial class Phase38_InventoryLinks : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
ALTER TABLE public.enters ADD COLUMN IF NOT EXISTS ""InventoryDocId"" uuid NULL;
ALTER TABLE public.losses ADD COLUMN IF NOT EXISTS ""InventoryDocId"" uuid NULL;
CREATE INDEX IF NOT EXISTS ""IX_enters_InventoryDocId"" ON public.enters (""InventoryDocId"") WHERE ""InventoryDocId"" IS NOT NULL;
CREATE INDEX IF NOT EXISTS ""IX_losses_InventoryDocId"" ON public.losses (""InventoryDocId"") WHERE ""InventoryDocId"" IS NOT NULL;
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
DROP INDEX IF EXISTS ""IX_enters_InventoryDocId"";
DROP INDEX IF EXISTS ""IX_losses_InventoryDocId"";
ALTER TABLE public.enters DROP COLUMN IF EXISTS ""InventoryDocId"";
ALTER TABLE public.losses DROP COLUMN IF EXISTS ""InventoryDocId"";
");
}
}
}

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>

View file

@ -195,13 +195,13 @@ public void Inventory_movement_loss_transfer_pocos()
Number = "ИН-001", Number = "ИН-001",
Date = DateTime.UtcNow, Date = DateTime.UtcNow,
StoreId = Guid.NewGuid(), StoreId = Guid.NewGuid(),
Status = InventoryStatus.Posted, Status = InventoryStatus.Completed,
}; };
inv.Lines.Add(new InventoryLine { ProductId = Guid.NewGuid(), BookQty = 10m, ActualQty = 9m, Diff = -1m }); inv.Lines.Add(new InventoryLine { ProductId = Guid.NewGuid(), BookQty = 10m, ActualQty = 9m, Diff = -1m });
loss.Status.Should().Be(LossStatus.Posted); loss.Status.Should().Be(LossStatus.Posted);
t.Status.Should().Be(TransferStatus.Posted); t.Status.Should().Be(TransferStatus.Posted);
inv.Status.Should().Be(InventoryStatus.Posted); inv.Status.Should().Be(InventoryStatus.Completed);
} }
[Fact] [Fact]

View file

@ -0,0 +1,76 @@
using foodmarket.Application.Inventory;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Логика разбивки результата инвентаризации на Оприходование/Списание
/// (модель МойСклада). diff==0 не участвует; суммы = кол-во × себестоимость.</summary>
public class InventoryResultTests
{
private static (Guid, decimal, decimal) L(decimal diff, decimal cost) => (Guid.NewGuid(), diff, cost);
[Fact]
public void Surplus_goes_to_enter_shortage_to_loss()
{
var p1 = Guid.NewGuid(); var p2 = Guid.NewGuid();
var split = InventoryResult.Build(new[]
{
(p1, 3m, 100m), // излишек +3
(p2, -2m, 50m), // недостача -2
});
Assert.Single(split.Surplus);
Assert.Single(split.Shortage);
Assert.Equal(p1, split.Surplus[0].ProductId);
Assert.Equal(3m, split.Surplus[0].Quantity);
Assert.Equal(p2, split.Shortage[0].ProductId);
Assert.Equal(2m, split.Shortage[0].Quantity); // |-2|
}
[Fact]
public void Zero_diff_lines_are_ignored()
{
var split = InventoryResult.Build(new[]
{
L(0m, 100m), // совпало — не трогаем
L(0m, 999m),
L(1m, 10m), // только эта
});
Assert.Single(split.Surplus);
Assert.Empty(split.Shortage);
Assert.Equal(1m, split.Surplus[0].Quantity);
}
[Fact]
public void Sums_are_quantity_times_cost()
{
var split = InventoryResult.Build(new[]
{
L(2m, 150m), // +2 × 150 = 300
L(5m, 20m), // +5 × 20 = 100
L(-3m, 40m), // 3 × 40 = 120 (недостача)
});
Assert.Equal(400m, split.SurplusTotal);
Assert.Equal(120m, split.ShortageTotal);
Assert.True(split.HasSurplus);
Assert.True(split.HasShortage);
}
[Fact]
public void Line_total_rounds_to_two_decimals()
{
var split = InventoryResult.Build(new[] { L(3m, 548.6441m) });
// 3 × 548.6441 = 1645.9323 → округление до 2 знаков
Assert.Equal(1645.93m, split.Surplus[0].LineTotal);
}
[Fact]
public void No_diffs_gives_empty_split()
{
var split = InventoryResult.Build(new[] { L(0m, 10m), L(0m, 20m) });
Assert.False(split.HasSurplus);
Assert.False(split.HasShortage);
Assert.Equal(0m, split.SurplusTotal);
Assert.Equal(0m, split.ShortageTotal);
}
}