Optimistic concurrency через системную колонку Postgres xmin — никакой
дополнительной колонки и миграции не нужно, xmin есть у каждой таблицы
и автоматически обновляется при UPDATE.
Конфигурация:
- IVersionedEntity (маркер) + uint Xmin на Supply, Demand, RetailSale,
Transfer, InventoryDoc.
- e.UseXminAsConcurrencyToken() в EF-конфиге для каждой — создаёт shadow
property "xmin" с IsConcurrencyToken + ValueGeneratedOnAddOrUpdate.
- e.Ignore(x => x.Xmin): .NET-property живёт только для транспорта в DTO,
не маппится в БД (xmin тащим shadow'ом).
- GetInternal в SuppliesController читает xmin через
EF.Property<uint>(s, "xmin") в LINQ-проекции и складывает в DTO.
Wire-up:
- SuppliesController.Update принимает input.Xmin (uint?), сверяет с
shadow xmin загруженного supply через EF.Entry().Property("xmin").
Несовпадение → 409 с code=concurrency_conflict. null/0 от клиента →
legacy compat, проверки нет.
- SaveOrFkErrorAsync ловит DbUpdateConcurrencyException → 409 (двойная
защита: и явная сверка, и EF auto-check в SaveChanges).
Bonus: Supply.Update перешёл на тот же паттерн что Demand/RetailSale —
ExecuteDelete старых строк + AddRange новых напрямую в DbSet. Старый
RemoveRange-then-Add через nav-collection ломал EF concurrency check
(UPDATE supply_lines одной из старых строк падал 0 affected внутри той
же SaveChanges-транзакции).
Тесты: 2 интеграционных:
- two parallel updates with same xmin → один 204, другой 409; retry
с новым xmin тоже 204.
- legacy clients без xmin → PUT работает без concurrency-проверки.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
563 lines
28 KiB
C#
563 lines
28 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;
|
||
|
||
[ApiController]
|
||
[Authorize]
|
||
[Route("api/purchases/supplies")]
|
||
public class SuppliesController : ControllerBase
|
||
{
|
||
private readonly AppDbContext _db;
|
||
private readonly IStockService _stock;
|
||
|
||
public SuppliesController(AppDbContext db, IStockService stock)
|
||
{
|
||
_db = db;
|
||
_stock = stock;
|
||
}
|
||
|
||
public record SupplyListRow(
|
||
Guid Id, string Number, DateTime Date, SupplyStatus Status,
|
||
Guid SupplierId, string SupplierName,
|
||
Guid StoreId, string StoreName,
|
||
Guid CurrencyId, string CurrencyCode,
|
||
decimal Total, int LineCount,
|
||
DateTime? PostedAt);
|
||
|
||
public record SupplyLineDto(
|
||
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
|
||
string? ProductBarcode,
|
||
string? UnitSymbol,
|
||
decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder,
|
||
bool RetailPriceManuallyOverridden, decimal? RetailPriceOverride,
|
||
decimal? CurrentRetailPrice);
|
||
|
||
public record SupplyDto(
|
||
Guid Id, string Number, DateTime Date, SupplyStatus Status,
|
||
Guid SupplierId, string SupplierName,
|
||
Guid StoreId, string StoreName,
|
||
Guid CurrencyId, string CurrencyCode,
|
||
string? Notes,
|
||
decimal Total, DateTime? PostedAt,
|
||
uint Xmin,
|
||
IReadOnlyList<SupplyLineDto> Lines);
|
||
|
||
public record SupplyLineInput(
|
||
Guid ProductId,
|
||
[Range(0, 1e10)] decimal Quantity,
|
||
[Range(0, 1e10)] decimal UnitPrice,
|
||
bool RetailPriceManuallyOverridden = false,
|
||
[Range(0, 1e10)] decimal? RetailPriceOverride = null);
|
||
public record SupplyInput(
|
||
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
|
||
string? Notes,
|
||
IReadOnlyList<SupplyLineInput> Lines,
|
||
// Optimistic concurrency token. null/0 для нового черновика (POST),
|
||
// обязателен для PUT — иначе считаем что клиент не передал version
|
||
// и пускаем без сверки (legacy). При несовпадении контроллер возвращает 409.
|
||
uint? Xmin = null);
|
||
|
||
[HttpGet]
|
||
public async Task<ActionResult<PagedResult<SupplyListRow>>> List(
|
||
[FromQuery] PagedRequest req,
|
||
[FromQuery] SupplyStatus? status,
|
||
[FromQuery] Guid? storeId,
|
||
[FromQuery] Guid? supplierId,
|
||
CancellationToken ct)
|
||
{
|
||
var q = from s in _db.Supplies.AsNoTracking()
|
||
join cp in _db.Counterparties on s.SupplierId equals cp.Id
|
||
join st in _db.Stores on s.StoreId equals st.Id
|
||
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||
select new { s, cp, st, cu };
|
||
|
||
if (status is not null) q = q.Where(x => x.s.Status == status);
|
||
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
|
||
if (supplierId is not null) q = q.Where(x => x.s.SupplierId == supplierId);
|
||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||
{
|
||
var s = req.Search.Trim().ToLower();
|
||
q = q.Where(x => x.s.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s));
|
||
}
|
||
|
||
var total = await q.CountAsync(ct);
|
||
q = (req.Sort, req.Desc) switch
|
||
{
|
||
("number", false) => q.OrderBy(x => x.s.Number),
|
||
("number", true) => q.OrderByDescending(x => x.s.Number),
|
||
("supplier", false) => q.OrderBy(x => x.cp.Name).ThenByDescending(x => x.s.Date),
|
||
("supplier", true) => q.OrderByDescending(x => x.cp.Name).ThenByDescending(x => x.s.Date),
|
||
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.s.Date),
|
||
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.s.Date),
|
||
("status", false) => q.OrderBy(x => x.s.Status).ThenByDescending(x => x.s.Date),
|
||
("status", true) => q.OrderByDescending(x => x.s.Status).ThenByDescending(x => x.s.Date),
|
||
("total", false) => q.OrderBy(x => x.s.Total).ThenByDescending(x => x.s.Date),
|
||
("total", true) => q.OrderByDescending(x => x.s.Total).ThenByDescending(x => x.s.Date),
|
||
("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number),
|
||
_ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number),
|
||
};
|
||
var items = await q
|
||
.Skip(req.Skip).Take(req.Take)
|
||
.Select(x => new SupplyListRow(
|
||
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
|
||
x.cp.Id, x.cp.Name,
|
||
x.st.Id, x.st.Name,
|
||
x.cu.Id, x.cu.Code,
|
||
x.s.Total,
|
||
x.s.Lines.Count,
|
||
x.s.PostedAt))
|
||
.ToListAsync(ct);
|
||
|
||
return new PagedResult<SupplyListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||
}
|
||
|
||
[HttpGet("{id:guid}")]
|
||
public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
|
||
{
|
||
var dto = await GetInternal(id, ct);
|
||
return dto is null ? NotFound() : Ok(dto);
|
||
}
|
||
|
||
[HttpPost, RequiresPermission("SuppliesEdit")]
|
||
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
|
||
{
|
||
if (RequiredGuid.FirstMissing(
|
||
(nameof(input.SupplierId), input.SupplierId),
|
||
(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 allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||
var supply = new Supply
|
||
{
|
||
Number = number,
|
||
Date = input.Date,
|
||
Status = SupplyStatus.Draft,
|
||
SupplierId = input.SupplierId,
|
||
StoreId = input.StoreId,
|
||
CurrencyId = input.CurrencyId,
|
||
Notes = input.Notes,
|
||
};
|
||
|
||
var order = 0;
|
||
foreach (var l in input.Lines)
|
||
{
|
||
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
|
||
supply.Lines.Add(new SupplyLine
|
||
{
|
||
ProductId = l.ProductId,
|
||
Quantity = l.Quantity,
|
||
UnitPrice = unitPrice,
|
||
LineTotal = l.Quantity * unitPrice,
|
||
SortOrder = order++,
|
||
RetailPriceManuallyOverridden = l.RetailPriceManuallyOverridden,
|
||
RetailPriceOverride = l.RetailPriceOverride.HasValue
|
||
? (allowFractional ? l.RetailPriceOverride : Math.Round(l.RetailPriceOverride.Value, 0, MidpointRounding.AwayFromZero))
|
||
: null,
|
||
});
|
||
}
|
||
supply.Total = supply.Lines.Sum(x => x.LineTotal);
|
||
|
||
_db.Supplies.Add(supply);
|
||
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
||
var dto = await GetInternal(supply.Id, ct);
|
||
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
|
||
}
|
||
|
||
/// <summary>SaveChanges + перехват PostgresException 23503 (FK violation).
|
||
/// Возвращает 400 с указанием поля если FK не сошёлся (например, SupplierId
|
||
/// указывает на несуществующего контрагента) — это лучше чем 500.</summary>
|
||
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
await _db.SaveChangesAsync(ct);
|
||
return null;
|
||
}
|
||
catch (DbUpdateConcurrencyException)
|
||
{
|
||
// Optimistic concurrency: кто-то другой обновил документ между
|
||
// SELECT и UPDATE. Клиент должен перезагрузить и попробовать снова.
|
||
return Conflict(new
|
||
{
|
||
error = "Документ изменён другим пользователем. Обновите страницу и повторите.",
|
||
code = "concurrency_conflict",
|
||
});
|
||
}
|
||
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
|
||
{
|
||
// pg.ConstraintName выглядит как FK_supplies_counterparties_SupplierId
|
||
// — вытащим из него имя FK-поля для UI.
|
||
var name = pg.ConstraintName ?? "";
|
||
string field = name.Contains("Supplier") ? "supplierId"
|
||
: 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("SuppliesEdit")]
|
||
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
|
||
{
|
||
if (RequiredGuid.FirstMissing(
|
||
(nameof(input.SupplierId), input.SupplierId),
|
||
(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 supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||
if (supply is null) return NotFound();
|
||
if (supply.Status != SupplyStatus.Draft)
|
||
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
|
||
|
||
// Optimistic concurrency: если клиент прислал Xmin, сверяем с тем,
|
||
// что EF только что прочитал через Include. Несовпадение → 409
|
||
// (кто-то изменил документ между нашими GET и PUT). Прозрачнее, чем
|
||
// полагаться на EF DbUpdateConcurrencyException — там сообщение
|
||
// зависит от других UPDATE'ов в одной SaveChanges. null/0 от клиента
|
||
// → пропускаем проверку (старые клиенты).
|
||
// Optimistic concurrency: если клиент прислал Xmin, сверяем со shadow
|
||
// property "xmin", которую EF только что загрузила. Несовпадение → 409.
|
||
if (input.Xmin is { } cliXmin && cliXmin != 0)
|
||
{
|
||
var serverXmin = (uint)_db.Entry(supply).Property("xmin").CurrentValue!;
|
||
if (serverXmin != cliXmin)
|
||
{
|
||
return Conflict(new
|
||
{
|
||
error = "Документ изменён другим пользователем. Обновите страницу и повторите.",
|
||
code = "concurrency_conflict",
|
||
});
|
||
}
|
||
}
|
||
|
||
supply.Date = input.Date;
|
||
supply.SupplierId = input.SupplierId;
|
||
supply.StoreId = input.StoreId;
|
||
supply.CurrencyId = input.CurrencyId;
|
||
supply.Notes = input.Notes;
|
||
|
||
// Удаляем старые строки через ExecuteDelete (минует трекер), новые
|
||
// добавляем напрямую в DbSet — иначе EF8 на nav-collection+client-side Id
|
||
// путается и UPDATE supplies с concurrency-token-WHERE падает 0 affected.
|
||
// Тот же паттерн что в RetailSale/Demand.Update.
|
||
await _db.SupplyLines.Where(l => l.SupplyId == supply.Id).ExecuteDeleteAsync(ct);
|
||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||
var order = 0;
|
||
foreach (var l in input.Lines)
|
||
{
|
||
var unitPrice = allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero);
|
||
_db.SupplyLines.Add(new SupplyLine
|
||
{
|
||
SupplyId = supply.Id,
|
||
ProductId = l.ProductId,
|
||
Quantity = l.Quantity,
|
||
UnitPrice = unitPrice,
|
||
LineTotal = l.Quantity * unitPrice,
|
||
SortOrder = order++,
|
||
RetailPriceManuallyOverridden = l.RetailPriceManuallyOverridden,
|
||
RetailPriceOverride = l.RetailPriceOverride.HasValue
|
||
? (allowFractional ? l.RetailPriceOverride : Math.Round(l.RetailPriceOverride.Value, 0, MidpointRounding.AwayFromZero))
|
||
: null,
|
||
});
|
||
}
|
||
// Total считаем из input напрямую — supply.Lines navigation в этом
|
||
// подходе пустая (мы добавляли через DbSet).
|
||
supply.Total = input.Lines.Sum(l =>
|
||
l.Quantity * (allowFractional ? l.UnitPrice : Math.Round(l.UnitPrice, 0, MidpointRounding.AwayFromZero)));
|
||
|
||
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpDelete("{id:guid}"), RequiresPermission("SuppliesDelete")]
|
||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||
{
|
||
var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||
if (supply is null) return NotFound();
|
||
if (supply.Status != SupplyStatus.Draft)
|
||
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
|
||
_db.Supplies.Remove(supply);
|
||
await _db.SaveChangesAsync(ct);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("{id:guid}/post"), RequiresPermission("SuppliesPost")]
|
||
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||
{
|
||
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||
if (supply is null) return NotFound();
|
||
if (supply.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
|
||
if (supply.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
||
|
||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||
|
||
// Serializable: ApplyMovementAsync делает read-modify-write по
|
||
// Stock.Quantity без RowVersion. На дефолтной изоляции два
|
||
// одновременных проведения (в т.ч. двойное проведение ОДНОГО
|
||
// документа, проскочившее проверку статуса выше до коммита соседа)
|
||
// дают lost update: остаток отстаёт от Σ StockMovement, приёмка
|
||
// применяется дважды, скользящее среднее Cost считается от
|
||
// устаревшего currentQty. Serializable заставляет конкурирующую
|
||
// транзакцию откатиться (40001) — ловим ниже и отдаём 409.
|
||
await using var tx = await _db.Database.BeginTransactionAsync(
|
||
System.Data.IsolationLevel.Serializable, ct);
|
||
var now = DateTime.UtcNow;
|
||
|
||
foreach (var line in supply.Lines)
|
||
{
|
||
var product = await _db.Products
|
||
.Include(p => p.ProductGroup)
|
||
.Include(p => p.Prices)
|
||
.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;
|
||
|
||
// 1. Cost — скользящее среднее (формула в MovingAverageCost, юнит-тест).
|
||
product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute(
|
||
currentQty, product.Cost, line.Quantity, line.UnitPrice);
|
||
|
||
// 2. ReferencePrice — автозаполнение при первой приёмке.
|
||
if (product.ReferencePrice is null)
|
||
{
|
||
product.ReferencePrice = line.UnitPrice;
|
||
product.ReferencePriceUpdatedAt = now;
|
||
}
|
||
product.LastSupplyAt = now;
|
||
|
||
// 3. Розничная: либо явный override строки, либо автонаценка по группе.
|
||
if (line.RetailPriceManuallyOverridden && line.RetailPriceOverride.HasValue)
|
||
{
|
||
SetDefaultRetail(product, line.RetailPriceOverride.Value, supply.CurrencyId);
|
||
}
|
||
else if (product.ProductGroup?.MarkupPercent is decimal pct)
|
||
{
|
||
var raw = product.Cost * (1m + pct / 100m);
|
||
var newRetail = allowFractional
|
||
? Math.Ceiling(raw * 100m) / 100m
|
||
: Math.Ceiling(raw);
|
||
SetDefaultRetail(product, newRetail, supply.CurrencyId);
|
||
}
|
||
|
||
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||
ProductId: line.ProductId,
|
||
StoreId: supply.StoreId,
|
||
Quantity: line.Quantity,
|
||
Type: MovementType.Supply,
|
||
DocumentType: "supply",
|
||
DocumentId: supply.Id,
|
||
DocumentNumber: supply.Number,
|
||
UnitCost: line.UnitPrice,
|
||
OccurredAt: supply.Date), ct);
|
||
}
|
||
|
||
supply.Status = SupplyStatus.Posted;
|
||
supply.PostedAt = now;
|
||
try
|
||
{
|
||
await _db.SaveChangesAsync(ct);
|
||
await tx.CommitAsync(ct);
|
||
}
|
||
catch (Exception ex) when (IsSerializationConflict(ex))
|
||
{
|
||
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("supply", "serialization");
|
||
return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." });
|
||
}
|
||
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("supply");
|
||
return NoContent();
|
||
}
|
||
|
||
/// <summary>True, если исключение (или любое вложенное) — конфликт
|
||
/// сериализации/дедлок Postgres (SQLSTATE 40001 / 40P01). Возникает, когда
|
||
/// Serializable-транзакция откатывается из-за конкурирующего проведения.
|
||
/// Используем System.Data.Common.DbException.SqlState (.NET 8), чтобы не
|
||
/// тянуть прямую зависимость на Npgsql в API-слой.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Записывает значение в дефолтный розничный PriceType. Если в списке
|
||
/// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType
|
||
/// с IsSystem=true; если такого нет — первый IsRetail; иначе — первый
|
||
/// PriceType в списке. Currency берётся из приёмки (или из существующей записи).</summary>
|
||
private void SetDefaultRetail(foodmarket.Domain.Catalog.Product p, decimal value, Guid fallbackCurrencyId)
|
||
{
|
||
var defaultType = _db.PriceTypes
|
||
.OrderByDescending(pt => pt.IsSystem)
|
||
.ThenByDescending(pt => pt.IsRetail)
|
||
.ThenBy(pt => pt.SortOrder)
|
||
.ThenBy(pt => pt.Name)
|
||
.FirstOrDefault();
|
||
if (defaultType is null) return;
|
||
var existing = p.Prices.FirstOrDefault(x => x.PriceTypeId == defaultType.Id);
|
||
if (existing is null)
|
||
{
|
||
p.Prices.Add(new foodmarket.Domain.Catalog.ProductPrice
|
||
{
|
||
PriceTypeId = defaultType.Id,
|
||
Amount = value,
|
||
CurrencyId = fallbackCurrencyId,
|
||
});
|
||
}
|
||
else
|
||
{
|
||
existing.Amount = value;
|
||
}
|
||
}
|
||
|
||
[HttpPost("{id:guid}/unpost"), RequiresPermission("SuppliesPost")]
|
||
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||
{
|
||
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||
if (supply is null) return NotFound();
|
||
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
|
||
|
||
// Защита от ухода stock в минус: суммируем то, что вернём (по строкам
|
||
// с тем же ProductId — могут быть дубли), сравниваем с текущим Stock.
|
||
// Если приёмку «расковать», stock уменьшится на line.Quantity. Если
|
||
// покупатели уже что-то купили из этой партии, остаток станет
|
||
// отрицательным — это нарушение инварианта учёта.
|
||
var reverseByProduct = supply.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 == supply.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,
|
||
});
|
||
}
|
||
|
||
// Reverse: negative movements with same document reference
|
||
foreach (var line in supply.Lines)
|
||
{
|
||
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||
ProductId: line.ProductId,
|
||
StoreId: supply.StoreId,
|
||
Quantity: -line.Quantity,
|
||
Type: MovementType.Supply,
|
||
DocumentType: "supply-reversal",
|
||
DocumentId: supply.Id,
|
||
DocumentNumber: supply.Number,
|
||
UnitCost: line.UnitPrice,
|
||
OccurredAt: DateTime.UtcNow,
|
||
Notes: $"Отмена проведения документа {supply.Number}"), ct);
|
||
}
|
||
|
||
supply.Status = SupplyStatus.Draft;
|
||
supply.PostedAt = null;
|
||
await _db.SaveChangesAsync(ct);
|
||
return NoContent();
|
||
}
|
||
|
||
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
|
||
{
|
||
var year = date.Year;
|
||
var prefix = $"П-{year}-";
|
||
var lastNumber = await _db.Supplies
|
||
.Where(s => s.Number.StartsWith(prefix))
|
||
.OrderByDescending(s => s.Number)
|
||
.Select(s => s.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<SupplyDto?> GetInternal(Guid id, CancellationToken ct)
|
||
{
|
||
// Shadow-property xmin читаем через EF.Property — она не на entity,
|
||
// а в model metadata (UseXminAsConcurrencyToken).
|
||
var row = await (from s in _db.Supplies.AsNoTracking()
|
||
join cp in _db.Counterparties on s.SupplierId equals cp.Id
|
||
join st in _db.Stores on s.StoreId equals st.Id
|
||
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||
where s.Id == id
|
||
select new { s, cp, st, cu, Xmin = EF.Property<uint>(s, "xmin") }).FirstOrDefaultAsync(ct);
|
||
if (row is null) return null;
|
||
|
||
// CurrentRetailPrice — текущая розничная цена товара (дефолтный PriceType),
|
||
// отображается в строке приёмки как «Розничная (из карточки)».
|
||
var lines = await (from l in _db.SupplyLines.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.SupplyId == id
|
||
orderby l.SortOrder
|
||
select new SupplyLineDto(
|
||
l.Id, l.ProductId, p.Name, p.Article,
|
||
// Основной штрихкод (IsPrimary=true), иначе первый по порядку.
|
||
p.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault(),
|
||
u.Name,
|
||
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder,
|
||
l.RetailPriceManuallyOverridden, l.RetailPriceOverride,
|
||
p.Prices
|
||
.OrderByDescending(pr => pr.PriceType!.IsSystem)
|
||
.ThenByDescending(pr => pr.PriceType!.IsRetail)
|
||
.ThenBy(pr => pr.PriceType!.SortOrder)
|
||
.ThenBy(pr => pr.PriceType!.Name)
|
||
.Select(pr => (decimal?)pr.Amount)
|
||
.FirstOrDefault()))
|
||
.ToListAsync(ct);
|
||
|
||
return new SupplyDto(
|
||
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
|
||
row.cp.Id, row.cp.Name,
|
||
row.st.Id, row.st.Name,
|
||
row.cu.Id, row.cu.Code,
|
||
row.s.Notes,
|
||
row.s.Total, row.s.PostedAt,
|
||
row.Xmin,
|
||
lines);
|
||
}
|
||
}
|