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:
parent
606b81b4d9
commit
2b63bc9c66
|
|
@ -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" />
|
||||
|
|
|
|||
65
src/food-market.pos.core/Auth/AuthService.cs
Normal file
65
src/food-market.pos.core/Auth/AuthService.cs
Normal 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) { } }
|
||||
76
src/food-market.pos.core/Auth/TokenStore.cs
Normal file
76
src/food-market.pos.core/Auth/TokenStore.cs
Normal 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;
|
||||
}
|
||||
241
src/food-market.pos.core/Data/PosStore.cs
Normal file
241
src/food-market.pos.core/Data/PosStore.cs
Normal 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();
|
||||
}
|
||||
53
src/food-market.pos.core/Logic/BarcodeParser.cs
Normal file
53
src/food-market.pos.core/Logic/BarcodeParser.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
78
src/food-market.pos.core/Logic/Cart.cs
Normal file
78
src/food-market.pos.core/Logic/Cart.cs
Normal 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;
|
||||
}
|
||||
82
src/food-market.pos.core/Logic/Checkout.cs
Normal file
82
src/food-market.pos.core/Logic/Checkout.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/food-market.pos.core/Logic/PaymentMath.cs
Normal file
31
src/food-market.pos.core/Logic/PaymentMath.cs
Normal 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);
|
||||
}
|
||||
35
src/food-market.pos.core/Logic/UpdateCheck.cs
Normal file
35
src/food-market.pos.core/Logic/UpdateCheck.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
54
src/food-market.pos.core/Models/PosModels.cs
Normal file
54
src/food-market.pos.core/Models/PosModels.cs
Normal 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 }
|
||||
58
src/food-market.pos.core/Sync/IServerApi.cs
Normal file
58
src/food-market.pos.core/Sync/IServerApi.cs
Normal 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);
|
||||
52
src/food-market.pos.core/Sync/SyncService.cs
Normal file
52
src/food-market.pos.core/Sync/SyncService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
377
tests/food-market.UnitTests/PosCoreTests.cs
Normal file
377
tests/food-market.UnitTests/PosCoreTests.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue