using System.Diagnostics; using System.Globalization; using System.Text; using System.Text.Json; namespace foodmarket.Api.Infrastructure.Invoices; /// Читает накладную через локальный Claude Code в headless-режиме /// (claude -p --output-format json). Нагрузка списывается с кредита /// подписки — отдельная оплата по токенам не нужна. Если /// Invoices:ClaudeCli:RemoteHost задан, модель живёт на отдельной /// машине и мы ходим туда по ssh (копируем файл, выполняем, подчищаем). /// /// Промпт и правила разбора — порт из ~/ref/sklad/sklad/Invoices/ /// ClaudeCliInvoiceExtractor.cs: копировать каждую строку, не угадывать /// (null если не читается), штрихкод != код поставщика, сверка с «Итого». public class ClaudeCliInvoiceExtractor : IInvoiceExtractor { private readonly ILogger _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 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 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()); } /// --output-format json отдаёт конверт Claude Code; нужный JSON /// лежит в поле result строкой, иногда обёрнутой в ```json. 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) + "…"); }