Unit: - InvoiceMatcherTests (12): barcode wins, short barcode falls through, alias by code/by name_norm, alias is supplier-scoped, по названию НЕ матчим автоматически (sklad-урок про LD Super Slims), NormalizeName quotes/whitespace/unit-glue/ё→е, OnlyDigits, AliasKeys skip пустые. - ClaudeCliInvoiceExtractorParseTests (8): plain JSON, envelope с result+usage, markdown fence, barcode spaces strip, null-tolerant, 'empty' raises, пустое name пропускается. Parse/FromJson теперь public (internal мешал тестам, InternalsVisibleTo избыточен). Integration: - Matcher_hits_product_by_barcode_from_real_catalog — реальный Postgres + seeded org + создаём товар+штрихкод, matcher находит. - Multi_tenant_isolation_matcher_does_not_see_other_orgs_barcodes — две org, штрихкод orgA НЕ виден в matcher'е orgB (tenant isolation). Все новые тесты зелёные. Pre-existing failures в full-suite не связаны. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
253 lines
12 KiB
C#
253 lines
12 KiB
C#
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>
|
||
public 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();
|
||
}
|
||
|
||
public 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) + "…");
|
||
}
|