using System.Security.Cryptography; using System.Text; using foodmarket.Pos.Core.Data; using foodmarket.Shared.Pos.V1; namespace foodmarket.Pos.Core.Sync; public record PushResult(int Accepted, int Failed, bool ReplayedFromCache, IReadOnlyList Failures); /// Синхронизация кассы с сервером: дельта-загрузка каталога (pull) и /// выгрузка очереди продаж (push, идемпотентно). Чистая логика — тестируется /// против моков / реального API. public sealed class SyncService { private readonly PosStore _store; private readonly IServerApi _api; public SyncService(PosStore store, IServerApi api) { _store = store; _api = api; } /// Загрузить изменения каталога с сервера (дельта по last_sync). public async Task PullCatalogAsync(CancellationToken ct = default) { var resp = await _api.SyncAsync(_store.LastSync(), ct); _store.ApplySync(resp); return _store.ProductCount(); } /// Отправить накопленные продажи батчем. Идемпотентный ключ стабилен /// для одного и того же набора client-id, поэтому повтор не создаёт дублей. public async Task PushSalesAsync(CancellationToken ct = default) { var pending = _store.PendingSales(); if (pending.Count == 0) return new PushResult(0, 0, false, Array.Empty()); var key = StableBatchKey(pending.Select(s => s.ClientSaleId)); var batch = new PosSaleBatchDto { IdempotencyKey = key, Sales = pending }; var resp = await _api.AcceptSalesAsync(batch, ct); foreach (var a in resp.Accepted) _store.MarkSent(a.ClientSaleId); foreach (var f in resp.Failed) _store.MarkFailed(f.ClientSaleId, f.Error); return new PushResult(resp.Accepted.Count, resp.Failed.Count, resp.ReplayedFromCache, resp.Failed); } /// Детерминированный Guid из набора client-id (отсортированных) — /// один и тот же набор продаж → один и тот же ключ (идемпотентность при ретрае). public static Guid StableBatchKey(IEnumerable clientSaleIds) { var joined = string.Join(",", clientSaleIds.Select(g => g.ToString("N")).OrderBy(x => x, StringComparer.Ordinal)); var hash = MD5.HashData(Encoding.UTF8.GetBytes(joined)); return new Guid(hash); } }