food-market/src/food-market.api/Infrastructure/Invoices/ClaudeCliInvoiceExtractor.cs
nns 457d8f5877 test(s31): unit 20/20 + integration 2/2 для invoice pipeline (7/8)
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>
2026-10-04 21:46:10 +05:00

253 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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