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>
This commit is contained in:
parent
e1718ec972
commit
11b43aefe5
57
docs/supply-inventory-mobile-progress.md
Normal file
57
docs/supply-inventory-mobile-progress.md
Normal file
|
|
@ -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]`.
|
||||
348
src/food-market.api/Controllers/Purchases/InvoicesController.cs
Normal file
348
src/food-market.api/Controllers/Purchases/InvoicesController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace foodmarket.Api.Infrastructure.Invoices;
|
||||
|
||||
/// <summary>Читает накладную через локальный Claude Code в headless-режиме
|
||||
/// (<c>claude -p --output-format json</c>). Нагрузка списывается с кредита
|
||||
/// подписки — отдельная оплата по токенам не нужна. Если
|
||||
/// <c>Invoices:ClaudeCli:RemoteHost</c> задан, модель живёт на отдельной
|
||||
/// машине и мы ходим туда по ssh (копируем файл, выполняем, подчищаем).
|
||||
///
|
||||
/// Промпт и правила разбора — порт из <c>~/ref/sklad/sklad/Invoices/
|
||||
/// ClaudeCliInvoiceExtractor.cs</c>: копировать каждую строку, не угадывать
|
||||
/// (null если не читается), штрихкод != код поставщика, сверка с «Итого».</summary>
|
||||
public class ClaudeCliInvoiceExtractor : IInvoiceExtractor
|
||||
{
|
||||
private readonly ILogger<ClaudeCliInvoiceExtractor> _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<ClaudeCliInvoiceExtractor> 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<ParsedInvoice> 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());
|
||||
}
|
||||
|
||||
/// <summary><c>--output-format json</c> отдаёт конверт Claude Code; нужный JSON
|
||||
/// лежит в поле <c>result</c> строкой, иногда обёрнутой в <c>```json</c>.</summary>
|
||||
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) + "…");
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace foodmarket.Api.Infrastructure.Invoices;
|
||||
|
||||
/// <summary>Привязывает строку накладной к нашему товару. Порядок:
|
||||
/// (1) штрихкод — единственный однозначный ключ;
|
||||
/// (2) <see cref="InvoiceAlias"/> — то, что кладовщик уже сопоставлял;
|
||||
/// (3) по названию — НЕ автоматически (слишком много ошибок на накладных
|
||||
/// без штрихкодов). Порт из <c>~/ref/sklad/sklad/Invoices/InvoiceMatcher.cs</c>.</summary>
|
||||
public sealed class InvoiceMatcher
|
||||
{
|
||||
public sealed class CatalogItem
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Name { get; init; } = "";
|
||||
public List<string> Barcodes { get; init; } = new();
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, CatalogItem> _byBarcode = new();
|
||||
// (supplier c:code или supplier n:nameNorm) → productId + name
|
||||
private readonly Dictionary<string, (Guid id, string name)> _alias = new();
|
||||
|
||||
public InvoiceMatcher(IEnumerable<CatalogItem> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Добавить запомненное соответствие. Один alias — максимум 2 ключа
|
||||
/// (code и name_norm); пустые пропускаем.</summary>
|
||||
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<string> 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;
|
||||
}
|
||||
}
|
||||
53
src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs
Normal file
53
src/food-market.api/Infrastructure/Invoices/InvoiceModels.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
namespace foodmarket.Api.Infrastructure.Invoices;
|
||||
|
||||
/// <summary>Строка накладной как её прочитала модель — ещё без привязки
|
||||
/// к нашему товару. Матчится по порядку: barcode → alias → ручной.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Guid нашего товара если удалось сопоставить. Null — нет.</summary>
|
||||
public Guid? ProductId { get; set; }
|
||||
public string? ProductName { get; set; }
|
||||
/// <summary>«barcode» | «alias» | «manual» | «none».</summary>
|
||||
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<InvoiceLine> 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<string> Warnings { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Чем именно читается документ. Реализации взаимозаменяемы:
|
||||
/// Excel-парсер (не реализован на этом шаге), Claude CLI, API-клиент — все
|
||||
/// возвращают один и тот же <see cref="ParsedInvoice"/>. Выбор делает
|
||||
/// конфиг, остальной код об этом не знает.</summary>
|
||||
public interface IInvoiceExtractor
|
||||
{
|
||||
string Name { get; }
|
||||
bool CanHandle(string fileName, string? contentType);
|
||||
Task<ParsedInvoice> ExtractAsync(string filePath, CancellationToken ct);
|
||||
}
|
||||
|
||||
public class InvoiceExtractionException : Exception
|
||||
{
|
||||
public InvoiceExtractionException(string message) : base(message) { }
|
||||
}
|
||||
191
src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs
Normal file
191
src/food-market.api/Infrastructure/Invoices/InvoiceProcessor.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
using System.Text.Json;
|
||||
using foodmarket.Domain.Purchases;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Api.Infrastructure.Invoices;
|
||||
|
||||
/// <summary>Разбирает загруженный файл накладной: вызывает extractor
|
||||
/// (Claude CLI), сопоставляет строки с нашими товарами (barcode + alias),
|
||||
/// сохраняет результат в <see cref="InvoiceUpload"/>. Supply на этом шаге
|
||||
/// НЕ создаётся — кладовщик смотрит и правит на экране проверки.
|
||||
///
|
||||
/// Multi-tenant: все запросы к <c>Products/ProductBarcodes/InvoiceAliases</c>
|
||||
/// — через IgnoreQueryFilters с явной фильтрацией по OrganizationId
|
||||
/// загрузки, потому что в background-worker нет HTTP-контекста и
|
||||
/// обычный tenant-фильтр не сработает.</summary>
|
||||
public sealed class InvoiceProcessor
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly ILogger<InvoiceProcessor> _log;
|
||||
|
||||
public InvoiceProcessor(IServiceScopeFactory scopes, ILogger<InvoiceProcessor> log)
|
||||
{ _scopes = scopes; _log = log; }
|
||||
|
||||
public async Task ProcessAsync(Guid uploadId, CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopes.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var extractor = scope.ServiceProvider.GetRequiredService<IInvoiceExtractor>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Строит matcher из каталога орги + её alias'ов.
|
||||
/// Запрашивается в scope без tenant-контекста (worker без HTTP-запроса) —
|
||||
/// используем IgnoreQueryFilters + явный Where по OrganizationId.</summary>
|
||||
public static async Task<InvoiceMatcher> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Разбирает всё, что в очереди (<c>Status=pending</c>). Фото
|
||||
/// читается 10-20 секунд, http-запрос на это держать не нужно. Запускается
|
||||
/// как BackgroundService (один worker на процесс). Раз в час подчищает
|
||||
/// старые файлы (<c>Invoices:KeepFilesDays</c>, default 7); разобранные
|
||||
/// строки остаются навсегда.</summary>
|
||||
public sealed class InvoiceWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly InvoiceProcessor _proc;
|
||||
private readonly ILogger<InvoiceWorker> _log;
|
||||
private DateTime _lastSweep = DateTime.MinValue;
|
||||
|
||||
public InvoiceWorker(IServiceScopeFactory s, InvoiceProcessor p, ILogger<InvoiceWorker> 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<AppDbContext>();
|
||||
var cfg = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
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<AppDbContext>();
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -446,6 +446,14 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
builder.Services.AddSingleton<foodmarket.Api.Realtime.INotificationsPublisher,
|
||||
foodmarket.Api.Realtime.NotificationsPublisher>();
|
||||
|
||||
// Sprint 31: приёмка по фото накладной. Extractor — Claude CLI headless
|
||||
// (без API-ключей, нагрузка списывается с подписки). InvoiceWorker в фоне
|
||||
// обрабатывает очередь pending → needs_review.
|
||||
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Invoices.IInvoiceExtractor,
|
||||
foodmarket.Api.Infrastructure.Invoices.ClaudeCliInvoiceExtractor>();
|
||||
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Invoices.InvoiceProcessor>();
|
||||
builder.Services.AddHostedService<foodmarket.Api.Infrastructure.Invoices.InvoiceWorker>();
|
||||
|
||||
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
||||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||||
builder.Services.AddHostedService<DevDataSeeder>();
|
||||
|
|
|
|||
82
src/food-market.domain/Purchases/InvoiceUpload.cs
Normal file
82
src/food-market.domain/Purchases/InvoiceUpload.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using foodmarket.Domain.Common;
|
||||
|
||||
namespace foodmarket.Domain.Purchases;
|
||||
|
||||
/// <summary>Загрузка файла накладной (фото/PDF) для авто-распознавания.
|
||||
/// Файл кладётся в <c>uploads/invoices/{yyyy-MM}/{guid}.ext</c>, запись
|
||||
/// появляется со статусом <c>Pending</c>. <c>InvoiceWorker</c> в фоне
|
||||
/// переводит её в <c>Parsing</c>, зовёт <c>IInvoiceExtractor</c>
|
||||
/// (Claude CLI headless), сопоставляет строки с товарами и ставит
|
||||
/// <c>NeedsReview</c>. Кладовщик правит сопоставление и жмёт «Создать
|
||||
/// приёмку» — у нас появляется Supply в Draft, <c>SupplyId</c> ссылается
|
||||
/// на него, статус → <c>Applied</c>.
|
||||
///
|
||||
/// Файл-картинка хранится настраиваемое число дней (default 7); разобранные
|
||||
/// строки (<c>ParsedJson</c>) не удаляются никогда — по ним всегда можно
|
||||
/// посмотреть, что было в накладной.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>«pending» — только загружен; «parsing» — модель разбирает;
|
||||
/// «needs_review» — строки готовы, кладовщик проверяет; «applied» —
|
||||
/// Supply Draft создан; «failed» — ошибка (см. Error).</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>JSON-массив распознанных строк (<c>List<InvoiceLine></c>
|
||||
/// из DTO-слоя). Храним как text (JSONB в Postgres).</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Если создана наша приёмка — ссылка на неё. На момент создания
|
||||
/// документ в статусе Draft; проводит кладовщик вручную.</summary>
|
||||
public Guid? SupplyId { get; set; }
|
||||
public Supply? Supply { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Запомненное соответствие «строка накладной конкретного поставщика
|
||||
/// → наш товар». Заполняется автоматически при КАЖДОМ ручном сопоставлении
|
||||
/// в экране проверки накладной. На следующих накладных того же поставщика
|
||||
/// matcher находит товар по этой таблице раньше, чем просит человека.
|
||||
///
|
||||
/// Ключ: (OrgId, SupplierKey, SourceCode) ИЛИ (OrgId, SupplierKey,
|
||||
/// SourceNameNorm). SourceCode/SourceNameNorm — один из них должен быть
|
||||
/// заполнен (не оба nullable), и подходит любой — поэтому ключи
|
||||
/// индексируются раздельно.</summary>
|
||||
public class InvoiceAlias : TenantEntity
|
||||
{
|
||||
/// <summary>Нормализованное имя поставщика (lower-case, trimmed).
|
||||
/// Используется вместо CounterpartyId потому что в момент сопоставления
|
||||
/// контрагент мог ещё не быть создан.</summary>
|
||||
public string SupplierKey { get; set; } = "";
|
||||
|
||||
/// <summary>Код/артикул из колонки накладной (nullable).</summary>
|
||||
public string? SourceCode { get; set; }
|
||||
|
||||
/// <summary>Нормализованное имя товара из накладной (nullable). Используется
|
||||
/// только когда SourceCode отсутствует.</summary>
|
||||
public string? SourceNameNorm { get; set; }
|
||||
|
||||
public Guid ProductId { get; set; }
|
||||
|
||||
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
|
||||
public int HitCount { get; set; } = 1;
|
||||
}
|
||||
|
|
@ -43,6 +43,8 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
|||
|
||||
public DbSet<Supply> Supplies => Set<Supply>();
|
||||
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
|
||||
public DbSet<InvoiceUpload> InvoiceUploads => Set<InvoiceUpload>();
|
||||
public DbSet<InvoiceAlias> InvoiceAliases => Set<InvoiceAlias>();
|
||||
|
||||
public DbSet<Enter> Enters => Set<Enter>();
|
||||
public DbSet<EnterLine> EnterLines => Set<EnterLine>();
|
||||
|
|
|
|||
|
|
@ -46,6 +46,46 @@ public static void ConfigurePurchases(this ModelBuilder b)
|
|||
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
|
||||
});
|
||||
|
||||
// Sprint 31: загрузки накладных для авто-распознавания (фото/PDF).
|
||||
b.Entity<InvoiceUpload>(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<InvoiceAlias>(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<Enter>(e =>
|
||||
{
|
||||
e.ToTable("enters");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>Sprint 31 — приёмка по фото/PDF накладной.
|
||||
///
|
||||
/// Таблица <c>invoice_uploads</c> — файл + метаданные + результат разбора
|
||||
/// Claude CLI (ParsedJson в jsonb). Таблица <c>invoice_aliases</c> —
|
||||
/// запомненные соответствия «поставщик+код → товар», автоматически
|
||||
/// пополняется при каждом ручном сопоставлении на экране проверки.
|
||||
///
|
||||
/// Делаем ADD/CREATE через IF NOT EXISTS, индексы — идемпотентно, чтобы
|
||||
/// пере-apply на уже-мигрированной БД не падал.</summary>
|
||||
[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;
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue