Some checks are pending
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>
403 lines
17 KiB
C#
403 lines
17 KiB
C#
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);
|
||
}
|
||
}
|