diff --git a/Directory.Packages.props b/Directory.Packages.props
index 27d447b..bb883d5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -59,6 +59,7 @@
+
diff --git a/food-market.sln b/food-market.sln
index 9fdd449..478408a 100644
--- a/food-market.sln
+++ b/food-market.sln
@@ -19,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos.core", "src
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.pos", "src\food-market.pos\food-market.pos.csproj", "{B178B74E-A739-4722-BFA8-D9AB694024BB}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F29E3026-31A5-4277-A265-081E87C76A28}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "food-market.UnitTests", "tests\food-market.UnitTests\food-market.UnitTests.csproj", "{D8556BE1-70E3-49AD-84FD-209C80B17B57}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -56,6 +60,10 @@ Global
{B178B74E-A739-4722-BFA8-D9AB694024BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B178B74E-A739-4722-BFA8-D9AB694024BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B178B74E-A739-4722-BFA8-D9AB694024BB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D8556BE1-70E3-49AD-84FD-209C80B17B57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D8556BE1-70E3-49AD-84FD-209C80B17B57}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D8556BE1-70E3-49AD-84FD-209C80B17B57}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{BB7142C2-94F3-423F-938C-A44FF79133C0} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
@@ -65,5 +73,6 @@ Global
{9E075C56-081E-4ABB-8DB3-ED649FD696FA} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
{BF3FBFD2-F40D-4510-8067-37305FFE1D14} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
{B178B74E-A739-4722-BFA8-D9AB694024BB} = {8EAE9F35-BE6B-4B77-A1F4-383EF17D9870}
+ {D8556BE1-70E3-49AD-84FD-209C80B17B57} = {F29E3026-31A5-4277-A265-081E87C76A28}
EndGlobalSection
EndGlobal
diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs
index 7df7d43..1bf8501 100644
--- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs
+++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs
@@ -289,12 +289,9 @@ public async Task Post(Guid id, CancellationToken ct)
.Where(s => s.ProductId == line.ProductId)
.SumAsync(s => (decimal?)s.Quantity, ct) ?? 0m;
- // 1. Cost — скользящее среднее.
- var totalQty = currentQty + line.Quantity;
- var newCost = totalQty == 0m || product.Cost == 0m && currentQty == 0m
- ? line.UnitPrice
- : (currentQty * product.Cost + line.Quantity * line.UnitPrice) / totalQty;
- product.Cost = Math.Round(newCost, 4, MidpointRounding.AwayFromZero);
+ // 1. Cost — скользящее среднее (формула в MovingAverageCost, юнит-тест).
+ product.Cost = foodmarket.Application.Inventory.MovingAverageCost.Compute(
+ currentQty, product.Cost, line.Quantity, line.UnitPrice);
// 2. ReferencePrice — автозаполнение при первой приёмке.
if (product.ReferencePrice is null)
diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs
index d8cb2ff..ca54243 100644
--- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs
+++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs
@@ -308,10 +308,13 @@ public async Task Post(Guid id, CancellationToken ct)
// Сдача — нормально (PaidCash > Total), недоплата — нет. Иначе касса
// может «провести» чек на 5000 ₸, не получив с покупателя ни тенге.
// Округление до 2 знаков защищает от floating-point дрейфа.
- var paid = decimal.Round(sale.PaidCash + sale.PaidCard, 2);
- var due = decimal.Round(sale.Total, 2);
- if (paid < due)
+ // Правило вынесено в RetailPaymentValidator (юнит-тест). paid/due также
+ // считаем локально для текста ошибки — округление совпадает с валидатором.
+ if (!foodmarket.Application.Sales.RetailPaymentValidator.IsSufficient(
+ sale.PaidCash, sale.PaidCard, sale.Total))
{
+ var paid = decimal.Round(sale.PaidCash + sale.PaidCard, 2);
+ var due = decimal.Round(sale.Total, 2);
return BadRequest(new
{
error = $"Сумма оплаты {paid} меньше итога {due}. Доплатите или измените позиции чека.",
diff --git a/src/food-market.application/Inventory/MovingAverageCost.cs b/src/food-market.application/Inventory/MovingAverageCost.cs
new file mode 100644
index 0000000..f1599d9
--- /dev/null
+++ b/src/food-market.application/Inventory/MovingAverageCost.cs
@@ -0,0 +1,25 @@
+namespace foodmarket.Application.Inventory;
+
+/// Скользящее средневзвешенное себестоимости товара при приёмке.
+///
+/// Себестоимость пересчитывается так, чтобы отражать средневзвешенную цену
+/// всех закупленных единиц: (остаток·текущаяСебестоимость + приход·ценаЗакупки)
+/// / (остаток + приход). На первой приёмке (нет остатка и себестоимости)
+/// берётся цена закупки. Результат округляется до 4 знаков.
+///
+/// Вынесено из SuppliesController.Post для юнит-тестируемости —
+/// контроллер вызывает этот метод.
+public static class MovingAverageCost
+{
+ public static decimal Compute(
+ decimal currentQty, decimal currentCost, decimal incomingQty, decimal incomingUnitPrice)
+ {
+ var totalQty = currentQty + incomingQty;
+ // Порядок важен: && связывает крепче ||, т.е.
+ // (totalQty==0) || ((currentCost==0) && (currentQty==0)).
+ var newCost = totalQty == 0m || currentCost == 0m && currentQty == 0m
+ ? incomingUnitPrice
+ : (currentQty * currentCost + incomingQty * incomingUnitPrice) / totalQty;
+ return Math.Round(newCost, 4, MidpointRounding.AwayFromZero);
+ }
+}
diff --git a/src/food-market.application/Sales/RetailPaymentValidator.cs b/src/food-market.application/Sales/RetailPaymentValidator.cs
new file mode 100644
index 0000000..b847330
--- /dev/null
+++ b/src/food-market.application/Sales/RetailPaymentValidator.cs
@@ -0,0 +1,15 @@
+namespace foodmarket.Application.Sales;
+
+/// Правило достаточности оплаты розничного чека при проведении.
+///
+/// Сумма наличных + по карте должна покрывать итог чека. Переплата (сдача)
+/// допустима, недоплата — нет (иначе касса может «провести» чек, не получив
+/// денег). Округление до 2 знаков защищает от floating-point дрейфа.
+///
+/// Вынесено из RetailSalesController.Post для юнит-тестируемости —
+/// контроллер вызывает этот метод для решения о проведении.
+public static class RetailPaymentValidator
+{
+ public static bool IsSufficient(decimal paidCash, decimal paidCard, decimal total)
+ => decimal.Round(paidCash + paidCard, 2) >= decimal.Round(total, 2);
+}
diff --git a/tests/food-market.UnitTests/MovingAverageCostTests.cs b/tests/food-market.UnitTests/MovingAverageCostTests.cs
new file mode 100644
index 0000000..f066d5e
--- /dev/null
+++ b/tests/food-market.UnitTests/MovingAverageCostTests.cs
@@ -0,0 +1,58 @@
+using FluentAssertions;
+using foodmarket.Application.Inventory;
+using Xunit;
+
+namespace foodmarket.UnitTests;
+
+/// Скользящее средневзвешенное себестоимости (логика SuppliesController.Post).
+public class MovingAverageCostTests
+{
+ [Fact]
+ public void First_supply_with_no_stock_uses_purchase_price()
+ {
+ // нет остатка, нет себестоимости → берём цену закупки
+ MovingAverageCost.Compute(currentQty: 0m, currentCost: 0m, incomingQty: 10m, incomingUnitPrice: 100m)
+ .Should().Be(100m);
+ }
+
+ [Fact]
+ public void Weighted_average_of_old_and_new()
+ {
+ // 10 шт по 100 + 10 шт по 200 → среднее 150
+ MovingAverageCost.Compute(currentQty: 10m, currentCost: 100m, incomingQty: 10m, incomingUnitPrice: 200m)
+ .Should().Be(150m);
+ }
+
+ [Fact]
+ public void Weighted_average_respects_quantities()
+ {
+ // 30 шт по 100 + 10 шт по 200 → (3000+2000)/40 = 125
+ MovingAverageCost.Compute(currentQty: 30m, currentCost: 100m, incomingQty: 10m, incomingUnitPrice: 200m)
+ .Should().Be(125m);
+ }
+
+ [Fact]
+ public void Result_is_rounded_to_four_decimals()
+ {
+ // (1*1 + 2*2)/3 = 1.6666... → 1.6667
+ MovingAverageCost.Compute(currentQty: 1m, currentCost: 1m, incomingQty: 2m, incomingUnitPrice: 2m)
+ .Should().Be(1.6667m);
+ }
+
+ [Fact]
+ public void Existing_stock_with_zero_cost_still_averages_when_qty_positive()
+ {
+ // currentCost==0 но currentQty>0 → НЕ берём цену закупки целиком,
+ // считаем среднее: (5*0 + 5*200)/10 = 100
+ MovingAverageCost.Compute(currentQty: 5m, currentCost: 0m, incomingQty: 5m, incomingUnitPrice: 200m)
+ .Should().Be(100m);
+ }
+
+ [Fact]
+ public void Zero_total_quantity_falls_back_to_purchase_price()
+ {
+ // возврат/сторно может дать totalQty==0 → не делим на ноль
+ MovingAverageCost.Compute(currentQty: -10m, currentCost: 50m, incomingQty: 10m, incomingUnitPrice: 70m)
+ .Should().Be(70m);
+ }
+}
diff --git a/tests/food-market.UnitTests/RetailPaymentValidatorTests.cs b/tests/food-market.UnitTests/RetailPaymentValidatorTests.cs
new file mode 100644
index 0000000..eafd0bc
--- /dev/null
+++ b/tests/food-market.UnitTests/RetailPaymentValidatorTests.cs
@@ -0,0 +1,34 @@
+using FluentAssertions;
+using foodmarket.Application.Sales;
+using Xunit;
+
+namespace foodmarket.UnitTests;
+
+/// Достаточность оплаты розничного чека (логика RetailSalesController.Post).
+public class RetailPaymentValidatorTests
+{
+ [Theory]
+ [InlineData(1000, 0, 1000)] // ровно наличными
+ [InlineData(0, 1000, 1000)] // ровно картой
+ [InlineData(600, 400, 1000)] // смешанная оплата, ровно
+ [InlineData(1500, 0, 1000)] // переплата наличными (сдача) — ок
+ [InlineData(700, 700, 1000)] // переплата смешанная — ок
+ public void Sufficient_payment_passes(decimal cash, decimal card, decimal total)
+ => RetailPaymentValidator.IsSufficient(cash, card, total).Should().BeTrue();
+
+ [Theory]
+ [InlineData(999, 0, 1000)] // недоплата наличными
+ [InlineData(0, 0, 1000)] // не оплачено вовсе
+ [InlineData(400, 400, 1000)] // смешанная недоплата
+ public void Insufficient_payment_fails(decimal cash, decimal card, decimal total)
+ => RetailPaymentValidator.IsSufficient(cash, card, total).Should().BeFalse();
+
+ [Fact]
+ public void Rounds_to_two_decimals_before_comparing()
+ {
+ // 999.999 округляется до 1000.00 ≥ 1000 → ок (защита от float-дрейфа)
+ RetailPaymentValidator.IsSufficient(999.999m, 0m, 1000m).Should().BeTrue();
+ // 999.994 → 999.99 < 1000 → недоплата
+ RetailPaymentValidator.IsSufficient(999.994m, 0m, 1000m).Should().BeFalse();
+ }
+}
diff --git a/tests/food-market.UnitTests/StockServiceTests.cs b/tests/food-market.UnitTests/StockServiceTests.cs
new file mode 100644
index 0000000..1b8c7ba
--- /dev/null
+++ b/tests/food-market.UnitTests/StockServiceTests.cs
@@ -0,0 +1,99 @@
+using FluentAssertions;
+using foodmarket.Application.Inventory;
+using foodmarket.Domain.Inventory;
+using foodmarket.Infrastructure.Inventory;
+using foodmarket.UnitTests.Support;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace foodmarket.UnitTests;
+
+/// StockService.ApplyMovement: материализация Stock + запись StockMovement.
+/// FK отключены — тестируем арифметику остатка и факт записи движения, не целостность.
+public class StockServiceTests
+{
+ private static StockMovementDraft Draft(Guid product, Guid store, decimal qty) =>
+ new(product, store, qty, MovementType.Supply, "supply",
+ DocumentId: Guid.NewGuid(), DocumentNumber: "DOC-1", UnitCost: 100m, OccurredAt: DateTime.UtcNow);
+
+ [Fact]
+ public async Task Creates_stock_and_movement_on_first_application()
+ {
+ using var sqlite = new SqliteDb(foreignKeys: false);
+ var org = Guid.NewGuid();
+ var tenant = new FakeTenantContext { OrganizationId = org };
+ var product = Guid.NewGuid();
+ var store = Guid.NewGuid();
+
+ decimal returned;
+ using (var db = sqlite.Create(tenant))
+ {
+ returned = await new StockService(db, tenant).ApplyMovementAsync(Draft(product, store, 10m));
+ await db.SaveChangesAsync();
+ }
+
+ returned.Should().Be(10m);
+ using (var db = sqlite.Create(tenant))
+ {
+ var stock = await db.Stocks.SingleAsync(s => s.ProductId == product && s.StoreId == store);
+ stock.Quantity.Should().Be(10m);
+ stock.OrganizationId.Should().Be(org);
+ (await db.StockMovements.CountAsync()).Should().Be(1);
+ }
+ }
+
+ // Каждое проведение документа = свой SaveChanges, поэтому повторное движение
+ // по тому же товару находит уже материализованный Stock и инкрементит его.
+ [Fact]
+ public async Task Second_application_increments_existing_stock()
+ {
+ using var sqlite = new SqliteDb(foreignKeys: false);
+ var tenant = new FakeTenantContext { OrganizationId = Guid.NewGuid() };
+ var product = Guid.NewGuid();
+ var store = Guid.NewGuid();
+
+ using var db = sqlite.Create(tenant);
+ var svc = new StockService(db, tenant);
+
+ await svc.ApplyMovementAsync(Draft(product, store, 10m));
+ await db.SaveChangesAsync();
+ var afterSecond = await svc.ApplyMovementAsync(Draft(product, store, 5m));
+ await db.SaveChangesAsync();
+
+ afterSecond.Should().Be(15m);
+ (await db.Stocks.SingleAsync(s => s.ProductId == product && s.StoreId == store)).Quantity.Should().Be(15m);
+ (await db.StockMovements.CountAsync()).Should().Be(2);
+ }
+
+ [Fact]
+ public async Task Negative_movement_decrements_stock()
+ {
+ using var sqlite = new SqliteDb(foreignKeys: false);
+ var tenant = new FakeTenantContext { OrganizationId = Guid.NewGuid() };
+ var product = Guid.NewGuid();
+ var store = Guid.NewGuid();
+
+ using var db = sqlite.Create(tenant);
+ var svc = new StockService(db, tenant);
+
+ await svc.ApplyMovementAsync(Draft(product, store, 10m));
+ await db.SaveChangesAsync();
+ var afterSale = await svc.ApplyMovementAsync(Draft(product, store, -3m));
+ await db.SaveChangesAsync();
+
+ afterSale.Should().Be(7m);
+ }
+
+ [Fact]
+ public async Task Throws_when_tenant_not_set()
+ {
+ using var sqlite = new SqliteDb(foreignKeys: false);
+ var tenant = new FakeTenantContext { OrganizationId = null };
+ using var db = sqlite.Create(tenant);
+ var svc = new StockService(db, tenant);
+
+ await FluentActions
+ .Awaiting(() => svc.ApplyMovementAsync(Draft(Guid.NewGuid(), Guid.NewGuid(), 1m)))
+ .Should().ThrowAsync();
+ }
+}
diff --git a/tests/food-market.UnitTests/Support/FakeTenantContext.cs b/tests/food-market.UnitTests/Support/FakeTenantContext.cs
new file mode 100644
index 0000000..e08f666
--- /dev/null
+++ b/tests/food-market.UnitTests/Support/FakeTenantContext.cs
@@ -0,0 +1,13 @@
+using foodmarket.Application.Common.Tenancy;
+
+namespace foodmarket.UnitTests.Support;
+
+/// Подменяемый ITenantContext для тестов: значения задаются напрямую,
+/// без HttpContext/JWT.
+public sealed class FakeTenantContext : ITenantContext
+{
+ public Guid? OrganizationId { get; set; }
+ public bool IsAuthenticated { get; set; } = true;
+ public bool IsSuperAdmin { get; set; }
+ public bool IsTenantOverride { get; set; }
+}
diff --git a/tests/food-market.UnitTests/Support/SqliteDb.cs b/tests/food-market.UnitTests/Support/SqliteDb.cs
new file mode 100644
index 0000000..a94020d
--- /dev/null
+++ b/tests/food-market.UnitTests/Support/SqliteDb.cs
@@ -0,0 +1,38 @@
+using foodmarket.Application.Common.Tenancy;
+using foodmarket.Infrastructure.Persistence;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace foodmarket.UnitTests.Support;
+
+/// SQLite in-memory БД для тестов, использующих реальный AppDbContext
+/// (query-фильтр мультитенантности, StockService). Соединение держим открытым —
+/// in-memory БД живёт, пока открыт коннект; разные DbContext'ы на одном коннекте
+/// видят одни данные. EnsureCreated строит схему по реальной модели (включая
+/// tenant query-filter).
+public sealed class SqliteDb : IDisposable
+{
+ private readonly SqliteConnection _connection;
+
+ /// false — отключить проверку FK (для фокусных
+ /// тестов логики, где не хотим засевать все родительские строки).
+ public SqliteDb(bool foreignKeys = true)
+ {
+ _connection = new SqliteConnection($"DataSource=:memory:;Foreign Keys={foreignKeys}");
+ _connection.Open();
+ // Схему создаём контекстом-сидером с правами SuperAdmin (фильтр на запись
+ // не влияет, но единообразно). EnsureCreated идемпотентен в рамках коннекта.
+ using var db = Create(new FakeTenantContext { IsSuperAdmin = true });
+ db.Database.EnsureCreated();
+ }
+
+ public AppDbContext Create(ITenantContext tenant)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+ return new AppDbContext(options, tenant);
+ }
+
+ public void Dispose() => _connection.Dispose();
+}
diff --git a/tests/food-market.UnitTests/TenantQueryFilterTests.cs b/tests/food-market.UnitTests/TenantQueryFilterTests.cs
new file mode 100644
index 0000000..59e36da
--- /dev/null
+++ b/tests/food-market.UnitTests/TenantQueryFilterTests.cs
@@ -0,0 +1,81 @@
+using FluentAssertions;
+using foodmarket.Domain.Organizations;
+using foodmarket.UnitTests.Support;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace foodmarket.UnitTests;
+
+/// Мультитенантный query-filter AppDbContext: tenant видит только свои
+/// строки; SuperAdmin без override — все; SuperAdmin в режиме «открыть как…»
+/// (override) снова ограничен выбранной оргой. EmployeeRole выбран как
+/// TenantEntity без внешних FK — минимальный засев.
+public class TenantQueryFilterTests
+{
+ private static readonly Guid OrgA = Guid.NewGuid();
+ private static readonly Guid OrgB = Guid.NewGuid();
+
+ private static EmployeeRole Role(Guid org, string name) =>
+ new() { OrganizationId = org, Name = name, Permissions = new RolePermissions() };
+
+ private static async Task SeedTwoOrgsAsync(SqliteDb sqlite)
+ {
+ using var seed = sqlite.Create(new FakeTenantContext { IsSuperAdmin = true });
+ seed.EmployeeRoles.AddRange(Role(OrgA, "A-role"), Role(OrgB, "B-role"));
+ await seed.SaveChangesAsync();
+ }
+
+ [Fact]
+ public async Task Tenant_sees_only_its_own_rows()
+ {
+ using var sqlite = new SqliteDb();
+ await SeedTwoOrgsAsync(sqlite);
+
+ using var db = sqlite.Create(new FakeTenantContext { OrganizationId = OrgA });
+ var roles = await db.EmployeeRoles.ToListAsync();
+
+ roles.Should().ContainSingle()
+ .Which.OrganizationId.Should().Be(OrgA);
+ }
+
+ [Fact]
+ public async Task Other_tenant_cannot_see_foreign_rows()
+ {
+ using var sqlite = new SqliteDb();
+ await SeedTwoOrgsAsync(sqlite);
+
+ using var db = sqlite.Create(new FakeTenantContext { OrganizationId = OrgB });
+ var roles = await db.EmployeeRoles.ToListAsync();
+
+ roles.Should().OnlyContain(r => r.OrganizationId == OrgB);
+ roles.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public async Task SuperAdmin_without_override_sees_all_tenants()
+ {
+ using var sqlite = new SqliteDb();
+ await SeedTwoOrgsAsync(sqlite);
+
+ using var db = sqlite.Create(new FakeTenantContext { IsSuperAdmin = true });
+ (await db.EmployeeRoles.CountAsync()).Should().Be(2);
+ }
+
+ [Fact]
+ public async Task SuperAdmin_with_override_is_scoped_to_selected_org()
+ {
+ using var sqlite = new SqliteDb();
+ await SeedTwoOrgsAsync(sqlite);
+
+ // «Открыть как…» орг A — изоляция обязана работать даже для SuperAdmin.
+ using var db = sqlite.Create(new FakeTenantContext
+ {
+ IsSuperAdmin = true,
+ IsTenantOverride = true,
+ OrganizationId = OrgA,
+ });
+ var roles = await db.EmployeeRoles.ToListAsync();
+
+ roles.Should().ContainSingle().Which.OrganizationId.Should().Be(OrgA);
+ }
+}
diff --git a/tests/food-market.UnitTests/food-market.UnitTests.csproj b/tests/food-market.UnitTests/food-market.UnitTests.csproj
new file mode 100644
index 0000000..a2966d7
--- /dev/null
+++ b/tests/food-market.UnitTests/food-market.UnitTests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ foodmarket.UnitTests
+ foodmarket.UnitTests
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+