Часть 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>
153 lines
7.8 KiB
C#
153 lines
7.8 KiB
C#
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);
|
||
}
|
||
}
|