food-market/src/food-market.api/Controllers/Purchases/InvoicesController.cs
nns 11b43aefe5 feat(s31): приёмка по фото — upload → Claude CLI → match → Supply Draft (1/8)
Backend-pipeline по образцу ~/ref/sklad/sklad/Invoices/, но документы
создаются в НАШЕЙ базе (Supply), не в МойСкладе.

Домен (food-market.domain/Purchases/InvoiceUpload.cs):
- InvoiceUpload: файл + статус (pending/parsing/needs_review/applied/failed)
  + распознанная шапка + ParsedJson (jsonb) + ссылка на созданный Supply.
- InvoiceAlias: запомненное соответствие (SupplierKey, SourceCode|
  SourceNameNorm) → ProductId. Два частично-уникальных индекса (filter
  SourceCode/SourceNameNorm IS NOT NULL).

Миграция Phase31a (идемпотентная ADD/CREATE IF NOT EXISTS; [Migration]+
[DbContext]).

Infrastructure/Invoices/:
- IInvoiceExtractor + ParsedInvoice/InvoiceLine DTO.
- ClaudeCliInvoiceExtractor — порт из sklad: 'claude -p --output-format
  json', промпт один-в-один (копировать все строки, null если не читается,
  barcode != supplier_code, сверка с «Итого»). Поддерживает local и
  Invoices:ClaudeCli:RemoteHost (ssh+scp+cleanup).
- InvoiceMatcher — barcode → alias → none. По названию НЕ ищем
  (sklad-урок: 2-5 ошибок из 10, подмена хуже пустой строки).
- InvoiceProcessor + InvoiceWorker (BackgroundService):
  pending → parsing → needs_review | failed. SweepAsync раз в час
  удаляет файлы старше Invoices:KeepFilesDays (default 7); строки
  остаются. Multi-tenant через IgnoreQueryFilters + явный OrgId.

Controller api/purchases/invoices:
- POST /upload (SuppliesEdit, 60 MB max): файлы в uploads/invoices/{yyyy-
  MM}/, запись в pending.
- GET /, GET /{id} — список + детали со строками.
- GET /{id}/file — отдать файл.
- DELETE /{id} — удалить запись + файл.
- PUT /{id}/lines — правки шапки + строк + авто-upsert InvoiceAlias
  для каждой строки с MatchedBy=manual|alias (два ключа: по code и по
  name_norm).
- POST /{id}/apply — создать Supply (Draft) с данным поставщиком/
  складом/валютой, статус → applied, SupplyId ссылается, файл удаляется.
- GET /product-by-barcode, /counterparty-search — помощь UI.

Build зелёный. Web + тесты — следующими шагами.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:30:02 +05:00

349 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>Приёмка по фото/PDF накладной.
///
/// Жизненный цикл загрузки:
/// <list type="number">
/// <item><c>POST /upload</c> — файл(ы) сохраняются, запись
/// <see cref="InvoiceUpload"/> в статусе <c>pending</c>.</item>
/// <item><see cref="InvoiceWorker"/> в фоне: <c>pending → parsing →
/// needs_review | failed</c>.</item>
/// <item><c>GET /{id}</c> возвращает распознанные строки + сопоставление.</item>
/// <item><c>PUT /{id}/lines</c> — кладовщик правит строки + ручные
/// сопоставления; каждое ручное сопоставление пишется в
/// <see cref="InvoiceAlias"/> — на следующих накладных того же
/// поставщика matcher уже знает ответ.</item>
/// <item><c>POST /{id}/apply</c> — создаёт Supply (Draft) с указанными
/// поставщиком/складом/валютой. Status → <c>applied</c>, <c>SupplyId</c>
/// ставится.</item>
/// </list>
///
/// Доступ: Admin + Storekeeper (SuppliesEdit permission).</summary>
[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<InvoicesController> _log;
public InvoicesController(AppDbContext db, IWebHostEnvironment env,
ITenantContext tenant, ILogger<InvoicesController> 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<Guid> Ids);
[HttpPost("upload"), RequiresPermission("SuppliesEdit"), RequestSizeLimit(60_000_000)]
public async Task<ActionResult<UploadResult>> Upload([FromForm] IFormFileCollection files, CancellationToken ct)
{
if (files is null || files.Count == 0)
return BadRequest(new { error = "Файлы не выбраны." });
Directory.CreateDirectory(StorageDir);
var created = new List<Guid>();
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<IActionResult> 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<InvoiceLine> Lines);
[HttpGet("{id:guid}")]
public async Task<ActionResult<InvoiceDetailDto>> 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<InvoiceLine>()
: JsonSerializer.Deserialize<List<InvoiceLine>>(up.ParsedJson) ?? new List<InvoiceLine>();
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<IActionResult> 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<IActionResult> 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<InvoiceLine> Lines, string? SupplierName,
string? InvoiceNumber, DateTime? InvoiceDate, decimal? TotalSum);
/// <summary>Сохраняет правки кладовщика: строки + «шапка». Любое ручное
/// сопоставление (MatchedBy=«manual» и ProductId != null) УЧИТСЯ: мы
/// пишем/обновляем <see cref="InvoiceAlias"/> для текущего поставщика
/// → этот товар. Следующая накладная того же поставщика с таким же
/// SourceCode/NameNorm сопоставится автоматически.</summary>
[HttpPut("{id:guid}/lines"), RequiresPermission("SuppliesEdit")]
public async Task<IActionResult> 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<ActionResult<ApplyResult>> 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<List<InvoiceLine>>(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 ────────────────────────────────────────────────
/// <summary>Поиск товара по штрихкоду — для ручной привязки строки без
/// открытия отдельного экрана поиска. Возвращает 404 если нет.</summary>
[HttpGet("product-by-barcode")]
public async Task<IActionResult> 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 });
}
/// <summary>Поиск контрагента по названию (fuzzy через ILIKE) — предлагаем
/// на экране «применить накладную», чтобы кладовщик не искал в отдельном
/// списке. Если ничего не нашлось, UI предложит создать нового.</summary>
[HttpGet("counterparty-search")]
public async Task<IActionResult> CounterpartySearch([FromQuery] string q, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(q)) return Ok(Array.Empty<object>());
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<string> 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");
}
}