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