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

View file

@ -3,6 +3,7 @@
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
@ -11,11 +12,13 @@
namespace foodmarket.Api.Controllers.Inventory;
/// <summary>Инвентаризация (пересчёт). Создание подгружает текущие остатки
/// склада в <c>bookQty</c>; пользователь вносит фактические количества;
/// при Post создаются корректирующие движения <see cref="MovementType.InventoryAdjustment"/>
/// на <c>diff = actual - book</c> (положительный — приход, отрицательный
/// — списание).</summary>
/// <summary>Инвентаризация (пересчёт) по модели МойСклада. Создание подгружает
/// текущие остатки склада в <c>bookQty</c>; пользователь вносит фактические
/// количества. «Завершение» (<c>complete</c>) замораживает строки (Completed) и
/// НЕ двигает остатки. По результату создаются связанные Оприходование
/// (<c>create-enter</c>, излишки diff&gt;0) и Списание (<c>create-loss</c>, недостача
/// diff&lt;0) — их проводят как обычные документы (вот тогда меняются остатки).
/// <c>post</c>/<c>unpost</c> — временная совместимость для мобильного «FM Склад».</summary>
[ApiController]
[Authorize]
[Route("api/inventory/inventories")]
@ -35,19 +38,26 @@ public record InventoryListRow(
Guid StoreId, string StoreName,
int LineCount,
decimal SurplusValue, decimal ShortageValue,
DateTime? PostedAt);
DateTime? PostedAt,
// Результат: связанные документы (null — «не применено»).
Guid? EnterId, string? EnterNumber, Guid? LossId, string? LossNumber);
public record InventoryLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal BookQty, decimal ActualQty, decimal Diff, decimal UnitCost,
int SortOrder);
/// <summary>Связанный документ результата: «enter» (оприходование) / «loss» (списание).
/// <c>Posted</c> — проведён ли (двигал ли остатки).</summary>
public record RelatedDocDto(string Kind, Guid Id, string Number, bool Posted);
public record InventoryDto(
Guid Id, string Number, DateTime Date, InventoryStatus Status,
Guid StoreId, string StoreName,
string? Notes,
DateTime? PostedAt,
IReadOnlyList<InventoryLineDto> Lines);
IReadOnlyList<InventoryLineDto> Lines,
IReadOnlyList<RelatedDocDto> Related);
public record InventoryLineInput(
Guid ProductId,
@ -239,7 +249,11 @@ await using (var fs = System.IO.File.Create(tmp))
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.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);
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();
}
// ── Завершение + результирующие документы (модель МойСклада) ─────────
// Инвентаризация НЕ двигает остатки. Завершаем (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")]
public async Task<IActionResult> Post(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.Posted) return Conflict(new { error = "Документ уже проведён." });
if (doc.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
var withDiff = doc.Lines.Where(l => l.Diff != 0m).ToList();
if (withDiff.Count == 0)
return BadRequest(new { error = "Нет расхождений учётного и фактического количества — нечего проводить." });
await using var tx = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
if (doc.Status != InventoryStatus.Completed) { doc.Status = InventoryStatus.Completed; doc.PostedAt = DateTime.UtcNow; }
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow;
foreach (var line in withDiff)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
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 = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
var split = InventoryResult.Build(doc.Lines.Select(l => (l.ProductId, l.Diff, l.UnitCost)));
if (split.HasSurplus && !await _db.Enters.AnyAsync(e => e.InventoryDocId == id, ct))
await BuildEnterAsync(doc, split.Surplus, post: true, ct);
if (split.HasShortage && !await _db.Losses.AnyAsync(l => l.InventoryDocId == id, ct))
await BuildLossAsync(doc, split.Shortage, post: true, ct);
try { await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); }
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ проводится параллельно. Повторите попытку." }); }
return NoContent();
}
/// <summary>Отменить результаты: отменить проведение и удалить связанные
/// Оприходование/Списание (вернуть остатки), затем вернуть инвентаризацию в
/// черновик. Веб предупреждает пользователя об удалении связанных документов.
/// Мобильный /unpost (совместимость) — эта же логика.</summary>
[HttpPost("{id:guid}/unpost"), RequiresPermission("InventoryEdit")]
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.Status != InventoryStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: для каждой строки c diff != 0 — обратное движение на -diff.
// Защита от ухода в минус: если diff был положительный (излишек), при unpost
// мы списываем (-diff = -surplus) — стоит проверить что эта величина в наличии.
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)
await using var tx = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
var enters = await _db.Enters.Include(e => e.Lines).Where(e => e.InventoryDocId == id).ToListAsync(ct);
foreach (var e in enters)
{
stocks.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products.Where(p => p.Id == r.ProductId)
.Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new
{
productId = r.ProductId,
productName = name,
reverseQty = r.Quantity,
available,
});
}
if (e.Status == EnterStatus.Posted)
foreach (var l in e.Lines)
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, e.StoreId, -l.Quantity,
MovementType.Enter, "enter-reversal", e.Id, e.Number, l.UnitCost, DateTime.UtcNow,
Notes: $"Отмена {e.Number} (инвентаризация {doc.Number})"), ct);
_db.Enters.Remove(e);
}
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
{
error = "Нельзя отменить проведение: остаток уйдёт в минус (излишек уже расходован).",
lines = conflicts,
});
}
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);
if (ls.Status == LossStatus.Posted)
foreach (var l in ls.Lines)
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, ls.StoreId, l.Quantity,
MovementType.WriteOff, "loss-reversal", ls.Id, ls.Number, l.UnitCost, DateTime.UtcNow,
Notes: $"Отмена {ls.Number} (инвентаризация {doc.Number})"), ct);
_db.Losses.Remove(ls);
}
doc.Status = InventoryStatus.Draft;
doc.PostedAt = null;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." });
}
try { await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); }
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." }); }
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)
{
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))
.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(
row.i.Id, row.i.Number, row.i.Date, row.i.Status,
row.st.Id, row.st.Name,
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,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<LossLineDto> Lines);
IReadOnlyList<LossLineDto> Lines,
// «На основании инвентаризации №…» (null — обычное списание).
Guid? InventoryDocId, string? InventoryNumber);
public record LossLineInput(
Guid ProductId,
@ -387,12 +389,16 @@ orderby l.SortOrder
.Select(s => (decimal?)s.Quantity).FirstOrDefault()))
.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(
row.l.Id, row.l.Number, row.l.Date, row.l.Status, row.l.Reason,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.l.Notes,
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,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<EnterLineDto> Lines);
IReadOnlyList<EnterLineDto> Lines,
// «На основании инвентаризации №…» (null — обычное оприходование).
Guid? InventoryDocId, string? InventoryNumber);
public record EnterLineInput(
Guid ProductId,
@ -391,12 +393,16 @@ orderby l.SortOrder
l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder))
.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(
row.e.Id, row.e.Number, row.e.Date, row.e.Status,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.e.Notes,
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);
_db.Transfers.Add(transfer);
// ── 1 инвентаризация (Posted) с небольшим diff ──────────────────────
// ── 1 инвентаризация (Completed) с небольшим diff ───────────────────
// По модели МойСклада инвентаризация остатки НЕ двигает; результаты
// применяются связанными Оприходованием/Списанием (кнопки в карточке).
// Демо оставляем в статусе «Завершена, не применено».
var invProds = products.OrderBy(_ => _rng.Next()).Take(5).ToList();
var inv = new InventoryDoc
{
OrganizationId = orgId,
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",
};
int iOrder = 0;
@ -383,7 +386,6 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
var book = await _db.Stocks
.Where(s => s.StoreId == mainStore.Id && s.ProductId == p.Id)
.Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m;
// Для демо: +/- 1 от книги (можно случайно)
var diff = _rng.Next(-1, 2);
var actual = Math.Max(0m, book + diff);
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,
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);

View file

@ -464,7 +464,9 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
{
OrganizationId = orgId,
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",
};
int order = 0;
@ -480,18 +482,6 @@ public async Task<SeedSummary> SeedAsync(Guid orgId, CancellationToken ct = defa
BookQty = book, ActualQty = actual, Diff = actual - book,
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);
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
{
/// <summary>Идёт подсчёт — строки можно править.</summary>
Draft = 0,
Posted = 1,
/// <summary>Подсчёт закончен, строки заморожены. По модели МойСклада
/// инвентаризация НЕ двигает остатки — изменения остатков делают связанные
/// Оприходование (излишки) и Списание (недостача), создаваемые «на основании».
/// Прежнее значение называлось Posted (двигало остатки напрямую) — после
/// миграции 20261009 переименовано в Completed, движения перенесены в документы.</summary>
Completed = 1,
}
/// <summary>Документ инвентаризации (пересчёта). При создании контроллер
/// заполняет <c>bookQty</c> по текущему <see cref="Stock"/> склада. После
/// внесения фактических количеств (<c>actualQty</c>) при Post создаются
/// корректирующие движения <see cref="MovementType.InventoryAdjustment"/>
/// на <c>diff = actual - book</c>: положительные приходят (излишек),
/// отрицательные списываются (недостача).
/// заполняет <c>bookQty</c> по текущему <see cref="Stock"/> склада. Пользователь
/// вносит фактические количества (<c>actualQty</c>); документ завершается
/// (Completed) БЕЗ движения остатков. По результату создаются связанные
/// Оприходование/Списание на <c>diff = actual - book</c> (излишек/недостача).
///
/// Назван <c>InventoryDoc</c> чтобы не конфликтовать с .NET-неймспейсом
/// <c>System.Collections.Specialized.Inventory</c> и не путаться с самой

View file

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

View file

@ -36,6 +36,10 @@ public class Enter : TenantEntity
/// <summary>Сумма по строкам = Σ Quantity·UnitCost.</summary>
public decimal Total { get; set; }
/// <summary>Если документ создан «на основании инвентаризации» (излишки) —
/// ссылка на неё. Иначе null (обычное оприходование начальных остатков).</summary>
public Guid? InventoryDocId { get; set; }
public DateTime? PostedAt { 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;
total: number; postedAt: string | null;
lines: EnterLineDto[];
inventoryDocId: string | null; inventoryNumber: string | null;
}
export const DemandStatus = { Draft: 0, Posted: 1 } as const
@ -219,6 +220,7 @@ export interface LossDto {
notes: string | null;
total: number; postedAt: string | null;
lines: LossLineDto[];
inventoryDocId: string | null; inventoryNumber: string | null;
}
export const TransferStatus = { Draft: 0, Posted: 1 } as const
@ -247,7 +249,8 @@ export interface TransferDto {
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 interface InventoryListRow {
@ -255,6 +258,9 @@ export interface InventoryListRow {
storeId: string; storeName: string;
lineCount: number; surplusValue: number; shortageValue: number;
postedAt: string | null;
// Результат: связанные документы (null — «не применено»).
enterId: string | null; enterNumber: string | null;
lossId: string | null; lossNumber: string | null;
}
export interface InventoryLineDto {
@ -264,12 +270,17 @@ export interface InventoryLineDto {
sortOrder: number;
}
export interface InventoryRelatedDoc {
kind: 'enter' | 'loss'; id: string; number: string; posted: boolean;
}
export interface InventoryDto {
id: string; number: string; date: string; status: InventoryStatus;
storeId: string; storeName: string;
notes: string | null;
postedAt: string | null;
lines: InventoryLineDto[];
related: InventoryRelatedDoc[];
}
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">
{isNew ? 'Новое оприходование' : existing.data?.number ?? 'Оприходование'}
</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 && (
<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>

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: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
r.status === InventoryStatus.Posted
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
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-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Склад', cell: (r) => r.storeName },
{ 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: '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-green-700', cell: (r) => `+${r.surplusValue.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="Инвентаризаций пока нет."
/>

View file

@ -4,7 +4,7 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Trash2, Save, CheckCircle, Upload } from 'lucide-react'
import { api } from '@/lib/api'
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 { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
@ -86,10 +86,15 @@ export function InventoryEditPage() {
}, [isNew, stores.data, form.storeId])
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 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({
mutationFn: async () => {
@ -129,33 +134,52 @@ export function InventoryEditPage() {
meta: { successMessage: 'Сохранено' },
})
const post = useMutation({
mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/post`) },
const errMsg = (e: Error) => (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
// Завершить инвентаризацию — заморозить строки (остатки НЕ двигаются).
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: () => {
qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
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: 'Снято с проведения' },
onError: (e: Error) => setError(errMsg(e)),
meta: { successMessage: 'Возвращено в черновик' },
})
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 на
// существующей), Esc = назад к списку. Esc отключается когда открыт ConfirmDialog.
@ -239,9 +262,9 @@ export function InventoryEditPage() {
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новая инвентаризация' : existing.data?.number ?? 'Инвентаризация'}
</h1>
{isPosted && (
{isCompleted && (
<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>
)}
</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">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата *">
<DateField required value={form.date || null} disabled={isPosted}
<DateField required value={form.date || null} disabled={!isDraft}
onChange={(iso) => setForm({ ...form, date: iso ?? '' })} />
</Field>
<Field label="Склад *">
@ -296,7 +319,7 @@ export function InventoryEditPage() {
</Select>
</Field>
<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 })}
placeholder="Состав комиссии, ссылка на акт…" />
</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>
</div>
{!isNew && (
{!isNew && isDraft && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<Checkbox
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || !canPost && !isPosted}
onChange={async (v) => {
if (v) {
<Button type="button" variant="secondary" disabled={complete.isPending || form.lines.length === 0}
onClick={async () => {
if (await confirm({
title: 'Завершить инвентаризацию?',
description: 'Строки будут заморожены. Остатки НЕ изменятся — по результату создайте Оприходование и Списание.',
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({
title: 'Провести инвентаризацию?',
description: 'Учтённые остатки будут скорректированы на разницу.',
confirmLabel: 'Провести',
tone: 'warning',
})) post.mutate()
} else {
if (await confirm({
title: 'Снять проведение?',
description: 'Корректировки отменятся.',
confirmLabel: 'Снять',
title: 'Вернуть в черновик?',
description: related.length > 0
? 'Связанные Оприходование/Списание будут отменены и удалены, остатки вернутся. Затем можно снова править.'
: 'Инвентаризация вернётся в черновик для правки.',
confirmLabel: 'Вернуть в черновик',
tone: 'warning',
})) 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>
)}
</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-right font-mono text-slate-500 dark:text-slate-400">{l.bookQty.toLocaleString('ru')}</td>
<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)} />
</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'}`}>

View file

@ -221,6 +221,14 @@ export function LossEditPage() {
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новое списание' : existing.data?.number ?? 'Списание'}
</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 && (
<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>

View file

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