food-market/src/food-market.pos/Services/AppServices.cs
nns 195dfb1a2c feat(pos): весовой товар на кассе — вес с весов/вручную + автопоиск весов
Часть 3 + автопоиск (данные sysadmin: касса МойСклад спамит «Порт не выбран»):
- Весовой товар при сканировании/плитке → вес с весов (если на связи), иначе
  модалка ручного ввода веса. Сумма = цена/кг × вес; подпись «1,235 кг × 690 ₸/кг».
- ScaleMonitor (pos.core): опрос ТОЛЬКО по требованию (при весовом товаре), мягкая
  деградация на ручной ввод, запись о потере/восстановлении связи РОВНО ОДИН раз
  (не сотни «Таймаут»). Юнит-тесты (100 опросов → 1 запись).
- ScaleDiscovery (pos): автоскан всех COM-портов (вкл. USB-COM CH340/Prolific) ×
  протоколы Масса-К/CAS × скорости; кнопка «Найти весы» в верхней панели; найденный
  порт запоминается (kv) и восстанавливается при старте.
- UnitShort («шт»/«кг»/«л») протянут через sync→SQLite→PosProduct/CartLine; цена
  весового показывается «690 ₸/кг» вместо кода ОКЕИ «690 ₸/796».
- Разбор этикеток в кассе — по настройкам орг (по умолчанию ВЫКЛ, точный штрихкод).
- Юнит-тесты 321/321 (обновлены PosBarcodeTests под дефолт-off).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:17:35 +05:00

153 lines
7.8 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.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;
/// <summary>Лёгкий контейнер сервисов кассы (без тяжёлого DI — быстрый старт).
/// Держит локальное хранилище, http-клиент, авторизацию, Refit-API (после входа),
/// сервис синхронизации и оборудование (печать/весы/фискализация-заглушка).</summary>
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();
/// <summary>Монитор весов: опрос по требованию, мягкая деградация на ручной ввод,
/// запись о потере связи один раз. Источник (COM-порт) — из автопоиска/настроек.</summary>
public ScaleMonitor Scales { get; } = new ScaleMonitor(AppendScaleLog);
/// <summary>Настройки разбора весовых этикеток организации (по умолчанию ВЫКЛ).
/// Касса использует точный штрихкод; этикетки разбирает только если включено.</summary>
public foodmarket.Shared.Barcodes.WeightedBarcodeSettings WeightedSettings { get; set; }
= foodmarket.Shared.Barcodes.WeightedBarcodeSettings.Default;
private CheckoutService? _checkout;
/// <summary>Оркестратор завершения чека (очередь → push → печать).</summary>
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 { /* логирование весов не должно мешать кассе */ }
}
/// <summary>Поднять сохранённый порт весов (если был выбран ранее).</summary>
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 { /* нет сохранённого порта — работаем с ручным вводом */ }
}
/// <summary>Назначить весы на конкретный порт и запомнить выбор.</summary>
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());
}
/// <summary>Автопоиск весов по COM-портам; при успехе — запоминаем. Возвращает,
/// нашлись ли весы.</summary>
public async Task<bool> 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;
}
/// <summary>Контейнер с готовым хранилищем — для headless UI-тестов/демо
/// (без файлов на диске и без токен-стора).</summary>
public AppServices(PosStore store)
{
Store = store;
Http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Auth = new AuthService(Http);
Tokens = new InMemoryTokenStore();
}
/// <summary>Поднять Refit-клиент на базовый URL сервера с Bearer-токеном сессии.</summary>
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<IServerApi>(client);
Sync = new SyncService(Store, Api);
}
/// <summary>HTTP-handler с автоматической декомпрессией (gzip/deflate/brotli):
/// сервер жмёт JSON каталога (Program.cs AddResponseCompression), но без этого
/// флага HttpClient не шлёт Accept-Encoding и каталог едет несжатым. Для
/// первичной загрузки 30 000 товаров это кратно меньше трафика → быстрее старт.</summary>
private static HttpClientHandler NewHandler() => new()
{
AutomaticDecompression = System.Net.DecompressionMethods.GZip
| System.Net.DecompressionMethods.Deflate | System.Net.DecompressionMethods.Brotli,
};
}
/// <summary>Подставляет Bearer-токен в каждый запрос.</summary>
internal sealed class AuthHeaderHandler : DelegatingHandler
{
private readonly Func<string?> _token;
public AuthHeaderHandler(Func<string?> token, HttpMessageHandler inner) : base(inner) { _token = token; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var t = _token();
if (!string.IsNullOrEmpty(t)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", t);
return base.SendAsync(request, ct);
}
}