using System.Text.Json; using foodmarket.Api.Infrastructure.Authorization; using foodmarket.Api.Infrastructure.Invoices; using foodmarket.Application.Common.Tenancy; using foodmarket.Domain.Catalog; using foodmarket.Domain.Purchases; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace foodmarket.Api.Controllers.Purchases; /// Приёмка по фото/PDF накладной. /// /// Жизненный цикл загрузки: /// /// POST /upload — файл(ы) сохраняются, запись /// в статусе pending. /// в фоне: pending → parsing → /// needs_review | failed. /// GET /{id} возвращает распознанные строки + сопоставление. /// PUT /{id}/lines — кладовщик правит строки + ручные /// сопоставления; каждое ручное сопоставление пишется в /// — на следующих накладных того же /// поставщика matcher уже знает ответ. /// POST /{id}/apply — создаёт Supply (Draft) с указанными /// поставщиком/складом/валютой. Status → applied, SupplyId /// ставится. /// /// /// Доступ: Admin + Storekeeper (SuppliesEdit permission). [ApiController] [Authorize] [Route("api/purchases/invoices")] public class InvoicesController : ControllerBase { private readonly AppDbContext _db; private readonly IWebHostEnvironment _env; private readonly ITenantContext _tenant; private readonly ILogger _log; public InvoicesController(AppDbContext db, IWebHostEnvironment env, ITenantContext tenant, ILogger log) { _db = db; _env = env; _tenant = tenant; _log = log; } private string StorageDir => Path.Combine(_env.ContentRootPath, "uploads", "invoices", DateTime.UtcNow.ToString("yyyy-MM")); // ── upload ────────────────────────────────────────────────────────── public record UploadResult(List Ids); [HttpPost("upload"), RequiresPermission("SuppliesEdit"), RequestSizeLimit(60_000_000)] public async Task> Upload([FromForm] IFormFileCollection files, CancellationToken ct) { if (files is null || files.Count == 0) return BadRequest(new { error = "Файлы не выбраны." }); Directory.CreateDirectory(StorageDir); var created = new List(); foreach (var f in files) { if (f.Length == 0) continue; var ext = Path.GetExtension(f.FileName); var path = Path.Combine(StorageDir, $"{Guid.NewGuid():N}{ext}"); await using (var fs = System.IO.File.Create(path)) await f.CopyToAsync(fs, ct); var up = new InvoiceUpload { FileName = Path.GetFileName(f.FileName), StoredPath = path, ContentType = f.ContentType, SizeBytes = f.Length, UploadedByUserId = _tenant.UserId, Status = "pending", }; _db.InvoiceUploads.Add(up); await _db.SaveChangesAsync(ct); created.Add(up.Id); } return Ok(new UploadResult(created)); } public record UploadPhotosResult(Guid Id); /// Фото-приёмка: пачка снимков накладной + товаров одной загрузкой. /// Штрихкоды с каждого снимка телефон уже прочитал ZXing-ом; /// — JSON-массив массивов (по порядку файлов), служит подсказкой модели. /// Папка со снимками хранится до создания черновика приёмки / удаления /// загрузки / 24 ч (брошенная на проверке) — см. . [HttpPost("upload-photos"), RequiresPermission("SuppliesEdit"), RequestSizeLimit(60_000_000)] public async Task> UploadPhotos( [FromForm] IFormFileCollection files, [FromForm] string? codes, CancellationToken ct) { if (files is null || files.Count == 0) return BadRequest(new { error = "Нет снимков." }); if (files.Count > 80) return BadRequest(new { error = "Слишком много снимков за раз (больше 80)." }); var dir = Path.Combine(StorageDir, $"photos-{Guid.NewGuid():N}"); Directory.CreateDirectory(dir); long size = 0; int n = 0; foreach (var f in files) { if (f.Length == 0) continue; n++; await using var fs = System.IO.File.Create(Path.Combine(dir, $"{n:D2}.jpg")); await f.CopyToAsync(fs, ct); size += f.Length; } if (!string.IsNullOrWhiteSpace(codes)) await System.IO.File.WriteAllTextAsync(Path.Combine(dir, "codes.json"), codes, ct); var up = new InvoiceUpload { FileName = $"фото-приёмка: {n} снимков", StoredPath = dir, ContentType = "photos", Source = "photos", SizeBytes = size, UploadedByUserId = _tenant.UserId, Status = "pending", }; _db.InvoiceUploads.Add(up); await _db.SaveChangesAsync(ct); return Ok(new UploadPhotosResult(up.Id)); } /// Снимок товара для миниатюры рядом со строкой приёмки /// (NN.jpg из папки фото-пачки). После или /// папка удаляется — тогда вернёт 404. [HttpGet("{id:guid}/photo/{n:int}")] public async Task Photo(Guid id, int n, CancellationToken ct) { var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct); if (up is null || string.IsNullOrEmpty(up.StoredPath) || !Directory.Exists(up.StoredPath)) return NotFound(); var f = Path.Combine(up.StoredPath, $"{n:D2}.jpg"); return System.IO.File.Exists(f) ? PhysicalFile(f, "image/jpeg") : NotFound(); } // ── list + detail ─────────────────────────────────────────────────── public record InvoiceListRow(Guid Id, string FileName, string Status, DateTime UploadedAt, string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum, int LinesTotal, int LinesMatched, Guid? SupplyId, DateTime? ProcessedAt, string? Error); [HttpGet] public async Task List([FromQuery] int take = 50) { var rows = await _db.InvoiceUploads .OrderByDescending(x => x.UploadedAt) .Take(Math.Clamp(take, 1, 200)) // Port sklad 06.10 §6: UploadedAt — UTC, фронт умножает на локальную // зону. Postgres timestamptz возвращается как Kind=Utc, но Dapper/ // JSON сериализация иногда теряет флаг — явно EnsureUtcKind перед // отдачей не нужен для EF (он выставляет Kind=Utc для timestamptz), // но клиент дополнительно подшивает «Z» если его нет (sklad §6). .Select(x => new InvoiceListRow(x.Id, x.FileName, x.Status, x.UploadedAt, x.SupplierName, x.InvoiceNumber, x.InvoiceDate, x.TotalSum, x.LinesTotal, x.LinesMatched, x.SupplyId, x.ProcessedAt, x.Error)) .ToListAsync(); return Ok(rows); } public record InvoiceDetailDto(Guid Id, string FileName, string Status, DateTime UploadedAt, string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum, int LinesTotal, int LinesMatched, Guid? SupplyId, string? SupplyNumber, string? Error, string? Source, bool HasFiles, DateTime? ProcessedAt, List Lines); [HttpGet("{id:guid}")] public async Task> Detail(Guid id, CancellationToken ct) { var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct); if (up is null) return NotFound(); var lines = string.IsNullOrEmpty(up.ParsedJson) ? new List() : JsonSerializer.Deserialize>(up.ParsedJson) ?? new List(); // hasFiles — миниатюры для фото-приёмки доступны только пока папка жива // (до apply/delete/24ч). Клиент читает флаг, чтобы не плодить 404. var hasFiles = !string.IsNullOrEmpty(up.StoredPath) && (Directory.Exists(up.StoredPath) || System.IO.File.Exists(up.StoredPath)); // supplyNumber — показывается плашкой на readOnly-экране готовой // накладной (порт sklad 06.10 §10). string? supplyNumber = null; if (up.SupplyId is { } sid) supplyNumber = await _db.Supplies.Where(s => s.Id == sid) .Select(s => (string?)s.Number).FirstOrDefaultAsync(ct); return Ok(new InvoiceDetailDto(up.Id, up.FileName, up.Status, up.UploadedAt, up.SupplierName, up.InvoiceNumber, up.InvoiceDate, up.TotalSum, up.LinesTotal, up.LinesMatched, up.SupplyId, supplyNumber, up.Error, up.Source, hasFiles, up.ProcessedAt, lines)); } [HttpGet("{id:guid}/file")] public async Task File(Guid id, CancellationToken ct) { var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct); if (up is null) return NotFound(); if (string.IsNullOrEmpty(up.StoredPath) || !System.IO.File.Exists(up.StoredPath)) return NotFound(new { error = "Файл удалён." }); return PhysicalFile(up.StoredPath, up.ContentType ?? "application/octet-stream"); } [HttpDelete("{id:guid}"), RequiresPermission("SuppliesEdit")] public async Task Delete(Guid id, CancellationToken ct) { var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct); if (up is null) return NotFound(); // Удаляем файл И папку фото-пачки (owner-policy: не копить фото). InvoiceProcessor.DeleteStored(up); _db.InvoiceUploads.Remove(up); await _db.SaveChangesAsync(ct); return NoContent(); } // ── правки + ручные сопоставления ─────────────────────────────────── public record SaveLinesDto(List Lines, string? SupplierName, string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum); /// Сохраняет правки кладовщика: строки + «шапка». Любое ручное /// сопоставление (MatchedBy=«manual» и ProductId != null) УЧИТСЯ: мы /// пишем/обновляем для текущего поставщика /// → этот товар. Следующая накладная того же поставщика с таким же /// SourceCode/NameNorm сопоставится автоматически. [HttpPut("{id:guid}/lines"), RequiresPermission("SuppliesEdit")] public async Task SaveLines(Guid id, [FromBody] SaveLinesDto dto, CancellationToken ct) { var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct); if (up is null) return NotFound(); if (up.Status == "applied") return BadRequest(new { error = "Накладная уже перенесена в приёмку." }); up.SupplierName = dto.SupplierName ?? up.SupplierName; up.InvoiceNumber = dto.InvoiceNumber ?? up.InvoiceNumber; up.InvoiceDate = dto.InvoiceDate.HasValue ? DateTime.SpecifyKind(dto.InvoiceDate.Value, DateTimeKind.Utc) : up.InvoiceDate; up.TotalSum = dto.TotalSum ?? up.TotalSum; up.ParsedJson = JsonSerializer.Serialize(dto.Lines); up.LinesTotal = dto.Lines.Count; up.LinesMatched = dto.Lines.Count(l => l.ProductId.HasValue); // Запоминаем ручные сопоставления. Делаем до SaveChanges — если // уникальный индекс выстрелит (concurrent write), ловим и // игнорируем — приоритет на первой записи. var supplierKey = (up.SupplierName ?? "").Trim().ToLowerInvariant(); if (!string.IsNullOrEmpty(supplierKey)) { foreach (var l in dto.Lines) { if (!l.ProductId.HasValue) continue; if (l.MatchedBy != "manual" && l.MatchedBy != "alias") continue; await UpsertAliasAsync(supplierKey, l, ct); } } await _db.SaveChangesAsync(ct); return NoContent(); } private async Task UpsertAliasAsync(string supplierKey, InvoiceLine l, CancellationToken ct) { var nameNorm = InvoiceMatcher.NormalizeName(l.Name); var code = string.IsNullOrWhiteSpace(l.SupplierCode) ? null : l.SupplierCode.Trim(); // Code и NameNorm — две отдельные уникальные записи (NULL в одной // ветке допустим фильтр-индексом). Пишем обе параллельно. foreach (var (byCode, byName) in new[] { (true, false), (false, true) }) { var searchCode = byCode ? code : null; var searchName = byName ? nameNorm : null; if (byCode && string.IsNullOrEmpty(searchCode)) continue; if (byName && string.IsNullOrEmpty(searchName)) continue; var existing = await _db.InvoiceAliases .Where(a => a.SupplierKey == supplierKey && ((byCode && a.SourceCode == searchCode) || (byName && a.SourceNameNorm == searchName))) .FirstOrDefaultAsync(ct); if (existing is null) { _db.InvoiceAliases.Add(new InvoiceAlias { SupplierKey = supplierKey, SourceCode = searchCode, SourceNameNorm = searchName, ProductId = l.ProductId!.Value, LastSeenAt = DateTime.UtcNow, HitCount = 1, }); } else { existing.ProductId = l.ProductId.Value; existing.LastSeenAt = DateTime.UtcNow; existing.HitCount++; } } } // ── apply → Supply Draft ──────────────────────────────────────────── /// SupplierId опционален (null или ): /// в этом случае черновик ссылается на служебного «Поставщик не указан» /// — порт sklad 06.10 §11, адаптировано под multi-tenant food-market. /// Кладовщик может указать реального поставщика позже в карточке приёмки. public record ApplyDto(Guid? SupplierId, Guid StoreId, Guid CurrencyId, string? Notes); public record ApplyResult(Guid SupplyId, string Number, decimal Total, int Lines); [HttpPost("{id:guid}/apply"), RequiresPermission("SuppliesEdit")] public async Task> Apply(Guid id, [FromBody] ApplyDto dto, CancellationToken ct) { var up = await _db.InvoiceUploads.FirstOrDefaultAsync(x => x.Id == id, ct); if (up is null) return NotFound(); if (up.Status == "applied" && up.SupplyId.HasValue) return Conflict(new { error = "Накладная уже перенесена.", supplyId = up.SupplyId }); if (string.IsNullOrEmpty(up.ParsedJson)) return BadRequest(new { error = "Накладная ещё не разобрана." }); var lines = JsonSerializer.Deserialize>(up.ParsedJson) ?? new(); var ready = lines.Where(l => l.ProductId.HasValue && l.Quantity > 0).ToList(); if (ready.Count == 0) return BadRequest(new { error = "Нет строк с сопоставленным товаром и количеством > 0." }); // Поставщик: если явно не выбран — служебный «Поставщик не указан» // (one per org, idempotent, помечен IsPlaceholder=true). var supplierId = dto.SupplierId is { } sid && sid != Guid.Empty ? sid : await ResolvePlaceholderCounterpartyAsync(ct); // Генерация номера приёмки — тот же шаблон что в SuppliesController. var number = await GenerateSupplyNumberAsync(up.InvoiceDate ?? DateTime.UtcNow, ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); var supply = new Supply { Number = number, Date = up.InvoiceDate ?? DateTime.UtcNow, Status = SupplyStatus.Draft, SupplierId = supplierId, StoreId = dto.StoreId, CurrencyId = dto.CurrencyId, // Port sklad 06.10 §11: имя поставщика из накладной в комментарий // НЕ пишем — на карточке приёмки оно уже есть как подсказка, в // комментарии только номер + дата исходного документа. Notes = string.IsNullOrWhiteSpace(dto.Notes) ? $"Накладная {up.InvoiceNumber} от {up.InvoiceDate:dd.MM.yyyy}".Trim() : dto.Notes, }; int sort = 0; foreach (var l in ready) { var price = allowFractional ? l.Price : Math.Round(l.Price, 0, MidpointRounding.AwayFromZero); supply.Lines.Add(new SupplyLine { ProductId = l.ProductId!.Value, Quantity = l.Quantity, UnitPrice = price, LineTotal = l.Quantity * price, SortOrder = sort++, }); } supply.Total = supply.Lines.Sum(x => x.LineTotal); _db.Supplies.Add(supply); try { await _db.SaveChangesAsync(ct); } catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503") { return BadRequest(new { error = "Связанная запись не найдена (поставщик/склад/валюта/товар).", constraint = pg.ConstraintName }); } up.SupplyId = supply.Id; up.Status = "applied"; up.ProcessedAt = DateTime.UtcNow; // Снимки/файл накладной больше не нужны (строки сохранены, черновик // приёмки создан — миниатюры у строк кладовщик уже видел). Owner-policy // 2026-10-05: фото накладных на сервере не копим. InvoiceProcessor.DeleteStored(up); await _db.SaveChangesAsync(ct); return Ok(new ApplyResult(supply.Id, supply.Number, supply.Total, supply.Lines.Count)); } // ── помощники для UI ──────────────────────────────────────────────── /// Поиск товара по штрихкоду — для ручной привязки строки без /// открытия отдельного экрана поиска. Возвращает 404 если нет. Отдаёт /// ещё и последнюю закупочную цену (чтобы scan-mode приёмки /// подставлял цену автоматически). [HttpGet("product-by-barcode")] public async Task ProductByBarcode([FromQuery] string barcode, CancellationToken ct) { var digits = InvoiceMatcher.OnlyDigits(barcode); if (digits.Length < 6) return BadRequest(new { error = "Штрихкод короче 6 цифр." }); var hit = await _db.ProductBarcodes .Where(b => b.Code == digits) .Select(b => new { b.ProductId, Name = b.Product.Name, Article = b.Product.Article }) .FirstOrDefaultAsync(ct); if (hit is null) return NotFound(new { barcode = digits }); // Последняя закупочная цена: берём из последней Posted supply-line // где участвовал этот товар. Если никогда не поставляли — null. var lastPrice = await _db.SupplyLines .Where(l => l.ProductId == hit.ProductId && l.Supply.Status == SupplyStatus.Posted) .OrderByDescending(l => l.Supply.Date) .Select(l => (decimal?)l.UnitPrice) .FirstOrDefaultAsync(ct); return Ok(new { productId = hit.ProductId, name = hit.Name, article = hit.Article, barcode = digits, lastPurchasePrice = lastPrice }); } /// Поиск контрагента по названию (fuzzy через ILIKE) — предлагаем /// на экране «применить накладную», чтобы кладовщик не искал в отдельном /// списке. Если ничего не нашлось, UI предложит создать нового. [HttpGet("counterparty-search")] public async Task CounterpartySearch([FromQuery] string q, CancellationToken ct) { if (string.IsNullOrWhiteSpace(q)) return Ok(Array.Empty()); var term = $"%{q.Trim()}%"; var rows = await _db.Counterparties .Where(c => EF.Functions.ILike(c.Name, term) || (c.Bin != null && EF.Functions.ILike(c.Bin, term))) .OrderBy(c => c.Name) .Take(15) .Select(c => new { c.Id, c.Name, c.Bin }) .ToListAsync(ct); return Ok(rows); } /// Служебный «Поставщик не указан» — один на организацию. /// Ищем по IsPlaceholder=true (partial-index, O(1)); если нет — /// создаём и возвращаем id. Параллельный INSERT (две accept на пустую орг) /// допустим: оба получат разные id, запись-«победитель» та, что в итоге /// используется первой; дубль чистится ночным housekeeping'ом по /// отсутствию ссылок. Для реального потока вероятность ~0. private async Task ResolvePlaceholderCounterpartyAsync(CancellationToken ct) { var existing = await _db.Counterparties .Where(c => c.IsPlaceholder) .Select(c => (Guid?)c.Id) .FirstOrDefaultAsync(ct); if (existing is { } id) return id; var e = new Counterparty { Name = "Поставщик не указан", LegalName = null, Type = CounterpartyType.LegalEntity, Notes = "Служебный: ставится в черновики приёмок, созданные через накладную без выбора поставщика. " + "Кладовщик меняет его на реального поставщика в карточке приёмки. Не удалять.", IsPlaceholder = true, }; _db.Counterparties.Add(e); await _db.SaveChangesAsync(ct); _log.LogInformation("[Накладные] Заведён служебный контрагент «Поставщик не указан» для орги {OrgId}", _tenant.OrganizationId); return e.Id; } private async Task GenerateSupplyNumberAsync(DateTime date, CancellationToken ct) { var year = date.Year; var prefix = $"П-{year}-"; var max = await _db.Supplies .Where(s => s.Number.StartsWith(prefix)) .OrderByDescending(s => s.Number) .Select(s => s.Number) .FirstOrDefaultAsync(ct); var seq = 1; if (!string.IsNullOrEmpty(max) && int.TryParse(max.Split('-').LastOrDefault(), out var n)) seq = n + 1; return prefix + seq.ToString("D6"); } }