food-market/src/food-market.api/Controllers/Purchases/SuppliesController.cs
nns 9ba1edb236
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(stock): разрешение продажи в минус + предупреждения об отрицательных остатках
Решение владельца 08.10: продавать/списывать в минус РАЗРЕШЕНО, но с постоянным
предупреждением и требованием провести инвентаризацию.

- Снята блокировка «недостаточно остатка» (409 oversell): розница, касса (POS-синк),
  опт-отгрузка, списание, перемещение, возврат поставщику, отмена приёмки. Деньги
  (минус счёта) — по-прежнему запрещены (другое правило).
- NegativeStockAlert (сущность + миграция Phase39): создаётся/обновляется при уходе
  остатка < 0 (единая точка — StockService), авто-закрытие при возврате ≥ 0. Частичный
  уникальный индекс: один открытый алерт на (org, store, product). Причина: тип/номер
  документа/автор/время.
- API /api/negative-stock: список (с подсказкой «возможно, не проведена приёмка П-…»),
  /count (для баннеров), /recount (черновик инвентаризации по минусам склада). Отчёт
  «Остатки» — фильтр negativeOnly.
- Веб: постоянный баннер «N товаров в минусе — нужна инвентаризация» (сворачивается,
  крестиком не закрыть) + «Пересчитать эти товары»; в остатках — красная пометка и
  фильтр «Только минусы».
- Тесты: integration 2/2 (минус разрешён → алерт → recount → авто-закрытие; фильтр);
  full-chain шаг 9/13 переписаны (oversell/отмена приёмки → проведено + предупреждение).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:18:29 +05:00

614 lines
31 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;
private readonly ILogger<SuppliesController> _log;
private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
public SuppliesController(AppDbContext db, IStockService stock, ILogger<SuppliesController> log,
foodmarket.Api.Realtime.INotificationsPublisher notify)
{
_db = db;
_stock = stock;
_log = log;
_notify = notify;
}
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,
decimal PayedSum, string PaymentStatus);
/// <summary>Статус оплаты приёмки — вычисляемая строка:
/// "unpaid" (0), "partial" (0 &lt; payed &lt; total), "paid" (payed ≥ total).
/// Фронт показывает цветной бейдж. Расчёт здесь — простейший порог.</summary>
private static string ComputePaymentStatus(decimal total, decimal payed)
=> foodmarket.Application.Money.MoneyMath.PaymentStatus(total, payed);
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,
decimal PayedSum, string PaymentStatus);
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,
[FromQuery] string? paymentStatus,
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));
}
// Фильтр по статусу оплаты (unpaid/partial/paid). "all"/null — без фильтра.
q = paymentStatus switch
{
"unpaid" => q.Where(x => x.s.Status == SupplyStatus.Posted && x.s.PayedSum <= 0),
"partial" => q.Where(x => x.s.Status == SupplyStatus.Posted && x.s.PayedSum > 0 && x.s.PayedSum < x.s.Total),
"paid" => q.Where(x => x.s.Status == SupplyStatus.Posted && x.s.PayedSum >= x.s.Total),
_ => q,
};
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 raw = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new
{
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
SupplierId = x.cp.Id, SupplierName = x.cp.Name,
StoreId = x.st.Id, StoreName = x.st.Name,
CurrencyId = x.cu.Id, CurrencyCode = x.cu.Code,
x.s.Total,
LineCount = x.s.Lines.Count,
x.s.PostedAt,
x.s.PayedSum,
})
.ToListAsync(ct);
var items = raw.Select(x => new SupplyListRow(
x.Id, x.Number, x.Date, x.Status,
x.SupplierId, x.SupplierName,
x.StoreId, x.StoreName,
x.CurrencyId, x.CurrencyCode,
x.Total, x.LineCount, x.PostedAt,
x.PayedSum, ComputePaymentStatus(x.Total, x.PayedSum))).ToList();
return new PagedResult<SupplyListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
/// <summary>Sprint 19: экспорт списка приёмок с теми же фильтрами.</summary>
[HttpGet("export")]
public async Task<IActionResult> Export(
[FromQuery] string? format,
[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);
var rows = await q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number)
.Select(x => new SupplyExportRow(
x.s.Number, x.s.Date, x.s.Status.ToString(), x.cp.Name, x.st.Name,
x.s.Total, x.cu.Code, x.s.PostedAt))
.ToListAsync(ct);
var headers = new[] { "Номер", "Дата", "Статус", "Поставщик", "Склад", "Сумма", "Валюта", "Проведена" };
return (format ?? "csv").Equals("xlsx", StringComparison.OrdinalIgnoreCase)
? foodmarket.Api.Controllers.Reports.ReportExport.Xlsx(rows, "supplies", "Приёмки", headers)
: foodmarket.Api.Controllers.Reports.ReportExport.Csv(rows, "supplies", headers);
}
public record SupplyExportRow(string Number, DateTime Date, string Status,
string Supplier, string Store, decimal Total, string Currency, DateTime? PostedAt);
[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");
// Structured business log: лейблы Number/SupplierId/StoreId/LinesCount/Total
// попадут в Serilog как отдельные свойства, не часть текста — удобно
// фильтровать в Loki/ELK без regex'ов. CorrelationId/OrgId/UserId
// подмешает LogEnrichmentMiddleware из LogContext.
_log.LogInformation(
"Supply posted: {SupplyNumber} supplier={SupplierId} store={StoreId} lines={LinesCount} total={Total}",
supply.Number, supply.SupplierId, supply.StoreId, supply.Lines.Count, supply.Total);
// SignalR-уведомление в группу org (best-effort, не валит транзакцию).
try
{
var supplierName = await _db.Counterparties.IgnoreQueryFilters()
.Where(c => c.Id == supply.SupplierId)
.Select(c => c.Name).FirstOrDefaultAsync(ct);
await _notify.PublishAsync(supply.OrganizationId,
foodmarket.Api.Realtime.NotificationEvents.SupplyPosted,
new foodmarket.Api.Realtime.SupplyPostedPayload(
supply.Id, supply.Number, supply.Total, supply.SupplierId,
supplierName, supply.PostedAt!.Value));
}
catch (Exception ex)
{
_log.LogWarning(ex, "SignalR notify failed for supply {SupplyId}", supply.Id);
}
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 = "Документ не проведён." });
// Отмена приёмки в минус РАЗРЕШЕНА (решение владельца 08.10): если часть
// полученного товара уже продана, остаток уйдёт ниже нуля — это допустимо,
// при проведении (reversal) создаётся предупреждение об отрицательном остатке.
// 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,
row.s.PayedSum, ComputePaymentStatus(row.s.Total, row.s.PayedSum));
}
}