Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled
Domain:
- LoyaltyProgram { Type=Percentage|FixedAmount|PointsAccrual, Rate,
MinSubtotal, IsActive } — org-scoped.
- LoyaltyCard { ProgramId, CounterpartyId, CardNumber unique per org,
Balance, IsBlocked }.
- Promotion { Type=Percent|FixedDiscount, Value, Scope=All|ProductGroups|
Products, Code unique per org, period, ProductGroupIds/ProductIds (jsonb) }.
- RetailSale: LoyaltyCardId, LoyaltyBonusApplied, LoyaltyPointsAccrued,
PromotionId, PromotionCode (snapshot), PromotionDiscount.
EF:
- SalesConfigurations: indexes, FK Restrict, jsonb-converters для Guid-
списков Promotion (ValueComparer для change-tracker).
- Phase9b миграция: 3 таблицы + 6 колонок на retail_sales.
- RolePermissions: LoyaltyManage, PromotionsManage добавлены (попадают
в All() для Admin).
API:
- /api/loyalty/programs CRUD (Get/List/Create/Update/Delete; запрет delete
при существующих картах → 409).
- /api/loyalty/cards CRUD + /issue + /{id}/block + /{id}/unblock + /lookup
(POS использует при оплате — 404 если нет, 409 если blocked/inactive).
- /api/promotions CRUD; код уникален per org (БД-индекс + 23505 → 409).
- RetailSale.Create/Update: новые поля input.LoyaltyCardNumber +
input.PromotionCode. Метод ApplyLoyaltyAndPromotionAsync:
• Lookup карты, проверка active/blocked/MinSubtotal.
• Расчёт скидки или баллов в зависимости от Type.
• Lookup промокода, проверка периода/MinSaleAmount/scope.
• MatchingSubtotal для Scope=ProductGroups/Products считаем по
input.Lines (sale.Lines ещё пустой в этот момент).
• Финальный Total = Subtotal - DiscountTotal - LoyaltyBonusApplied
- PromotionDiscount, max(0).
- RetailSale.Post: начисление баллов на LoyaltyCard.Balance (внутри
транзакции, чтобы rollback не оставил orphan баллы).
UI:
- /loyalty/programs — list + create/edit modal с Type/Rate/MinSubtotal.
- /loyalty/cards — list + issue modal (Program select + AsyncSelect
counterparty + CardNumber).
- /promotions — list + create/edit modal (Type/Value/период/MinSaleAmount/Code).
- Sidebar: новый блок «Продажи» с пунктами Промокоды/Программы/Карты
(Admin-only).
- i18n: ru.json + en.json пополнены nav-ключами.
Тесты:
- LoyaltyFlowTests (3/3 ✓): percentage уменьшает Total на 10%, points-accrual
пополняет Balance после Post, multi-tenant lookup→404 чужой org.
- PromotionFlowTests (2/2 ✓): SALE20 уменьшает Total на 20%, невалидный
код→400 с понятной message и field=promotionCode.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
218 lines
11 KiB
C#
218 lines
11 KiB
C#
using System.Net.Http.Json;
|
||
using System.Text.Json;
|
||
using FluentAssertions;
|
||
using foodmarket.IntegrationTests.Support;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.IntegrationTests;
|
||
|
||
/// <summary>End-to-end лояльности: создание программы Percentage 10% →
|
||
/// выпуск карты → создание чека с loyaltyCardNumber → проверка Total
|
||
/// уменьшился ровно на 10% от Subtotal.
|
||
///
|
||
/// Multi-tenant: B не видит программу/карту A.</summary>
|
||
[Collection(ApiCollection.Name)]
|
||
public class LoyaltyFlowTests
|
||
{
|
||
private readonly ApiFactory _factory;
|
||
public LoyaltyFlowTests(ApiFactory factory) => _factory = factory;
|
||
|
||
[Fact]
|
||
public async Task Percentage_program_discounts_total_in_retail_sale()
|
||
{
|
||
var actor = new ApiActor(_factory.CreateClient());
|
||
var email = $"loy-{Guid.NewGuid():N}@example.kz";
|
||
(await actor.SignupAsync(email, "Passw0rd!", "Loy Org")).EnsureSuccessStatusCode();
|
||
actor.UseToken(await actor.TokenAsync(email, "Passw0rd!"));
|
||
|
||
// Программа Percentage 10%
|
||
var progResp = await actor.Http.PostAsJsonAsync("/api/loyalty/programs", new
|
||
{
|
||
name = "Постоянник 10%", type = 1 /* Percentage */, rate = 10m,
|
||
minSubtotal = 0m, isActive = true, description = (string?)null,
|
||
});
|
||
progResp.EnsureSuccessStatusCode();
|
||
var program = await progResp.Content.ReadFromJsonAsync<JsonElement>();
|
||
var programId = program.GetProperty("id").GetString()!;
|
||
|
||
// Контрагент-покупатель
|
||
var cpResp = await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
|
||
new { name = "VIP покупатель", type = 1 /* Individual */ });
|
||
cpResp.EnsureSuccessStatusCode();
|
||
var cp = await cpResp.Content.ReadFromJsonAsync<JsonElement>();
|
||
var counterpartyId = cp.GetProperty("id").GetString()!;
|
||
|
||
// Карта
|
||
var cardResp = await actor.Http.PostAsJsonAsync("/api/loyalty/cards/issue", new
|
||
{
|
||
programId, counterpartyId, cardNumber = "VIP-001",
|
||
});
|
||
cardResp.EnsureSuccessStatusCode();
|
||
|
||
// Lookup карты должен вернуть данные
|
||
var lookupResp = await actor.Http.GetAsync("/api/loyalty/cards/lookup?number=VIP-001");
|
||
lookupResp.EnsureSuccessStatusCode();
|
||
var lookup = await lookupResp.Content.ReadFromJsonAsync<JsonElement>();
|
||
lookup.GetProperty("programType").GetInt32().Should().Be(1); // Percentage
|
||
|
||
// Сидим товар + остаток через приёмку
|
||
var (productId, storeId, retailPointId, currencyId) = await SeedProductAsync(actor);
|
||
|
||
// Создаём чек с loyaltyCardNumber=VIP-001, Subtotal=1000
|
||
var saleResp = await actor.Http.PostAsJsonAsync("/api/sales/retail", new
|
||
{
|
||
date = DateTime.UtcNow,
|
||
storeId, retailPointId, currencyId,
|
||
payment = 0, isReturn = false,
|
||
lines = new[] { new { productId, quantity = 10m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
|
||
subtotal = 1000m, discountTotal = 0m, total = 1000m,
|
||
paidCash = 900m, paidCard = 0m,
|
||
loyaltyCardNumber = "VIP-001",
|
||
});
|
||
saleResp.EnsureSuccessStatusCode();
|
||
var sale = await saleResp.Content.ReadFromJsonAsync<JsonElement>();
|
||
// 10% от 1000 = 100 → loyaltyBonusApplied=100, total=900
|
||
sale.GetProperty("loyaltyBonusApplied").GetDecimal().Should().Be(100m);
|
||
sale.GetProperty("total").GetDecimal().Should().Be(900m);
|
||
sale.GetProperty("loyaltyCardId").GetString().Should().NotBeNullOrEmpty();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Points_accrual_credits_card_balance_on_post()
|
||
{
|
||
var actor = new ApiActor(_factory.CreateClient());
|
||
var email = $"loy2-{Guid.NewGuid():N}@example.kz";
|
||
(await actor.SignupAsync(email, "Passw0rd!", "Loy2 Org")).EnsureSuccessStatusCode();
|
||
actor.UseToken(await actor.TokenAsync(email, "Passw0rd!"));
|
||
|
||
// Программа PointsAccrual 5% → 5% от чека идёт в Balance
|
||
var progResp = await actor.Http.PostAsJsonAsync("/api/loyalty/programs", new
|
||
{
|
||
name = "Баллы 5%", type = 3 /* PointsAccrual */, rate = 5m,
|
||
minSubtotal = 0m, isActive = true, description = (string?)null,
|
||
});
|
||
progResp.EnsureSuccessStatusCode();
|
||
var programId = (await progResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||
|
||
var cpResp = await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
|
||
new { name = "Bonus Buyer", type = 1 });
|
||
cpResp.EnsureSuccessStatusCode();
|
||
var counterpartyId = (await cpResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||
|
||
var cardResp = await actor.Http.PostAsJsonAsync("/api/loyalty/cards/issue", new
|
||
{
|
||
programId, counterpartyId, cardNumber = "BNS-001",
|
||
});
|
||
cardResp.EnsureSuccessStatusCode();
|
||
var cardId = (await cardResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||
|
||
var (productId, storeId, retailPointId, currencyId) = await SeedProductAsync(actor);
|
||
|
||
// Создаём чек: Subtotal=2000, total остаётся 2000 (баллы не уменьшают)
|
||
var saleResp = await actor.Http.PostAsJsonAsync("/api/sales/retail", new
|
||
{
|
||
date = DateTime.UtcNow,
|
||
storeId, retailPointId, currencyId,
|
||
payment = 0, isReturn = false,
|
||
lines = new[] { new { productId, quantity = 20m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
|
||
subtotal = 2000m, discountTotal = 0m, total = 2000m,
|
||
paidCash = 2000m, paidCard = 0m,
|
||
loyaltyCardNumber = "BNS-001",
|
||
});
|
||
saleResp.EnsureSuccessStatusCode();
|
||
var sale = await saleResp.Content.ReadFromJsonAsync<JsonElement>();
|
||
sale.GetProperty("loyaltyPointsAccrued").GetDecimal().Should().Be(100m); // 5% от 2000
|
||
sale.GetProperty("total").GetDecimal().Should().Be(2000m); // не уменьшилось
|
||
var saleId = sale.GetProperty("id").GetString()!;
|
||
|
||
// Post чек → баланс карты должен стать 100
|
||
(await actor.Http.PostAsync($"/api/sales/retail/{saleId}/post", null)).EnsureSuccessStatusCode();
|
||
var cardListResp = await actor.Http.GetAsync("/api/loyalty/cards?pageSize=10");
|
||
cardListResp.EnsureSuccessStatusCode();
|
||
var cardList = await cardListResp.Content.ReadFromJsonAsync<JsonElement>();
|
||
var card = cardList.GetProperty("items").EnumerateArray().First(x => x.GetProperty("id").GetString() == cardId);
|
||
card.GetProperty("balance").GetDecimal().Should().Be(100m);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Multi_tenant_isolation_lookup_returns_404_for_other_org_card()
|
||
{
|
||
var actorA = new ApiActor(_factory.CreateClient());
|
||
var emailA = $"loyA-{Guid.NewGuid():N}@example.kz";
|
||
(await actorA.SignupAsync(emailA, "Passw0rd!", "LoyA Org")).EnsureSuccessStatusCode();
|
||
actorA.UseToken(await actorA.TokenAsync(emailA, "Passw0rd!"));
|
||
var actorB = new ApiActor(_factory.CreateClient());
|
||
var emailB = $"loyB-{Guid.NewGuid():N}@example.kz";
|
||
(await actorB.SignupAsync(emailB, "Passw0rd!", "LoyB Org")).EnsureSuccessStatusCode();
|
||
actorB.UseToken(await actorB.TokenAsync(emailB, "Passw0rd!"));
|
||
|
||
// A создаёт программу + карту ISOL-A-001
|
||
var progA = await (await actorA.Http.PostAsJsonAsync("/api/loyalty/programs", new
|
||
{
|
||
name = "p", type = 1, rate = 10m, minSubtotal = 0m, isActive = true, description = (string?)null,
|
||
})).Content.ReadFromJsonAsync<JsonElement>();
|
||
var cpA = await (await actorA.Http.PostAsJsonAsync("/api/catalog/counterparties",
|
||
new { name = "cp", type = 1 })).Content.ReadFromJsonAsync<JsonElement>();
|
||
var cardResp = await actorA.Http.PostAsJsonAsync("/api/loyalty/cards/issue", new
|
||
{
|
||
programId = progA.GetProperty("id").GetString(),
|
||
counterpartyId = cpA.GetProperty("id").GetString(),
|
||
cardNumber = "ISOL-A-001",
|
||
});
|
||
cardResp.EnsureSuccessStatusCode();
|
||
|
||
// B пытается lookup → 404
|
||
var lookupB = await actorB.Http.GetAsync("/api/loyalty/cards/lookup?number=ISOL-A-001");
|
||
lookupB.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
|
||
}
|
||
|
||
private async Task<(string ProductId, string StoreId, string RetailPointId, string CurrencyId)>
|
||
SeedProductAsync(ApiActor actor)
|
||
{
|
||
var units = (await actor.GetJsonAsync("/api/catalog/units-of-measure?pageSize=200"))
|
||
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "796");
|
||
var groups = (await actor.GetJsonAsync("/api/catalog/product-groups"))
|
||
.GetProperty("items").EnumerateArray().First();
|
||
var pts = (await actor.GetJsonAsync("/api/catalog/price-types"))
|
||
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("isRetail").GetBoolean());
|
||
var curs = (await actor.GetJsonAsync("/api/catalog/currencies"))
|
||
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "KZT");
|
||
var stores = (await actor.GetJsonAsync("/api/catalog/stores"))
|
||
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("isMain").GetBoolean());
|
||
var retailPoints = (await actor.GetJsonAsync("/api/catalog/retail-points"))
|
||
.GetProperty("items").EnumerateArray().First();
|
||
|
||
var prodResp = await actor.Http.PostAsJsonAsync("/api/catalog/products", new
|
||
{
|
||
name = "Loy test", article = $"LOY-{Guid.NewGuid():N}",
|
||
unitOfMeasureId = units.GetProperty("id").GetString(),
|
||
vat = 12, vatEnabled = true,
|
||
productGroupId = groups.GetProperty("id").GetString(),
|
||
packaging = 1,
|
||
prices = new[] { new { priceTypeId = pts.GetProperty("id").GetString(), amount = 100m, currencyId = curs.GetProperty("id").GetString() } },
|
||
barcodes = new[] { new { code = $"600000{Guid.NewGuid().GetHashCode():X}".Replace("-", "").Substring(0, 12) + "0", type = 1, isPrimary = true } },
|
||
});
|
||
prodResp.EnsureSuccessStatusCode();
|
||
var prod = await prodResp.Content.ReadFromJsonAsync<JsonElement>();
|
||
var productId = prod.GetProperty("id").GetString()!;
|
||
|
||
var supplier = await (await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
|
||
new { name = "Sup", type = 2 })).Content.ReadFromJsonAsync<JsonElement>();
|
||
var supply = await (await actor.Http.PostAsJsonAsync("/api/purchases/supplies", new
|
||
{
|
||
date = DateTime.UtcNow,
|
||
supplierId = supplier.GetProperty("id").GetString(),
|
||
storeId = stores.GetProperty("id").GetString(),
|
||
currencyId = curs.GetProperty("id").GetString(),
|
||
lines = new[] { new { productId, quantity = 100m, unitPrice = 50m } },
|
||
})).Content.ReadFromJsonAsync<JsonElement>();
|
||
(await actor.Http.PostAsync($"/api/purchases/supplies/{supply.GetProperty("id").GetString()}/post", null))
|
||
.EnsureSuccessStatusCode();
|
||
|
||
return (productId,
|
||
stores.GetProperty("id").GetString()!,
|
||
retailPoints.GetProperty("id").GetString()!,
|
||
curs.GetProperty("id").GetString()!);
|
||
}
|
||
}
|