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;
/// Списание (Loss): уменьшение склада с указанием причины
/// (брак/просрочка/повреждение/недостача/прочее). При проведении создаёт
/// тип с
/// отрицательным Quantity. Unpost блокируется проверкой не нужно (мы
/// добавляем обратно — остаток всегда увеличится).
[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 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 Lines);
[HttpGet]
public async Task>> 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 { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("LossEdit")]
public async Task> 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 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 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 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 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