feat(pos-core): логика кассы без UI + 37 unit-тестов

- Модели (товар/корзина/строка/смена), парсер весовых ШК (EAN-13 20–29),
  корзина (штучный инкремент, весовой, скидки строка/чек с клампом), математика
  оплаты и смены (должно/факт/разница).
- PosStore на raw Microsoft.Data.Sqlite (быстрый старт, AOT-дружелюбно): каталог
  + штрихкоды + очередь продаж + kv; индексы по штрихкоду/NameLower (кириллица
  не сворачивается через LIKE) и barcodes.ProductId (дельта-синк O(n), не O(n²)).
- SyncService: дельта-pull каталога + push очереди с детерминированным
  idempotency-ключом (MD5 отсортированных ClientSaleId → один набор = один ключ).
- Auth: password/refresh grant, ITokenStore (FileTokenStore 0600 на Unix,
  InMemory для тестов). Checkout.BuildSale: сборка серверного чека, скидка на чек
  распределяется по строкам (сервер понимает только построчную). UpdateCheck.
- Тесты: штрихкод/корзина/оплата/смена/стор/синк-ключ/checkout/офлайн(5 без
  дублей)/версия — 37 зелёных.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 04:58:22 +05:00
parent 606b81b4d9
commit 2b63bc9c66
15 changed files with 1212 additions and 2 deletions

View file

@ -62,7 +62,15 @@
<PackageVersion Include="Polly" Version="8.5.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<!-- Avalonia 11 — кроссплатформенная касса (Windows/Linux), MIT. -->
<PackageVersion Include="Avalonia" Version="11.1.0" />
<PackageVersion Include="Avalonia.Desktop" Version="11.1.0" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.1.0" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.1.0" />
<PackageVersion Include="Avalonia.Headless" Version="11.1.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.11" />
<PackageVersion Include="System.IO.Ports" Version="8.0.0" />
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<!-- Testing -->
<PackageVersion Include="xunit" Version="2.9.3" />

View file

@ -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);
/// <summary>Вход кассира (password grant) и обновление токена (refresh) против
/// OpenIddict-сервера food-market. Касса использует тот же client_id,
/// что и веб. PIN поддерживается как пароль кассира (PIN = пароль учётки).</summary>
public sealed class AuthService
{
private readonly HttpClient _http;
public AuthService(HttpClient http) { _http = http; }
public async Task<AuthTokens> LoginAsync(string serverUrl, string username, string password, CancellationToken ct = default)
{
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["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<AuthTokens> RefreshAsync(string serverUrl, string refreshToken, CancellationToken ct = default)
{
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["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) { } }

View file

@ -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();
}
/// <summary>Кроссплатформенное хранение сессии в файле. На Unix выставляем права
/// 0600 (только владелец). На Windows полагаемся на ACL пользовательского профиля;
/// усиленный вариант (DPAPI) — в приложении под Windows (DpapiTokenStore).
/// Содержит refresh-токен — файл не логируем и не коммитим.</summary>
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<PosSession>(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;
}
/// <summary>Токен-стор в памяти — для тестов/демо (без диска).</summary>
public sealed class InMemoryTokenStore : ITokenStore
{
private PosSession? _session;
public void Save(PosSession session) => _session = session;
public PosSession? Load() => _session;
public void Clear() => _session = null;
}

View file

@ -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;
/// <summary>Локальное хранилище кассы на SQLite (Microsoft.Data.Sqlite напрямую —
/// без EF: быстрый холодный старт и дружелюбность к Native AOT). Хранит каталог
/// (товары+штрихкоды с индексом), очередь продаж и key-value (курсор синка, смена).
/// Потокобезопасность: один connection, операции короткие; UI-поток дёргает через await Task.Run.</summary>
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<PosProduct> SearchByName(string term, int limit = 50)
{
term = (term ?? string.Empty).Trim();
if (term.Length == 0) return Array.Empty<PosProduct>();
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<PosProduct>();
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<PosSaleDto> 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<PosSaleDto>();
while (r.Read()) list.Add(JsonSerializer.Deserialize<PosSaleDto>(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<string>(),
};
}
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<string, SqliteParameter> AddParams(SqliteCommand cmd, params string[] names)
{
var d = new Dictionary<string, SqliteParameter>(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();
}

View file

@ -0,0 +1,53 @@
namespace foodmarket.Pos.Core.Logic;
/// <summary>Результат разбора штрихкода.</summary>
public readonly record struct ScannedBarcode(
string Raw,
string Lookup, // код для поиска товара (для весовых — встроенный код товара)
bool IsWeighted,
decimal? EmbeddedWeight, // кг (для EAN-13 с весом)
decimal? EmbeddedPrice); // ₸ (для EAN-13 с ценой)
/// <summary>Разбор весовых EAN-13 с префиксом 20–29 (внутримагазинные коды).
///
/// Формат (РФ/РК, распространённый): <c>PP CCCCC Q QQQQ K</c> —
/// 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 цифр, без лидирующих нулей
/// не режем — каталог сам сматчит по хранимому коду).</summary>
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);
}
}

View file

@ -0,0 +1,78 @@
using foodmarket.Pos.Core.Models;
namespace foodmarket.Pos.Core.Logic;
/// <summary>Итоги чека.</summary>
public readonly record struct CartTotals(decimal Subtotal, decimal LineDiscounts, decimal CartDiscount, decimal Total, decimal Vat, int LineCount, decimal Quantity);
/// <summary>Корзина (чек) — чистая логика, без UI. Штучный товар инкрементится,
/// весовой — задаётся количеством (вес). Скидки: на строку (абсолютная) и на
/// весь чек (абсолютная, поверх строчных). Итог не уходит ниже нуля.</summary>
public sealed class Cart
{
private readonly List<CartLine> _lines = new();
public IReadOnlyList<CartLine> Lines => _lines;
public decimal CartDiscount { get; private set; }
/// <summary>Добавить товар. Для весового qty = вес (обязателен),
/// для штучного — прибавка к количеству (по умолчанию 1).</summary>
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;
}

View file

@ -0,0 +1,82 @@
using foodmarket.Pos.Core.Models;
using foodmarket.Shared.Pos.V1;
namespace foodmarket.Pos.Core.Logic;
/// <summary>Контекст чека: кто пробивает, на какой точке и в какой смене.
/// Проставляется из сессии кассира и открытой смены.</summary>
public sealed record CheckoutContext(Guid? CashierUserId, Guid? RetailPointId, Guid? CashShiftId);
/// <summary>Сборка серверного чека (PosSaleDto) из корзины — чистая логика.
/// Ключевой момент: сервер учитывает скидку только построчно, поэтому скидку
/// на чек распределяем по строкам пропорционально их сумме (остаток округления
/// падает на последнюю строку), чтобы итог сервера совпал с итогом кассы.</summary>
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(),
};
}
/// <summary>Итоговая скидка по каждой строке = строчная скидка + доля скидки
/// на чек (пропорционально сумме строки). Сумма долей точно равна скидке на
/// чек: остаток от округления добавляется к последней непустой строке.</summary>
private static decimal[] DistributeCartDiscount(IReadOnlyList<CartLine> 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;
}
}

View file

@ -0,0 +1,31 @@
namespace foodmarket.Pos.Core.Logic;
public readonly record struct PaymentResult(bool Sufficient, decimal Paid, decimal Due, decimal Change, decimal Shortfall);
/// <summary>Расчёт оплаты и сдачи. Нал + карта должны покрыть итог; переплата —
/// сдача (всегда наличными). Недоплата — чек провести нельзя.</summary>
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);
}
}
/// <summary>Расчёт смены (зеркало серверного MoneyMath — чтобы касса показывала
/// «должно быть/факт/разница» офлайн, до подтверждения сервером).</summary>
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);
}

View file

@ -0,0 +1,35 @@
namespace foodmarket.Pos.Core.Logic;
/// <summary>Сравнение версий кассы для автообновления. Версия — набор чисел
/// через точку (напр. 2026.10.8 или 1.2.3); сравниваются покомпонентно.
/// Чистая логика — тестируется.</summary>
public static class UpdateCheck
{
/// <summary>true — <paramref name="remote"/> новее <paramref name="local"/>.</summary>
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<int>();
var parts = v.Trim().Split('.', '-', '+');
var nums = new List<int>();
foreach (var p in parts)
{
if (int.TryParse(p, out var x)) nums.Add(x);
else break; // на первом нечисловом сегменте останавливаемся (напр. «-beta»)
}
return nums.ToArray();
}
}

View file

@ -0,0 +1,54 @@
namespace foodmarket.Pos.Core.Models;
/// <summary>Тип упаковки (зеркало Domain.Packaging): штучный/весовой/разливной.</summary>
public enum PosPackaging { Piece = 1, Weight = 2, Liquid = 3 }
public enum PosPaymentMethod { Cash = 0, Card = 1, BankTransfer = 2, Bonus = 3, Mixed = 99 }
/// <summary>Товар в локальном каталоге кассы (из ProductSyncDto + системная цена).</summary>
public sealed class PosProduct
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public string? Article { get; init; }
public required IReadOnlyList<string> 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;
}
/// <summary>Строка чека. Количество дробное (весовой/разлив). Скидка — абсолютная
/// сумма на строку (как на сервере RetailSaleLineInput.Discount).</summary>
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; }
/// <summary>Сумма строки = цена×кол-во − скидка (не меньше нуля).</summary>
public decimal LineTotal => Math.Max(0m, decimal.Round(UnitPrice * Quantity, 2) - Discount);
}
/// <summary>Состояние открытой смены (локальная проекция серверной CashShift).</summary>
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;
}
/// <summary>Статус записи в очереди синхронизации продаж.</summary>
public enum QueuedSaleStatus { Pending = 0, Sent = 1, Failed = 2 }

View file

@ -0,0 +1,58 @@
using foodmarket.Shared.Pos.V1;
using Refit;
namespace foodmarket.Pos.Core.Sync;
/// <summary>Серверный API, который дёргает касса. Авторизация — через
/// DelegatingHandler (Bearer из токен-стора + refresh). Версия POS-API закреплена
/// заголовком. Смены/деньги — общий серверный модуль «Деньги».</summary>
[Headers("X-Pos-Api-Version: 1")]
public interface IServerApi
{
[Get("/api/pos/v1/sync")]
Task<PosSyncResponse> SyncAsync([Query] DateTime? since, CancellationToken ct = default);
[Post("/api/pos/v1/sales")]
Task<PosSaleBatchResponse> AcceptSalesAsync([Body] PosSaleBatchDto batch, CancellationToken ct = default);
[Get("/api/pos/v1/version")]
Task<PosVersionDto> VersionAsync(CancellationToken ct = default);
[Get("/api/me")]
Task<MeDto> MeAsync(CancellationToken ct = default);
[Get("/api/catalog/retail-points")]
Task<PagedDto<RetailPointDto>> RetailPointsAsync([Query] int pageSize = 200, CancellationToken ct = default);
[Get("/api/money/accounts")]
Task<PagedDto<MoneyAccountDto>> MoneyAccountsAsync([Query] int pageSize = 200, CancellationToken ct = default);
[Get("/api/money/shifts/current")]
Task<ApiResponse<CurrentShiftDto>> CurrentShiftAsync([Query] Guid retailPointId, CancellationToken ct = default);
[Post("/api/money/shifts/open")]
Task<OpenShiftResultDto> OpenShiftAsync([Body] OpenShiftBody body, CancellationToken ct = default);
[Post("/api/money/shifts/{id}/close")]
Task<CloseShiftResultDto> 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<ShiftReportDto> ShiftReportAsync(Guid id, CancellationToken ct = default);
}
public record MeDto(Guid? orgId, string[] roles, bool advancedMode, string? name, string? email, string sub);
public record PagedDto<T>(IReadOnlyList<T> 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);

View file

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

View file

@ -10,8 +10,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Refit" />
<PackageReference Include="Refit.HttpClientFactory" />

View file

@ -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<StockSyncDto>(), Counterparties = Array.Empty<CounterpartySyncDto>(), DeletedProductIds = Array.Empty<Guid>(),
};
[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() }));
}
}
/// <summary>Офлайн-сценарий задачи (п.8): нет сети → кассир пробивает 5 чеков →
/// появляется сеть → синхронизация → ровно 5 продаж на «сервере», без дублей
/// даже при повторном пуше. Прогоняется против фейкового IServerApi, который
/// повторяет серверную идемпотентность по ClientSaleId.</summary>
public class PosOfflineFlowTests
{
/// <summary>Фейковый сервер: хранит принятые продажи по ClientSaleId.
/// Повторный приём той же продажи не создаёт дубля (как реальный сервер).</summary>
private sealed class FakeServer : IServerApi
{
public readonly Dictionary<Guid, Guid> ServerByClient = new();
public int BatchCalls;
public bool Online;
public Task<PosSaleBatchResponse> AcceptSalesAsync(PosSaleBatchDto batch, CancellationToken ct = default)
{
if (!Online) throw new HttpRequestException("нет сети");
BatchCalls++;
bool anyNew = false;
var accepted = new List<PosSaleAcceptedDto>();
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<PosSaleFailedDto>(), ReplayedFromCache = !anyNew,
});
}
public Task<PosVersionDto> VersionAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<PosSyncResponse> SyncAsync(DateTime? since, CancellationToken ct = default) => throw new NotImplementedException();
public Task<MeDto> MeAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task<PagedDto<RetailPointDto>> RetailPointsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException();
public Task<PagedDto<MoneyAccountDto>> MoneyAccountsAsync(int pageSize = 200, CancellationToken ct = default) => throw new NotImplementedException();
public Task<Refit.ApiResponse<CurrentShiftDto>> CurrentShiftAsync(Guid retailPointId, CancellationToken ct = default) => throw new NotImplementedException();
public Task<OpenShiftResultDto> OpenShiftAsync(OpenShiftBody body, CancellationToken ct = default) => throw new NotImplementedException();
public Task<CloseShiftResultDto> 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<ShiftReportDto> 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<Exception>(() => 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);
}
}
/// <summary>Сборка серверного чека из корзины: распределение скидки на чек по
/// строкам (сервер понимает только построчную скидку), способ оплаты, привязка
/// к смене/точке, возвраты.</summary>
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<InvalidOperationException>();
}
}
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);
}

View file

@ -16,6 +16,7 @@
<ProjectReference Include="..\..\src\food-market.infrastructure\food-market.infrastructure.csproj" />
<ProjectReference Include="..\..\src\food-market.api\food-market.api.csproj" />
<ProjectReference Include="..\..\src\food-market.shared\food-market.shared.csproj" />
<ProjectReference Include="..\..\src\food-market.pos.core\food-market.pos.core.csproj" />
</ItemGroup>
<ItemGroup>