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>
349 lines
17 KiB
C#
349 lines
17 KiB
C#
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");
|
||
}
|
||
}
|