using System.ComponentModel.DataAnnotations;
using foodmarket.Api.Infrastructure.Invoices;
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Inventory;
/// Инвентаризация (пересчёт). Создание подгружает текущие остатки
/// склада в bookQty ; пользователь вносит фактические количества;
/// при Post создаются корректирующие движения
/// на diff = actual - book (положительный — приход, отрицательный
/// — списание).
[ApiController]
[Authorize]
[Route("api/inventory/inventories")]
public class InventoriesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public InventoriesController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record InventoryListRow(
Guid Id, string Number, DateTime Date, InventoryStatus Status,
Guid StoreId, string StoreName,
int LineCount,
decimal SurplusValue, decimal ShortageValue,
DateTime? PostedAt);
public record InventoryLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal BookQty, decimal ActualQty, decimal Diff, decimal UnitCost,
int SortOrder);
public record InventoryDto(
Guid Id, string Number, DateTime Date, InventoryStatus Status,
Guid StoreId, string StoreName,
string? Notes,
DateTime? PostedAt,
IReadOnlyList Lines);
public record InventoryLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal ActualQty);
public record InventoryInput(
DateTime Date, Guid StoreId,
string? Notes,
/// Если null/пусто — контроллер сам заполнит строками всеми
/// товарами склада с их текущим Stock в качестве bookQty и actual=0.
IReadOnlyList? Lines);
/// Компактный слепок каталога для телефона: тройки
/// [штрихкод, productId, name]. Телефон держит его в IndexedDB,
/// работает без сети. Опциональный режет
/// каталог для частичной инвентаризации (считаем только одну группу).
public record MobileCatalogRow(string Barcode, Guid ProductId, string Name);
public record MobileCatalogResponse(int Count, DateTime BuiltAt, List Rows);
// ── импорт Excel/CSV для инвентаризации ─────────────────────────────
public record InventoryImportFoundRow(Guid Id, string Name, decimal Qty);
public record InventoryImportUnknownRow(string Code, decimal Qty);
public record InventoryImportResponse(
IReadOnlyList Found,
IReadOnlyList Unknown);
/// Импорт файла в текущий сеанс инвентаризации. Колонки «Штрихкод» и
/// «Количество» (если нет заголовков — 1-я и 2-я); повторы одного штрихкода
/// суммируются. Документ НЕ создаётся: клиент добавляет строки к текущему
/// списку, кладовщик может ещё досканировать камерой/сканером, и уже
/// потом создаёт черновик обычным POST /inventories . Порт поведения
/// из ~/ref/sklad/sklad/Controllers/InventoryController.cs :Import.
[HttpPost("import"), RequiresPermission("InventoryEdit"), RequestSizeLimit(20_000_000)]
public async Task> Import(
[FromForm] IFormFile file, CancellationToken ct)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "Файл не выбран." });
if (!TableReader.CanRead(file.FileName))
return BadRequest(new { error = "Поддерживаются только .xlsx и .csv." });
var tmp = Path.Combine(Path.GetTempPath(), $"fm-inv-{Guid.NewGuid():N}{Path.GetExtension(file.FileName)}");
try
{
await using (var fs = System.IO.File.Create(tmp))
await file.CopyToAsync(fs, ct);
var rows = TableReader.Read(tmp, file.FileName);
// Если шапка есть — читаем из колонок «штрихкод»/«кол»; иначе 1-я и 2-я.
var h = TableReader.HeaderRow(rows);
int cBc, cQty, start;
if (h >= 0)
{
var head = rows[h];
cBc = TableReader.Col(head, "штрих", "barcode", "^ean", "^шк$");
cQty = TableReader.Col(head, "кол", "qty", "quantity", "^факт");
start = h + 1;
}
else { cBc = 0; cQty = 1; start = 0; }
// Суммируем количества одного штрихкода в один проход.
var agg = new Dictionary(StringComparer.Ordinal);
for (int i = start; i < rows.Count; i++)
{
var r = rows[i];
var bc = InvoiceMatcher.OnlyDigits(TableReader.At(r, cBc));
if (bc.Length < 6) continue;
var qty = TableReader.Num(TableReader.At(r, cQty)) ?? 0m;
if (qty <= 0) continue;
agg[bc] = (agg.TryGetValue(bc, out var e) ? e : 0m) + qty;
}
if (agg.Count == 0)
return BadRequest(new { error = "В файле нет строк со штрихкодом и количеством." });
// Разом ищем товары по всем кодам. Index на (OrgId, Code) — попадаем в индекс.
var codes = agg.Keys.ToList();
var hits = await (from pb in _db.ProductBarcodes.AsNoTracking()
join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id
where codes.Contains(pb.Code) && !p.IsArchived
select new { pb.Code, pb.ProductId, p.Name })
.ToListAsync(ct);
var byCode = new Dictionary();
foreach (var h2 in hits)
byCode.TryAdd(h2.Code, (h2.ProductId, h2.Name));
var found = new List();
var unknown = new List();
foreach (var kv in agg)
{
if (byCode.TryGetValue(kv.Key, out var p))
{
// Если один товар привязан к нескольким штрихкодам, которые встретились
// в файле — суммируем в одну строку клиенту.
var existing = found.FindIndex(f => f.Id == p.Id);
if (existing >= 0) found[existing] = found[existing] with { Qty = found[existing].Qty + kv.Value };
else found.Add(new InventoryImportFoundRow(p.Id, p.Name, kv.Value));
}
else unknown.Add(new InventoryImportUnknownRow(kv.Key, kv.Value));
}
return Ok(new InventoryImportResponse(found, unknown));
}
catch (InvoiceExtractionException ex)
{
return BadRequest(new { error = ex.Message });
}
finally
{
try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { }
}
}
[HttpGet("mobile-catalog")]
public async Task> MobileCatalog(
[FromQuery] Guid? groupId, CancellationToken ct)
{
// Берём все (OrgId implicit by filter) активные товары + их штрихкоды.
// Отдаём массивом троек (а не объектом с полями) — для 30 тыс. позиций
// JSON объекта весит 12 МБ, троек втрое меньше. Response-compression
// middleware жмёт результат gzip-ом (Program.cs:AddResponseCompression).
//
// Weak ETag из «последняя правка товара/штрихкода + их число» —
// клиент на повторной загрузке получит 304, если каталог не менялся.
// (handoff §9: «без простоев» — быстрый повтор при обрыве.)
var q = from pb in _db.ProductBarcodes.AsNoTracking()
join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id
where !p.IsArchived
select new { pb.Code, pb.ProductId, p.Name, p.ProductGroupId };
if (groupId.HasValue) q = q.Where(x => x.ProductGroupId == groupId);
var raw = await q.ToListAsync(ct);
var seen = new HashSet();
var rows = new List(raw.Count);
foreach (var r in raw)
{
var k = new string(r.Code.Where(char.IsDigit).ToArray());
if (k.Length < 6 || !seen.Add(k)) continue;
rows.Add(new object[] { k, r.ProductId, r.Name });
}
// ETag — hash от уникального содержимого. 304 на If-None-Match избавляет
// от повторной передачи при обрыве/перезагрузке страницы инвентаризации.
unchecked
{
long h = rows.Count;
foreach (var r in rows)
h = h * 1315423911L ^ ((string)r[0]).GetHashCode() ^ ((Guid)r[1]).GetHashCode();
var etag = $"\"inv-cat-{h:x}-{(groupId?.ToString("N") ?? "all")}\"";
if (Request.Headers.IfNoneMatch == etag)
return StatusCode(304);
Response.Headers.ETag = etag;
Response.Headers.CacheControl = "private, max-age=60";
}
return Ok(new MobileCatalogResponse(rows.Count, DateTime.UtcNow, rows));
}
[HttpGet]
public async Task>> List(
[FromQuery] PagedRequest req,
[FromQuery] InventoryStatus? status,
[FromQuery] Guid? storeId,
CancellationToken ct)
{
var q = from i in _db.InventoryDocs.AsNoTracking()
join st in _db.Stores on i.StoreId equals st.Id
select new { i, st };
if (status is not null) q = q.Where(x => x.i.Status == status);
if (storeId is not null) q = q.Where(x => x.i.StoreId == storeId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.i.Number.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
q = (req.Sort, req.Desc) switch
{
("number", false) => q.OrderBy(x => x.i.Number),
("number", true) => q.OrderByDescending(x => x.i.Number),
("status", false) => q.OrderBy(x => x.i.Status).ThenByDescending(x => x.i.Date),
("status", true) => q.OrderByDescending(x => x.i.Status).ThenByDescending(x => x.i.Date),
("date", false) => q.OrderBy(x => x.i.Date).ThenBy(x => x.i.Number),
_ => q.OrderByDescending(x => x.i.Date).ThenByDescending(x => x.i.Number),
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(x => new InventoryListRow(
x.i.Id, x.i.Number, x.i.Date, x.i.Status,
x.st.Id, x.st.Name,
x.i.Lines.Count,
x.i.Lines.Where(l => l.Diff > 0).Sum(l => l.Diff * l.UnitCost),
x.i.Lines.Where(l => l.Diff < 0).Sum(l => l.Diff * l.UnitCost),
x.i.PostedAt))
.ToListAsync(ct);
return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("InventoryEdit")]
public async Task> Create([FromBody] InventoryInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing((nameof(input.StoreId), input.StoreId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
var number = await GenerateNumberAsync(input.Date, ct);
var doc = new InventoryDoc
{
Number = number,
Date = input.Date,
Status = InventoryStatus.Draft,
StoreId = input.StoreId,
Notes = input.Notes,
};
// Если строки не указаны — подтягиваем товары склада как «ещё не
// посчитанные»: ActualQty = BookQty, Diff = 0. Так проведение НЕ трогает
// непосчитанные позиции (Diff=0 пропускается). Раньше здесь ставилось
// ActualQty=0 → Diff=-stock, и проведение ОБНУЛЯЛО всё, что кладовщик не
// пересчитал (в т.ч. всё вне выбранной группы). Кладовщик меняет ActualQty
// только у реально пересчитанных товаров (через add-line / правку строки);
// истинный ноль на складе вводится явным ActualQty=0.
if (input.Lines is null || input.Lines.Count == 0)
{
var stocks = await (from s in _db.Stocks.AsNoTracking()
join p in _db.Products.AsNoTracking() on s.ProductId equals p.Id
where s.StoreId == input.StoreId
select new { s.ProductId, s.Quantity, p.Cost })
.ToListAsync(ct);
var order = 0;
foreach (var st in stocks.OrderBy(x => x.ProductId))
{
doc.Lines.Add(new InventoryLine
{
ProductId = st.ProductId,
BookQty = st.Quantity,
ActualQty = st.Quantity,
Diff = 0,
UnitCost = st.Cost,
SortOrder = order++,
});
}
}
else
{
var productIds = input.Lines.Select(l => l.ProductId).Distinct().ToList();
var book = await _db.Stocks.Where(s => s.StoreId == input.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var costs = await _db.Products.Where(p => productIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id, p => p.Cost, ct);
var order = 0;
foreach (var l in input.Lines)
{
book.TryGetValue(l.ProductId, out var b);
costs.TryGetValue(l.ProductId, out var c);
doc.Lines.Add(new InventoryLine
{
ProductId = l.ProductId,
BookQty = b,
ActualQty = l.ActualQty,
Diff = l.ActualQty - b,
UnitCost = c,
SortOrder = order++,
});
}
}
_db.InventoryDocs.Add(doc);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(doc.Id, ct);
return CreatedAtAction(nameof(Get), new { id = doc.Id }, dto);
}
private async Task 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("Product") ? "productId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), RequiresPermission("InventoryEdit")]
public async Task Update(Guid id, [FromBody] InventoryInput input, CancellationToken ct)
{
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status != InventoryStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
doc.Date = input.Date;
doc.Notes = input.Notes;
// StoreId на UPDATE не меняем — это пересчитало бы bookQty целиком.
if (input.Lines is not null && input.Lines.Count > 0)
{
// Обновление actualQty по существующим строкам — in-place правка
// (без Add/Remove на навигации, чтобы не словить EF8 bug на
// nav-collection, см. фикс на Products/Enters/Losses.Update).
var byProduct = doc.Lines.ToDictionary(l => l.ProductId);
var existingCount = doc.Lines.Count;
foreach (var ln in input.Lines)
{
if (byProduct.TryGetValue(ln.ProductId, out var existing))
{
existing.ActualQty = ln.ActualQty;
existing.Diff = ln.ActualQty - existing.BookQty;
}
else
{
// Новая строка — подгружаем book на момент изменения.
// Добавляем через DbSet, не через doc.Lines.Add, чтобы EF
// не путал nav-collection и xmin-токен на InventoryDoc.
var b = await _db.Stocks.Where(s => s.StoreId == doc.StoreId && s.ProductId == ln.ProductId)
.Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m;
var c = await _db.Products.Where(p => p.Id == ln.ProductId).Select(p => p.Cost).FirstOrDefaultAsync(ct);
_db.InventoryLines.Add(new InventoryLine
{
InventoryDocId = doc.Id,
ProductId = ln.ProductId,
BookQty = b,
ActualQty = ln.ActualQty,
Diff = ln.ActualQty - b,
UnitCost = c,
SortOrder = existingCount++,
});
}
}
}
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
/// Sprint 31: быстрое добавление позиции по productId
/// (мобильный сканер — +1 к существующей, иначе новая строка).
/// bookQty подставляется из текущего Stock склада. Отличается от
/// общего Update: пишет только одну строку, не трогает остальные.
public record AddLineInput([Required] Guid ProductId, decimal ActualQty);
[HttpPost("{id:guid}/add-line"), RequiresPermission("InventoryEdit")]
public async Task AddLine(Guid id, [FromBody] AddLineInput input, CancellationToken ct)
{
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status != InventoryStatus.Draft)
return Conflict(new { error = "Только черновик можно править." });
var existing = doc.Lines.FirstOrDefault(l => l.ProductId == input.ProductId);
if (existing is not null)
{
existing.ActualQty = input.ActualQty > 0 ? input.ActualQty : existing.ActualQty + 1;
existing.Diff = existing.ActualQty - existing.BookQty;
}
else
{
var b = await _db.Stocks.Where(s => s.StoreId == doc.StoreId && s.ProductId == input.ProductId)
.Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m;
var c = await _db.Products.Where(p => p.Id == input.ProductId).Select(p => p.Cost).FirstOrDefaultAsync(ct);
var qty = input.ActualQty > 0 ? input.ActualQty : 1m;
_db.InventoryLines.Add(new InventoryLine
{
InventoryDocId = doc.Id,
ProductId = input.ProductId,
BookQty = b,
ActualQty = qty,
Diff = qty - b,
UnitCost = c,
SortOrder = doc.Lines.Count,
});
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
/// Sprint 31 (пункт 6): проверка «учётный изменился с момента
/// создания документа» — чтобы перед Post показать, что stock поплыл
/// (были продажи/приёмки). Возвращает список строк, у которых текущий
/// Stock != сохранённый BookQty. Клиент показывает предупреждение.
public record BookDriftRow(Guid ProductId, string ProductName, decimal SavedBook, decimal CurrentBook);
[HttpGet("{id:guid}/book-drift")]
public async Task>> BookDrift(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
var lines = await _db.InventoryLines
.Where(l => l.InventoryDocId == id)
.Select(l => new { l.ProductId, l.BookQty, l.Product.Name })
.ToListAsync(ct);
var ids = lines.Select(l => l.ProductId).ToList();
var cur = await _db.Stocks
.Where(s => s.StoreId == doc.StoreId && ids.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var drift = new List();
foreach (var l in lines)
{
cur.TryGetValue(l.ProductId, out var now);
if (now != l.BookQty) drift.Add(new BookDriftRow(l.ProductId, l.Name, l.BookQty, now));
}
return Ok(drift);
}
[HttpDelete("{id:guid}"), RequiresPermission("InventoryEdit")]
public async Task Delete(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status != InventoryStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.InventoryDocs.Remove(doc);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), RequiresPermission("InventoryEdit")]
public async Task Post(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status == InventoryStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (doc.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
var withDiff = doc.Lines.Where(l => l.Diff != 0m).ToList();
if (withDiff.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 withDiff)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: doc.StoreId,
Quantity: line.Diff, // positive: surplus; negative: shortage
Type: MovementType.InventoryAdjustment,
DocumentType: "inventory",
DocumentId: doc.Id,
DocumentNumber: doc.Number,
UnitCost: line.UnitCost,
OccurredAt: doc.Date,
Notes: line.Diff > 0 ? "surplus" : "shortage"), ct);
}
doc.Status = InventoryStatus.Posted;
doc.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("InventoryEdit")]
public async Task Unpost(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
if (doc.Status != InventoryStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: для каждой строки c diff != 0 — обратное движение на -diff.
// Защита от ухода в минус: если diff был положительный (излишек), при unpost
// мы списываем (-diff = -surplus) — стоит проверить что эта величина в наличии.
var positive = doc.Lines.Where(l => l.Diff > 0)
.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Diff) }).ToList();
var productIds = positive.Select(x => x.ProductId).ToList();
var stocks = await _db.Stocks
.Where(s => s.StoreId == doc.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var conflicts = new List();
foreach (var r in positive)
{
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,
});
}
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
foreach (var line in doc.Lines.Where(l => l.Diff != 0m))
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: doc.StoreId,
Quantity: -line.Diff,
Type: MovementType.InventoryAdjustment,
DocumentType: "inventory-reversal",
DocumentId: doc.Id,
DocumentNumber: doc.Number,
UnitCost: line.UnitCost,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {doc.Number}"), ct);
}
doc.Status = InventoryStatus.Draft;
doc.PostedAt = null;
try
{
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex))
{
return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." });
}
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 GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"И-{year}-";
var lastNumber = await _db.InventoryDocs
.Where(i => i.Number.StartsWith(prefix))
.OrderByDescending(i => i.Number)
.Select(i => i.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 GetInternal(Guid id, CancellationToken ct)
{
var row = await (from i in _db.InventoryDocs.AsNoTracking()
join st in _db.Stores on i.StoreId equals st.Id
where i.Id == id
select new { i, st }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.InventoryLines.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.InventoryDocId == id
orderby l.SortOrder
select new InventoryLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Name,
l.BookQty, l.ActualQty, l.Diff, l.UnitCost, l.SortOrder))
.ToListAsync(ct);
return new InventoryDto(
row.i.Id, row.i.Number, row.i.Date, row.i.Status,
row.st.Id, row.st.Name,
row.i.Notes, row.i.PostedAt,
lines);
}
}