- Модели (товар/корзина/строка/смена), парсер весовых ШК (EAN-13 20–29), корзина (штучный инкремент, весовой, скидки строка/чек с клампом), математика оплаты и смены (должно/факт/разница). - PosStore на raw Microsoft.Data.Sqlite (быстрый старт, AOT-дружелюбно): каталог + штрихкоды + очередь продаж + kv; индексы по штрихкоду/NameLower (кириллица не сворачивается через LIKE) и barcodes.ProductId (дельта-синк O(n), не O(n²)). - SyncService: дельта-pull каталога + push очереди с детерминированным idempotency-ключом (MD5 отсортированных ClientSaleId → один набор = один ключ). - Auth: password/refresh grant, ITokenStore (FileTokenStore 0600 на Unix, InMemory для тестов). Checkout.BuildSale: сборка серверного чека, скидка на чек распределяется по строкам (сервер понимает только построчную). UpdateCheck. - Тесты: штрихкод/корзина/оплата/смена/стор/синк-ключ/checkout/офлайн(5 без дублей)/версия — 37 зелёных. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
53 lines
2.7 KiB
C#
53 lines
2.7 KiB
C#
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<PosSaleFailedDto> Failures);
|
||
|
||
/// <summary>Синхронизация кассы с сервером: дельта-загрузка каталога (pull) и
|
||
/// выгрузка очереди продаж (push, идемпотентно). Чистая логика — тестируется
|
||
/// против моков / реального API.</summary>
|
||
public sealed class SyncService
|
||
{
|
||
private readonly PosStore _store;
|
||
private readonly IServerApi _api;
|
||
|
||
public SyncService(PosStore store, IServerApi api) { _store = store; _api = api; }
|
||
|
||
/// <summary>Загрузить изменения каталога с сервера (дельта по last_sync).</summary>
|
||
public async Task<int> PullCatalogAsync(CancellationToken ct = default)
|
||
{
|
||
var resp = await _api.SyncAsync(_store.LastSync(), ct);
|
||
_store.ApplySync(resp);
|
||
return _store.ProductCount();
|
||
}
|
||
|
||
/// <summary>Отправить накопленные продажи батчем. Идемпотентный ключ стабилен
|
||
/// для одного и того же набора client-id, поэтому повтор не создаёт дублей.</summary>
|
||
public async Task<PushResult> PushSalesAsync(CancellationToken ct = default)
|
||
{
|
||
var pending = _store.PendingSales();
|
||
if (pending.Count == 0) return new PushResult(0, 0, false, Array.Empty<PosSaleFailedDto>());
|
||
|
||
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);
|
||
}
|
||
|
||
/// <summary>Детерминированный Guid из набора client-id (отсортированных) —
|
||
/// один и тот же набор продаж → один и тот же ключ (идемпотентность при ретрае).</summary>
|
||
public static Guid StableBatchKey(IEnumerable<Guid> 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);
|
||
}
|
||
}
|