food-market/src/food-market.pos.core/Sync/SyncService.cs
nns 2b63bc9c66 feat(pos-core): логика кассы без UI + 37 unit-тестов
- Модели (товар/корзина/строка/смена), парсер весовых ШК (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>
2026-10-08 04:58:22 +05:00

53 lines
2.7 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.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);
}
}