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:
nns 2026-10-04 21:30:02 +05:00
parent e1718ec972
commit 11b43aefe5
11 changed files with 1213 additions and 0 deletions

View 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]`.

View 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");
}
}

View file

@ -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) + "…");
}

View file

@ -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;
}
}

View 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) { }
}

View 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; }
}
}
}

View file

@ -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>();

View 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&lt;InvoiceLine&gt;</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;
}

View file

@ -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>();

View file

@ -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");

View file

@ -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;
");
}
}
}