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>
This commit is contained in:
nns 2026-10-08 20:34:05 +05:00
parent 466e546ae2
commit 0d44cdabaf
15 changed files with 659 additions and 140 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,34 @@
# Инвентаризация по модели МойСклада — прогресс
Замечание владельца (08.10.2026): «инвентаризация стоит в состоянии „проведено“,
но без оприходования и списания — логика непонятная».
**Сейчас (неправильно):** `InventoriesController.Post` сам применяет движения
`MovementType.InventoryAdjustment` к остаткам и ставит `Status=Posted` — без создания
Оприходования/Списания. Владелец видит «Проведено» без связанных документов; в отчётах
непонятно, откуда изменения.
**Как должно быть (по МойСкладу):** инвентаризация НЕ двигает остатки; она фиксирует
расчётный/фактический/разницу, а движения создаются связанными документами
Оприходование (излишки) и Списание (недостача).
## План / статус
0. [ ] Сверка с МойСкладом (headless Playwright) → `docs/inventory-moysklad-reference.md`.
1. [ ] Инвентаризация не двигает остатки; статусы «Черновик» → «Завершена» (без «Проведено»).
2. [ ] На завершённой — кнопки «Создать оприходование излишков» / «Создать списание недостачи»
/ «Применить результаты» (обе). Документы со строками разницы + ссылка на инвентаризацию.
3. [ ] Блок «Связанные документы» в карточке; колонка «Результат» в списке; обратная ссылка в enter/loss.
4. [ ] Отмена проведения связанных — штатно; инвентаризацию нельзя вернуть в черновик при
проведённых связанных (понятный отказ).
5. [ ] Миграция Posted+InventoryAdjustment → связанные проведённые Оприходование/Списание
(движения перенести, не задвоить); статус → «Завершена». Остатки до/после совпадают. Backup stage.
6. [ ] Веб + мобильное API (post/unpost совместимо: post = завершить+применить) + CSV-импорт.
7. [ ] Тесты: unit / integration / e2e на стенде; full-chain шаг 10 + UAT.
8. [ ] docs/supply-inventory.md — инструкция «Как провести инвентаризацию».
9. [ ] deploy-stage; коммиты в main (Co-Authored-By: Claude Opus 5.5); global.json не коммитить; не мешать с rebrand.
## Что НЕ трогаем
Прод, shared-postgres, global.json, gateway nginx, sklad.
## Журнал
- 08.10 — старт: прочитано замечание, составлен план. Следующий шаг — сверка с МойСкладом.

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

@ -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);
}
}