food-market/src/food-market.api/Controllers/Purchases/EntersController.cs
nns 97d5ae5eb0
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(reports): 3 фикса по итогам stage-тестирования
1. **DateTime Kind=Unspecified → UTC** в ResolveRange / AsUtc.
   ASP.NET парсит 'from=2026-05-29' с Kind=Unspecified, Npgsql 8
   отказывается слать такие в timestamp with time zone (500).
   Принудительно конвертим Unspecified→UTC (трактуем как полночь
   UTC), Local→ToUniversalTime. Применено к Sales/Profit/ABC/Stock.

2. **Enter.Post теперь пересчитывает Product.Cost** по той же
   формуле скользящего среднего что Supply.Post. Без этого товары,
   попавшие в систему через Оприходование (а не через Supply),
   имели Cost=0 — Profit/ABC-отчёты показывали cost=0 и неверную
   маржу. Воспроизведение: Enter 100@30 + RetailSale 10@500 →
   Profit-отчёт показывал revenue=5000, cost=0 (должно cost=300).

3. **ABC report: Парето-граница по cumBefore (а не cumAfter).**
   Единственный товар с cumShare=100% валился в класс C, хотя
   полностью покрывает Парето — должен быть A. Чиним: товар
   принадлежит классу A если он нужен чтобы пересечь порог
   80% (cumBefore < 80%). Стандартный Парето-алгоритм.

stage-reports (8 шагов): Sales/Stock/Profit/ABC + CSV/XLSX
export + edge — все зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:35:31 +05:00

403 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.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;
/// <summary>Оприходование (Enter) — постановка товара на склад без поставщика.
/// Используется при запуске учёта (начальные остатки) и для излишков
/// инвентаризации. Зеркалит <see cref="SuppliesController"/>, но проще:
/// нет SupplierId, нет пересчёта <c>Product.Cost</c> (UnitCost — балансовая
/// цена для отчёта).</summary>
[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<EnterLineDto> 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<EnterLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<EnterListRow>>> 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<EnterListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<EnterDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("EnterEdit")]
public async Task<ActionResult<EnterDto>> 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<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("EnterEdit")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<object>();
foreach (var r in reverseByProduct)
{
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 (conflicts.Count > 0)
{
return Conflict(new
{
error = "Нельзя отменить проведение: остаток уйдёт в минус (часть товара уже расходована).",
lines = conflicts,
});
}
foreach (var line in enter.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: enter.StoreId,
Quantity: -line.Quantity,
Type: MovementType.Enter,
DocumentType: "enter-reversal",
DocumentId: enter.Id,
DocumentNumber: enter.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {enter.Number}"), ct);
}
enter.Status = EnterStatus.Draft;
enter.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.Enters
.Where(e => e.Number.StartsWith(prefix))
.OrderByDescending(e => e.Number)
.Select(e => e.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<EnterDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (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
where e.Id == id
select new { e, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.EnterLines.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.EnterId == id
orderby l.SortOrder
select new EnterLineDto(
l.Id, l.ProductId, p.Name, p.Article,
u.Name,
l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder))
.ToListAsync(ct);
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);
}
}