using System.ComponentModel.DataAnnotations;
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;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Purchases;
/// Оприходование (Enter) — постановка товара на склад без поставщика.
/// Используется при запуске учёта (начальные остатки) и для излишков
/// инвентаризации. Зеркалит , но проще:
/// нет SupplierId, нет пересчёта Product.Cost (UnitCost — балансовая
/// цена для отчёта).
[ApiController]
[Authorize]
[Route("api/inventory/enters")]
public class EntersController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public EntersController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record EnterListRow(
Guid Id, string Number, DateTime Date, EnterStatus Status,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt);
public record EnterLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? UnitSymbol,
decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder);
public record EnterDto(
Guid Id, string Number, DateTime Date, EnterStatus Status,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList Lines);
public record EnterLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal Quantity,
[Range(0, 1e10)] decimal UnitCost);
public record EnterInput(
DateTime Date, Guid StoreId, Guid CurrencyId,
string? Notes,
IReadOnlyList Lines);
[HttpGet]
public async Task>> List(
[FromQuery] PagedRequest req,
[FromQuery] EnterStatus? status,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from e in _db.Enters.AsNoTracking()
join st in _db.Stores on e.StoreId equals st.Id
join cu in _db.Currencies on e.CurrencyId equals cu.Id
select new { e, st, cu };
if (status is not null) q = q.Where(x => x.e.Status == status);
if (storeId is not null) q = q.Where(x => x.e.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.e.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.e.Number),
("number", true) => q.OrderByDescending(x => x.e.Number),
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.e.Date),
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.e.Date),
("status", false) => q.OrderBy(x => x.e.Status).ThenByDescending(x => x.e.Date),
("status", true) => q.OrderByDescending(x => x.e.Status).ThenByDescending(x => x.e.Date),
("total", false) => q.OrderBy(x => x.e.Total).ThenByDescending(x => x.e.Date),
("total", true) => q.OrderByDescending(x => x.e.Total).ThenByDescending(x => x.e.Date),
("date", false) => q.OrderBy(x => x.e.Date).ThenBy(x => x.e.Number),
_ => q.OrderByDescending(x => x.e.Date).ThenByDescending(x => x.e.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new EnterListRow(
x.e.Id, x.e.Number, x.e.Date, x.e.Status,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.e.Total,
x.e.Lines.Count,
x.e.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("EnterEdit")]
public async Task> Create([FromBody] EnterInput 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 enter = new Enter
{
Number = number,
Date = input.Date,
Status = EnterStatus.Draft,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
enter.Lines.Add(new EnterLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = l.Quantity * l.UnitCost,
SortOrder = order++,
});
}
enter.Total = enter.Lines.Sum(x => x.LineTotal);
_db.Enters.Add(enter);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(enter.Id, ct);
return CreatedAtAction(nameof(Get), new { id = enter.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("EnterEdit")]
public async Task Update(Guid id, [FromBody] EnterInput 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 enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status != EnterStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
enter.Date = input.Date;
enter.StoreId = input.StoreId;
enter.CurrencyId = input.CurrencyId;
enter.Notes = input.Notes;
// ExecuteDelete + DbSet.Add — иначе EF8 на nav-collection путается
// и UPDATE enters падает с DbUpdateConcurrencyException «0 rows affected»
// даже без concurrency-токена. Тот же паттерн что в Supplies/Demands/RetailSales.Update.
await _db.EnterLines.Where(l => l.EnterId == enter.Id).ExecuteDeleteAsync(ct);
var order = 0;
decimal total = 0;
foreach (var l in input.Lines)
{
var lineTotal = l.Quantity * l.UnitCost;
_db.EnterLines.Add(new EnterLine
{
EnterId = enter.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitCost = l.UnitCost,
LineTotal = lineTotal,
SortOrder = order++,
});
total += lineTotal;
}
enter.Total = total;
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("EnterEdit")]
public async Task Delete(Guid id, CancellationToken ct)
{
var enter = await _db.Enters.FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status != EnterStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Enters.Remove(enter);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("EnterEdit")]
public async Task Post(Guid id, CancellationToken ct)
{
var enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status == EnterStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (enter.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
var now = DateTime.UtcNow;
foreach (var line in enter.Lines)
{
// Cost — скользящее среднее: Enter полноправно «вносит» товар на
// склад с указанной себестоимостью. Без этого Profit/COGS-отчёты
// показывают cost=0 для товаров, попавших в систему через
// Оприходование (а не через Supply).
var product = await _db.Products.FirstAsync(p => p.Id == line.ProductId, ct);
var currentQty = await _db.Stocks
.Where(s => s.ProductId == line.ProductId)
.SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m;
product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute(
currentQty, product.Cost, line.Quantity, line.UnitCost);
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: enter.StoreId,
Quantity: line.Quantity,
Type: MovementType.Enter,
DocumentType: "enter",
DocumentId: enter.Id,
DocumentNumber: enter.Number,
UnitCost: line.UnitCost,
OccurredAt: enter.Date), ct);
}
enter.Status = EnterStatus.Posted;
enter.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("EnterEdit")]
public async Task Unpost(Guid id, CancellationToken ct)
{
var enter = await _db.Enters.Include(e => e.Lines).FirstOrDefaultAsync(e => e.Id == id, ct);
if (enter is null) return NotFound();
if (enter.Status != EnterStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: проверяем что текущий остаток позволяет «снять» оприходование
// без ухода в минус (часть товара может быть уже продана).
var reverseByProduct = enter.Lines
.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
.ToList();
var productIds = reverseByProduct.Select(r => r.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == enter.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List