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 @@
+