From 11b43aefe516da2ac9aae7ced12416c46de04e6c Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 4 Oct 2026 21:30:02 +0500 Subject: [PATCH] =?UTF-8?q?feat(s31):=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D1=84=D0=BE=D1=82=D0=BE=20?= =?UTF-8?q?=E2=80=94=20upload=20=E2=86=92=20Claude=20CLI=20=E2=86=92=20mat?= =?UTF-8?q?ch=20=E2=86=92=20Supply=20Draft=20(1/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/supply-inventory-mobile-progress.md | 57 +++ .../Purchases/InvoicesController.cs | 348 ++++++++++++++++++ .../Invoices/ClaudeCliInvoiceExtractor.cs | 252 +++++++++++++ .../Infrastructure/Invoices/InvoiceMatcher.cs | 88 +++++ .../Infrastructure/Invoices/InvoiceModels.cs | 53 +++ .../Invoices/InvoiceProcessor.cs | 191 ++++++++++ src/food-market.api/Program.cs | 8 + .../Purchases/InvoiceUpload.cs | 82 +++++ .../Persistence/AppDbContext.cs | 2 + .../Configurations/PurchasesConfigurations.cs | 40 ++ .../20261004170000_Phase31a_InvoiceUploads.cs | 92 +++++ 11 files changed, 1213 insertions(+) create mode 100644 docs/supply-inventory-mobile-progress.md create mode 100644 src/food-market.api/Controllers/Purchases/InvoicesController.cs create mode 100644 src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs create mode 100644 src/food-market.api/Infrastructure/Invoices/InvoiceMatcher.cs create mode 100644 src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs create mode 100644 src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs create mode 100644 src/food-market.domain/Purchases/InvoiceUpload.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20261004170000_Phase31a_InvoiceUploads.cs diff --git a/docs/supply-inventory-mobile-progress.md b/docs/supply-inventory-mobile-progress.md new file mode 100644 index 0000000..ec7ce96 --- /dev/null +++ b/docs/supply-inventory-mobile-progress.md @@ -0,0 +1,57 @@ +# Supply + Inventory mobile — прогресс + +Задача: добавить в food-market два мобильных варианта приёмки (по фото +накладной + сканированием штрихкодов) и инвентаризацию с телефона с +офлайн-режимом. Образец логики — `~/ref/sklad/sklad/` (read-only); но +документы создаются в НАШЕЙ базе (Supply/Inventory), а не черновиками +в МойСклад. + +Все три фичи — основные (видны в простом режиме), доступны Admin + +Storekeeper. + +## Чек-лист + +### Приёмка + +- [x] 1. **Приёмка по фото/PDF накладной** — загрузка, фоновое + распознавание через Claude CLI (`claude -p --output-format json`, + промпт из `ClaudeCliInvoiceExtractor.cs`), запуск локально + или через ssh на RemoteHost. Статусы: `queued → extracting → + review | failed`. Платные API-ключи не заводим. +- [ ] 2. **Экран проверки накладной.** Редактируемые строки, помощь в + сопоставлении с товаром (barcode → saved-mapping → ручной), создание + товара прямо из строки, проверка «итого», предложение создать + контрагента. Коммит → наш Supply (Draft). +- [ ] 3. **Приёмка сканированием.** Непрерывная камера, +1 к строке на + скан, последняя закупочная подставляется, неизвестный штрихкод → + предложить создать товар или отложить. + +### Инвентаризация + +- [ ] 4. **Инвентаризация с телефона.** Выбор склада + опц. группы, + офлайн-каталог в IndexedDB, прогресс сохраняется локально; скан / + ручной ввод; неизвестные штрихкоды в отдельный список. +- [ ] 5. **Расхождения видны сразу.** Учёт/факт/дельта в шт. и в деньгах, + фильтр «только расхождения». Частичная (по группе) НЕ обнуляет + непосчитанные; полная — явный выбор. +- [ ] 6. **Учётный остаток подставляется автоматически** (без ручной + кнопки «Пересчитать»): при создании bookQty = текущий Stock; при + добавлении позиции — тоже; перед проведением пересчёт + warning о + расхождениях с момента создания (продажи/приёмки). Если где-то + экспорт в МойСклад — там тоже явный расчётный остаток. + +### Общее + +- [ ] 7. Мобильная вёрстка 375×667 + PWA. Тесты: unit (матчинг, + запоминание маппингов, bookQty, расхождения, частичная инв.), + integration (накладная → Supply Draft; частичная инв. не трогает + другие товары; bookQty = текущий Stock), e2e на стенде. +- [ ] 8. `~/deploy-stage.sh`, ретест, `docs/supply-inventory.md` — + пользовательская инструкция. + +## Правила + +НЕ трогать: `~/ref/sklad` (только читать), прод admin.food-market.kz, +`global.json`, gateway nginx, POS WPF. + +После каждого пункта: build + тесты + коммит порцией + `[x]`. diff --git a/src/food-market.api/Controllers/Purchases/InvoicesController.cs b/src/food-market.api/Controllers/Purchases/InvoicesController.cs new file mode 100644 index 0000000..177918e --- /dev/null +++ b/src/food-market.api/Controllers/Purchases/InvoicesController.cs @@ -0,0 +1,348 @@ +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"); + } +} diff --git a/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs b/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs new file mode 100644 index 0000000..98c4abd --- /dev/null +++ b/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs @@ -0,0 +1,252 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace foodmarket.Api.Infrastructure.Invoices; + +/// Читает накладную через локальный Claude Code в headless-режиме +/// (claude -p --output-format json). Нагрузка списывается с кредита +/// подписки — отдельная оплата по токенам не нужна. Если +/// Invoices:ClaudeCli:RemoteHost задан, модель живёт на отдельной +/// машине и мы ходим туда по ssh (копируем файл, выполняем, подчищаем). +/// +/// Промпт и правила разбора — порт из ~/ref/sklad/sklad/Invoices/ +/// ClaudeCliInvoiceExtractor.cs: копировать каждую строку, не угадывать +/// (null если не читается), штрихкод != код поставщика, сверка с «Итого». +public class ClaudeCliInvoiceExtractor : IInvoiceExtractor +{ + private readonly ILogger _log; + private readonly string _exe; + private readonly string _model; + private readonly int _timeoutSec; + private readonly string? _remoteHost; + private readonly string _remoteTmp; + + public string Name => "claude-cli"; + + public ClaudeCliInvoiceExtractor(IConfiguration cfg, ILogger log) + { + _log = log; + _exe = cfg["Invoices:ClaudeCli:Executable"] ?? "claude"; + _model = cfg["Invoices:ClaudeCli:Model"] ?? "claude-sonnet-4-6"; + _timeoutSec = int.TryParse(cfg["Invoices:ClaudeCli:TimeoutSeconds"], out var t) ? t : 180; + _remoteHost = cfg["Invoices:ClaudeCli:RemoteHost"]; + _remoteTmp = cfg["Invoices:ClaudeCli:RemoteTmpDir"] ?? "/tmp/food-market-invoices"; + } + + public bool CanHandle(string fileName, string? contentType) + { + var ext = (Path.GetExtension(fileName) ?? "").ToLowerInvariant(); + return ext is ".jpg" or ".jpeg" or ".png" or ".webp" or ".heic" or ".pdf"; + } + + private static string BuildPrompt(string filePath) => $@" +Прочитай накладную на изображении: {filePath} + +Верни ТОЛЬКО JSON, без пояснений и без markdown-обёртки, строго такой формы: +{{ + ""supplier"": ""название организации-поставщика"", + ""number"": ""номер документа"", + ""date"": ""ГГГГ-ММ-ДД"", + ""total"": число или null, + ""lines"": [ + {{ + ""barcode"": ""ШТРИХКОД: 8-14 цифр из колонки «Штрихкод»/EAN. Если такой колонки нет - null"", + ""supplier_code"": ""код/артикул/номенклатурный номер поставщика - любой другой его код"", + ""name"": ""наименование как написано в накладной"", + ""qty"": число, + ""unit"": ""шт|кг|л|уп|пачка|короб|бут"", + ""price"": число (цена за ЕДИНИЦУ, не сумма), + ""sum"": число или null, + ""vat"": число или null + }} + ] +}} + +Важно: +- Переноси ВСЕ строки таблицы, ничего не пропускай и ничего не придумывай. +- Если значение в документе не читается — поставь null, не угадывай. +- Отрицательная цена или сумма означает возврат или скидку. Сохраняй знак как есть. +- Если количество указано сразу в двух единицах (шт и кг), бери ту, в которой + посчитана сумма, вторую игнорируй. +- Штрихкод мог быть напечатан с пробелами — убери их, оставь только цифры. +- НЕ путай штрихкод с номенклатурным номером: номенклатурный номер это внутренний + код поставщика (колонка «Номенклатурный номер», «Код», «Артикул»), он идёт + в supplier_code. В barcode — только настоящий штрихкод с упаковки. +- Проверь себя: сумма по строкам должна совпасть с «Итого» документа. +- Текст вокруг таблицы (реквизиты, юридические предупреждения, реклама) не разбирай. +".Trim(); + + public async Task ExtractAsync(string filePath, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(_remoteHost)) + { + var remote = $"{_remoteTmp}/{Guid.NewGuid():N}{Path.GetExtension(filePath)}"; + await RunAsync("ssh", new[] { _remoteHost, $"mkdir -p {_remoteTmp}" }, null, 30, ct); + await RunAsync("scp", new[] { "-q", filePath, $"{_remoteHost}:{remote}" }, null, 120, ct); + try + { + var (code, so, se) = await RunAsync("ssh", + new[] { _remoteHost, $"{_exe} -p --output-format json --model {_model} --allowedTools Read" }, + BuildPrompt(remote), _timeoutSec, ct); + if (code != 0) + throw new InvoiceExtractionException($"claude на {_remoteHost}: код {code}, {Truncate(se, 400)}"); + return Parse(so); + } + finally + { + try { await RunAsync("ssh", new[] { _remoteHost, $"rm -f {remote}" }, null, 30, CancellationToken.None); } + catch { /* временный файл — не повод валить разбор */ } + } + } + + var (c2, out2, err2) = await RunAsync(_exe, + new[] { "-p", "--output-format", "json", "--model", _model, "--allowedTools", "Read" }, + BuildPrompt(filePath), _timeoutSec, ct); + if (c2 != 0) throw new InvoiceExtractionException($"claude: код {c2}, {Truncate(err2, 400)}"); + return Parse(out2); + } + + private async Task<(int code, string stdout, string stderr)> RunAsync( + string exe, string[] args, string? stdin, int timeoutSec, CancellationToken ct) + { + var psi = new ProcessStartInfo + { + FileName = exe, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + + using var proc = new Process { StartInfo = psi }; + var so = new StringBuilder(); + var se = new StringBuilder(); + proc.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; + proc.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; + + proc.Start(); + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + if (stdin != null) await proc.StandardInput.WriteAsync(stdin); + proc.StandardInput.Close(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(timeoutSec)); + try { await proc.WaitForExitAsync(cts.Token); } + catch (OperationCanceledException) + { + try { proc.Kill(entireProcessTree: true); } catch { } + throw new InvoiceExtractionException($"{exe} не ответил за {timeoutSec} с"); + } + return (proc.ExitCode, so.ToString(), se.ToString()); + } + + /// --output-format json отдаёт конверт Claude Code; нужный JSON + /// лежит в поле result строкой, иногда обёрнутой в ```json. + internal static ParsedInvoice Parse(string cliOutput) + { + string payload = cliOutput.Trim(); + int inTok = 0, outTok = 0; + + try + { + using var env = JsonDocument.Parse(payload); + var root = env.RootElement; + if (root.ValueKind == JsonValueKind.Object) + { + if (root.TryGetProperty("result", out var r) && r.ValueKind == JsonValueKind.String) + payload = r.GetString()!; + if (root.TryGetProperty("usage", out var u) && u.ValueKind == JsonValueKind.Object) + { + if (u.TryGetProperty("input_tokens", out var i)) i.TryGetInt32(out inTok); + if (u.TryGetProperty("output_tokens", out var o)) o.TryGetInt32(out outTok); + } + } + } + catch (JsonException) { /* не конверт — значит сразу наш JSON */ } + + payload = StripFence(payload ?? "").Trim(); + if (payload.Length == 0) throw new InvoiceExtractionException("пустой ответ от claude"); + + ParsedInvoice inv; + try { inv = FromJson(payload); } + catch (JsonException ex) { throw new InvoiceExtractionException("не разобрать JSON от claude: " + ex.Message); } + inv.Source = "vision"; + inv.RawJson = payload; + inv.InputTokens = inTok; + inv.OutputTokens = outTok; + return inv; + } + + private static string StripFence(string s) + { + s = s.Trim(); + if (!s.StartsWith("```")) return s; + int nl = s.IndexOf('\n'); + if (nl < 0) return s; + s = s.Substring(nl + 1); + int fence = s.LastIndexOf("```", StringComparison.Ordinal); + return fence >= 0 ? s.Substring(0, fence) : s; + } + + private static decimal? Dec(JsonElement e, string prop) + { + if (!e.TryGetProperty(prop, out var v)) return null; + if (v.ValueKind == JsonValueKind.Number && v.TryGetDecimal(out var d)) return d; + if (v.ValueKind == JsonValueKind.String && + decimal.TryParse((v.GetString() ?? "").Replace(",", ".").Replace(" ", ""), + NumberStyles.Any, CultureInfo.InvariantCulture, out var ds)) return ds; + return null; + } + + private static string? Str(JsonElement e, string prop) + { + if (!e.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return null; + var s = v.ValueKind == JsonValueKind.String ? v.GetString() : v.ToString(); + return string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + } + + internal static ParsedInvoice FromJson(string json) + { + using var doc = JsonDocument.Parse(json); + var r = doc.RootElement; + var inv = new ParsedInvoice + { + SupplierName = Str(r, "supplier"), + InvoiceNumber = Str(r, "number"), + TotalSum = Dec(r, "total"), + }; + var ds = Str(r, "date"); + if (ds != null && DateTime.TryParse(ds, CultureInfo.InvariantCulture, + DateTimeStyles.None, out var dt)) inv.InvoiceDate = dt; + + if (r.TryGetProperty("lines", out var lines) && lines.ValueKind == JsonValueKind.Array) + { + foreach (var l in lines.EnumerateArray()) + { + var name = Str(l, "name"); + if (string.IsNullOrWhiteSpace(name)) continue; + var bc = Str(l, "barcode"); + if (bc != null) bc = bc.Trim().Replace(" ", ""); + inv.Lines.Add(new InvoiceLine + { + Barcode = string.IsNullOrWhiteSpace(bc) ? null : bc, + SupplierCode = Str(l, "supplier_code"), + Name = name, + Quantity = Dec(l, "qty") ?? 0m, + Unit = Str(l, "unit"), + Price = Dec(l, "price") ?? 0m, + Sum = Dec(l, "sum"), + Vat = Dec(l, "vat"), + }); + } + } + return inv; + } + + private static string Truncate(string s, int n) => + string.IsNullOrEmpty(s) ? "" : (s.Length <= n ? s : s.Substring(0, n) + "…"); +} diff --git a/src/food-market.api/Infrastructure/Invoices/InvoiceMatcher.cs b/src/food-market.api/Infrastructure/Invoices/InvoiceMatcher.cs new file mode 100644 index 0000000..34768d4 --- /dev/null +++ b/src/food-market.api/Infrastructure/Invoices/InvoiceMatcher.cs @@ -0,0 +1,88 @@ +using System.Text.RegularExpressions; + +namespace foodmarket.Api.Infrastructure.Invoices; + +/// Привязывает строку накладной к нашему товару. Порядок: +/// (1) штрихкод — единственный однозначный ключ; +/// (2) — то, что кладовщик уже сопоставлял; +/// (3) по названию — НЕ автоматически (слишком много ошибок на накладных +/// без штрихкодов). Порт из ~/ref/sklad/sklad/Invoices/InvoiceMatcher.cs. +public sealed class InvoiceMatcher +{ + public sealed class CatalogItem + { + public Guid Id { get; init; } + public string Name { get; init; } = ""; + public List Barcodes { get; init; } = new(); + } + + private readonly Dictionary _byBarcode = new(); + // (supplier  c:code или supplier  n:nameNorm) → productId + name + private readonly Dictionary _alias = new(); + + public InvoiceMatcher(IEnumerable catalog) + { + foreach (var c in catalog) + { + foreach (var b in c.Barcodes) + { + var k = OnlyDigits(b); + if (k.Length >= 6 && !_byBarcode.ContainsKey(k)) + _byBarcode[k] = c; + } + } + } + + /// Добавить запомненное соответствие. Один alias — максимум 2 ключа + /// (code и name_norm); пустые пропускаем. + public void AddAlias(string supplierKey, string? sourceCode, string? sourceNameNorm, + Guid productId, string productName) + { + foreach (var k in AliasKeys(supplierKey, sourceCode, sourceNameNorm)) + _alias[k] = (productId, productName); + } + + public static IEnumerable AliasKeys(string? supplierKey, string? code, string? nameNorm) + { + var s = (supplierKey ?? "").Trim().ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(code)) yield return s + "c:" + code.Trim().ToLowerInvariant(); + if (!string.IsNullOrWhiteSpace(nameNorm)) yield return s + "n:" + nameNorm; + } + + public static string OnlyDigits(string? s) => + string.IsNullOrEmpty(s) ? "" : Regex.Replace(s, @"\D", ""); + + public static string NormalizeName(string? s) + { + s = (s ?? "").ToUpperInvariant().Replace('Ё', 'Е'); + s = Regex.Replace(s, @"[«»""'`]", " "); + s = Regex.Replace(s, @"(\d)\s*(ГР|Г|МЛ|КГ|Л)\b", "$1$2"); + s = Regex.Replace(s, @"[^A-ZА-Я0-9.,%]+", " "); + return Regex.Replace(s, @"\s+", " ").Trim(); + } + + public void Match(InvoiceLine line, string supplierKey) + { + var bc = OnlyDigits(line.Barcode); + if (bc.Length >= 6 && _byBarcode.TryGetValue(bc, out var byBc)) + { + Assign(line, byBc.Id, byBc.Name, "barcode", 1.0); + return; + } + + var nameNorm = NormalizeName(line.Name); + foreach (var k in AliasKeys(supplierKey, line.SupplierCode, nameNorm)) + if (_alias.TryGetValue(k, out var a)) + { + Assign(line, a.id, a.name, "alias", 1.0); + return; + } + + line.MatchedBy = "none"; + } + + private static void Assign(InvoiceLine l, Guid id, string name, string how, double score) + { + l.ProductId = id; l.ProductName = name; l.MatchedBy = how; l.MatchScore = score; + } +} diff --git a/src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs b/src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs new file mode 100644 index 0000000..5d472e4 --- /dev/null +++ b/src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs @@ -0,0 +1,53 @@ +namespace foodmarket.Api.Infrastructure.Invoices; + +/// Строка накладной как её прочитала модель — ещё без привязки +/// к нашему товару. Матчится по порядку: barcode → alias → ручной. +public class InvoiceLine +{ + public string? Barcode { get; set; } + public string? SupplierCode { get; set; } // код/артикул поставщика + public string Name { get; set; } = ""; + public decimal Quantity { get; set; } + public string? Unit { get; set; } + public decimal Price { get; set; } + public decimal? Sum { get; set; } + public decimal? Vat { get; set; } + + /// Guid нашего товара если удалось сопоставить. Null — нет. + public Guid? ProductId { get; set; } + public string? ProductName { get; set; } + /// «barcode» | «alias» | «manual» | «none». + public string MatchedBy { get; set; } = "none"; + public double MatchScore { get; set; } +} + +public class ParsedInvoice +{ + public string? SupplierName { get; set; } + public string? InvoiceNumber { get; set; } + public DateTime? InvoiceDate { get; set; } + public decimal? TotalSum { get; set; } + public List Lines { get; set; } = new(); + + public string Source { get; set; } = "vision"; + public int InputTokens { get; set; } + public int OutputTokens { get; set; } + public string? RawJson { get; set; } + public List Warnings { get; set; } = new(); +} + +/// Чем именно читается документ. Реализации взаимозаменяемы: +/// Excel-парсер (не реализован на этом шаге), Claude CLI, API-клиент — все +/// возвращают один и тот же . Выбор делает +/// конфиг, остальной код об этом не знает. +public interface IInvoiceExtractor +{ + string Name { get; } + bool CanHandle(string fileName, string? contentType); + Task ExtractAsync(string filePath, CancellationToken ct); +} + +public class InvoiceExtractionException : Exception +{ + public InvoiceExtractionException(string message) : base(message) { } +} diff --git a/src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs b/src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs new file mode 100644 index 0000000..796e02b --- /dev/null +++ b/src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs @@ -0,0 +1,191 @@ +using System.Text.Json; +using foodmarket.Domain.Purchases; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Infrastructure.Invoices; + +/// Разбирает загруженный файл накладной: вызывает extractor +/// (Claude CLI), сопоставляет строки с нашими товарами (barcode + alias), +/// сохраняет результат в . Supply на этом шаге +/// НЕ создаётся — кладовщик смотрит и правит на экране проверки. +/// +/// Multi-tenant: все запросы к Products/ProductBarcodes/InvoiceAliases +/// — через IgnoreQueryFilters с явной фильтрацией по OrganizationId +/// загрузки, потому что в background-worker нет HTTP-контекста и +/// обычный tenant-фильтр не сработает. +public sealed class InvoiceProcessor +{ + private readonly IServiceScopeFactory _scopes; + private readonly ILogger _log; + + public InvoiceProcessor(IServiceScopeFactory scopes, ILogger log) + { _scopes = scopes; _log = log; } + + public async Task ProcessAsync(Guid uploadId, CancellationToken ct) + { + using var scope = _scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var extractor = scope.ServiceProvider.GetRequiredService(); + + var up = await db.InvoiceUploads.IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == uploadId, ct); + if (up is null) return; + + try + { + up.Status = "parsing"; + await db.SaveChangesAsync(ct); + + if (!File.Exists(up.StoredPath)) + throw new InvoiceExtractionException("Файл не найден на диске"); + + var parsed = await extractor.ExtractAsync(up.StoredPath, ct); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var matcher = await BuildMatcherAsync(db, up.OrganizationId, parsed.SupplierName, ct); + _log.LogInformation("[Invoices] {Id}: matcher built in {Ms} ms", up.Id, sw.ElapsedMilliseconds); + + var supplierKey = (parsed.SupplierName ?? "").Trim().ToLowerInvariant(); + foreach (var l in parsed.Lines) + matcher.Match(l, supplierKey); + + up.SupplierName = parsed.SupplierName; + up.InvoiceNumber = parsed.InvoiceNumber; + up.InvoiceDate = parsed.InvoiceDate.HasValue + ? DateTime.SpecifyKind(parsed.InvoiceDate.Value, DateTimeKind.Utc) + : null; + up.TotalSum = parsed.TotalSum; + up.LinesTotal = parsed.Lines.Count; + up.LinesMatched = parsed.Lines.Count(l => l.ProductId.HasValue); + up.Source = parsed.Source; + up.ApiInputTokens = parsed.InputTokens; + up.ApiOutputTokens = parsed.OutputTokens; + up.ParsedJson = JsonSerializer.Serialize(parsed.Lines); + up.Status = "needs_review"; + up.ProcessedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + + _log.LogInformation("[Invoices] {Id}: {Total} lines, matched {Matched}", + up.Id, up.LinesTotal, up.LinesMatched); + } + catch (Exception ex) + { + up.Status = "failed"; + up.Error = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message; + up.ProcessedAt = DateTime.UtcNow; + await db.SaveChangesAsync(CancellationToken.None); + _log.LogError(ex, "[Invoices] {Id} failed to parse", uploadId); + } + } + + /// Строит matcher из каталога орги + её alias'ов. + /// Запрашивается в scope без tenant-контекста (worker без HTTP-запроса) — + /// используем IgnoreQueryFilters + явный Where по OrganizationId. + public static async Task BuildMatcherAsync( + AppDbContext db, Guid orgId, string? supplierName, CancellationToken ct) + { + // Товары + их штрихкоды. Берём только активные. + var products = await db.Products.IgnoreQueryFilters() + .Where(p => p.OrganizationId == orgId && !p.IsArchived) + .Select(p => new { p.Id, p.Name }) + .ToListAsync(ct); + var byProduct = products.ToDictionary(p => p.Id, + p => new InvoiceMatcher.CatalogItem { Id = p.Id, Name = p.Name }); + + var barcodes = await db.ProductBarcodes.IgnoreQueryFilters() + .Where(b => b.OrganizationId == orgId) + .Select(b => new { b.ProductId, b.Code }) + .ToListAsync(ct); + foreach (var b in barcodes) + if (byProduct.TryGetValue(b.ProductId, out var ci)) + ci.Barcodes.Add(b.Code); + + var matcher = new InvoiceMatcher(byProduct.Values); + + // Alias'ы — только для текущего поставщика, иначе накачиваем всё и + // забиваем память; поставщик идентифицируется строкой-ключом. + var sKey = (supplierName ?? "").Trim().ToLowerInvariant(); + var aliases = await db.InvoiceAliases.IgnoreQueryFilters() + .Where(a => a.OrganizationId == orgId && a.SupplierKey == sKey) + .ToListAsync(ct); + foreach (var a in aliases) + { + if (!byProduct.TryGetValue(a.ProductId, out var p)) continue; + matcher.AddAlias(a.SupplierKey, a.SourceCode, a.SourceNameNorm, p.Id, p.Name); + } + + return matcher; + } +} + +/// Разбирает всё, что в очереди (Status=pending). Фото +/// читается 10-20 секунд, http-запрос на это держать не нужно. Запускается +/// как BackgroundService (один worker на процесс). Раз в час подчищает +/// старые файлы (Invoices:KeepFilesDays, default 7); разобранные +/// строки остаются навсегда. +public sealed class InvoiceWorker : BackgroundService +{ + private readonly IServiceScopeFactory _scopes; + private readonly InvoiceProcessor _proc; + private readonly ILogger _log; + private DateTime _lastSweep = DateTime.MinValue; + + public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger l) + { _scopes = s; _proc = p; _log = l; } + + private async Task SweepAsync(CancellationToken ct) + { + if (DateTime.UtcNow - _lastSweep < TimeSpan.FromHours(1)) return; + _lastSweep = DateTime.UtcNow; + + using var scope = _scopes.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var cfg = scope.ServiceProvider.GetRequiredService(); + var days = int.TryParse(cfg["Invoices:KeepFilesDays"], out var d) ? d : 7; + var edge = DateTime.UtcNow.AddDays(-days); + + var old = await db.InvoiceUploads.IgnoreQueryFilters() + .Where(x => x.StoredPath != "" && x.UploadedAt < edge) + .ToListAsync(ct); + int n = 0; + foreach (var up in old) + { + try { if (File.Exists(up.StoredPath)) File.Delete(up.StoredPath); } catch { } + up.StoredPath = ""; n++; + } + if (n > 0) + { + await db.SaveChangesAsync(ct); + _log.LogInformation("[Invoices] swept {N} old files (>{D}d)", n, days); + } + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + Guid[] pending; + using (var scope = _scopes.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + pending = await db.InvoiceUploads.IgnoreQueryFilters() + .Where(x => x.Status == "pending") + .OrderBy(x => x.UploadedAt).Select(x => x.Id).Take(5).ToArrayAsync(ct); + } + foreach (var id in pending) await _proc.ProcessAsync(id, ct); + } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _log.LogError(ex, "[Invoices] worker glitch"); } + + try { await SweepAsync(ct); } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _log.LogWarning(ex, "[Invoices] sweep failed"); } + + try { await Task.Delay(TimeSpan.FromSeconds(3), ct); } + catch (OperationCanceledException) { break; } + } + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index fc68b96..76c6d2b 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -446,6 +446,14 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme builder.Services.AddSingleton(); + // Sprint 31: приёмка по фото накладной. Extractor — Claude CLI headless + // (без API-ключей, нагрузка списывается с подписки). InvoiceWorker в фоне + // обрабатывает очередь pending → needs_review. + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/food-market.domain/Purchases/InvoiceUpload.cs b/src/food-market.domain/Purchases/InvoiceUpload.cs new file mode 100644 index 0000000..c0dac19 --- /dev/null +++ b/src/food-market.domain/Purchases/InvoiceUpload.cs @@ -0,0 +1,82 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Purchases; + +/// Загрузка файла накладной (фото/PDF) для авто-распознавания. +/// Файл кладётся в uploads/invoices/{yyyy-MM}/{guid}.ext, запись +/// появляется со статусом Pending. InvoiceWorker в фоне +/// переводит её в Parsing, зовёт IInvoiceExtractor +/// (Claude CLI headless), сопоставляет строки с товарами и ставит +/// NeedsReview. Кладовщик правит сопоставление и жмёт «Создать +/// приёмку» — у нас появляется Supply в Draft, SupplyId ссылается +/// на него, статус → Applied. +/// +/// Файл-картинка хранится настраиваемое число дней (default 7); разобранные +/// строки (ParsedJson) не удаляются никогда — по ним всегда можно +/// посмотреть, что было в накладной. +public class InvoiceUpload : TenantEntity +{ + public string FileName { get; set; } = ""; + public string StoredPath { get; set; } = ""; + public string? ContentType { get; set; } + public long SizeBytes { get; set; } + + public Guid? UploadedByUserId { get; set; } + public DateTime UploadedAt { get; set; } = DateTime.UtcNow; + + /// «pending» — только загружен; «parsing» — модель разбирает; + /// «needs_review» — строки готовы, кладовщик проверяет; «applied» — + /// Supply Draft создан; «failed» — ошибка (см. Error). + public string Status { get; set; } = "pending"; + + public string? SupplierName { get; set; } + public string? InvoiceNumber { get; set; } + public DateTime? InvoiceDate { get; set; } + public decimal? TotalSum { get; set; } + public int LinesTotal { get; set; } + public int LinesMatched { get; set; } + + /// JSON-массив распознанных строк (List<InvoiceLine> + /// из DTO-слоя). Храним как text (JSONB в Postgres). + public string? ParsedJson { get; set; } + + public string? Source { get; set; } // "vision" | "excel" | … + public int ApiInputTokens { get; set; } + public int ApiOutputTokens { get; set; } + public string? Error { get; set; } + public DateTime? ProcessedAt { get; set; } + + /// Если создана наша приёмка — ссылка на неё. На момент создания + /// документ в статусе Draft; проводит кладовщик вручную. + public Guid? SupplyId { get; set; } + public Supply? Supply { get; set; } +} + +/// Запомненное соответствие «строка накладной конкретного поставщика +/// → наш товар». Заполняется автоматически при КАЖДОМ ручном сопоставлении +/// в экране проверки накладной. На следующих накладных того же поставщика +/// matcher находит товар по этой таблице раньше, чем просит человека. +/// +/// Ключ: (OrgId, SupplierKey, SourceCode) ИЛИ (OrgId, SupplierKey, +/// SourceNameNorm). SourceCode/SourceNameNorm — один из них должен быть +/// заполнен (не оба nullable), и подходит любой — поэтому ключи +/// индексируются раздельно. +public class InvoiceAlias : TenantEntity +{ + /// Нормализованное имя поставщика (lower-case, trimmed). + /// Используется вместо CounterpartyId потому что в момент сопоставления + /// контрагент мог ещё не быть создан. + public string SupplierKey { get; set; } = ""; + + /// Код/артикул из колонки накладной (nullable). + public string? SourceCode { get; set; } + + /// Нормализованное имя товара из накладной (nullable). Используется + /// только когда SourceCode отсутствует. + public string? SourceNameNorm { get; set; } + + public Guid ProductId { get; set; } + + public DateTime LastSeenAt { get; set; } = DateTime.UtcNow; + public int HitCount { get; set; } = 1; +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 426eb65..93448da 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -43,6 +43,8 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet Supplies => Set(); public DbSet SupplyLines => Set(); + public DbSet InvoiceUploads => Set(); + public DbSet InvoiceAliases => Set(); public DbSet Enters => Set(); public DbSet EnterLines => Set(); diff --git a/src/food-market.infrastructure/Persistence/Configurations/PurchasesConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/PurchasesConfigurations.cs index 78308f6..d21d433 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/PurchasesConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/PurchasesConfigurations.cs @@ -46,6 +46,46 @@ public static void ConfigurePurchases(this ModelBuilder b) e.HasIndex(x => new { x.OrganizationId, x.ProductId }); }); + // Sprint 31: загрузки накладных для авто-распознавания (фото/PDF). + b.Entity(e => + { + e.ToTable("invoice_uploads"); + e.Property(x => x.FileName).HasMaxLength(260).IsRequired(); + e.Property(x => x.StoredPath).HasMaxLength(500); + e.Property(x => x.ContentType).HasMaxLength(200); + e.Property(x => x.Status).HasMaxLength(40).IsRequired(); + e.Property(x => x.SupplierName).HasMaxLength(300); + e.Property(x => x.InvoiceNumber).HasMaxLength(100); + e.Property(x => x.Source).HasMaxLength(40); + e.Property(x => x.Error).HasMaxLength(2000); + e.Property(x => x.ParsedJson).HasColumnType("jsonb"); + e.Property(x => x.TotalSum).HasPrecision(18, 4); + e.HasOne(x => x.Supply).WithMany().HasForeignKey(x => x.SupplyId).OnDelete(DeleteBehavior.SetNull); + e.HasIndex(x => new { x.OrganizationId, x.Status }); + e.HasIndex(x => new { x.OrganizationId, x.UploadedAt }); + }); + + // Sprint 31: запомненные соответствия «накладная поставщика → наш товар». + // Два разных alias-ключа (по SourceCode и по SourceNameNorm) — отдельные + // индексы, уникальные в рамках org+supplier. + b.Entity(e => + { + e.ToTable("invoice_aliases"); + e.Property(x => x.SupplierKey).HasMaxLength(300).IsRequired(); + e.Property(x => x.SourceCode).HasMaxLength(200); + e.Property(x => x.SourceNameNorm).HasMaxLength(400); + // Уникальность — по тройкам с NULL в разных колонках: Postgres их + // считает различимыми, так что составной индекс (SupplierKey, + // SourceCode) не ловит дубль, если SourceCode = NULL у двух строк. + // Это нам подходит: уникальность только когда оба Code одинаковы. + e.HasIndex(x => new { x.OrganizationId, x.SupplierKey, x.SourceCode }) + .IsUnique() + .HasFilter("\"SourceCode\" IS NOT NULL"); + e.HasIndex(x => new { x.OrganizationId, x.SupplierKey, x.SourceNameNorm }) + .IsUnique() + .HasFilter("\"SourceNameNorm\" IS NOT NULL"); + }); + b.Entity(e => { e.ToTable("enters"); diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261004170000_Phase31a_InvoiceUploads.cs b/src/food-market.infrastructure/Persistence/Migrations/20261004170000_Phase31a_InvoiceUploads.cs new file mode 100644 index 0000000..2d438da --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261004170000_Phase31a_InvoiceUploads.cs @@ -0,0 +1,92 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Sprint 31 — приёмка по фото/PDF накладной. + /// + /// Таблица invoice_uploads — файл + метаданные + результат разбора + /// Claude CLI (ParsedJson в jsonb). Таблица invoice_aliases — + /// запомненные соответствия «поставщик+код → товар», автоматически + /// пополняется при каждом ручном сопоставлении на экране проверки. + /// + /// Делаем ADD/CREATE через IF NOT EXISTS, индексы — идемпотентно, чтобы + /// пере-apply на уже-мигрированной БД не падал. + [DbContext(typeof(AppDbContext))] + [Migration("20261004170000_Phase31a_InvoiceUploads")] + public partial class Phase31a_InvoiceUploads : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + CREATE TABLE IF NOT EXISTS public.invoice_uploads ( + ""Id"" uuid NOT NULL PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(), + ""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now(), + ""FileName"" varchar(260) NOT NULL, + ""StoredPath"" varchar(500) NOT NULL DEFAULT '', + ""ContentType"" varchar(200) NULL, + ""SizeBytes"" bigint NOT NULL DEFAULT 0, + ""UploadedByUserId"" uuid NULL, + ""UploadedAt"" timestamp with time zone NOT NULL DEFAULT now(), + ""Status"" varchar(40) NOT NULL DEFAULT 'pending', + ""SupplierName"" varchar(300) NULL, + ""InvoiceNumber"" varchar(100) NULL, + ""InvoiceDate"" timestamp with time zone NULL, + ""TotalSum"" numeric(18,4) NULL, + ""LinesTotal"" integer NOT NULL DEFAULT 0, + ""LinesMatched"" integer NOT NULL DEFAULT 0, + ""ParsedJson"" jsonb NULL, + ""Source"" varchar(40) NULL, + ""ApiInputTokens"" integer NOT NULL DEFAULT 0, + ""ApiOutputTokens"" integer NOT NULL DEFAULT 0, + ""Error"" varchar(2000) NULL, + ""ProcessedAt"" timestamp with time zone NULL, + ""SupplyId"" uuid NULL + ); + + ALTER TABLE public.invoice_uploads DROP CONSTRAINT IF EXISTS fk_invoice_uploads_supplies; + ALTER TABLE public.invoice_uploads + ADD CONSTRAINT fk_invoice_uploads_supplies + FOREIGN KEY (""SupplyId"") REFERENCES public.supplies(""Id"") ON DELETE SET NULL; + + CREATE INDEX IF NOT EXISTS ix_invoice_uploads_org_status + ON public.invoice_uploads (""OrganizationId"", ""Status""); + CREATE INDEX IF NOT EXISTS ix_invoice_uploads_org_uploaded_at + ON public.invoice_uploads (""OrganizationId"", ""UploadedAt""); + + CREATE TABLE IF NOT EXISTS public.invoice_aliases ( + ""Id"" uuid NOT NULL PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""CreatedAt"" timestamp with time zone NOT NULL DEFAULT now(), + ""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now(), + ""SupplierKey"" varchar(300) NOT NULL, + ""SourceCode"" varchar(200) NULL, + ""SourceNameNorm"" varchar(400) NULL, + ""ProductId"" uuid NOT NULL, + ""LastSeenAt"" timestamp with time zone NOT NULL DEFAULT now(), + ""HitCount"" integer NOT NULL DEFAULT 1 + ); + + CREATE UNIQUE INDEX IF NOT EXISTS ux_invoice_aliases_code + ON public.invoice_aliases (""OrganizationId"", ""SupplierKey"", ""SourceCode"") + WHERE ""SourceCode"" IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS ux_invoice_aliases_name + ON public.invoice_aliases (""OrganizationId"", ""SupplierKey"", ""SourceNameNorm"") + WHERE ""SourceNameNorm"" IS NOT NULL; + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + DROP TABLE IF EXISTS public.invoice_aliases; + DROP TABLE IF EXISTS public.invoice_uploads; + "); + } + } +}