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