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
Было: unpost при проведённых связанных Оприходовании/Списании молча снимал их с
проведения и УДАЛЯЛ (204, номера исчезали). Хуже МойСклада, теряется история.
- unpost при ПРОВЕДЁННЫХ связанных → 409 с номерами О-/С- и текстом «Сначала
отмените результаты». Если связанные только черновики — удаляем черновики
(не влияли на остатки) и возвращаем инвентаризацию в черновик.
- Новое POST /{id}/cancel-results (InventoryEdit + EnterEdit/LossEdit): снимает
связанные Оприходование/Списание с проведения, НЕ удаляя (остаются черновиками,
видны в «Связанных документах», истории, аудите); остатки возвращаются; для
Оприходования — гард на уход остатка в минус. Аудит InventoryCancelResults.
- Веб: кнопка «Отменить результаты» (при проведённых связанных) + диалог со
списком документов и влиянием на остатки; «Вернуть в черновик» переформулирован.
- docs/mobile-api.md: unpost больше не удаляет проведённые (409), добавлен cancel-results.
- Тесты: integration (409 + cancel-results оставляет черновики + повторное
проведение) + full-chain шаг 10.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
861 lines
48 KiB
C#
861 lines
48 KiB
C#
using System.ComponentModel.DataAnnotations;
|
||
using foodmarket.Api.Infrastructure.Invoices;
|
||
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.Inventory;
|
||
|
||
/// <summary>Инвентаризация (пересчёт) по модели МойСклада. Создание подгружает
|
||
/// текущие остатки склада в <c>bookQty</c>; пользователь вносит фактические
|
||
/// количества. «Завершение» (<c>complete</c>) замораживает строки (Completed) и
|
||
/// НЕ двигает остатки. По результату создаются связанные Оприходование
|
||
/// (<c>create-enter</c>, излишки diff>0) и Списание (<c>create-loss</c>, недостача
|
||
/// diff<0) — их проводят как обычные документы (вот тогда меняются остатки).
|
||
/// <c>post</c>/<c>unpost</c> — временная совместимость для мобильного «FM Склад».</summary>
|
||
[ApiController]
|
||
[Authorize]
|
||
[Route("api/inventory/inventories")]
|
||
public class InventoriesController : ControllerBase
|
||
{
|
||
private readonly AppDbContext _db;
|
||
private readonly IStockService _stock;
|
||
private readonly IAuthorizationService _authz;
|
||
private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit;
|
||
|
||
public InventoriesController(AppDbContext db, IStockService stock,
|
||
IAuthorizationService authz, foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit)
|
||
{
|
||
_db = db;
|
||
_stock = stock;
|
||
_authz = authz;
|
||
_audit = audit;
|
||
}
|
||
|
||
public record InventoryListRow(
|
||
Guid Id, string Number, DateTime Date, InventoryStatus Status,
|
||
Guid StoreId, string StoreName,
|
||
int LineCount,
|
||
decimal SurplusValue, decimal ShortageValue,
|
||
DateTime? PostedAt,
|
||
// Результат: связанные документы (null — «не применено»).
|
||
Guid? EnterId, string? EnterNumber, Guid? LossId, string? LossNumber);
|
||
|
||
public record InventoryLineDto(
|
||
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
|
||
decimal BookQty, decimal ActualQty, decimal Diff, decimal UnitCost,
|
||
int SortOrder);
|
||
|
||
/// <summary>Связанный документ результата: «enter» (оприходование) / «loss» (списание).
|
||
/// <c>Posted</c> — проведён ли (двигал ли остатки).</summary>
|
||
public record RelatedDocDto(string Kind, Guid Id, string Number, bool Posted);
|
||
|
||
public record InventoryDto(
|
||
Guid Id, string Number, DateTime Date, InventoryStatus Status,
|
||
Guid StoreId, string StoreName,
|
||
string? Notes,
|
||
DateTime? PostedAt,
|
||
IReadOnlyList<InventoryLineDto> Lines,
|
||
IReadOnlyList<RelatedDocDto> Related);
|
||
|
||
public record InventoryLineInput(
|
||
Guid ProductId,
|
||
[Range(0, 1e10)] decimal ActualQty);
|
||
|
||
public record InventoryInput(
|
||
DateTime Date, Guid StoreId,
|
||
string? Notes,
|
||
/// <summary>Если null/пусто — контроллер сам заполнит строками всеми
|
||
/// товарами склада с их текущим Stock в качестве bookQty и actual=0.</summary>
|
||
IReadOnlyList<InventoryLineInput>? Lines);
|
||
|
||
/// <summary>Компактный слепок каталога для телефона: тройки
|
||
/// [штрихкод, productId, name]. Телефон держит его в IndexedDB,
|
||
/// работает без сети. Опциональный <paramref name="groupId"/> режет
|
||
/// каталог для частичной инвентаризации (считаем только одну группу).</summary>
|
||
public record MobileCatalogRow(string Barcode, Guid ProductId, string Name);
|
||
public record MobileCatalogResponse(int Count, DateTime BuiltAt, List<object[]> Rows);
|
||
|
||
// ── импорт Excel/CSV для инвентаризации ─────────────────────────────
|
||
|
||
public record InventoryImportFoundRow(Guid Id, string Name, decimal Qty);
|
||
public record InventoryImportUnknownRow(string Code, decimal Qty);
|
||
public record InventoryImportResponse(
|
||
IReadOnlyList<InventoryImportFoundRow> Found,
|
||
IReadOnlyList<InventoryImportUnknownRow> Unknown);
|
||
|
||
/// <summary>Импорт файла в текущий сеанс инвентаризации. Колонки «Штрихкод» и
|
||
/// «Количество» (если нет заголовков — 1-я и 2-я); повторы одного штрихкода
|
||
/// суммируются. Документ НЕ создаётся: клиент добавляет строки к текущему
|
||
/// списку, кладовщик может ещё досканировать камерой/сканером, и уже
|
||
/// потом создаёт черновик обычным <c>POST /inventories</c>. Порт поведения
|
||
/// из <c>~/ref/sklad/sklad/Controllers/InventoryController.cs</c>:Import.</summary>
|
||
[HttpPost("import"), RequiresPermission("InventoryEdit"), RequestSizeLimit(20_000_000)]
|
||
public async Task<ActionResult<InventoryImportResponse>> 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<string, decimal>(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<string, (Guid Id, string Name)>();
|
||
foreach (var h2 in hits)
|
||
byCode.TryAdd(h2.Code, (h2.ProductId, h2.Name));
|
||
|
||
var found = new List<InventoryImportFoundRow>();
|
||
var unknown = new List<InventoryImportUnknownRow>();
|
||
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<ActionResult<MobileCatalogResponse>> 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<string>();
|
||
var rows = new List<object[]>(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<ActionResult<PagedResult<InventoryListRow>>> 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,
|
||
_db.Enters.Where(e => e.InventoryDocId == x.i.Id).Select(e => (Guid?)e.Id).FirstOrDefault(),
|
||
_db.Enters.Where(e => e.InventoryDocId == x.i.Id).Select(e => e.Number).FirstOrDefault(),
|
||
_db.Losses.Where(l => l.InventoryDocId == x.i.Id).Select(l => (Guid?)l.Id).FirstOrDefault(),
|
||
_db.Losses.Where(l => l.InventoryDocId == x.i.Id).Select(l => l.Number).FirstOrDefault()))
|
||
.ToListAsync(ct);
|
||
|
||
return new PagedResult<InventoryListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||
}
|
||
|
||
[HttpGet("{id:guid}")]
|
||
public async Task<ActionResult<InventoryDto>> Get(Guid id, CancellationToken ct)
|
||
{
|
||
var dto = await GetInternal(id, ct);
|
||
return dto is null ? NotFound() : Ok(dto);
|
||
}
|
||
|
||
[HttpPost, RequiresPermission("InventoryEdit")]
|
||
public async Task<ActionResult<InventoryDto>> 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<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
await _db.SaveChangesAsync(ct);
|
||
return null;
|
||
}
|
||
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
|
||
{
|
||
var name = pg.ConstraintName ?? "";
|
||
string field = name.Contains("Store") ? "storeId"
|
||
: name.Contains("Product") ? "productId"
|
||
: "(unknown)";
|
||
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
|
||
}
|
||
}
|
||
|
||
[HttpPut("{id:guid}"), RequiresPermission("InventoryEdit")]
|
||
public async Task<IActionResult> 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();
|
||
}
|
||
|
||
/// <summary>Sprint 31: быстрое добавление позиции по productId
|
||
/// (мобильный сканер — +1 к существующей, иначе новая строка).
|
||
/// bookQty подставляется из текущего Stock склада. Отличается от
|
||
/// общего Update: пишет только одну строку, не трогает остальные.</summary>
|
||
public record AddLineInput([Required] Guid ProductId, decimal ActualQty);
|
||
|
||
[HttpPost("{id:guid}/add-line"), RequiresPermission("InventoryEdit")]
|
||
public async Task<IActionResult> 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();
|
||
}
|
||
|
||
/// <summary>Sprint 31 (пункт 6): проверка «учётный изменился с момента
|
||
/// создания документа» — чтобы перед Post показать, что stock поплыл
|
||
/// (были продажи/приёмки). Возвращает список строк, у которых текущий
|
||
/// Stock != сохранённый BookQty. Клиент показывает предупреждение.</summary>
|
||
public record BookDriftRow(Guid ProductId, string ProductName, decimal SavedBook, decimal CurrentBook);
|
||
|
||
[HttpGet("{id:guid}/book-drift")]
|
||
public async Task<ActionResult<List<BookDriftRow>>> 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<BookDriftRow>();
|
||
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<IActionResult> 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();
|
||
}
|
||
|
||
// ── Завершение + результирующие документы (модель МойСклада) ─────────
|
||
// Инвентаризация НЕ двигает остатки. Завершаем (Completed), затем по
|
||
// результату создаём связанные Оприходование (излишки) и Списание (недостача).
|
||
|
||
/// <summary>Завершить инвентаризацию: заморозить строки (Status=Completed).
|
||
/// Остатки НЕ двигаются. После — кнопки «Создать оприходование/списание».</summary>
|
||
[HttpPost("{id:guid}/complete"), RequiresPermission("InventoryEdit")]
|
||
public async Task<IActionResult> Complete(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.Completed) return Conflict(new { error = "Инвентаризация уже завершена." });
|
||
if (doc.Lines.Count == 0) return BadRequest(new { error = "Нельзя завершить инвентаризацию без строк." });
|
||
|
||
doc.Status = InventoryStatus.Completed;
|
||
doc.PostedAt = DateTime.UtcNow;
|
||
try { await _db.SaveChangesAsync(ct); }
|
||
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите." }); }
|
||
return NoContent();
|
||
}
|
||
|
||
/// <summary>Создать Оприходование излишков (diff>0) «на основании» инвентаризации —
|
||
/// черновик, проводится отдельно как обычный документ. Повторно нельзя.</summary>
|
||
[HttpPost("{id:guid}/create-enter"), RequiresPermission("EnterEdit")]
|
||
public Task<IActionResult> CreateEnter(Guid id, CancellationToken ct) => CreateResultDocAsync(id, surplus: true, ct);
|
||
|
||
/// <summary>Создать Списание недостачи (diff<0) «на основании» инвентаризации.</summary>
|
||
[HttpPost("{id:guid}/create-loss"), RequiresPermission("LossEdit")]
|
||
public Task<IActionResult> CreateLoss(Guid id, CancellationToken ct) => CreateResultDocAsync(id, surplus: false, ct);
|
||
|
||
private async Task<IActionResult> CreateResultDocAsync(Guid id, bool surplus, 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.Completed)
|
||
return Conflict(new { error = "Сначала завершите инвентаризацию." });
|
||
|
||
var split = InventoryResult.Build(doc.Lines.Select(l => (l.ProductId, l.Diff, l.UnitCost)));
|
||
if (surplus)
|
||
{
|
||
if (!split.HasSurplus) return BadRequest(new { error = "Нет излишков — оприходовать нечего." });
|
||
var ex = await _db.Enters.Where(e => e.InventoryDocId == id).Select(e => new { e.Id, e.Number }).FirstOrDefaultAsync(ct);
|
||
if (ex is not null) return Conflict(new { error = $"Оприходование {ex.Number} уже создано по этой инвентаризации.", enterId = ex.Id, number = ex.Number });
|
||
var enter = await BuildEnterAsync(doc, split.Surplus, post: false, ct);
|
||
await _db.SaveChangesAsync(ct);
|
||
return Ok(new { enterId = enter.Id, number = enter.Number });
|
||
}
|
||
else
|
||
{
|
||
if (!split.HasShortage) return BadRequest(new { error = "Нет недостачи — списывать нечего." });
|
||
var ex = await _db.Losses.Where(l => l.InventoryDocId == id).Select(l => new { l.Id, l.Number }).FirstOrDefaultAsync(ct);
|
||
if (ex is not null) return Conflict(new { error = $"Списание {ex.Number} уже создано по этой инвентаризации.", lossId = ex.Id, number = ex.Number });
|
||
var loss = await BuildLossAsync(doc, split.Shortage, post: false, ct);
|
||
await _db.SaveChangesAsync(ct);
|
||
return Ok(new { lossId = loss.Id, number = loss.Number });
|
||
}
|
||
}
|
||
|
||
/// <summary>Строит Оприходование из строк излишков. Если post — сразу проводит
|
||
/// (движения Enter, +остаток). Не сохраняет (SaveChanges делает вызывающий).</summary>
|
||
private async Task<Enter> BuildEnterAsync(InventoryDoc doc, IReadOnlyList<InventoryResult.ResultLine> lines, bool post, CancellationToken ct)
|
||
{
|
||
var currencyId = await DefaultCurrencyIdAsync(ct);
|
||
var enter = new Enter
|
||
{
|
||
Number = await GenerateDocNumberAsync<Enter>("О-", doc.Date, ct),
|
||
Date = doc.Date,
|
||
Status = EnterStatus.Draft,
|
||
StoreId = doc.StoreId,
|
||
CurrencyId = currencyId,
|
||
InventoryDocId = doc.Id,
|
||
Notes = $"Излишки по инвентаризации {doc.Number}",
|
||
Total = lines.Sum(l => l.LineTotal),
|
||
Lines = lines.Select(l => new EnterLine { ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, LineTotal = l.LineTotal }).ToList(),
|
||
};
|
||
_db.Enters.Add(enter);
|
||
if (post)
|
||
{
|
||
foreach (var l in enter.Lines)
|
||
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, enter.StoreId, l.Quantity,
|
||
MovementType.Enter, "enter", enter.Id, enter.Number, l.UnitCost, enter.Date), ct);
|
||
enter.Status = EnterStatus.Posted;
|
||
enter.PostedAt = DateTime.UtcNow;
|
||
}
|
||
return enter;
|
||
}
|
||
|
||
/// <summary>Строит Списание из строк недостачи. Если post — проводит (WriteOff, −остаток).</summary>
|
||
private async Task<Loss> BuildLossAsync(InventoryDoc doc, IReadOnlyList<InventoryResult.ResultLine> lines, bool post, CancellationToken ct)
|
||
{
|
||
var currencyId = await DefaultCurrencyIdAsync(ct);
|
||
var loss = new Loss
|
||
{
|
||
Number = await GenerateDocNumberAsync<Loss>("С-", doc.Date, ct),
|
||
Date = doc.Date,
|
||
Status = LossStatus.Draft,
|
||
StoreId = doc.StoreId,
|
||
CurrencyId = currencyId,
|
||
Reason = LossReason.Shortage,
|
||
InventoryDocId = doc.Id,
|
||
Notes = $"Недостача по инвентаризации {doc.Number}",
|
||
Total = lines.Sum(l => l.LineTotal),
|
||
Lines = lines.Select(l => new LossLine { ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, LineTotal = l.LineTotal }).ToList(),
|
||
};
|
||
_db.Losses.Add(loss);
|
||
if (post)
|
||
{
|
||
foreach (var l in loss.Lines)
|
||
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, loss.StoreId, -l.Quantity,
|
||
MovementType.WriteOff, "loss", loss.Id, loss.Number, l.UnitCost, loss.Date, Notes: $"reason={loss.Reason}"), ct);
|
||
loss.Status = LossStatus.Posted;
|
||
loss.PostedAt = DateTime.UtcNow;
|
||
}
|
||
return loss;
|
||
}
|
||
|
||
/// <summary>МОБИЛЬНАЯ СОВМЕСТИМОСТЬ (временная, см. docs/mobile-api.md): iOS
|
||
/// «FM Склад» вызывает post. post = завершить + создать и ПРОВЕСТИ Оприходование
|
||
/// (излишки) и Списание (недостача). Веб использует complete + create-enter/create-loss.</summary>
|
||
[HttpPost("{id:guid}/post"), RequiresPermission("InventoryEdit")]
|
||
public async Task<IActionResult> 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.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
|
||
|
||
await using var tx = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
|
||
if (doc.Status != InventoryStatus.Completed) { doc.Status = InventoryStatus.Completed; doc.PostedAt = DateTime.UtcNow; }
|
||
|
||
var split = InventoryResult.Build(doc.Lines.Select(l => (l.ProductId, l.Diff, l.UnitCost)));
|
||
if (split.HasSurplus && !await _db.Enters.AnyAsync(e => e.InventoryDocId == id, ct))
|
||
await BuildEnterAsync(doc, split.Surplus, post: true, ct);
|
||
if (split.HasShortage && !await _db.Losses.AnyAsync(l => l.InventoryDocId == id, ct))
|
||
await BuildLossAsync(doc, split.Shortage, post: true, ct);
|
||
try { await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); }
|
||
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ проводится параллельно. Повторите попытку." }); }
|
||
return NoContent();
|
||
}
|
||
|
||
/// <summary>«Вернуть в черновик» инвентаризацию. Если по ней ПРОВЕДЕНЫ
|
||
/// связанные Оприходование/Списание — возвращаем 409 и просим сначала
|
||
/// «Отменить результаты»: мы НЕ удаляем проведённые документы молча (это
|
||
/// хуже МойСклада и теряет историю). Если связанные документы только
|
||
/// черновики (или их нет) — удаляем черновики (они не влияли на остатки) и
|
||
/// возвращаем инвентаризацию в черновик.
|
||
///
|
||
/// Мобильный /unpost (совместимость, см. docs/mobile-api.md) — та же логика:
|
||
/// при проведённых связанных документах возвращает тот же 409.</summary>
|
||
[HttpPost("{id:guid}/unpost"), RequiresPermission("InventoryEdit")]
|
||
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||
{
|
||
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
|
||
if (doc is null) return NotFound();
|
||
|
||
var postedEnters = await _db.Enters
|
||
.Where(e => e.InventoryDocId == id && e.Status == EnterStatus.Posted)
|
||
.Select(e => new { e.Id, e.Number }).ToListAsync(ct);
|
||
var postedLosses = await _db.Losses
|
||
.Where(l => l.InventoryDocId == id && l.Status == LossStatus.Posted)
|
||
.Select(l => new { l.Id, l.Number }).ToListAsync(ct);
|
||
|
||
if (postedEnters.Count > 0 || postedLosses.Count > 0)
|
||
{
|
||
var parts = new List<string>();
|
||
if (postedEnters.Count > 0) parts.Add("Оприходование " + string.Join(", ", postedEnters.Select(e => e.Number)));
|
||
if (postedLosses.Count > 0) parts.Add("Списание " + string.Join(", ", postedLosses.Select(l => l.Number)));
|
||
return Conflict(new
|
||
{
|
||
error = $"По инвентаризации проведены {string.Join(" и ", parts)}. Сначала отмените результаты.",
|
||
enters = postedEnters,
|
||
losses = postedLosses,
|
||
});
|
||
}
|
||
|
||
// Проведённых связанных нет — удаляем черновики (они не влияли на остатки)
|
||
// и возвращаем инвентаризацию в черновик.
|
||
var draftEnters = await _db.Enters.Where(e => e.InventoryDocId == id).ToListAsync(ct);
|
||
_db.Enters.RemoveRange(draftEnters);
|
||
var draftLosses = await _db.Losses.Where(l => l.InventoryDocId == id).ToListAsync(ct);
|
||
_db.Losses.RemoveRange(draftLosses);
|
||
|
||
doc.Status = InventoryStatus.Draft;
|
||
doc.PostedAt = null;
|
||
try { await _db.SaveChangesAsync(ct); }
|
||
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." }); }
|
||
return NoContent();
|
||
}
|
||
|
||
/// <summary>«Отменить результаты»: снять с проведения связанные Оприходование
|
||
/// и Списание, НЕ удаляя их — они становятся черновиками, остаются в
|
||
/// «Связанных документах», истории и аудите. Остатки возвращаются (как при
|
||
/// обычной отмене проведения Оприходования/Списания). После этого
|
||
/// инвентаризацию можно вернуть в черновик или снова провести документы.
|
||
///
|
||
/// Право: InventoryEdit (эндпоинт) + EnterEdit (если есть проведённые
|
||
/// Оприходования) и/или LossEdit (если есть проведённые Списания).</summary>
|
||
[HttpPost("{id:guid}/cancel-results"), RequiresPermission("InventoryEdit")]
|
||
public async Task<IActionResult> CancelResults(Guid id, CancellationToken ct)
|
||
{
|
||
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
|
||
if (doc is null) return NotFound();
|
||
|
||
var enters = await _db.Enters.Include(e => e.Lines)
|
||
.Where(e => e.InventoryDocId == id && e.Status == EnterStatus.Posted).ToListAsync(ct);
|
||
var losses = await _db.Losses.Include(l => l.Lines)
|
||
.Where(l => l.InventoryDocId == id && l.Status == LossStatus.Posted).ToListAsync(ct);
|
||
if (enters.Count == 0 && losses.Count == 0)
|
||
return Conflict(new { error = "Нет проведённых связанных документов — отменять нечего." });
|
||
|
||
// Доп. права под конкретные типы связанных документов.
|
||
if (enters.Count > 0 && !(await _authz.AuthorizeAsync(User, "perm:EnterEdit")).Succeeded)
|
||
return Forbid();
|
||
if (losses.Count > 0 && !(await _authz.AuthorizeAsync(User, "perm:LossEdit")).Succeeded)
|
||
return Forbid();
|
||
|
||
await using var tx = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
|
||
|
||
// Для Оприходований проверяем, что снятие не уведёт остаток в минус
|
||
// (часть оприходованного излишка могла быть уже продана).
|
||
var conflicts = new List<object>();
|
||
foreach (var e in enters)
|
||
{
|
||
var reverseByProduct = e.Lines.GroupBy(l => l.ProductId)
|
||
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
|
||
var pids = reverseByProduct.Select(r => r.ProductId).ToList();
|
||
var stocks = await _db.Stocks.Where(s => s.StoreId == e.StoreId && pids.Contains(s.ProductId))
|
||
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
||
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 { document = e.Number, productId = r.ProductId, productName = name, reverseQty = r.Quantity, available });
|
||
}
|
||
}
|
||
}
|
||
if (conflicts.Count > 0)
|
||
return Conflict(new { error = "Нельзя отменить результаты: остаток уйдёт в минус (часть оприходованного товара уже расходована).", lines = conflicts });
|
||
|
||
// Снимаем Оприходования с проведения (−остаток), оставляем черновиками.
|
||
foreach (var e in enters)
|
||
{
|
||
foreach (var l in e.Lines)
|
||
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, e.StoreId, -l.Quantity,
|
||
MovementType.Enter, "enter-reversal", e.Id, e.Number, l.UnitCost, DateTime.UtcNow,
|
||
Notes: $"Отмена результатов инвентаризации {doc.Number}"), ct);
|
||
e.Status = EnterStatus.Draft;
|
||
e.PostedAt = null;
|
||
}
|
||
// Снимаем Списания с проведения (+остаток обратно), оставляем черновиками.
|
||
foreach (var ls in losses)
|
||
{
|
||
foreach (var l in ls.Lines)
|
||
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, ls.StoreId, l.Quantity,
|
||
MovementType.WriteOff, "loss-reversal", ls.Id, ls.Number, l.UnitCost, DateTime.UtcNow,
|
||
Notes: $"Отмена результатов инвентаризации {doc.Number}"), ct);
|
||
ls.Status = LossStatus.Draft;
|
||
ls.PostedAt = null;
|
||
}
|
||
|
||
try
|
||
{
|
||
await _db.SaveChangesAsync(ct);
|
||
// Аудит в той же транзакции: кто/когда/из какой инвентаризации снял проведение.
|
||
await _audit.LogAsync("InventoryCancelResults", "inventory", doc.Id, new
|
||
{
|
||
inventoryNumber = doc.Number,
|
||
enters = enters.Select(e => new { e.Id, e.Number }).ToList(),
|
||
losses = losses.Select(l => new { l.Id, l.Number }).ToList(),
|
||
}, ct);
|
||
await tx.CommitAsync(ct);
|
||
}
|
||
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." }); }
|
||
|
||
return Ok(new
|
||
{
|
||
cancelled = new
|
||
{
|
||
enters = enters.Select(e => new { e.Id, e.Number }).ToList(),
|
||
losses = losses.Select(l => new { l.Id, l.Number }).ToList(),
|
||
},
|
||
});
|
||
}
|
||
|
||
private async Task<Guid> DefaultCurrencyIdAsync(CancellationToken ct)
|
||
{
|
||
var orgCur = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct);
|
||
if (orgCur is { } c) return c;
|
||
return await _db.Currencies.Where(x => x.Code == "KZT").Select(x => x.Id).FirstOrDefaultAsync(ct);
|
||
}
|
||
|
||
/// <summary>Генерирует номер документа Оприходования/Списания по образцу их
|
||
/// контроллеров: «<prefix><год>-NNNNNN».</summary>
|
||
private async Task<string> GenerateDocNumberAsync<T>(string prefix, DateTime date, CancellationToken ct) where T : class
|
||
{
|
||
var pfx = $"{prefix}{date.Year}-";
|
||
var last = typeof(T) == typeof(Enter)
|
||
? await _db.Enters.Where(e => e.Number.StartsWith(pfx)).OrderByDescending(e => e.Number).Select(e => e.Number).FirstOrDefaultAsync(ct)
|
||
: await _db.Losses.Where(l => l.Number.StartsWith(pfx)).OrderByDescending(l => l.Number).Select(l => l.Number).FirstOrDefaultAsync(ct);
|
||
var seq = 1;
|
||
if (last is not null && int.TryParse(last[pfx.Length..], out var n)) seq = n + 1;
|
||
return $"{pfx}{seq:D6}";
|
||
}
|
||
|
||
private static bool IsSerializationConflict(Exception ex)
|
||
{
|
||
for (Exception? e = ex; e is not null; e = e.InnerException)
|
||
{
|
||
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
|
||
{
|
||
var year = date.Year;
|
||
var prefix = $"И-{year}-";
|
||
var lastNumber = await _db.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<InventoryDto?> 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);
|
||
|
||
var related = new List<RelatedDocDto>();
|
||
related.AddRange(await _db.Enters.AsNoTracking().Where(e => e.InventoryDocId == id)
|
||
.Select(e => new RelatedDocDto("enter", e.Id, e.Number, e.Status == EnterStatus.Posted)).ToListAsync(ct));
|
||
related.AddRange(await _db.Losses.AsNoTracking().Where(l => l.InventoryDocId == id)
|
||
.Select(l => new RelatedDocDto("loss", l.Id, l.Number, l.Status == LossStatus.Posted)).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, related);
|
||
}
|
||
}
|