food-market/src/food-market.api/Controllers/Purchases/InvoicesController.cs
nns 6b0fc68f77
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(invoices+inventory): порт улучшений 05.10.2026 из sklad
Диапазон коммитов f455fa9..b7eb637 + 3ced706 (handoff).

Backend (src/food-market.api/Infrastructure/Invoices):
- Ean.cs — контрольная цифра EAN-8/13/UPC-A/GTIN-14 (ловит одну ошибку в цифре).
- TableReader.cs — своя читалка .xlsx/.csv без библиотек, UTF-8/1251,
  .xls → понятная ошибка; TableInvoiceParser — шаблон накладной.
- InvoiceModels: +Photo/BarcodeSource/Note в InvoiceLine, PhotoHint,
  IInvoiceExtractor.ExtractPhotosAsync.
- InvoiceMatcher.FindByBarcode — для photo-matching.
- InvoiceProcessor: ветки photos/excel/vision, MatchFromPhoto с EAN-проверкой
  (barcode_source="read" → валидация), SweepAbandonedPhotosAsync раз в час
  убирает папки needs_review старше 24ч; DeleteStored (owner-policy: НЕ копить).
- ClaudeCliInvoiceExtractor: ExtractPhotosAsync + BuildPhotosPrompt +
  FromPhotosJson (barcode_source → BarcodeSource, comment → Note, unmatched_photos
  → Warnings); дефолт модели claude-sonnet-5-5, PhotoTimeoutSeconds=600.

Backend (Controllers):
- POST /purchases/invoices/upload-photos (≤80 файлов, 60 МБ) +
  GET /purchases/invoices/{id}/photo/{n}; Apply/Delete убирают папку (не только файл).
- InvoiceDetailDto +Source/+HasFiles — клиент не ломится за 404 миниатюрами.
- POST /inventory/inventories/import — xlsx/csv → {found, unknown}, повторы ШК
  суммируются, документ не создаётся.
- Program.cs: UseResponseCompression + GzipCompressionProvider для application/json.
- InventoriesController.mobile-catalog: weak ETag + If-None-Match → 304;
  Cache-Control private max-age=60. Замер 31k: JSON 3.60 МБ → gzip 0.98 МБ.

Frontend:
- lib/shots.ts — ZXing TRY_HARDER на 1x+1600; compress JPEG 0.85 до 2400.
- components/PhotoSession.tsx — полноэкранная пачка (камера + готовые фото).
- components/CameraScanner.tsx: onCodeRef + эффект [], камера не закрывается
  после каждого скана.
- pages/InvoicesPage.tsx — четыре способа: камера одиночными, файл (xlsx/csv/img/pdf),
  камера многостраничная, фото-приёмка (камера или готовые фото) + «Отправить ещё раз».
- pages/InvoiceReviewPage.tsx — автосохранение 700 мс + индикатор; row-picker панель
  «в какую строку?» если fuzzy-match неуверен; linesRef; глобальный keydown; миниатюра
  снимка у строки; пометки Note.
- pages/SupplyScanPage.tsx + InventoryMobilePage.tsx — глобальный keydown.
- InventoryMobilePage: поле сканера на виду + импорт Excel/CSV.
- docs/supplier-invoice-template.xlsx — шаблон для поставщиков (9-я строка заголовки).

Хранение файлов (owner-policy 2026-10-05): фото НЕ копим.
- Обычная загрузка → удаление сразу после разбора.
- Фото-приёмка → удаление при Apply/Delete/24h abandoned.
- KeepFilesDays удалён из флоу (конфиг-ключ игнорируется).

Тесты (unit, зелёные):
- EanTests (6): валидные/невалидные/one-digit error.
- TableReaderTests (5): xlsx cells, шапка «Поставщик/Номер/Дата», CSV UTF-8+BOM,
  CSV Windows-1251, .xls friendly error.
- ClaudeCliInvoicePhotosParseTests (4): decoded проходит, source=null чистит Barcode,
  unmatched_photos → Warnings, comment → Note.
- InvoicePhotoRetentionTests (3): DeleteStored файл/папка/идемпотентность.

Preexisting ApiReferenceDocsJob (Sprint 28) failure не связан с портом.

Прогресс: docs/sklad-port-20261005-progress.md. Пользовательская инструкция:
docs/supply-inventory.md обновлена.

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

425 lines
21 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));
}
public record UploadPhotosResult(Guid Id);
/// <summary>Фото-приёмка: пачка снимков накладной + товаров одной загрузкой.
/// Штрихкоды с каждого снимка телефон уже прочитал ZXing-ом; <paramref name="codes"/>
/// — JSON-массив массивов (по порядку файлов), служит подсказкой модели.
/// Папка со снимками хранится до создания черновика приёмки / удаления
/// загрузки / 24 ч (брошенная на проверке) — см. <see cref="InvoiceWorker"/>.</summary>
[HttpPost("upload-photos"), RequiresPermission("SuppliesEdit"), RequestSizeLimit(60_000_000)]
public async Task<ActionResult<UploadPhotosResult>> 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));
}
/// <summary>Снимок товара для миниатюры рядом со строкой приёмки
/// (<c>NN.jpg</c> из папки фото-пачки). После <see cref="Apply"/> или
/// <see cref="Delete"/> папка удаляется — тогда вернёт 404.</summary>
[HttpGet("{id:guid}/photo/{n:int}")]
public async Task<IActionResult> 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, 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,
string? Source, bool HasFiles,
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>();
// hasFiles — миниатюры для фото-приёмки доступны только пока папка жива
// (до apply/delete/24ч). Клиент читает флаг, чтобы не плодить 404.
var hasFiles = !string.IsNullOrEmpty(up.StoredPath)
&& (Directory.Exists(up.StoredPath) || System.IO.File.Exists(up.StoredPath));
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,
up.Source, hasFiles, 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();
// Удаляем файл И папку фото-пачки (owner-policy: не копить фото).
InvoiceProcessor.DeleteStored(up);
_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;
// Снимки/файл накладной больше не нужны (строки сохранены, черновик
// приёмки создан — миниатюры у строк кладовщик уже видел). 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 ────────────────────────────────────────────────
/// <summary>Поиск товара по штрихкоду — для ручной привязки строки без
/// открытия отдельного экрана поиска. Возвращает 404 если нет. Отдаёт
/// ещё и последнюю закупочную цену (чтобы scan-mode приёмки
/// подставлял цену автоматически).</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 });
// Последняя закупочная цена: берём из последней 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 });
}
/// <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");
}
}