food-market/src/food-market.api/Controllers/Inventory/LossesController.cs
nns 4e15359378
Some checks are pending
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
fix(docs): EF8 nav-collection bug в Enters/Losses/Transfers/SupplierReturns/Inventories.Update
Тот же баг что в TD-6 чинили на Supplies/Demands/RetailSales и в pt 2
на Products: добавление/замена line'ов через nav-collection даёт
DbUpdateConcurrencyException «0 rows affected» при следующем UPDATE
родителя. На документах без xmin это становится 500, на InventoryDoc
(с xmin от TD-6) — 409.

Переводим Enters/Losses/Transfers/SupplierReturns.Update на
ExecuteDelete + DbSet.Add (как Supplies). InventoriesController
дополнительно: добавление новых строк через _db.InventoryLines.Add
вместо doc.Lines.Add (RemoveRange/Clear там не было — merge-in-place
по ProductId).

Воспроизведение (на Enters):
1. POST /api/inventory/enters {lines:[A]}
2. PUT … {lines:[A,B]} (одна оставлена, одна новая) → было 500
   DbUpdateConcurrencyException ; стало 204.

stage-enter (10 шагов): CRUD + Post + Unpost + edge + multi-tenant +
concurrent PUT — все зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:57:48 +05:00

399 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Inventory;
/// <summary>Списание (Loss): уменьшение склада с указанием причины
/// (брак/просрочка/повреждение/недостача/прочее). При проведении создаёт
/// <see cref="StockMovement"/> тип <see cref="MovementType.WriteOff"/> с
/// отрицательным Quantity. Unpost блокируется проверкой не нужно (мы
/// добавляем обратно — остаток всегда увеличится).</summary>
[ApiController]
[Authorize]
[Route("api/inventory/losses")]
public class LossesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public LossesController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record LossListRow(
Guid Id, string Number, DateTime Date, LossStatus Status, LossReason Reason,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt);
public record LossLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? UnitSymbol,
decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder,
decimal? StockAtStore);
public record LossDto(
Guid Id, string Number, DateTime Date, LossStatus Status, LossReason Reason,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<LossLineDto> Lines);
public record LossLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitCost);
public record LossInput(
DateTime Date, Guid StoreId, Guid CurrencyId, LossReason Reason,
string? Notes,
IReadOnlyList<LossLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<LossListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] LossStatus? status,
[FromQuery] LossReason? reason,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from l in _db.Losses.AsNoTracking()
join st in _db.Stores on l.StoreId equals st.Id
join cu in _db.Currencies on l.CurrencyId equals cu.Id
select new { l, st, cu };
if (status is not null) q = q.Where(x => x.l.Status == status);
if (reason is not null) q = q.Where(x => x.l.Reason == reason);
if (storeId is not null) q = q.Where(x => x.l.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.l.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.l.Number),
("number", true) => q.OrderByDescending(x => x.l.Number),
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.l.Date),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.l.Date),
("status", false) => q.OrderBy(x => x.l.Status).ThenByDescending(x => x.l.Date),
("status", true) => q.OrderByDescending(x => x.l.Status).ThenByDescending(x => x.l.Date),
("reason", false) => q.OrderBy(x => x.l.Reason).ThenByDescending(x => x.l.Date),
("reason", true) => q.OrderByDescending(x => x.l.Reason).ThenByDescending(x => x.l.Date),
("total", false) => q.OrderBy(x => x.l.Total).ThenByDescending(x => x.l.Date),
("total", true) => q.OrderByDescending(x => x.l.Total).ThenByDescending(x => x.l.Date),
("date", false) => q.OrderBy(x => x.l.Date).ThenBy(x => x.l.Number),
_ => q.OrderByDescending(x => x.l.Date).ThenByDescending(x => x.l.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new LossListRow(
x.l.Id, x.l.Number, x.l.Date, x.l.Status, x.l.Reason,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.l.Total,
x.l.Lines.Count,
x.l.PostedAt))
.ToListAsync(ct);
return new PagedResult<LossListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<LossDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("LossEdit")]
public async Task<ActionResult<LossDto>> Create([FromBody] LossInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Списание должно содержать хотя бы одну позицию." });
var number = await GenerateNumberAsync(input.Date, ct);
var loss = new Loss
{
Number = number,
Date = input.Date,
Status = LossStatus.Draft,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
Reason = input.Reason,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
loss.Lines.Add(new LossLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
loss.Total = loss.Lines.Sum(x => x.LineTotal);
_db.Losses.Add(loss);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(loss.Id, ct);
return CreatedAtAction(nameof(Get), new { id = loss.Id }, dto);
}
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
{
try
{
await _db.SaveChangesAsync(ct);
return null;
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
var name = pg.ConstraintName ?? "";
string field = name.Contains("Store") ? "storeId"
: name.Contains("Currency") ? "currencyId"
: name.Contains("Product") ? "productId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Update(Guid id, [FromBody] LossInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Списание должно содержать хотя бы одну позицию." });
var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status != LossStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
loss.Date = input.Date;
loss.StoreId = input.StoreId;
loss.CurrencyId = input.CurrencyId;
loss.Reason = input.Reason;
loss.Notes = input.Notes;
// ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается и
// UPDATE losses падает с DbUpdateConcurrencyException «0 rows affected»
// даже без concurrency-токена. Тот же паттерн что в Supplies/Demands/RetailSales.Update.
await _db.LossLines.Where(l => l.LossId == loss.Id).ExecuteDeleteAsync(ct);
var order = 0;
decimal total = 0;
foreach (var l in input.Lines)
{
var lineTotal = l.Quantity * l.UnitCost;
_db.LossLines.Add(new LossLine
{
LossId = loss.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = lineTotal,
SortOrder = order++,
});
total += lineTotal;
}
loss.Total = total;
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var loss = await _db.Losses.FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status != LossStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Losses.Remove(loss);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status == LossStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (loss.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
// Защита от ухода stock в минус.
var byProduct = loss.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
var productIds = byProduct.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == loss.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List<object>();
foreach (var r in byProduct)
{
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,
writeOffQty = r.Quantity,
available,
});
}
}
if (conflicts.Count > 0)
{
return Conflict(new
{
error = "Нельзя списать больше, чем есть в наличии.",
lines = conflicts,
});
}
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow;
foreach (var line in loss.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: loss.StoreId,
Quantity: -line.Quantity,
Type: MovementType.WriteOff,
DocumentType: "loss",
DocumentId: loss.Id,
DocumentNumber: loss.Number,
UnitCost: line.UnitCost,
OccurredAt: loss.Date,
Notes: $"reason={loss.Reason}"), ct);
}
loss.Status = LossStatus.Posted;
loss.PostedAt = now;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
}
return NoContent();
}
[HttpPost("{id:guid}/unpost"), RequiresPermission("LossEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var loss = await _db.Losses.Include(l => l.Lines).FirstOrDefaultAsync(l => l.Id == id, ct);
if (loss is null) return NotFound();
if (loss.Status != LossStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: возвращаем товар обратно — остаток только увеличится, проверки на минус не нужно.
foreach (var line in loss.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: loss.StoreId,
Quantity: line.Quantity,
Type: MovementType.WriteOff,
DocumentType: "loss-reversal",
DocumentId: loss.Id,
DocumentNumber: loss.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {loss.Number}"), ct);
}
loss.Status = LossStatus.Draft;
loss.PostedAt = null;
await _db.SaveChangesAsync(ct);
return NoContent();
}
private static bool IsSerializationConflict(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
return true;
}
return false;
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"С-{year}-";
var lastNumber = await _db.Losses
.Where(l => l.Number.StartsWith(prefix))
.OrderByDescending(l => l.Number)
.Select(l => l.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<LossDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from l in _db.Losses.AsNoTracking()
join st in _db.Stores on l.StoreId equals st.Id
join cu in _db.Currencies on l.CurrencyId equals cu.Id
where l.Id == id
select new { l, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.LossLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.LossId == id
orderby l.SortOrder
select new LossLineDto(
l.Id, l.ProductId, p.Name, p.Article,
u.Name,
l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder,
_db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.l.StoreId)
.Select(s => (decimal?)s.Quantity).FirstOrDefault()))
.ToListAsync(ct);
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);
}
}