food-market/src/food-market.api/Controllers/Purchases/SuppliesController.cs
nns ec0cff7fc4 feat(concurrency): RowVersion на документах через Postgres xmin (TD-6)
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>
2026-05-28 17:33:01 +05:00

563 lines
28 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;
[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);
}
}