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,
|
builder.Services.AddSingleton<foodmarket.Api.Realtime.INotificationsPublisher,
|
||||||
foodmarket.Api.Realtime.NotificationsPublisher>();
|
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<OpenIddictClientSeeder>();
|
||||||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||||||
builder.Services.AddHostedService<DevDataSeeder>();
|
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<Supply> Supplies => Set<Supply>();
|
||||||
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
|
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<Enter> Enters => Set<Enter>();
|
||||||
public DbSet<EnterLine> EnterLines => Set<EnterLine>();
|
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 });
|
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 =>
|
b.Entity<Enter>(e =>
|
||||||
{
|
{
|
||||||
e.ToTable("enters");
|
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