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>
138 lines
6.8 KiB
C#
138 lines
6.8 KiB
C#
using System.Net.Http.Json;
|
||
using System.Text.Json;
|
||
using FluentAssertions;
|
||
using foodmarket.IntegrationTests.Support;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.IntegrationTests;
|
||
|
||
/// <summary>Промокод: создание + применение к чеку (Percent от Subtotal,
|
||
/// scope=All) + проверка что Total уменьшился ровно на промо-скидку.
|
||
/// Проверка валидации: невалидный код → 400 с понятным сообщением.</summary>
|
||
[Collection(ApiCollection.Name)]
|
||
public class PromotionFlowTests
|
||
{
|
||
private readonly ApiFactory _factory;
|
||
public PromotionFlowTests(ApiFactory factory) => _factory = factory;
|
||
|
||
[Fact]
|
||
public async Task Promocode_percent_discounts_total()
|
||
{
|
||
var actor = new ApiActor(_factory.CreateClient());
|
||
var email = $"pro-{Guid.NewGuid():N}@example.kz";
|
||
(await actor.SignupAsync(email, "Passw0rd!", "Pro Org")).EnsureSuccessStatusCode();
|
||
actor.UseToken(await actor.TokenAsync(email, "Passw0rd!"));
|
||
|
||
// Промокод SALE20 = 20%
|
||
var promoResp = await actor.Http.PostAsJsonAsync("/api/promotions", new
|
||
{
|
||
name = "Скидка 20%",
|
||
description = (string?)null,
|
||
code = "SALE20",
|
||
type = 1 /* Percent */,
|
||
value = 20m,
|
||
scope = 1 /* All */,
|
||
minSaleAmount = 0m,
|
||
startsAt = DateTime.UtcNow.AddDays(-1),
|
||
endsAt = (DateTime?)null,
|
||
isActive = true,
|
||
productGroupIds = Array.Empty<Guid>(),
|
||
productIds = Array.Empty<Guid>(),
|
||
});
|
||
promoResp.EnsureSuccessStatusCode();
|
||
|
||
var (productId, storeId, retailPointId, currencyId) = await SeedAsync(actor);
|
||
|
||
// Чек на 500, промокод SALE20 → total = 400
|
||
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 = 5m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
|
||
subtotal = 500m, discountTotal = 0m, total = 500m,
|
||
paidCash = 500m, paidCard = 0m,
|
||
promotionCode = "SALE20",
|
||
});
|
||
saleResp.EnsureSuccessStatusCode();
|
||
var sale = await saleResp.Content.ReadFromJsonAsync<JsonElement>();
|
||
sale.GetProperty("promotionDiscount").GetDecimal().Should().Be(100m); // 20% от 500
|
||
sale.GetProperty("total").GetDecimal().Should().Be(400m);
|
||
sale.GetProperty("promotionCode").GetString().Should().Be("SALE20");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Invalid_promocode_returns_400_with_message()
|
||
{
|
||
var actor = new ApiActor(_factory.CreateClient());
|
||
var email = $"proi-{Guid.NewGuid():N}@example.kz";
|
||
(await actor.SignupAsync(email, "Passw0rd!", "ProI Org")).EnsureSuccessStatusCode();
|
||
actor.UseToken(await actor.TokenAsync(email, "Passw0rd!"));
|
||
var (productId, storeId, retailPointId, currencyId) = await SeedAsync(actor);
|
||
|
||
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 = 1m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
|
||
subtotal = 100m, discountTotal = 0m, total = 100m,
|
||
paidCash = 100m, paidCard = 0m,
|
||
promotionCode = "NOPE-NOEXIST",
|
||
});
|
||
saleResp.StatusCode.Should().Be(System.Net.HttpStatusCode.BadRequest);
|
||
var body = await saleResp.Content.ReadFromJsonAsync<JsonElement>();
|
||
body.GetProperty("error").GetString().Should().MatchRegex("(NOPE|не активен|не найден)");
|
||
body.GetProperty("field").GetString().Should().Be("promotionCode");
|
||
}
|
||
|
||
private async Task<(string ProductId, string StoreId, string RetailPointId, string CurrencyId)>
|
||
SeedAsync(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 = "Promo test", article = $"PRM-{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 = "7000000000017", 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 = 50m, 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()!);
|
||
}
|
||
}
|