using System.Net.Http; using System.Net.Http.Headers; using foodmarket.Pos.Core.Auth; using foodmarket.Pos.Core.Data; using foodmarket.Pos.Core.Hardware; using foodmarket.Pos.Core.Sync; using foodmarket.Pos.Hardware; using Refit; namespace foodmarket.Pos.Services; /// Лёгкий контейнер сервисов кассы (без тяжёлого DI — быстрый старт). /// Держит локальное хранилище, http-клиент, авторизацию, Refit-API (после входа), /// сервис синхронизации и оборудование (печать/весы/фискализация-заглушка). public sealed class AppServices { public PosStore Store { get; } public HttpClient Http { get; } public AuthService Auth { get; } public ITokenStore Tokens { get; } public PosSession? Session { get; set; } public IServerApi? Api { get; private set; } public SyncService? Sync { get; private set; } public IReceiptPrinter Printer { get; set; } = new PdfReceiptPrinter(); // запасной по умолчанию; ESC/POS выбирается в настройках public IScale Scale { get; set; } = new ManualScale(); public IFiscalProvider Fiscal { get; } = new NullFiscalProvider(); /// Монитор весов: опрос по требованию, мягкая деградация на ручной ввод, /// запись о потере связи один раз. Источник (COM-порт) — из автопоиска/настроек. public ScaleMonitor Scales { get; } = new ScaleMonitor(AppendScaleLog); /// Настройки разбора весовых этикеток организации (по умолчанию ВЫКЛ). /// Касса использует точный штрихкод; этикетки разбирает только если включено. public foodmarket.Shared.Barcodes.WeightedBarcodeSettings WeightedSettings { get; set; } = foodmarket.Shared.Barcodes.WeightedBarcodeSettings.Default; private CheckoutService? _checkout; /// Оркестратор завершения чека (очередь → push → печать). public CheckoutService Checkout => _checkout ??= new CheckoutService(this); public AppServices() { var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos"); Directory.CreateDirectory(dir); Store = new PosStore(Path.Combine(dir, "catalog.db")); Http = new HttpClient(NewHandler()) { Timeout = TimeSpan.FromSeconds(30) }; Auth = new AuthService(Http); // Windows — DPAPI (привязка к учётке), иначе файл с правами 0600. Tokens = OperatingSystem.IsWindows() ? new DpapiTokenStore(FileTokenStore.DefaultPath()) : new FileTokenStore(FileTokenStore.DefaultPath()); Session = Tokens.Load(); if (Session is not null) ConfigureApi(Session); RestoreScaleFromSettings(); } // ── Весы ─────────────────────────────────────────────────────────────── private static readonly string ScaleLogPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos", "scale.log"); private static void AppendScaleLog(string msg) { try { Directory.CreateDirectory(Path.GetDirectoryName(ScaleLogPath)!); File.AppendAllText(ScaleLogPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {msg}{Environment.NewLine}"); } catch { /* логирование весов не должно мешать кассе */ } } /// Поднять сохранённый порт весов (если был выбран ранее). public void RestoreScaleFromSettings() { try { var port = Store.GetKv("scale_port"); if (string.IsNullOrWhiteSpace(port)) return; var baud = int.TryParse(Store.GetKv("scale_baud"), out var b) ? b : 9600; var proto = Store.GetKv("scale_proto") == "Cas" ? ComScale.Proto.Cas : ComScale.Proto.MassaK; SetScale(port, baud, proto); } catch { /* нет сохранённого порта — работаем с ручным вводом */ } } /// Назначить весы на конкретный порт и запомнить выбор. public void SetScale(string port, int baud, ComScale.Proto proto) { var scale = new ComScale(port, baud, proto); Scale = scale; Scales.SetReader(ct => scale.ReadWeightKgAsync(ct), scale.Name); Store.SetKv("scale_port", port); Store.SetKv("scale_baud", baud.ToString()); Store.SetKv("scale_proto", proto.ToString()); } /// Автопоиск весов по COM-портам; при успехе — запоминаем. Возвращает, /// нашлись ли весы. public async Task DiscoverScaleAsync(CancellationToken ct = default) { var found = await ScaleDiscovery.DiscoverAsync(AppendScaleLog, ct); if (found is null) return false; SetScale(found.Port, found.Baud, found.Proto); return true; } /// Контейнер с готовым хранилищем — для headless UI-тестов/демо /// (без файлов на диске и без токен-стора). public AppServices(PosStore store) { Store = store; Http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; Auth = new AuthService(Http); Tokens = new InMemoryTokenStore(); } /// Поднять Refit-клиент на базовый URL сервера с Bearer-токеном сессии. public void ConfigureApi(PosSession session) { Session = session; var client = new HttpClient(new AuthHeaderHandler(() => Session?.AccessToken, NewHandler())) { BaseAddress = new Uri(session.ServerUrl.TrimEnd('/')), Timeout = TimeSpan.FromSeconds(30), }; Api = RestService.For(client); Sync = new SyncService(Store, Api); } /// HTTP-handler с автоматической декомпрессией (gzip/deflate/brotli): /// сервер жмёт JSON каталога (Program.cs AddResponseCompression), но без этого /// флага HttpClient не шлёт Accept-Encoding и каталог едет несжатым. Для /// первичной загрузки 30 000 товаров это кратно меньше трафика → быстрее старт. private static HttpClientHandler NewHandler() => new() { AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate | System.Net.DecompressionMethods.Brotli, }; } /// Подставляет Bearer-токен в каждый запрос. internal sealed class AuthHeaderHandler : DelegatingHandler { private readonly Func _token; public AuthHeaderHandler(Func token, HttpMessageHandler inner) : base(inner) { _token = token; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) { var t = _token(); if (!string.IsNullOrEmpty(t)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", t); return base.SendAsync(request, ct); } }