From 2b63bc9c663442a2539ef8e731e429f327c72a7a Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 8 Oct 2026 04:58:22 +0500 Subject: [PATCH] =?UTF-8?q?feat(pos-core):=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BA=D0=B0=D1=81=D1=81=D1=8B=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20UI=20+=2037=20unit-=D1=82=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Модели (товар/корзина/строка/смена), парсер весовых ШК (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 --- Directory.Packages.props | 8 + src/food-market.pos.core/Auth/AuthService.cs | 65 +++ src/food-market.pos.core/Auth/TokenStore.cs | 76 ++++ src/food-market.pos.core/Data/PosStore.cs | 241 +++++++++++ .../Logic/BarcodeParser.cs | 53 +++ src/food-market.pos.core/Logic/Cart.cs | 78 ++++ src/food-market.pos.core/Logic/Checkout.cs | 82 ++++ src/food-market.pos.core/Logic/PaymentMath.cs | 31 ++ src/food-market.pos.core/Logic/UpdateCheck.cs | 35 ++ src/food-market.pos.core/Models/PosModels.cs | 54 +++ src/food-market.pos.core/Sync/IServerApi.cs | 58 +++ src/food-market.pos.core/Sync/SyncService.cs | 52 +++ .../food-market.pos.core.csproj | 3 +- tests/food-market.UnitTests/PosCoreTests.cs | 377 ++++++++++++++++++ .../food-market.UnitTests.csproj | 1 + 15 files changed, 1212 insertions(+), 2 deletions(-) create mode 100644 src/food-market.pos.core/Auth/AuthService.cs create mode 100644 src/food-market.pos.core/Auth/TokenStore.cs create mode 100644 src/food-market.pos.core/Data/PosStore.cs create mode 100644 src/food-market.pos.core/Logic/BarcodeParser.cs create mode 100644 src/food-market.pos.core/Logic/Cart.cs create mode 100644 src/food-market.pos.core/Logic/Checkout.cs create mode 100644 src/food-market.pos.core/Logic/PaymentMath.cs create mode 100644 src/food-market.pos.core/Logic/UpdateCheck.cs create mode 100644 src/food-market.pos.core/Models/PosModels.cs create mode 100644 src/food-market.pos.core/Sync/IServerApi.cs create mode 100644 src/food-market.pos.core/Sync/SyncService.cs create mode 100644 tests/food-market.UnitTests/PosCoreTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 46a7963..270613a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -62,7 +62,15 @@ + + + + + + + + diff --git a/src/food-market.pos.core/Auth/AuthService.cs b/src/food-market.pos.core/Auth/AuthService.cs new file mode 100644 index 0000000..f5979c1 --- /dev/null +++ b/src/food-market.pos.core/Auth/AuthService.cs @@ -0,0 +1,65 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace foodmarket.Pos.Core.Auth; + +public record AuthTokens(string AccessToken, string RefreshToken, int ExpiresIn); + +/// Вход кассира (password grant) и обновление токена (refresh) против +/// OpenIddict-сервера food-market. Касса использует тот же client_id, +/// что и веб. PIN поддерживается как пароль кассира (PIN = пароль учётки). +public sealed class AuthService +{ + private readonly HttpClient _http; + public AuthService(HttpClient http) { _http = http; } + + public async Task LoginAsync(string serverUrl, string username, string password, CancellationToken ct = default) + { + var form = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["username"] = username, + ["password"] = password, + ["client_id"] = "food-market-web", + ["scope"] = "openid profile email roles api offline_access", + }); + using var resp = await _http.PostAsync(serverUrl.TrimEnd('/') + "/connect/token", form, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (!resp.IsSuccessStatusCode) + throw new AuthException(DescribeError(body) ?? $"Вход не удался ({(int)resp.StatusCode})"); + return Parse(body); + } + + public async Task RefreshAsync(string serverUrl, string refreshToken, CancellationToken ct = default) + { + var form = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = "food-market-web", + }); + using var resp = await _http.PostAsync(serverUrl.TrimEnd('/') + "/connect/token", form, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (!resp.IsSuccessStatusCode) + throw new AuthException("Сессия истекла — войдите снова."); + return Parse(body); + } + + private static AuthTokens Parse(string body) + { + using var doc = JsonDocument.Parse(body); + var r = doc.RootElement; + return new AuthTokens( + r.GetProperty("access_token").GetString()!, + r.TryGetProperty("refresh_token", out var rt) ? rt.GetString()! : "", + r.TryGetProperty("expires_in", out var e) ? e.GetInt32() : 3600); + } + + private static string? DescribeError(string body) + { + try { using var d = JsonDocument.Parse(body); return d.RootElement.TryGetProperty("error_description", out var e) ? e.GetString() : null; } + catch { return null; } + } +} + +public sealed class AuthException : Exception { public AuthException(string m) : base(m) { } } diff --git a/src/food-market.pos.core/Auth/TokenStore.cs b/src/food-market.pos.core/Auth/TokenStore.cs new file mode 100644 index 0000000..5979d9d --- /dev/null +++ b/src/food-market.pos.core/Auth/TokenStore.cs @@ -0,0 +1,76 @@ +using System.Runtime.InteropServices; +using System.Text.Json; + +namespace foodmarket.Pos.Core.Auth; + +public sealed class PosSession +{ + public required string ServerUrl { get; set; } + public required string AccessToken { get; set; } + public required string RefreshToken { get; set; } + public string? CashierName { get; set; } + public Guid? CashierUserId { get; set; } + public string[]? Roles { get; set; } + public Guid? RetailPointId { get; set; } + public Guid? CashAccountId { get; set; } + public Guid? AcquiringAccountId { get; set; } + public DateTime SavedAt { get; set; } = DateTime.UtcNow; +} + +public interface ITokenStore +{ + void Save(PosSession session); + PosSession? Load(); + void Clear(); +} + +/// Кроссплатформенное хранение сессии в файле. На Unix выставляем права +/// 0600 (только владелец). На Windows полагаемся на ACL пользовательского профиля; +/// усиленный вариант (DPAPI) — в приложении под Windows (DpapiTokenStore). +/// Содержит refresh-токен — файл не логируем и не коммитим. +public class FileTokenStore : ITokenStore +{ + private readonly string _path; + private static readonly JsonSerializerOptions J = new(JsonSerializerDefaults.Web); + + public FileTokenStore(string path) { _path = path; } + + public static string DefaultPath() + { + var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "food-market-pos"); + Directory.CreateDirectory(dir); + return Path.Combine(dir, "session.json"); + } + + public void Save(PosSession session) + { + var json = JsonSerializer.Serialize(session, J); + File.WriteAllText(_path, Protect(json)); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + try { File.SetUnixFileMode(_path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { /* best effort */ } + } + } + + public PosSession? Load() + { + if (!File.Exists(_path)) return null; + try { return JsonSerializer.Deserialize(Unprotect(File.ReadAllText(_path)), J); } + catch { return null; } + } + + public void Clear() { try { if (File.Exists(_path)) File.Delete(_path); } catch { /* ignore */ } } + + // Точки расширения для DPAPI в приложении под Windows; базовый стор хранит как есть. + protected virtual string Protect(string plain) => plain; + protected virtual string Unprotect(string stored) => stored; +} + +/// Токен-стор в памяти — для тестов/демо (без диска). +public sealed class InMemoryTokenStore : ITokenStore +{ + private PosSession? _session; + public void Save(PosSession session) => _session = session; + public PosSession? Load() => _session; + public void Clear() => _session = null; +} diff --git a/src/food-market.pos.core/Data/PosStore.cs b/src/food-market.pos.core/Data/PosStore.cs new file mode 100644 index 0000000..ad8002c --- /dev/null +++ b/src/food-market.pos.core/Data/PosStore.cs @@ -0,0 +1,241 @@ +using System.Text.Json; +using foodmarket.Pos.Core.Models; +using foodmarket.Shared.Pos.V1; +using Microsoft.Data.Sqlite; + +namespace foodmarket.Pos.Core.Data; + +/// Локальное хранилище кассы на SQLite (Microsoft.Data.Sqlite напрямую — +/// без EF: быстрый холодный старт и дружелюбность к Native AOT). Хранит каталог +/// (товары+штрихкоды с индексом), очередь продаж и key-value (курсор синка, смена). +/// Потокобезопасность: один connection, операции короткие; UI-поток дёргает через await Task.Run. +public sealed class PosStore : IDisposable +{ + private readonly SqliteConnection _cn; + private static readonly JsonSerializerOptions J = new(JsonSerializerDefaults.Web); + + public PosStore(string dbPath) + { + _cn = new SqliteConnection($"Data Source={dbPath};Cache=Shared"); + _cn.Open(); + Exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA temp_store=MEMORY;"); + InitSchema(); + } + + public static PosStore InMemory() + { + var s = new PosStore(":memory:"); + return s; + } + + private void InitSchema() + { + Exec(@" + CREATE TABLE IF NOT EXISTS products( + Id TEXT PRIMARY KEY, Name TEXT NOT NULL, NameLower TEXT NOT NULL DEFAULT '', Article TEXT, UnitCode TEXT NOT NULL, + Packaging INTEGER NOT NULL, VatPercent TEXT NOT NULL, VatEnabled INTEGER NOT NULL, + Price TEXT NOT NULL DEFAULT '0', IsArchived INTEGER NOT NULL DEFAULT 0, UpdatedAt TEXT); + CREATE TABLE IF NOT EXISTS barcodes(Code TEXT NOT NULL, ProductId TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS ix_barcodes_code ON barcodes(Code); + CREATE INDEX IF NOT EXISTS ix_barcodes_productid ON barcodes(ProductId); + CREATE INDEX IF NOT EXISTS ix_products_namelower ON products(NameLower); + CREATE TABLE IF NOT EXISTS sale_queue( + ClientSaleId TEXT PRIMARY KEY, Json TEXT NOT NULL, Status INTEGER NOT NULL DEFAULT 0, + CreatedAt TEXT NOT NULL, Error TEXT); + CREATE TABLE IF NOT EXISTS kv(Key TEXT PRIMARY KEY, Value TEXT);"); + } + + // ── каталог ────────────────────────────────────────────────────────────── + public void ApplySync(PosSyncResponse sync) + { + var priceByProduct = sync.Prices + .Where(p => p.IsSystem) + .GroupBy(p => p.ProductId) + .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.UpdatedAt).First().Amount); + + using var tx = _cn.BeginTransaction(); + // Подготовленные команды с переиспользуемыми параметрами — массовая + // вставка 30k товаров за ~1с вместо десятков секунд (команда на строку). + var up = _cn.CreateCommand(); up.Transaction = tx; + up.CommandText = @"INSERT INTO products(Id,Name,NameLower,Article,UnitCode,Packaging,VatPercent,VatEnabled,Price,IsArchived,UpdatedAt) + VALUES($id,$n,$nl,$a,$u,$pk,$vp,$ve,$pr,$ar,$up) + ON CONFLICT(Id) DO UPDATE SET Name=$n,NameLower=$nl,Article=$a,UnitCode=$u,Packaging=$pk,VatPercent=$vp,VatEnabled=$ve, + Price=CASE WHEN $hasPrice=1 THEN $pr ELSE products.Price END, IsArchived=$ar, UpdatedAt=$up;"; + var upP = AddParams(up, "$id", "$n", "$nl", "$a", "$u", "$pk", "$vp", "$ve", "$pr", "$hasPrice", "$ar", "$up"); + up.Prepare(); + var delBc = _cn.CreateCommand(); delBc.Transaction = tx; delBc.CommandText = "DELETE FROM barcodes WHERE ProductId=$id"; + var delP = AddParams(delBc, "$id"); delBc.Prepare(); + var insBc = _cn.CreateCommand(); insBc.Transaction = tx; insBc.CommandText = "INSERT INTO barcodes(Code,ProductId) VALUES($c,$id)"; + var insP = AddParams(insBc, "$c", "$id"); insBc.Prepare(); + + foreach (var p in sync.Products) + { + priceByProduct.TryGetValue(p.Id, out var price); + var hasPrice = priceByProduct.ContainsKey(p.Id); + var idStr = p.Id.ToString(); + upP["$id"].Value = idStr; upP["$n"].Value = p.Name; upP["$nl"].Value = p.Name.ToLowerInvariant(); + upP["$a"].Value = (object?)p.Article ?? DBNull.Value; upP["$u"].Value = p.UnitCode; upP["$pk"].Value = p.Packaging; + upP["$vp"].Value = p.VatPercent.ToString(System.Globalization.CultureInfo.InvariantCulture); + upP["$ve"].Value = p.VatEnabled ? 1 : 0; + upP["$pr"].Value = price.ToString(System.Globalization.CultureInfo.InvariantCulture); + upP["$hasPrice"].Value = hasPrice ? 1 : 0; upP["$ar"].Value = p.IsArchived ? 1 : 0; upP["$up"].Value = p.UpdatedAt.ToString("O"); + up.ExecuteNonQuery(); + delP["$id"].Value = idStr; delBc.ExecuteNonQuery(); + foreach (var bc in p.Barcodes) + { + insP["$c"].Value = OnlyDigitsOrRaw(bc); insP["$id"].Value = idStr; + insBc.ExecuteNonQuery(); + } + } + up.Dispose(); delBc.Dispose(); insBc.Dispose(); + // цены для товаров, пришедших без карточки (только апдейт цены) + foreach (var kv in priceByProduct) + { + if (sync.Products.Any(x => x.Id == kv.Key)) continue; + using var cmd = _cn.CreateCommand(); cmd.Transaction = tx; + cmd.CommandText = "UPDATE products SET Price=$pr WHERE Id=$id"; + Bind(cmd, "$pr", kv.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); Bind(cmd, "$id", kv.Key.ToString()); + cmd.ExecuteNonQuery(); + } + foreach (var did in sync.DeletedProductIds) + { + using var cmd = _cn.CreateCommand(); cmd.Transaction = tx; + cmd.CommandText = "UPDATE products SET IsArchived=1 WHERE Id=$id"; Bind(cmd, "$id", did.ToString()); cmd.ExecuteNonQuery(); + } + tx.Commit(); + SetKv("last_sync", sync.ServerTime.ToString("O")); + } + + public int ProductCount() + { + using var cmd = _cn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM products WHERE IsArchived=0"; + return Convert.ToInt32(cmd.ExecuteScalar()); + } + + public PosProduct? FindByBarcode(string code) + { + var lookup = OnlyDigitsOrRaw(code); + using var cmd = _cn.CreateCommand(); + cmd.CommandText = @"SELECT p.* FROM products p JOIN barcodes b ON b.ProductId=p.Id + WHERE b.Code=$c AND p.IsArchived=0 LIMIT 1"; + Bind(cmd, "$c", lookup); + using var r = cmd.ExecuteReader(); + return r.Read() ? Map(r) : null; + } + + public IReadOnlyList SearchByName(string term, int limit = 50) + { + term = (term ?? string.Empty).Trim(); + if (term.Length == 0) return Array.Empty(); + using var cmd = _cn.CreateCommand(); + // сначала «начинается с», потом «содержит» — релевантнее; индекс ix_products_name помогает префиксу. + // LIKE в SQLite не сворачивает регистр у кириллицы → ищем по NameLower + // (строчное из C#, Unicode-aware); сначала «начинается с», потом «содержит». + var t = term.ToLowerInvariant(); + cmd.CommandText = @"SELECT * FROM products WHERE IsArchived=0 AND NameLower LIKE $pfx + UNION + SELECT * FROM products WHERE IsArchived=0 AND NameLower LIKE $sub AND NameLower NOT LIKE $pfx + LIMIT $lim"; + Bind(cmd, "$pfx", t + "%"); Bind(cmd, "$sub", "%" + t + "%"); Bind(cmd, "$lim", limit); + using var r = cmd.ExecuteReader(); + var list = new List(); + while (r.Read()) list.Add(Map(r)); + return list; + } + + public PosProduct? GetProduct(Guid id) + { + using var cmd = _cn.CreateCommand(); cmd.CommandText = "SELECT * FROM products WHERE Id=$id"; + Bind(cmd, "$id", id.ToString()); + using var r = cmd.ExecuteReader(); return r.Read() ? Map(r) : null; + } + + // ── очередь продаж ───────────────────────────────────────────────────────── + public void EnqueueSale(PosSaleDto sale) + { + using var cmd = _cn.CreateCommand(); + cmd.CommandText = @"INSERT INTO sale_queue(ClientSaleId,Json,Status,CreatedAt) VALUES($id,$j,0,$t) + ON CONFLICT(ClientSaleId) DO NOTHING"; + Bind(cmd, "$id", sale.ClientSaleId.ToString()); Bind(cmd, "$j", JsonSerializer.Serialize(sale, J)); Bind(cmd, "$t", DateTime.UtcNow.ToString("O")); + cmd.ExecuteNonQuery(); + } + + public IReadOnlyList PendingSales(int limit = 200) + { + using var cmd = _cn.CreateCommand(); + cmd.CommandText = "SELECT Json FROM sale_queue WHERE Status=0 ORDER BY CreatedAt LIMIT $lim"; + Bind(cmd, "$lim", limit); + using var r = cmd.ExecuteReader(); + var list = new List(); + while (r.Read()) list.Add(JsonSerializer.Deserialize(r.GetString(0), J)!); + return list; + } + + public int PendingCount() + { + using var cmd = _cn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM sale_queue WHERE Status=0"; + return Convert.ToInt32(cmd.ExecuteScalar()); + } + + public void MarkSent(Guid clientSaleId) + { + using var cmd = _cn.CreateCommand(); cmd.CommandText = "UPDATE sale_queue SET Status=1 WHERE ClientSaleId=$id"; + Bind(cmd, "$id", clientSaleId.ToString()); cmd.ExecuteNonQuery(); + } + + public void MarkFailed(Guid clientSaleId, string error) + { + using var cmd = _cn.CreateCommand(); cmd.CommandText = "UPDATE sale_queue SET Status=2, Error=$e WHERE ClientSaleId=$id"; + Bind(cmd, "$id", clientSaleId.ToString()); Bind(cmd, "$e", error); cmd.ExecuteNonQuery(); + } + + // ── key-value ────────────────────────────────────────────────────────────── + public string? GetKv(string key) + { + using var cmd = _cn.CreateCommand(); cmd.CommandText = "SELECT Value FROM kv WHERE Key=$k"; Bind(cmd, "$k", key); + return cmd.ExecuteScalar() as string; + } + public void SetKv(string key, string value) + { + using var cmd = _cn.CreateCommand(); + cmd.CommandText = "INSERT INTO kv(Key,Value) VALUES($k,$v) ON CONFLICT(Key) DO UPDATE SET Value=$v"; + Bind(cmd, "$k", key); Bind(cmd, "$v", value); cmd.ExecuteNonQuery(); + } + public DateTime? LastSync() => DateTime.TryParse(GetKv("last_sync"), null, System.Globalization.DateTimeStyles.RoundtripKind, out var d) ? d : null; + + // ── helpers ──────────────────────────────────────────────────────────────── + private static PosProduct Map(SqliteDataReader r) + { + var id = Guid.Parse(r.GetString(r.GetOrdinal("Id"))); + return new PosProduct + { + Id = id, + Name = r.GetString(r.GetOrdinal("Name")), + Article = r.IsDBNull(r.GetOrdinal("Article")) ? null : r.GetString(r.GetOrdinal("Article")), + UnitCode = r.GetString(r.GetOrdinal("UnitCode")), + Packaging = (PosPackaging)r.GetInt32(r.GetOrdinal("Packaging")), + VatPercent = decimal.Parse(r.GetString(r.GetOrdinal("VatPercent")), System.Globalization.CultureInfo.InvariantCulture), + VatEnabled = r.GetInt32(r.GetOrdinal("VatEnabled")) == 1, + Price = decimal.Parse(r.GetString(r.GetOrdinal("Price")), System.Globalization.CultureInfo.InvariantCulture), + IsArchived = r.GetInt32(r.GetOrdinal("IsArchived")) == 1, + Barcodes = Array.Empty(), + }; + } + + private static string OnlyDigitsOrRaw(string s) + { + var d = new string((s ?? string.Empty).Where(char.IsDigit).ToArray()); + return d.Length > 0 ? d : (s ?? string.Empty); + } + + private void Exec(string sql) { using var cmd = _cn.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); } + private static void Bind(SqliteCommand cmd, string name, object value) { var p = cmd.CreateParameter(); p.ParameterName = name; p.Value = value; cmd.Parameters.Add(p); } + private static Dictionary AddParams(SqliteCommand cmd, params string[] names) + { + var d = new Dictionary(names.Length); + foreach (var n in names) { var p = cmd.CreateParameter(); p.ParameterName = n; cmd.Parameters.Add(p); d[n] = p; } + return d; + } + + public void Dispose() => _cn.Dispose(); +} diff --git a/src/food-market.pos.core/Logic/BarcodeParser.cs b/src/food-market.pos.core/Logic/BarcodeParser.cs new file mode 100644 index 0000000..f8bf633 --- /dev/null +++ b/src/food-market.pos.core/Logic/BarcodeParser.cs @@ -0,0 +1,53 @@ +namespace foodmarket.Pos.Core.Logic; + +/// Результат разбора штрихкода. +public readonly record struct ScannedBarcode( + string Raw, + string Lookup, // код для поиска товара (для весовых — встроенный код товара) + bool IsWeighted, + decimal? EmbeddedWeight, // кг (для EAN-13 с весом) + decimal? EmbeddedPrice); // ₸ (для EAN-13 с ценой) + +/// Разбор весовых EAN-13 с префиксом 20–29 (внутримагазинные коды). +/// +/// Формат (РФ/РК, распространённый): PP CCCCC Q QQQQ K — +/// PP — префикс 20..29, +/// CCCCC — 5-значный код товара (PLU), +/// Q — встроенная величина: вес в кг (5 цифр, 3 знака после запятой) ИЛИ +/// цена (5 цифр, 2 знака), в зависимости от префикса/настройки, +/// K — контрольная цифра EAN-13. +/// +/// По умолчанию: префиксы 20–24 → ВЕС (кг, /1000), 25–29 → ЦЕНА (₸, /100). +/// Это настраивается, но даём рабочий дефолт. Для поиска товара возвращаем +/// Lookup = "2" + PP[1] + CCCCC? Нет — ищем товар по его обычному штрихкоду, +/// которым в весовом коде является PLU. Поэтому Lookup = PLU (5 цифр, без лидирующих нулей +/// не режем — каталог сам сматчит по хранимому коду). +public static class BarcodeParser +{ + public static ScannedBarcode Parse(string raw) + { + var code = new string((raw ?? string.Empty).Where(char.IsDigit).ToArray()); + + if (code.Length == 13 && code[0] == '2') + { + var prefix = int.Parse(code.Substring(0, 2)); + if (prefix is >= 20 and <= 29) + { + var plu = code.Substring(2, 5); // код товара + var valueDigits = code.Substring(7, 5); // вес или цена + var value = int.Parse(valueDigits); + if (prefix <= 24) + { + // вес в кг, 3 знака после запятой (12345 → 12.345 кг) + var weight = decimal.Round(value / 1000m, 3); + return new ScannedBarcode(code, plu, true, weight, null); + } + // цена в ₸, 2 знака (12345 → 123.45) + var price = decimal.Round(value / 100m, 2); + return new ScannedBarcode(code, plu, true, null, price); + } + } + + return new ScannedBarcode(code.Length > 0 ? code : (raw ?? string.Empty), code.Length > 0 ? code : (raw ?? string.Empty), false, null, null); + } +} diff --git a/src/food-market.pos.core/Logic/Cart.cs b/src/food-market.pos.core/Logic/Cart.cs new file mode 100644 index 0000000..1bcb7fc --- /dev/null +++ b/src/food-market.pos.core/Logic/Cart.cs @@ -0,0 +1,78 @@ +using foodmarket.Pos.Core.Models; + +namespace foodmarket.Pos.Core.Logic; + +/// Итоги чека. +public readonly record struct CartTotals(decimal Subtotal, decimal LineDiscounts, decimal CartDiscount, decimal Total, decimal Vat, int LineCount, decimal Quantity); + +/// Корзина (чек) — чистая логика, без UI. Штучный товар инкрементится, +/// весовой — задаётся количеством (вес). Скидки: на строку (абсолютная) и на +/// весь чек (абсолютная, поверх строчных). Итог не уходит ниже нуля. +public sealed class Cart +{ + private readonly List _lines = new(); + public IReadOnlyList Lines => _lines; + public decimal CartDiscount { get; private set; } + + /// Добавить товар. Для весового qty = вес (обязателен), + /// для штучного — прибавка к количеству (по умолчанию 1). + public CartLine AddProduct(PosProduct p, decimal qty = 1m, decimal? priceOverride = null) + { + if (p.IsWeighed) + { + if (qty <= 0) throw new ArgumentException("Для весового товара нужен вес > 0", nameof(qty)); + var wl = new CartLine + { + ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, IsWeighed = true, + UnitPrice = priceOverride ?? p.Price, Quantity = qty, + VatPercent = p.VatEnabled ? p.VatPercent : 0m, + }; + _lines.Add(wl); + return wl; + } + + var existing = _lines.FirstOrDefault(l => l.ProductId == p.Id && !l.IsWeighed && l.Discount == 0m); + if (existing is not null) + { + existing.Quantity += qty; + return existing; + } + var line = new CartLine + { + ProductId = p.Id, Name = p.Name, UnitCode = p.UnitCode, IsWeighed = false, + UnitPrice = priceOverride ?? p.Price, Quantity = qty, + VatPercent = p.VatEnabled ? p.VatPercent : 0m, + }; + _lines.Add(line); + return line; + } + + public void SetQuantity(CartLine line, decimal qty) + { + if (qty <= 0) { _lines.Remove(line); return; } + line.Quantity = qty; + } + + public void SetLineDiscount(CartLine line, decimal discount) + => line.Discount = Math.Clamp(discount, 0m, decimal.Round(line.UnitPrice * line.Quantity, 2)); + + public void SetCartDiscount(decimal discount) + => CartDiscount = Math.Clamp(discount, 0m, SumLineTotals()); + + public void Remove(CartLine line) => _lines.Remove(line); + public void Clear() { _lines.Clear(); CartDiscount = 0m; } + + private decimal SumLineTotals() => _lines.Sum(l => l.LineTotal); + + public CartTotals Totals() + { + var subtotal = decimal.Round(_lines.Sum(l => l.UnitPrice * l.Quantity), 2); + var lineDisc = _lines.Sum(l => l.Discount); + var cartDisc = Math.Min(CartDiscount, SumLineTotals()); + var total = Math.Max(0m, decimal.Round(SumLineTotals() - cartDisc, 2)); + var vat = decimal.Round(_lines.Sum(l => l.VatPercent <= 0 ? 0m : l.LineTotal * l.VatPercent / (100m + l.VatPercent)), 2); + return new CartTotals(subtotal, lineDisc, cartDisc, total, vat, _lines.Count, _lines.Sum(l => l.Quantity)); + } + + public bool IsEmpty => _lines.Count == 0; +} diff --git a/src/food-market.pos.core/Logic/Checkout.cs b/src/food-market.pos.core/Logic/Checkout.cs new file mode 100644 index 0000000..263c93a --- /dev/null +++ b/src/food-market.pos.core/Logic/Checkout.cs @@ -0,0 +1,82 @@ +using foodmarket.Pos.Core.Models; +using foodmarket.Shared.Pos.V1; + +namespace foodmarket.Pos.Core.Logic; + +/// Контекст чека: кто пробивает, на какой точке и в какой смене. +/// Проставляется из сессии кассира и открытой смены. +public sealed record CheckoutContext(Guid? CashierUserId, Guid? RetailPointId, Guid? CashShiftId); + +/// Сборка серверного чека (PosSaleDto) из корзины — чистая логика. +/// Ключевой момент: сервер учитывает скидку только построчно, поэтому скидку +/// на чек распределяем по строкам пропорционально их сумме (остаток округления +/// падает на последнюю строку), чтобы итог сервера совпал с итогом кассы. +public static class Checkout +{ + public static PosSaleDto BuildSale( + Cart cart, decimal paidCash, decimal paidCard, CheckoutContext ctx, + Guid? clientSaleId = null, DateTime? occurredAt = null, + bool isReturn = false, Guid? referenceSaleId = null, Guid? customerId = null, string? notes = null) + { + if (cart.IsEmpty) throw new InvalidOperationException("Пустой чек — нечего пробивать."); + + var lines = cart.Lines.ToList(); + var distributed = DistributeCartDiscount(lines, cart.CartDiscount); + + var method = (paidCard > 0m && paidCash > 0m) ? PosPaymentMethod.Mixed + : paidCard > 0m ? PosPaymentMethod.Card + : PosPaymentMethod.Cash; + + return new PosSaleDto + { + ClientSaleId = clientSaleId ?? Guid.NewGuid(), + OccurredAt = occurredAt ?? DateTime.UtcNow, + CustomerId = customerId, + CashierUserId = ctx.CashierUserId, + RetailPointId = ctx.RetailPointId, + CashShiftId = ctx.CashShiftId, + IsReturn = isReturn, + ReferenceSaleId = referenceSaleId, + Payment = (int)method, + PaidCash = paidCash, + PaidCard = paidCard, + Notes = notes, + Lines = lines.Select((l, i) => new PosSaleLineDto + { + ProductId = l.ProductId, + Quantity = l.Quantity, + UnitPrice = l.UnitPrice, + Discount = distributed[i], + VatPercent = l.VatPercent, + }).ToList(), + }; + } + + /// Итоговая скидка по каждой строке = строчная скидка + доля скидки + /// на чек (пропорционально сумме строки). Сумма долей точно равна скидке на + /// чек: остаток от округления добавляется к последней непустой строке. + private static decimal[] DistributeCartDiscount(IReadOnlyList lines, decimal cartDiscount) + { + var result = lines.Select(l => l.Discount).ToArray(); + if (cartDiscount <= 0m || lines.Count == 0) return result; + + var totals = lines.Select(l => l.LineTotal).ToArray(); + var sum = totals.Sum(); + if (sum <= 0m) return result; + + decimal allocated = 0m; + int lastIdx = -1; + for (int i = 0; i < lines.Count; i++) + { + if (totals[i] <= 0m) continue; + var share = decimal.Round(cartDiscount * totals[i] / sum, 2); + result[i] += share; + allocated += share; + lastIdx = i; + } + // Остаток округления — на последнюю участвовавшую строку. + if (lastIdx >= 0 && allocated != cartDiscount) + result[lastIdx] += cartDiscount - allocated; + return result; + } +} diff --git a/src/food-market.pos.core/Logic/PaymentMath.cs b/src/food-market.pos.core/Logic/PaymentMath.cs new file mode 100644 index 0000000..d9f48f1 --- /dev/null +++ b/src/food-market.pos.core/Logic/PaymentMath.cs @@ -0,0 +1,31 @@ +namespace foodmarket.Pos.Core.Logic; + +public readonly record struct PaymentResult(bool Sufficient, decimal Paid, decimal Due, decimal Change, decimal Shortfall); + +/// Расчёт оплаты и сдачи. Нал + карта должны покрыть итог; переплата — +/// сдача (всегда наличными). Недоплата — чек провести нельзя. +public static class PaymentMath +{ + private const decimal Eps = 0.005m; + + public static PaymentResult Compute(decimal total, decimal paidCash, decimal paidCard) + { + var due = decimal.Round(total, 2); + var paid = decimal.Round(paidCash + paidCard, 2); + var sufficient = paid + Eps >= due; + var change = sufficient ? Math.Max(0m, decimal.Round(paid - due, 2)) : 0m; + var shortfall = sufficient ? 0m : decimal.Round(due - paid, 2); + return new PaymentResult(sufficient, paid, due, change, shortfall); + } +} + +/// Расчёт смены (зеркало серверного MoneyMath — чтобы касса показывала +/// «должно быть/факт/разница» офлайн, до подтверждения сервером). +public static class ShiftMath +{ + public static decimal ExpectedCash(decimal openingFloat, decimal salesCash, decimal returnsCash, decimal drawerIn, decimal drawerOut) + => decimal.Round(openingFloat + salesCash - returnsCash + drawerIn - drawerOut, 2); + + public static decimal CashDifference(decimal actualCash, decimal expectedCash) + => decimal.Round(actualCash - expectedCash, 2); +} diff --git a/src/food-market.pos.core/Logic/UpdateCheck.cs b/src/food-market.pos.core/Logic/UpdateCheck.cs new file mode 100644 index 0000000..dbfd640 --- /dev/null +++ b/src/food-market.pos.core/Logic/UpdateCheck.cs @@ -0,0 +1,35 @@ +namespace foodmarket.Pos.Core.Logic; + +/// Сравнение версий кассы для автообновления. Версия — набор чисел +/// через точку (напр. 2026.10.8 или 1.2.3); сравниваются покомпонентно. +/// Чистая логика — тестируется. +public static class UpdateCheck +{ + /// true — новее . + public static bool IsNewer(string? local, string? remote) + { + var a = Parse(local); + var b = Parse(remote); + int n = Math.Max(a.Length, b.Length); + for (int i = 0; i < n; i++) + { + int ai = i < a.Length ? a[i] : 0; + int bi = i < b.Length ? b[i] : 0; + if (bi != ai) return bi > ai; + } + return false; + } + + private static int[] Parse(string? v) + { + if (string.IsNullOrWhiteSpace(v)) return Array.Empty(); + var parts = v.Trim().Split('.', '-', '+'); + var nums = new List(); + foreach (var p in parts) + { + if (int.TryParse(p, out var x)) nums.Add(x); + else break; // на первом нечисловом сегменте останавливаемся (напр. «-beta») + } + return nums.ToArray(); + } +} diff --git a/src/food-market.pos.core/Models/PosModels.cs b/src/food-market.pos.core/Models/PosModels.cs new file mode 100644 index 0000000..9b41dcb --- /dev/null +++ b/src/food-market.pos.core/Models/PosModels.cs @@ -0,0 +1,54 @@ +namespace foodmarket.Pos.Core.Models; + +/// Тип упаковки (зеркало Domain.Packaging): штучный/весовой/разливной. +public enum PosPackaging { Piece = 1, Weight = 2, Liquid = 3 } + +public enum PosPaymentMethod { Cash = 0, Card = 1, BankTransfer = 2, Bonus = 3, Mixed = 99 } + +/// Товар в локальном каталоге кассы (из ProductSyncDto + системная цена). +public sealed class PosProduct +{ + public required Guid Id { get; init; } + public required string Name { get; init; } + public string? Article { get; init; } + public required IReadOnlyList Barcodes { get; init; } + public required string UnitCode { get; init; } + public required PosPackaging Packaging { get; init; } + public required decimal VatPercent { get; init; } + public required bool VatEnabled { get; init; } + public required decimal Price { get; init; } + public bool IsArchived { get; init; } + public bool IsWeighed => Packaging == PosPackaging.Weight; +} + +/// Строка чека. Количество дробное (весовой/разлив). Скидка — абсолютная +/// сумма на строку (как на сервере RetailSaleLineInput.Discount). +public sealed class CartLine +{ + public required Guid ProductId { get; init; } + public required string Name { get; init; } + public required string UnitCode { get; init; } + public required bool IsWeighed { get; init; } + public required decimal UnitPrice { get; set; } + public decimal Quantity { get; set; } = 1m; + public decimal Discount { get; set; } + public decimal VatPercent { get; set; } + + /// Сумма строки = цена×кол-во − скидка (не меньше нуля). + public decimal LineTotal => Math.Max(0m, decimal.Round(UnitPrice * Quantity, 2) - Discount); +} + +/// Состояние открытой смены (локальная проекция серверной CashShift). +public sealed class PosShift +{ + public required Guid Id { get; init; } + public required string Number { get; init; } + public required Guid RetailPointId { get; init; } + public required Guid CashAccountId { get; init; } + public Guid? AcquiringAccountId { get; init; } + public required decimal OpeningFloat { get; init; } + public DateTime OpenedAt { get; init; } = DateTime.UtcNow; +} + +/// Статус записи в очереди синхронизации продаж. +public enum QueuedSaleStatus { Pending = 0, Sent = 1, Failed = 2 } diff --git a/src/food-market.pos.core/Sync/IServerApi.cs b/src/food-market.pos.core/Sync/IServerApi.cs new file mode 100644 index 0000000..339ca5b --- /dev/null +++ b/src/food-market.pos.core/Sync/IServerApi.cs @@ -0,0 +1,58 @@ +using foodmarket.Shared.Pos.V1; +using Refit; + +namespace foodmarket.Pos.Core.Sync; + +/// Серверный API, который дёргает касса. Авторизация — через +/// DelegatingHandler (Bearer из токен-стора + refresh). Версия POS-API закреплена +/// заголовком. Смены/деньги — общий серверный модуль «Деньги». +[Headers("X-Pos-Api-Version: 1")] +public interface IServerApi +{ + [Get("/api/pos/v1/sync")] + Task SyncAsync([Query] DateTime? since, CancellationToken ct = default); + + [Post("/api/pos/v1/sales")] + Task AcceptSalesAsync([Body] PosSaleBatchDto batch, CancellationToken ct = default); + + [Get("/api/pos/v1/version")] + Task VersionAsync(CancellationToken ct = default); + + [Get("/api/me")] + Task MeAsync(CancellationToken ct = default); + + [Get("/api/catalog/retail-points")] + Task> RetailPointsAsync([Query] int pageSize = 200, CancellationToken ct = default); + + [Get("/api/money/accounts")] + Task> MoneyAccountsAsync([Query] int pageSize = 200, CancellationToken ct = default); + + [Get("/api/money/shifts/current")] + Task> CurrentShiftAsync([Query] Guid retailPointId, CancellationToken ct = default); + + [Post("/api/money/shifts/open")] + Task OpenShiftAsync([Body] OpenShiftBody body, CancellationToken ct = default); + + [Post("/api/money/shifts/{id}/close")] + Task CloseShiftAsync(Guid id, [Body] CloseShiftBody body, CancellationToken ct = default); + + [Post("/api/money/shifts/{id}/drawer")] + Task DrawerAsync(Guid id, [Body] DrawerBody body, CancellationToken ct = default); + + [Get("/api/money/shifts/{id}/report")] + Task ShiftReportAsync(Guid id, CancellationToken ct = default); +} + +public record MeDto(Guid? orgId, string[] roles, bool advancedMode, string? name, string? email, string sub); +public record PagedDto(IReadOnlyList items, int total); +public record RetailPointDto(Guid id, string name, string? code, Guid? storeId, bool isActive); +public record MoneyAccountDto(Guid id, string name, int kind, Guid? storeId, bool isActive); +public record CurrentShiftDto(bool open, ShiftDto? shift, bool? isMine); +public record ShiftDto(Guid id, string number, Guid retailPointId, Guid cashAccountId, Guid? acquiringAccountId, decimal openingFloat); +public record OpenShiftBody(Guid RetailPointId, Guid CashAccountId, Guid? AcquiringAccountId, decimal OpeningFloat); +public record OpenShiftResultDto(Guid id, string number); +public record CloseShiftBody(decimal ActualCash, string? Notes); +public record CloseShiftResultDto(decimal expectedCash, decimal difference); +public record DrawerBody(int Direction, decimal Amount, string Reason); // 1=In, 2=Out +public record ShiftReportDto(string number, decimal openingFloat, ShiftSales sales, ShiftSales returns, decimal expectedCash, decimal? actualCash, decimal cashDifference); +public record ShiftSales(decimal cash, decimal card); diff --git a/src/food-market.pos.core/Sync/SyncService.cs b/src/food-market.pos.core/Sync/SyncService.cs new file mode 100644 index 0000000..f2a28ca --- /dev/null +++ b/src/food-market.pos.core/Sync/SyncService.cs @@ -0,0 +1,52 @@ +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); + } +} diff --git a/src/food-market.pos.core/food-market.pos.core.csproj b/src/food-market.pos.core/food-market.pos.core.csproj index ca19f49..d7b27eb 100644 --- a/src/food-market.pos.core/food-market.pos.core.csproj +++ b/src/food-market.pos.core/food-market.pos.core.csproj @@ -10,8 +10,7 @@ - - + diff --git a/tests/food-market.UnitTests/PosCoreTests.cs b/tests/food-market.UnitTests/PosCoreTests.cs new file mode 100644 index 0000000..a6cd834 --- /dev/null +++ b/tests/food-market.UnitTests/PosCoreTests.cs @@ -0,0 +1,377 @@ +using FluentAssertions; +using foodmarket.Pos.Core.Data; +using foodmarket.Pos.Core.Logic; +using foodmarket.Pos.Core.Models; +using foodmarket.Pos.Core.Sync; +using foodmarket.Shared.Pos.V1; +using Xunit; + +namespace foodmarket.UnitTests; + +public class PosBarcodeTests +{ + [Fact] + public void Normal_barcode_is_not_weighted() + { + var r = BarcodeParser.Parse("4870204010101"); + r.IsWeighted.Should().BeFalse(); + r.Lookup.Should().Be("4870204010101"); + } + + [Fact] + public void Weight_prefix_20_extracts_plu_and_kg() + { + // 21 | 12345 | 01500 | K → PLU=12345, вес 1.500 кг + var r = BarcodeParser.Parse("2112345015007"); + r.IsWeighted.Should().BeTrue(); + r.Lookup.Should().Be("12345"); + r.EmbeddedWeight.Should().Be(1.500m); + r.EmbeddedPrice.Should().BeNull(); + } + + [Fact] + public void Price_prefix_25_extracts_plu_and_price() + { + // 25 | 54321 | 12345 | K → PLU=54321, цена 123.45 ₸ + var r = BarcodeParser.Parse("2554321123456"); + r.IsWeighted.Should().BeTrue(); + r.Lookup.Should().Be("54321"); + r.EmbeddedPrice.Should().Be(123.45m); + r.EmbeddedWeight.Should().BeNull(); + } +} + +public class PosCartTests +{ + private static PosProduct Piece(decimal price = 500m) => new() + { Id = Guid.NewGuid(), Name = "Шт товар", Barcodes = new[] { "111" }, UnitCode = "шт", Packaging = PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price }; + private static PosProduct Weighed(decimal price = 1000m) => new() + { Id = Guid.NewGuid(), Name = "Вес товар", Barcodes = new[] { "222" }, UnitCode = "кг", Packaging = PosPackaging.Weight, VatPercent = 12m, VatEnabled = true, Price = price }; + + [Fact] + public void Piece_scans_increment_quantity() + { + var c = new Cart(); var p = Piece(); + c.AddProduct(p); c.AddProduct(p); c.AddProduct(p); + c.Lines.Should().HaveCount(1); + c.Lines[0].Quantity.Should().Be(3); + c.Totals().Total.Should().Be(1500m); + } + + [Fact] + public void Weighed_requires_weight_and_is_separate_line() + { + var c = new Cart(); var w = Weighed(1000m); + c.AddProduct(w, 1.5m); + c.AddProduct(w, 0.25m); + c.Lines.Should().HaveCount(2); + c.Totals().Total.Should().Be(1500m + 250m); + } + + [Fact] + public void Line_and_cart_discounts_apply_and_clamp() + { + var c = new Cart(); var p = Piece(500m); + var line = c.AddProduct(p, 4m); // 2000 + c.SetLineDiscount(line, 300m); // 1700 + c.SetCartDiscount(200m); // 1500 + c.Totals().Total.Should().Be(1500m); + // перебор скидки обрезается + c.SetLineDiscount(line, 999999m); + c.Lines[0].Discount.Should().Be(2000m); + } + + [Fact] + public void Remove_and_clear() + { + var c = new Cart(); var line = c.AddProduct(Piece()); + c.Remove(line); c.IsEmpty.Should().BeTrue(); + } +} + +public class PosPaymentShiftTests +{ + [Fact] + public void Cash_overpay_gives_change() + { + var r = PaymentMath.Compute(1500m, paidCash: 2000m, paidCard: 0m); + r.Sufficient.Should().BeTrue(); + r.Change.Should().Be(500m); + } + + [Fact] + public void Mixed_exact_no_change() + { + var r = PaymentMath.Compute(1500m, paidCash: 500m, paidCard: 1000m); + r.Sufficient.Should().BeTrue(); + r.Change.Should().Be(0m); + } + + [Fact] + public void Underpay_is_insufficient_with_shortfall() + { + var r = PaymentMath.Compute(1500m, paidCash: 1000m, paidCard: 0m); + r.Sufficient.Should().BeFalse(); + r.Shortfall.Should().Be(500m); + } + + [Fact] + public void Shift_expected_and_difference() + { + var exp = ShiftMath.ExpectedCash(5000m, 3775m, 1500m, 10000m, 3000m); + exp.Should().Be(14275m); + ShiftMath.CashDifference(14775m, exp).Should().Be(500m); + } +} + +public class PosStoreTests +{ + private static PosSyncResponse Sync(params (Guid id, string name, string bc, decimal price, int pack)[] items) => new() + { + ServerTime = DateTime.UtcNow, + Products = items.Select(i => new ProductSyncDto + { + Id = i.id, Name = i.name, Barcodes = new[] { i.bc }, UnitCode = i.pack == 2 ? "кг" : "шт", + Packaging = i.pack, VatPercent = 12m, VatEnabled = true, IsMarked = false, IsArchived = false, UpdatedAt = DateTime.UtcNow, + }).ToList(), + Prices = items.Select(i => new PriceSyncDto + { + ProductId = i.id, PriceTypeId = Guid.NewGuid(), PriceTypeName = "Розн", IsSystem = true, + Amount = i.price, CurrencyCode = "KZT", UpdatedAt = DateTime.UtcNow, + }).ToList(), + Stocks = Array.Empty(), Counterparties = Array.Empty(), DeletedProductIds = Array.Empty(), + }; + + [Fact] + public void Catalog_find_by_barcode_and_name() + { + using var s = PosStore.InMemory(); + var id = Guid.NewGuid(); + s.ApplySync(Sync((id, "Молоко 2.5%", "4870001112223", 450m, 1))); + s.ProductCount().Should().Be(1); + s.FindByBarcode("4870001112223")!.Name.Should().Be("Молоко 2.5%"); + s.FindByBarcode("4870001112223")!.Price.Should().Be(450m); + s.SearchByName("молок").Should().ContainSingle(); + } + + [Fact] + public void Queue_enqueue_pending_marksent() + { + using var s = PosStore.InMemory(); + var sale = new PosSaleDto + { + ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, Payment = 0, PaidCash = 100m, PaidCard = 0m, + Lines = new[] { new PosSaleLineDto { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 100m, Discount = 0m } }, + }; + s.EnqueueSale(sale); + s.EnqueueSale(sale); // повтор не дублирует (PK ClientSaleId) + s.PendingCount().Should().Be(1); + s.MarkSent(sale.ClientSaleId); + s.PendingCount().Should().Be(0); + } +} + +public class PosSyncKeyTests +{ + [Fact] + public void Batch_key_is_stable_for_same_set_regardless_of_order() + { + var a = Guid.NewGuid(); var b = Guid.NewGuid(); + SyncService.StableBatchKey(new[] { a, b }).Should().Be(SyncService.StableBatchKey(new[] { b, a })); + } + + [Fact] + public void Batch_key_differs_for_different_sets() + { + SyncService.StableBatchKey(new[] { Guid.NewGuid() }) + .Should().NotBe(SyncService.StableBatchKey(new[] { Guid.NewGuid() })); + } +} + +/// Офлайн-сценарий задачи (п.8): нет сети → кассир пробивает 5 чеков → +/// появляется сеть → синхронизация → ровно 5 продаж на «сервере», без дублей +/// даже при повторном пуше. Прогоняется против фейкового IServerApi, который +/// повторяет серверную идемпотентность по ClientSaleId. +public class PosOfflineFlowTests +{ + /// Фейковый сервер: хранит принятые продажи по ClientSaleId. + /// Повторный приём той же продажи не создаёт дубля (как реальный сервер). + private sealed class FakeServer : IServerApi + { + public readonly Dictionary ServerByClient = new(); + public int BatchCalls; + public bool Online; + + public Task AcceptSalesAsync(PosSaleBatchDto batch, CancellationToken ct = default) + { + if (!Online) throw new HttpRequestException("нет сети"); + BatchCalls++; + bool anyNew = false; + var accepted = new List(); + foreach (var s in batch.Sales) + { + if (!ServerByClient.TryGetValue(s.ClientSaleId, out var sid)) + { sid = Guid.NewGuid(); ServerByClient[s.ClientSaleId] = sid; anyNew = true; } + accepted.Add(new PosSaleAcceptedDto { ClientSaleId = s.ClientSaleId, ServerSaleId = sid, ServerSaleNumber = $"ПР-{sid:N}".Substring(0, 10) }); + } + return Task.FromResult(new PosSaleBatchResponse + { + IdempotencyKey = batch.IdempotencyKey, Accepted = accepted, + Failed = Array.Empty(), ReplayedFromCache = !anyNew, + }); + } + + public Task VersionAsync(CancellationToken ct = default) => throw new NotImplementedException(); + public Task SyncAsync(DateTime? since, CancellationToken ct = default) => throw new NotImplementedException(); + public Task MeAsync(CancellationToken ct = default) => throw new NotImplementedException(); + public Task> RetailPointsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException(); + public Task> MoneyAccountsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException(); + public Task> CurrentShiftAsync(Guid retailPointId, CancellationToken ct = default) => throw new NotImplementedException(); + public Task OpenShiftAsync(OpenShiftBody body, CancellationToken ct = default) => throw new NotImplementedException(); + public Task CloseShiftAsync(Guid id, CloseShiftBody body, CancellationToken ct = default) => throw new NotImplementedException(); + public Task DrawerAsync(Guid id, DrawerBody body, CancellationToken ct = default) => throw new NotImplementedException(); + public Task ShiftReportAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); + } + + private static PosSaleDto Sale() => new() + { + ClientSaleId = Guid.NewGuid(), OccurredAt = DateTime.UtcNow, + Payment = 0, PaidCash = 500m, PaidCard = 0m, + Lines = new[] { new PosSaleLineDto { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 500m, Discount = 0m } }, + }; + + [Fact] + public async Task No_network_then_five_sales_then_sync_yields_exactly_five_no_duplicates() + { + using var store = PosStore.InMemory(); + var server = new FakeServer { Online = false }; + var sync = new SyncService(store, server); + + // 1) Сети нет — попытка пуша кидает, очередь не теряется. + store.EnqueueSale(Sale()); + await Assert.ThrowsAnyAsync(() => sync.PushSalesAsync()); + server.ServerByClient.Should().BeEmpty("офлайн — на сервер ничего не ушло"); + + // 2) Кассир пробивает ещё 4 чека офлайн — всего 5 в очереди. + for (int i = 0; i < 4; i++) store.EnqueueSale(Sale()); + store.PendingCount().Should().Be(5); + + // 3) Появилась сеть — синхронизация. + server.Online = true; + var push = await sync.PushSalesAsync(); + push.Accepted.Should().Be(5); + push.Failed.Should().Be(0); + store.PendingCount().Should().Be(0, "все отправленные помечены sent"); + server.ServerByClient.Should().HaveCount(5, "на сервере ровно 5 чеков"); + + // 4) Повторный пуш (например, двойной клик/ретрай) не плодит дублей. + var replay = await sync.PushSalesAsync(); + replay.Accepted.Should().Be(0, "очередь уже пуста — слать нечего"); + server.ServerByClient.Should().HaveCount(5, "по-прежнему ровно 5"); + } + + [Fact] + public async Task Resend_of_same_queue_is_idempotent_on_server() + { + using var store = PosStore.InMemory(); + var server = new FakeServer { Online = true }; + var sync = new SyncService(store, server); + for (int i = 0; i < 5; i++) store.EnqueueSale(Sale()); + + // Эмулируем: пуш прошёл на сервере, но ответ не дошёл (касса не пометила sent). + var pending = store.PendingSales(); + await server.AcceptSalesAsync(new PosSaleBatchDto { IdempotencyKey = SyncService.StableBatchKey(pending.Select(s => s.ClientSaleId)), Sales = pending }); + server.ServerByClient.Should().HaveCount(5); + + // Касса повторяет весь набор — сервер распознаёт по ClientSaleId, дублей нет. + var push = await sync.PushSalesAsync(); + push.Accepted.Should().Be(5); + server.ServerByClient.Should().HaveCount(5, "повтор того же набора не создал новых чеков"); + store.PendingCount().Should().Be(0); + } +} + +/// Сборка серверного чека из корзины: распределение скидки на чек по +/// строкам (сервер понимает только построчную скидку), способ оплаты, привязка +/// к смене/точке, возвраты. +public class PosCheckoutTests +{ + private static PosProduct P(decimal price, bool weighed = false) => new() + { + Id = Guid.NewGuid(), Name = "Товар", Barcodes = new[] { "1" }, UnitCode = weighed ? "кг" : "шт", + Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price, + }; + + [Fact] + public void Cart_discount_is_distributed_across_lines_and_sums_exactly() + { + var cart = new Cart(); + cart.AddProduct(P(100m), 1); // 100 + cart.AddProduct(P(200m), 1); // 200 + cart.AddProduct(P(700m), 1); // 700 → сумма 1000 + cart.SetCartDiscount(100m); // 10% на чек + + var sale = Checkout.BuildSale(cart, paidCash: 900m, paidCard: 0m, + new CheckoutContext(null, null, null)); + + // Сумма построчных скидок точно равна скидке на чек. + sale.Lines.Sum(l => l.Discount).Should().Be(100m); + // Итог сервера (Σ qty*price − Σ disc) совпадает с итогом кассы. + var serverTotal = sale.Lines.Sum(l => l.Quantity * l.UnitPrice - l.Discount); + serverTotal.Should().Be(cart.Totals().Total); + } + + [Fact] + public void Payment_method_is_inferred_from_cash_and_card() + { + var cart = new Cart(); cart.AddProduct(P(500m), 1); + Checkout.BuildSale(cart, 500m, 0m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Cash); + Checkout.BuildSale(cart, 0m, 500m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Card); + Checkout.BuildSale(cart, 200m, 300m, new(null, null, null)).Payment.Should().Be((int)PosPaymentMethod.Mixed); + } + + [Fact] + public void Shift_and_point_context_is_carried_into_sale() + { + var cart = new Cart(); cart.AddProduct(P(500m), 1); + var rp = Guid.NewGuid(); var shift = Guid.NewGuid(); var cashier = Guid.NewGuid(); + var sale = Checkout.BuildSale(cart, 500m, 0m, new(cashier, rp, shift)); + sale.RetailPointId.Should().Be(rp); + sale.CashShiftId.Should().Be(shift); + sale.CashierUserId.Should().Be(cashier); + sale.IsReturn.Should().BeFalse(); + } + + [Fact] + public void Return_sets_flag_and_reference() + { + var cart = new Cart(); cart.AddProduct(P(500m), 1); + var refSale = Guid.NewGuid(); + var sale = Checkout.BuildSale(cart, 0m, 0m, new(null, null, null), isReturn: true, referenceSaleId: refSale); + sale.IsReturn.Should().BeTrue(); + sale.ReferenceSaleId.Should().Be(refSale); + } + + [Fact] + public void Empty_cart_throws() + { + var cart = new Cart(); + var act = () => Checkout.BuildSale(cart, 0m, 0m, new(null, null, null)); + act.Should().Throw(); + } +} + +public class PosUpdateCheckTests +{ + [Theory] + [InlineData("2026.10.7", "2026.10.8", true)] + [InlineData("2026.10.8", "2026.10.8", false)] + [InlineData("2026.10.8", "2026.10.7", false)] + [InlineData("1.9.0", "1.10.0", true)] + [InlineData("2026.9.30", "2026.10.1", true)] + [InlineData(null, "2026.10.1", true)] + [InlineData("2026.10.1", null, false)] + [InlineData("1.2.3", "1.2.3-beta", false)] + public void Version_comparison(string? local, string? remote, bool expected) + => UpdateCheck.IsNewer(local, remote).Should().Be(expected); +} diff --git a/tests/food-market.UnitTests/food-market.UnitTests.csproj b/tests/food-market.UnitTests/food-market.UnitTests.csproj index bb3e72b..f790d32 100644 --- a/tests/food-market.UnitTests/food-market.UnitTests.csproj +++ b/tests/food-market.UnitTests/food-market.UnitTests.csproj @@ -16,6 +16,7 @@ +