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