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)); } // ── 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, 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)) .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.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? Error, 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(); 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, up.Error, 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(); try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath)) System.IO.File.Delete(up.StoredPath); } catch { } _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 ──────────────────────────────────────────── 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." }); // Генерация номера приёмки — тот же шаблон что в 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 = dto.SupplierId, StoreId = dto.StoreId, CurrencyId = dto.CurrencyId, 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; // Файл накладной больше не нужен (строки сохранены). Удаление — тихое. try { if (!string.IsNullOrEmpty(up.StoredPath) && System.IO.File.Exists(up.StoredPath)) System.IO.File.Delete(up.StoredPath); } catch { } up.StoredPath = ""; await _db.SaveChangesAsync(ct); return Ok(new ApplyResult(supply.Id, supply.Number, supply.Total, supply.Lines.Count)); } // ── помощники для UI ──────────────────────────────────────────────── /// Поиск товара по штрихкоду — для ручной привязки строки без /// открытия отдельного экрана поиска. Возвращает 404 если нет. [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 }); return Ok(new { productId = hit.ProductId, name = hit.Name, article = hit.Article, barcode = digits }); } /// Поиск контрагента по названию (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); } 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"); } }