feat(loyalty+promotions): P2-12 + P2-13 — лояльность и промокоды (Sprint 9 п.1-2)
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>
This commit is contained in:
nns 2026-05-31 21:06:10 +05:00
parent a5314b5be9
commit 91128a7ed0
22 changed files with 1983 additions and 5 deletions

30
docs/sprint9-progress.md Normal file
View file

@ -0,0 +1,30 @@
# Sprint 9 — лояльность, акции, mobile-адаптация, PWA
Цель: программы лояльности (баллы/% скидка) для постоянных покупателей,
промокоды/акции в чеке, починить узкие экраны (телефон/планшет),
PWA-обёртка владельца для отчётов с homescreen-икоником.
Старт: 2026-06-01. Исполнитель: Claude Opus 4.7 (автономный режим).
Это последний автономно-безопасный спринт. Дальше нужен человек:
ОФД-интеграция, MoySklad-токены, POS WPF на Windows, kz-локализация,
прод-деплой.
## Принципы
- Multi-tenant обязателен (`OrganizationId` на каждой новой таблице, query filter).
- Каждый пункт: `dotnet build` + локальные тесты + `~/deploy-stage.sh` + retest на `https://test.admin.food-market.kz` (включая mobile viewport 375x667).
- НЕ трогать: `global.json`, прод-стек (admin.food-market.kz), POS WPF.
## Чек-лист
- [ ] **1. P2-12 Loyalty (программы + карты)** — Domain `LoyaltyProgram` (Percentage|FixedAmount|PointsAccrual) + `LoyaltyCard`. EF + миграция. CRUD-controller + `POST /api/loyalty/cards/issue`. RetailSale: автоприменение к привязанному CounterpartyId, поле `LoyaltyBonusApplied`. Web `/loyalty/programs` + `/loyalty/cards`. Тесты + UI smoke.
- [ ] **2. P2-13 Promotions (промокоды/акции)** — Domain `Promotion` (org-scoped, период, Percent|FixedDiscount, Code). RetailSale: ручной ввод кода / авто-применение к корзине. Web `/promotions`. Тесты.
- [ ] **3. Mobile-адаптация** — 375x667 + 768x1024 audit всех ключевых страниц. Таблицы → карточный режим на узких. Sidebar → drawer (уже есть). Screenshots до/после.
- [ ] **4. P2-9 PWA владельца (read-only)** — manifest.json + SW + offline-fallback на /dashboard/sales/profit/stock. Установка на homescreen. Lighthouse-аудит.
## Журнал
### 2026-06-01 — старт
Sprint 8 закрыт (`docs/sprint8-progress.md`, 4/4 ✓, 8/8 stage e2e). Перехожу к пункту 1 (Loyalty).

View file

@ -0,0 +1,145 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Loyalty;
/// <summary>Карты лояльности: выпуск/удаление/просмотр баланса. CardNumber
/// уникален в рамках org — это позволяет кассиру сканировать его как
/// идентификатор. Просмотр по номеру (lookup) используется во время оплаты —
/// кассир сканирует, мы возвращаем привязанную программу и Counterparty.</summary>
[ApiController]
[Authorize]
[Route("api/loyalty/cards")]
public class LoyaltyCardsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public LoyaltyCardsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public sealed record CardDto(Guid Id, Guid ProgramId, string ProgramName,
LoyaltyProgramType ProgramType, decimal ProgramRate,
Guid CounterpartyId, string CounterpartyName,
string CardNumber, decimal Balance, DateTime IssuedAt, bool IsBlocked);
public sealed record IssueInput(Guid ProgramId, Guid CounterpartyId, string CardNumber);
[HttpGet]
public async Task<ActionResult<foodmarket.Application.Common.PagedResult<CardDto>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50,
[FromQuery] string? search = null,
CancellationToken ct = default)
{
var take = Math.Clamp(pageSize, 1, 200);
var skip = Math.Max(0, (page - 1) * take);
var q = from c in _db.LoyaltyCards.AsNoTracking()
join p in _db.LoyaltyPrograms.AsNoTracking() on c.ProgramId equals p.Id
join cp in _db.Counterparties.AsNoTracking() on c.CounterpartyId equals cp.Id
select new { c, p, cp };
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLowerInvariant();
q = q.Where(x => x.c.CardNumber.ToLower().Contains(s)
|| x.cp.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
var items = await q.OrderByDescending(x => x.c.IssuedAt)
.Skip(skip).Take(take)
.Select(x => new CardDto(x.c.Id, x.p.Id, x.p.Name, x.p.Type, x.p.Rate,
x.cp.Id, x.cp.Name, x.c.CardNumber, x.c.Balance, x.c.IssuedAt, x.c.IsBlocked))
.ToListAsync(ct);
return Ok(new foodmarket.Application.Common.PagedResult<CardDto>
{
Items = items, Total = total, Page = page, PageSize = take,
});
}
/// <summary>Lookup по CardNumber — используется кассой при оплате. Возвращает
/// 404 если карты нет, 409 если карта блокирована. Тенантовое разделение
/// гарантировано query-фильтром AppDbContext.</summary>
[HttpGet("lookup")]
public async Task<ActionResult<CardDto>> Lookup([FromQuery] string number, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(number)) return BadRequest(new { error = "Number обязателен." });
var x = await (from c in _db.LoyaltyCards.AsNoTracking()
join p in _db.LoyaltyPrograms.AsNoTracking() on c.ProgramId equals p.Id
join cp in _db.Counterparties.AsNoTracking() on c.CounterpartyId equals cp.Id
where c.CardNumber == number.Trim()
select new { c, p, cp }).FirstOrDefaultAsync(ct);
if (x is null) return NotFound(new { error = "Карта не найдена." });
if (x.c.IsBlocked) return Conflict(new { error = "Карта заблокирована." });
if (!x.p.IsActive) return Conflict(new { error = "Программа лояльности отключена." });
return Ok(new CardDto(x.c.Id, x.p.Id, x.p.Name, x.p.Type, x.p.Rate,
x.cp.Id, x.cp.Name, x.c.CardNumber, x.c.Balance, x.c.IssuedAt, x.c.IsBlocked));
}
[HttpPost("issue"), RequiresPermission("LoyaltyManage")]
public async Task<ActionResult<CardDto>> Issue([FromBody] IssueInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
if (string.IsNullOrWhiteSpace(input.CardNumber))
return BadRequest(new { error = "CardNumber обязателен." });
var program = await _db.LoyaltyPrograms.FirstOrDefaultAsync(p => p.Id == input.ProgramId, ct);
if (program is null) return NotFound(new { error = "Программа не найдена." });
var cp = await _db.Counterparties.FirstOrDefaultAsync(c => c.Id == input.CounterpartyId, ct);
if (cp is null) return NotFound(new { error = "Контрагент не найден." });
// Унификация: trim, чтобы не было двух «1234 » и «1234» считались разными.
var num = input.CardNumber.Trim();
var dup = await _db.LoyaltyCards.AnyAsync(c => c.CardNumber == num, ct);
if (dup) return Conflict(new { error = $"Карта с номером «{num}» уже существует." });
var card = new LoyaltyCard
{
OrganizationId = orgId,
ProgramId = program.Id,
CounterpartyId = cp.Id,
CardNumber = num,
Balance = 0m,
IssuedAt = DateTime.UtcNow,
IsBlocked = false,
};
_db.LoyaltyCards.Add(card);
await _db.SaveChangesAsync(ct);
return Ok(new CardDto(card.Id, program.Id, program.Name, program.Type, program.Rate,
cp.Id, cp.Name, card.CardNumber, card.Balance, card.IssuedAt, card.IsBlocked));
}
[HttpPost("{id:guid}/block"), RequiresPermission("LoyaltyManage")]
public async Task<IActionResult> Block(Guid id, CancellationToken ct)
{
var c = await _db.LoyaltyCards.FirstOrDefaultAsync(x => x.Id == id, ct);
if (c is null) return NotFound();
c.IsBlocked = true; c.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/unblock"), RequiresPermission("LoyaltyManage")]
public async Task<IActionResult> Unblock(Guid id, CancellationToken ct)
{
var c = await _db.LoyaltyCards.FirstOrDefaultAsync(x => x.Id == id, ct);
if (c is null) return NotFound();
c.IsBlocked = false; c.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("LoyaltyManage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var c = await _db.LoyaltyCards.FirstOrDefaultAsync(x => x.Id == id, ct);
if (c is null) return NotFound();
_db.LoyaltyCards.Remove(c);
await _db.SaveChangesAsync(ct);
return NoContent();
}
}

View file

@ -0,0 +1,125 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Loyalty;
/// <summary>CRUD-программ лояльности. Org-scoped, Admin-only для мутаций;
/// чтение доступно всем tenant-ролям (используется при оформлении чека).
/// Не позволяем удалить программу с существующими картами — это бы оставило
/// orphan-карты и сломало history.</summary>
[ApiController]
[Authorize]
[Route("api/loyalty/programs")]
public class LoyaltyProgramsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public LoyaltyProgramsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public sealed record ProgramDto(Guid Id, string Name, LoyaltyProgramType Type,
decimal Rate, decimal MinSubtotal, bool IsActive, string? Description, int CardsCount);
public sealed record ProgramInput(string Name, LoyaltyProgramType Type, decimal Rate,
decimal MinSubtotal, bool IsActive, string? Description);
[HttpGet]
public async Task<ActionResult<foodmarket.Application.Common.PagedResult<ProgramDto>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50,
[FromQuery] string? search = null,
CancellationToken ct = default)
{
var take = Math.Clamp(pageSize, 1, 200);
var skip = Math.Max(0, (page - 1) * take);
var q = _db.LoyaltyPrograms.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLowerInvariant();
q = q.Where(p => p.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
var items = await q.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.Select(p => new ProgramDto(p.Id, p.Name, p.Type, p.Rate, p.MinSubtotal, p.IsActive, p.Description,
_db.LoyaltyCards.Count(c => c.ProgramId == p.Id)))
.ToListAsync(ct);
return Ok(new foodmarket.Application.Common.PagedResult<ProgramDto>
{
Items = items, Total = total, Page = page, PageSize = take,
});
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<ProgramDto>> Get(Guid id, CancellationToken ct)
{
var p = await _db.LoyaltyPrograms.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (p is null) return NotFound();
var cnt = await _db.LoyaltyCards.CountAsync(c => c.ProgramId == p.Id, ct);
return Ok(new ProgramDto(p.Id, p.Name, p.Type, p.Rate, p.MinSubtotal, p.IsActive, p.Description, cnt));
}
[HttpPost, RequiresPermission("LoyaltyManage")]
public async Task<ActionResult<ProgramDto>> Create([FromBody] ProgramInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
if (string.IsNullOrWhiteSpace(input.Name))
return BadRequest(new { error = "Имя программы обязательно." });
if (input.Rate < 0) return BadRequest(new { error = "Rate не может быть отрицательным." });
if (input.Type == LoyaltyProgramType.Percentage || input.Type == LoyaltyProgramType.PointsAccrual)
{
if (input.Rate > 100) return BadRequest(new { error = "Для % программы Rate в [0..100]." });
}
var p = new LoyaltyProgram
{
OrganizationId = orgId,
Name = input.Name.Trim(),
Type = input.Type,
Rate = input.Rate,
MinSubtotal = input.MinSubtotal,
IsActive = input.IsActive,
Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description.Trim(),
};
_db.LoyaltyPrograms.Add(p);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = p.Id },
new ProgramDto(p.Id, p.Name, p.Type, p.Rate, p.MinSubtotal, p.IsActive, p.Description, 0));
}
[HttpPut("{id:guid}"), RequiresPermission("LoyaltyManage")]
public async Task<IActionResult> Update(Guid id, [FromBody] ProgramInput input, CancellationToken ct)
{
var p = await _db.LoyaltyPrograms.FirstOrDefaultAsync(x => x.Id == id, ct);
if (p is null) return NotFound();
p.Name = input.Name.Trim();
p.Type = input.Type;
p.Rate = input.Rate;
p.MinSubtotal = input.MinSubtotal;
p.IsActive = input.IsActive;
p.Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description.Trim();
p.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("LoyaltyManage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var p = await _db.LoyaltyPrograms.FirstOrDefaultAsync(x => x.Id == id, ct);
if (p is null) return NotFound();
var hasCards = await _db.LoyaltyCards.AnyAsync(c => c.ProgramId == id, ct);
if (hasCards)
return Conflict(new { error = "У программы есть выпущенные карты. Сначала удалите/деактивируйте их." });
_db.LoyaltyPrograms.Remove(p);
await _db.SaveChangesAsync(ct);
return NoContent();
}
}

View file

@ -0,0 +1,161 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using foodmarket.Api.Infrastructure.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Promotions;
/// <summary>CRUD-промокодов/акций. Org-scoped. PromotionsManage permission.
/// Уникальность кода в рамках org — обеспечивается БД-индексом, но и в
/// контроллере отдаём 409 с понятным текстом если дубль.</summary>
[ApiController]
[Authorize]
[Route("api/promotions")]
public class PromotionsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public PromotionsController(AppDbContext db, ITenantContext tenant)
{
_db = db;
_tenant = tenant;
}
public sealed record PromotionDto(Guid Id, string Name, string? Description, string? Code,
PromotionType Type, decimal Value, PromotionScope Scope, decimal MinSaleAmount,
DateTime StartsAt, DateTime? EndsAt, bool IsActive,
IReadOnlyList<Guid> ProductGroupIds, IReadOnlyList<Guid> ProductIds);
public sealed record PromotionInput(string Name, string? Description, string? Code,
PromotionType Type, decimal Value, PromotionScope Scope, decimal MinSaleAmount,
DateTime StartsAt, DateTime? EndsAt, bool IsActive,
List<Guid> ProductGroupIds, List<Guid> ProductIds);
[HttpGet]
public async Task<ActionResult<foodmarket.Application.Common.PagedResult<PromotionDto>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50,
[FromQuery] string? search = null,
CancellationToken ct = default)
{
var take = Math.Clamp(pageSize, 1, 200);
var skip = Math.Max(0, (page - 1) * take);
var q = _db.Promotions.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLowerInvariant();
q = q.Where(p => p.Name.ToLower().Contains(s)
|| (p.Code != null && p.Code.ToLower().Contains(s)));
}
var total = await q.CountAsync(ct);
var items = await q.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.Select(p => new PromotionDto(p.Id, p.Name, p.Description, p.Code, p.Type, p.Value,
p.Scope, p.MinSaleAmount, p.StartsAt, p.EndsAt, p.IsActive,
p.ProductGroupIds, p.ProductIds))
.ToListAsync(ct);
return Ok(new foodmarket.Application.Common.PagedResult<PromotionDto>
{
Items = items, Total = total, Page = page, PageSize = take,
});
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<PromotionDto>> Get(Guid id, CancellationToken ct)
{
var p = await _db.Promotions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (p is null) return NotFound();
return Ok(new PromotionDto(p.Id, p.Name, p.Description, p.Code, p.Type, p.Value,
p.Scope, p.MinSaleAmount, p.StartsAt, p.EndsAt, p.IsActive, p.ProductGroupIds, p.ProductIds));
}
[HttpPost, RequiresPermission("PromotionsManage")]
public async Task<ActionResult<PromotionDto>> Create([FromBody] PromotionInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
if (string.IsNullOrWhiteSpace(input.Name)) return BadRequest(new { error = "Имя обязательно." });
if (input.Value < 0) return BadRequest(new { error = "Скидка не может быть отрицательной." });
if (input.Type == PromotionType.Percent && input.Value > 100)
return BadRequest(new { error = "Процент в диапазоне [0..100]." });
if (input.EndsAt is { } end && end <= input.StartsAt)
return BadRequest(new { error = "Дата окончания должна быть позже даты начала." });
var p = new Promotion
{
OrganizationId = orgId,
Name = input.Name.Trim(),
Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description.Trim(),
Code = string.IsNullOrWhiteSpace(input.Code) ? null : input.Code.Trim().ToUpperInvariant(),
Type = input.Type,
Value = input.Value,
Scope = input.Scope,
MinSaleAmount = input.MinSaleAmount,
StartsAt = input.StartsAt,
EndsAt = input.EndsAt,
IsActive = input.IsActive,
ProductGroupIds = input.ProductGroupIds ?? new List<Guid>(),
ProductIds = input.ProductIds ?? new List<Guid>(),
};
_db.Promotions.Add(p);
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException { SqlState: "23505" } pg
&& pg.ConstraintName?.Contains("Code") == true)
{
return Conflict(new { error = $"Промокод «{p.Code}» уже существует.", field = "Code" });
}
return CreatedAtAction(nameof(Get), new { id = p.Id }, ToDto(p));
}
[HttpPut("{id:guid}"), RequiresPermission("PromotionsManage")]
public async Task<IActionResult> Update(Guid id, [FromBody] PromotionInput input, CancellationToken ct)
{
var p = await _db.Promotions.FirstOrDefaultAsync(x => x.Id == id, ct);
if (p is null) return NotFound();
if (input.EndsAt is { } end && end <= input.StartsAt)
return BadRequest(new { error = "Дата окончания должна быть позже даты начала." });
p.Name = input.Name.Trim();
p.Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description.Trim();
p.Code = string.IsNullOrWhiteSpace(input.Code) ? null : input.Code.Trim().ToUpperInvariant();
p.Type = input.Type;
p.Value = input.Value;
p.Scope = input.Scope;
p.MinSaleAmount = input.MinSaleAmount;
p.StartsAt = input.StartsAt;
p.EndsAt = input.EndsAt;
p.IsActive = input.IsActive;
p.ProductGroupIds = input.ProductGroupIds ?? new List<Guid>();
p.ProductIds = input.ProductIds ?? new List<Guid>();
p.UpdatedAt = DateTime.UtcNow;
try
{
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException { SqlState: "23505" } pg
&& pg.ConstraintName?.Contains("Code") == true)
{
return Conflict(new { error = $"Промокод «{p.Code}» уже существует.", field = "Code" });
}
return NoContent();
}
[HttpDelete("{id:guid}"), RequiresPermission("PromotionsManage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var p = await _db.Promotions.FirstOrDefaultAsync(x => x.Id == id, ct);
if (p is null) return NotFound();
// Не валим если на акцию ссылаются чеки — promotionId в RetailSale хранится
// как snapshot, его можно оставить orphaned (на уровне БД ON DELETE SET NULL не делаем).
_db.Promotions.Remove(p);
await _db.SaveChangesAsync(ct);
return NoContent();
}
private static PromotionDto ToDto(Promotion p) => new(p.Id, p.Name, p.Description, p.Code, p.Type, p.Value,
p.Scope, p.MinSaleAmount, p.StartsAt, p.EndsAt, p.IsActive, p.ProductGroupIds, p.ProductIds);
}

View file

@ -55,7 +55,15 @@ public record RetailSaleDto(
PaymentMethod Payment, decimal PaidCash, decimal PaidCard, PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
string? Notes, DateTime? PostedAt, string? Notes, DateTime? PostedAt,
bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber, bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber,
IReadOnlyList<RetailSaleLineDto> Lines); IReadOnlyList<RetailSaleLineDto> Lines,
// Sprint 9: лояльность + промокод. Все поля snapshot'ы (промокод/карта
// могут быть удалены или переименованы — чек хранит то что было).
Guid? LoyaltyCardId = null,
decimal LoyaltyBonusApplied = 0m,
decimal LoyaltyPointsAccrued = 0m,
Guid? PromotionId = null,
string? PromotionCode = null,
decimal PromotionDiscount = 0m);
public record RetailSaleLineInput( public record RetailSaleLineInput(
Guid ProductId, Guid ProductId,
@ -71,7 +79,16 @@ public record RetailSaleInput(
string? Notes, string? Notes,
IReadOnlyList<RetailSaleLineInput> Lines, IReadOnlyList<RetailSaleLineInput> Lines,
bool IsReturn = false, bool IsReturn = false,
Guid? ReferenceSaleId = null); Guid? ReferenceSaleId = null,
/// <summary>Sprint 9: номер карты лояльности. Если задан — сервер
/// сматчит на active-карту, применит программу (скидка %, фикс или
/// начисление баллов), запишет snapshot в LoyaltyCardId / LoyaltyBonusApplied /
/// LoyaltyPointsAccrued.</summary>
string? LoyaltyCardNumber = null,
/// <summary>Sprint 9: код промокода. Если задан — сервер найдёт active-
/// promotion с этим кодом, проверит период/MinSaleAmount/Scope и
/// применит скидку (PromotionDiscount/PromotionId/PromotionCode).</summary>
string? PromotionCode = null);
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions); public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
@ -247,12 +264,134 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null, ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null,
}; };
ApplyLines(sale, input.Lines, allowFractional); ApplyLines(sale, input.Lines, allowFractional);
// Sprint 9: лояльность + промокод. Возвращает 400 если код невалидный.
if (await ApplyLoyaltyAndPromotionAsync(sale, input, allowFractional, ct) is { } loyErr)
return loyErr;
_db.RetailSales.Add(sale); _db.RetailSales.Add(sale);
if (await SaveOrFkErrorAsync(ct) is { } err) return err; if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(sale.Id, ct); var dto = await GetInternal(sale.Id, ct);
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto); return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
} }
/// <summary>Применяет программу лояльности и промокод к чеку.
/// - LoyaltyCardNumber: lookup карты по номеру, проверка active/!blocked/
/// program.IsActive, проверка MinSubtotal, расчёт скидки или баллов.
/// - PromotionCode: lookup promotion, проверка периода/IsActive/MinSaleAmount/
/// scope, расчёт скидки.
///
/// Total чека пересчитываем в конце: Subtotal - DiscountTotal - LoyaltyBonusApplied -
/// PromotionDiscount. Округляем по allowFractional. Если sale.IsReturn=true —
/// лояльность/промо игнорируем (возврат не накручивает баллы).</summary>
private async Task<ActionResult?> ApplyLoyaltyAndPromotionAsync(
RetailSale sale, RetailSaleInput input, bool allowFractional, CancellationToken ct)
{
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
if (sale.IsReturn)
{
sale.Total = R(sale.Subtotal - sale.DiscountTotal);
return null;
}
decimal subtotalAfterLineDiscount = sale.Subtotal - sale.DiscountTotal;
// ── Loyalty ────────────────────────────────────────────────────────
if (!string.IsNullOrWhiteSpace(input.LoyaltyCardNumber))
{
var num = input.LoyaltyCardNumber.Trim();
var card = await _db.LoyaltyCards
.Include(c => c.Program)
.FirstOrDefaultAsync(c => c.CardNumber == num, ct);
if (card is null)
return BadRequest(new { error = $"Карта лояльности «{num}» не найдена.", field = "loyaltyCardNumber" });
if (card.IsBlocked)
return BadRequest(new { error = "Карта заблокирована.", field = "loyaltyCardNumber" });
if (card.Program is null || !card.Program.IsActive)
return BadRequest(new { error = "Программа лояльности отключена.", field = "loyaltyCardNumber" });
if (subtotalAfterLineDiscount < card.Program.MinSubtotal)
return BadRequest(new
{
error = $"Чек на {subtotalAfterLineDiscount:N0} меньше минимума программы {card.Program.MinSubtotal:N0} ₸.",
field = "loyaltyCardNumber",
});
sale.LoyaltyCardId = card.Id;
switch (card.Program.Type)
{
case foodmarket.Domain.Sales.LoyaltyProgramType.Percentage:
sale.LoyaltyBonusApplied = R(subtotalAfterLineDiscount * card.Program.Rate / 100m);
sale.LoyaltyPointsAccrued = 0m;
break;
case foodmarket.Domain.Sales.LoyaltyProgramType.FixedAmount:
sale.LoyaltyBonusApplied = Math.Min(R(card.Program.Rate), subtotalAfterLineDiscount);
sale.LoyaltyPointsAccrued = 0m;
break;
case foodmarket.Domain.Sales.LoyaltyProgramType.PointsAccrual:
sale.LoyaltyBonusApplied = 0m;
sale.LoyaltyPointsAccrued = R(subtotalAfterLineDiscount * card.Program.Rate / 100m);
break;
}
}
// ── Promotion ──────────────────────────────────────────────────────
if (!string.IsNullOrWhiteSpace(input.PromotionCode))
{
var code = input.PromotionCode.Trim();
var now = DateTime.UtcNow;
var promo = await _db.Promotions
.FirstOrDefaultAsync(p => p.Code == code && p.IsActive
&& p.StartsAt <= now && (p.EndsAt == null || p.EndsAt > now), ct);
if (promo is null)
return BadRequest(new { error = $"Промокод «{code}» не активен или не найден.", field = "promotionCode" });
if (subtotalAfterLineDiscount < promo.MinSaleAmount)
return BadRequest(new
{
error = $"Минимум для промокода {promo.MinSaleAmount:N0} ₸, у вас {subtotalAfterLineDiscount:N0} ₸.",
field = "promotionCode",
});
// Расчёт «применимой» части чека для Scope=ProductGroups/Products.
// Используем input.Lines, потому что sale.Lines на этом этапе ещё
// пустой (мы добавляли строки прямо в _db.RetailSaleLines, не через
// nav-collection — см. ApplyLines). Линейный discount учитываем.
decimal LineTotal(RetailSaleLineInput l) => l.Quantity * l.UnitPrice - l.Discount;
decimal matchingSubtotal;
if (promo.Scope == foodmarket.Domain.Sales.PromotionScope.All)
{
matchingSubtotal = subtotalAfterLineDiscount;
}
else if (promo.Scope == foodmarket.Domain.Sales.PromotionScope.Products)
{
var pids = promo.ProductIds.ToHashSet();
matchingSubtotal = input.Lines.Where(l => pids.Contains(l.ProductId)).Sum(LineTotal);
}
else // ProductGroups
{
var gids = promo.ProductGroupIds.ToHashSet();
var prodIds = input.Lines.Select(l => l.ProductId).Distinct().ToList();
var productGroupMap = await _db.Products.IgnoreQueryFilters()
.Where(p => prodIds.Contains(p.Id))
.Select(p => new { p.Id, GroupId = p.ProductGroupId })
.ToDictionaryAsync(x => x.Id, x => x.GroupId, ct);
matchingSubtotal = input.Lines
.Where(l => productGroupMap.TryGetValue(l.ProductId, out var gid) && gids.Contains(gid))
.Sum(LineTotal);
}
if (matchingSubtotal <= 0)
return BadRequest(new { error = "Нет позиций, подходящих под промокод.", field = "promotionCode" });
sale.PromotionId = promo.Id;
sale.PromotionCode = code;
sale.PromotionDiscount = promo.Type == foodmarket.Domain.Sales.PromotionType.Percent
? R(matchingSubtotal * promo.Value / 100m)
: Math.Min(R(promo.Value), matchingSubtotal);
}
// ── Финальный Total ─────────────────────────────────────────────────
var total = sale.Subtotal - sale.DiscountTotal - sale.LoyaltyBonusApplied - sale.PromotionDiscount;
sale.Total = R(Math.Max(0, total));
return null;
}
/// <summary>SaveChanges + перехват PostgresException 23503 (FK violation). /// <summary>SaveChanges + перехват PostgresException 23503 (FK violation).
/// Возвращает 400 с указанием поля если FK не сошёлся (например, StoreId /// Возвращает 400 с указанием поля если FK не сошёлся (например, StoreId
/// или RetailPointId указывают на несуществующую запись) — это лучше /// или RetailPointId указывают на несуществующую запись) — это лучше
@ -307,6 +446,11 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput inpu
await _db.RetailSaleLines.Where(l => l.RetailSaleId == sale.Id).ExecuteDeleteAsync(ct); await _db.RetailSaleLines.Where(l => l.RetailSaleId == sale.Id).ExecuteDeleteAsync(ct);
ApplyLines(sale, input.Lines, allowFractional); ApplyLines(sale, input.Lines, allowFractional);
// Sprint 9: пересчёт лояльности/промо при обновлении draft'a.
// Сбрасываем старые значения, чтобы Apply пересчитал с чистого листа.
sale.LoyaltyCardId = null; sale.LoyaltyBonusApplied = 0m; sale.LoyaltyPointsAccrued = 0m;
sale.PromotionId = null; sale.PromotionCode = null; sale.PromotionDiscount = 0m;
if (await ApplyLoyaltyAndPromotionAsync(sale, input, allowFractional, ct) is { } loyErr) return loyErr;
if (await SaveOrFkErrorAsync(ct) is { } err) return err; if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent(); return NoContent();
@ -419,6 +563,18 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
sale.Status = RetailSaleStatus.Posted; sale.Status = RetailSaleStatus.Posted;
sale.PostedAt = DateTime.UtcNow; sale.PostedAt = DateTime.UtcNow;
// Sprint 9: начисляем баллы на карту (для PointsAccrual-программ).
// Делаем внутри той же транзакции, чтобы при rollback'е баллы не
// оказались списанными в воздух.
if (sale.LoyaltyCardId is { } cardId && sale.LoyaltyPointsAccrued > 0m)
{
var card = await _db.LoyaltyCards.FirstOrDefaultAsync(c => c.Id == cardId, ct);
if (card is not null)
{
card.Balance += sale.LoyaltyPointsAccrued;
card.UpdatedAt = DateTime.UtcNow;
}
}
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct); await tx.CommitAsync(ct);
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale"); foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale");
@ -828,6 +984,9 @@ orderby l.SortOrder
row.s.Payment, row.s.PaidCash, row.s.PaidCard, row.s.Payment, row.s.PaidCash, row.s.PaidCard,
row.s.Notes, row.s.PostedAt, row.s.Notes, row.s.PostedAt,
row.s.IsReturn, row.s.ReferenceSaleId, refNumber, row.s.IsReturn, row.s.ReferenceSaleId, refNumber,
lines); lines,
// Sprint 9
row.s.LoyaltyCardId, row.s.LoyaltyBonusApplied, row.s.LoyaltyPointsAccrued,
row.s.PromotionId, row.s.PromotionCode, row.s.PromotionDiscount);
} }
} }

View file

@ -51,6 +51,10 @@ public class RolePermissions
public bool CashRegistersManage { get; set; } public bool CashRegistersManage { get; set; }
public bool IntegrationsManage { get; set; } public bool IntegrationsManage { get; set; }
// Sprint 9: лояльность и акции (Admin-only по умолчанию).
public bool LoyaltyManage { get; set; }
public bool PromotionsManage { get; set; }
/// <summary>Полный набор всех true — для системной роли «Администратор».</summary> /// <summary>Полный набор всех true — для системной роли «Администратор».</summary>
public static RolePermissions All() => new() public static RolePermissions All() => new()
{ {
@ -65,5 +69,6 @@ public static RolePermissions All() => new()
OrgSettingsManage = true, EmployeesManage = true, RolesManage = true, OrgSettingsManage = true, EmployeesManage = true, RolesManage = true,
StoresManage = true, RetailPointsManage = true, StoresManage = true, RetailPointsManage = true,
CashRegistersManage = true, IntegrationsManage = true, CashRegistersManage = true, IntegrationsManage = true,
LoyaltyManage = true, PromotionsManage = true,
}; };
} }

View file

@ -0,0 +1,33 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Sales;
/// <summary>Карта лояльности — привязка покупателя (Counterparty) к программе.
/// Один Counterparty может иметь несколько карт (например для разных
/// программ); CardNumber должен быть уникален в рамках org.
///
/// Balance — накопленные баллы (для <see cref="LoyaltyProgramType.PointsAccrual"/>);
/// для Percentage/FixedAmount остаётся 0. Списание баллов планируется в
/// следующих итерациях.</summary>
public class LoyaltyCard : TenantEntity
{
public Guid ProgramId { get; set; }
public LoyaltyProgram? Program { get; set; }
public Guid CounterpartyId { get; set; }
public Counterparty? Counterparty { get; set; }
/// <summary>Уникальный номер карты в рамках org. Может быть штрихкод
/// или строка-серийник, который кассир сканирует/вводит при оплате.</summary>
public string CardNumber { get; set; } = null!;
/// <summary>Накопленные баллы для PointsAccrual-программ. Decimal(18,4).</summary>
public decimal Balance { get; set; }
public DateTime IssuedAt { get; set; } = DateTime.UtcNow;
/// <summary>Заблокирована ли (украдена/просрочена). Заблокированные карты
/// игнорируются при применении лояльности.</summary>
public bool IsBlocked { get; set; }
}

View file

@ -0,0 +1,49 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Sales;
/// <summary>Программа лояльности в магазине. Org-scoped, может быть несколько
/// (например «Скидка постоянных 5%» + «Бонусные баллы 3%»).
///
/// Тип определяет что происходит при выписке чека покупателю с привязанной
/// картой:
/// <list type="bullet">
/// <item><see cref="LoyaltyProgramType.Percentage"/> — скидка <see cref="Rate"/>%
/// от итога чека (применяется на Subtotal до VAT).</item>
/// <item><see cref="LoyaltyProgramType.FixedAmount"/> — фиксированная скидка
/// <see cref="Rate"/> ₸ с каждого чека (если Subtotal &gt; rate).</item>
/// <item><see cref="LoyaltyProgramType.PointsAccrual"/> — начисление баллов:
/// <see cref="Rate"/>% от суммы чека идёт на баланс карты. Списания баллов
/// пока не делаем (next-iteration; можно использовать <see cref="Balance"/>
/// карты в дальнейшем).</item>
/// </list>
/// </summary>
public enum LoyaltyProgramType
{
Percentage = 1,
FixedAmount = 2,
PointsAccrual = 3,
}
public class LoyaltyProgram : TenantEntity
{
public string Name { get; set; } = null!;
public LoyaltyProgramType Type { get; set; }
/// <summary>Ставка. Семантика зависит от Type: для Percentage/PointsAccrual
/// — проценты [0..100], для FixedAmount — рубли/тенге. Хранится как
/// decimal(18,4) — точности достаточно.</summary>
public decimal Rate { get; set; }
/// <summary>Активна ли. Неактивные программы не применяются к новым
/// чекам; existing-карты ссылаются на programId, история сохраняется.</summary>
public bool IsActive { get; set; } = true;
/// <summary>Минимальная сумма чека для применения. 0 = без ограничения.</summary>
public decimal MinSubtotal { get; set; }
public string? Description { get; set; }
public ICollection<LoyaltyCard> Cards { get; set; } = new List<LoyaltyCard>();
}

View file

@ -0,0 +1,64 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Sales;
/// <summary>Тип скидки акции.</summary>
public enum PromotionType
{
/// <summary>Процент от Subtotal (или от MatchingSubtotal если ProductGroupIds/ProductIds задан).</summary>
Percent = 1,
/// <summary>Фиксированная сумма скидки в валюте чека.</summary>
FixedDiscount = 2,
}
/// <summary>Область применения акции — на весь чек или только на товары из
/// указанных групп/конкретные товары.</summary>
public enum PromotionScope
{
All = 1,
ProductGroups = 2,
Products = 3,
}
/// <summary>Промокод/акция. Применяется к розничному чеку при выбивании.
///
/// Применение: либо кассир вводит <see cref="Code"/> вручную, либо для
/// акций с пустым <see cref="Code"/> применяется автоматически если
/// чек удовлетворяет правилам (период + минимум + scope).
///
/// Период: <see cref="StartsAt"/> &lt;= sale.Date &lt; <see cref="EndsAt"/>
/// (если EndsAt = null — бессрочная). <see cref="IsActive"/> позволяет
/// быстро отключить акцию не меняя дат.</summary>
public class Promotion : TenantEntity
{
public string Name { get; set; } = null!;
public string? Description { get; set; }
/// <summary>Код для ручного ввода. null/empty = auto-apply акция (без кода).
/// Уникальный в рамках org через unique-index (`OrganizationId`, `Code`).
/// </summary>
public string? Code { get; set; }
public PromotionType Type { get; set; }
/// <summary>Значение скидки. Для Percent — [0..100], для FixedDiscount — в валюте чека.
/// Хранится как decimal(18,4).</summary>
public decimal Value { get; set; }
public PromotionScope Scope { get; set; } = PromotionScope.All;
/// <summary>Минимальная сумма чека для применения (на Subtotal). 0 = без ограничения.</summary>
public decimal MinSaleAmount { get; set; }
public DateTime StartsAt { get; set; } = DateTime.UtcNow;
public DateTime? EndsAt { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>Применимые ProductGroupId'ы (для Scope=ProductGroups). Сериализуется
/// как JSONB-массив; пустой массив = ни одной группы (никогда не сматчит).</summary>
public List<Guid> ProductGroupIds { get; set; } = new();
/// <summary>Применимые ProductId'ы (для Scope=Products).</summary>
public List<Guid> ProductIds { get; set; } = new();
}

View file

@ -42,7 +42,30 @@ public class RetailSale : TenantEntity, IVersionedEntity
public decimal Subtotal { get; set; } // sum of LineTotal before discount public decimal Subtotal { get; set; } // sum of LineTotal before discount
public decimal DiscountTotal { get; set; } public decimal DiscountTotal { get; set; }
public decimal Total { get; set; } // = Subtotal - DiscountTotal public decimal Total { get; set; } // = Subtotal - DiscountTotal - LoyaltyBonusApplied - PromotionDiscount
/// <summary>Скидка применённой лояльности в валюте чека. Снапшот на
/// момент проведения. Для Percentage/FixedAmount — сумма скидки (вычтена
/// из Total). Для PointsAccrual — 0 (скидки нет, начисляем в
/// <see cref="LoyaltyPointsAccrued"/>). </summary>
public decimal LoyaltyBonusApplied { get; set; }
/// <summary>Начисленные на карту баллы (для PointsAccrual). 0 для
/// остальных типов программ.</summary>
public decimal LoyaltyPointsAccrued { get; set; }
/// <summary>Применённая карта лояльности (если была). null = без программы.</summary>
public Guid? LoyaltyCardId { get; set; }
/// <summary>Скидка применённой акции/промокода. Снапшот на момент проведения.</summary>
public decimal PromotionDiscount { get; set; }
/// <summary>Применённая акция (если была). null = без промокода.</summary>
public Guid? PromotionId { get; set; }
/// <summary>Введённый код акции (snapshot, чтобы переименование Promotion
/// не теряло информации в истории).</summary>
public string? PromotionCode { get; set; }
public PaymentMethod Payment { get; set; } = PaymentMethod.Cash; public PaymentMethod Payment { get; set; } = PaymentMethod.Cash;
public decimal PaidCash { get; set; } public decimal PaidCash { get; set; }

View file

@ -63,6 +63,11 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>(); public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();
public DbSet<PosBatchAck> PosBatchAcks => Set<PosBatchAck>(); public DbSet<PosBatchAck> PosBatchAcks => Set<PosBatchAck>();
// Sprint 9: лояльность и акции.
public DbSet<LoyaltyProgram> LoyaltyPrograms => Set<LoyaltyProgram>();
public DbSet<LoyaltyCard> LoyaltyCards => Set<LoyaltyCard>();
public DbSet<Promotion> Promotions => Set<Promotion>();
public DbSet<Demand> Demands => Set<Demand>(); public DbSet<Demand> Demands => Set<Demand>();
public DbSet<DemandLine> DemandLines => Set<DemandLine>(); public DbSet<DemandLine> DemandLines => Set<DemandLine>();

View file

@ -98,5 +98,78 @@ public static void ConfigureSales(this ModelBuilder b)
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict); e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.ProductId }); e.HasIndex(x => new { x.OrganizationId, x.ProductId });
}); });
// ─ Sprint 9: Лояльность ─────────────────────────────────────────────
b.Entity<LoyaltyProgram>(e =>
{
e.ToTable("loyalty_programs");
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Description).HasMaxLength(500);
e.Property(x => x.Rate).HasPrecision(18, 4);
e.Property(x => x.MinSubtotal).HasPrecision(18, 4);
e.HasIndex(x => new { x.OrganizationId, x.IsActive });
});
b.Entity<LoyaltyCard>(e =>
{
e.ToTable("loyalty_cards");
e.Property(x => x.CardNumber).HasMaxLength(60).IsRequired();
e.Property(x => x.Balance).HasPrecision(18, 4);
e.HasOne(x => x.Program).WithMany(p => p.Cards).HasForeignKey(x => x.ProgramId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Counterparty).WithMany().HasForeignKey(x => x.CounterpartyId).OnDelete(DeleteBehavior.Restrict);
// CardNumber должен быть уникален в рамках org — кассир сканирует
// его как идентификатор, дубликаты будут давать неоднозначность.
e.HasIndex(x => new { x.OrganizationId, x.CardNumber }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.CounterpartyId });
});
// ─ Sprint 9: Промокоды / акции ──────────────────────────────────────
b.Entity<Promotion>(e =>
{
e.ToTable("promotions");
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Description).HasMaxLength(500);
e.Property(x => x.Code).HasMaxLength(40);
e.Property(x => x.Value).HasPrecision(18, 4);
e.Property(x => x.MinSaleAmount).HasPrecision(18, 4);
// JSONB-массивы Guid'ов — на постгресе компактно и индексируется
// gin'ом при необходимости. Конвертация через стандартный
// List<Guid> → jsonb (через value converter ниже).
e.Property(x => x.ProductGroupIds)
.HasColumnType("jsonb")
.HasConversion(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<Guid>(),
new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<Guid>>(
(a, b2) => (a ?? new()).SequenceEqual(b2 ?? new()),
v => v.Aggregate(0, (acc, g) => HashCode.Combine(acc, g.GetHashCode())),
v => v.ToList()));
e.Property(x => x.ProductIds)
.HasColumnType("jsonb")
.HasConversion(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<Guid>(),
new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<Guid>>(
(a, b2) => (a ?? new()).SequenceEqual(b2 ?? new()),
v => v.Aggregate(0, (acc, g) => HashCode.Combine(acc, g.GetHashCode())),
v => v.ToList()));
// Code в рамках org — уникален (если задан). Postgres unique index
// не считает NULL-значения распыляющимися (по умолчанию NULL distinct
// = NULL DISTINCT → discrete NULLs).
e.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.IsActive });
});
// RetailSale: добавили loyalty/promotion-снапшоты (нужны явные
// precision'ы, иначе EF Warning'и). См. RetailSale.cs domain.
b.Entity<RetailSale>(e =>
{
e.Property(x => x.LoyaltyBonusApplied).HasPrecision(18, 4);
e.Property(x => x.LoyaltyPointsAccrued).HasPrecision(18, 4);
e.Property(x => x.PromotionDiscount).HasPrecision(18, 4);
e.Property(x => x.PromotionCode).HasMaxLength(40);
});
} }
} }

View file

@ -0,0 +1,177 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase9b — лояльность и промокоды.
///
/// Таблицы: <c>loyalty_programs</c>, <c>loyalty_cards</c>, <c>promotions</c>.
/// Колонки в <c>retail_sales</c>: <c>LoyaltyBonusApplied</c>,
/// <c>LoyaltyPointsAccrued</c>, <c>LoyaltyCardId</c>,
/// <c>PromotionDiscount</c>, <c>PromotionId</c>, <c>PromotionCode</c>.
/// FK на retail_sales опциональны (Cards/Promotions могут быть удалены —
/// чек remains; но мы ставим ON DELETE SET NULL не явно, EF сам сделает
/// nullable foreign key без cascade).</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260601100000_Phase9b_LoyaltyAndPromotions")]
public partial class Phase9b_LoyaltyAndPromotions : Migration
{
protected override void Up(MigrationBuilder b)
{
// ── loyalty_programs ────────────────────────────────────────────
b.CreateTable(
name: "loyalty_programs",
schema: "public",
columns: t => new
{
Id = t.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = t.Column<Guid>(type: "uuid", nullable: false),
Name = t.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = t.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
Type = t.Column<int>(type: "integer", nullable: false),
Rate = t.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
MinSubtotal = t.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
IsActive = t.Column<bool>(type: "boolean", nullable: false),
CreatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: t =>
{
t.PrimaryKey("PK_loyalty_programs", x => x.Id);
});
b.CreateIndex(
name: "IX_loyalty_programs_OrganizationId_IsActive",
schema: "public",
table: "loyalty_programs",
columns: new[] { "OrganizationId", "IsActive" });
// ── loyalty_cards ───────────────────────────────────────────────
b.CreateTable(
name: "loyalty_cards",
schema: "public",
columns: t => new
{
Id = t.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = t.Column<Guid>(type: "uuid", nullable: false),
ProgramId = t.Column<Guid>(type: "uuid", nullable: false),
CounterpartyId = t.Column<Guid>(type: "uuid", nullable: false),
CardNumber = t.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
Balance = t.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
IssuedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsBlocked = t.Column<bool>(type: "boolean", nullable: false),
CreatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: t =>
{
t.PrimaryKey("PK_loyalty_cards", x => x.Id);
t.ForeignKey(
name: "FK_loyalty_cards_loyalty_programs_ProgramId",
column: x => x.ProgramId,
principalSchema: "public",
principalTable: "loyalty_programs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
t.ForeignKey(
name: "FK_loyalty_cards_counterparties_CounterpartyId",
column: x => x.CounterpartyId,
principalSchema: "public",
principalTable: "counterparties",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
b.CreateIndex(
name: "IX_loyalty_cards_ProgramId",
schema: "public", table: "loyalty_cards",
column: "ProgramId");
b.CreateIndex(
name: "IX_loyalty_cards_CounterpartyId",
schema: "public", table: "loyalty_cards",
column: "CounterpartyId");
b.CreateIndex(
name: "IX_loyalty_cards_OrganizationId_CardNumber",
schema: "public", table: "loyalty_cards",
columns: new[] { "OrganizationId", "CardNumber" },
unique: true);
b.CreateIndex(
name: "IX_loyalty_cards_OrganizationId_CounterpartyId",
schema: "public", table: "loyalty_cards",
columns: new[] { "OrganizationId", "CounterpartyId" });
// ── promotions ──────────────────────────────────────────────────
b.CreateTable(
name: "promotions",
schema: "public",
columns: t => new
{
Id = t.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = t.Column<Guid>(type: "uuid", nullable: false),
Name = t.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = t.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
Code = t.Column<string>(type: "character varying(40)", maxLength: 40, nullable: true),
Type = t.Column<int>(type: "integer", nullable: false),
Value = t.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
Scope = t.Column<int>(type: "integer", nullable: false),
MinSaleAmount = t.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
StartsAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: false),
EndsAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsActive = t.Column<bool>(type: "boolean", nullable: false),
ProductGroupIds = t.Column<string>(type: "jsonb", nullable: false, defaultValue: "[]"),
ProductIds = t.Column<string>(type: "jsonb", nullable: false, defaultValue: "[]"),
CreatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: t =>
{
t.PrimaryKey("PK_promotions", x => x.Id);
});
b.CreateIndex(
name: "IX_promotions_OrganizationId_Code",
schema: "public", table: "promotions",
columns: new[] { "OrganizationId", "Code" },
unique: true);
b.CreateIndex(
name: "IX_promotions_OrganizationId_IsActive",
schema: "public", table: "promotions",
columns: new[] { "OrganizationId", "IsActive" });
// ── retail_sales: новые колонки ─────────────────────────────────
b.AddColumn<decimal>(
name: "LoyaltyBonusApplied", schema: "public", table: "retail_sales",
type: "numeric(18,4)", precision: 18, scale: 4, nullable: false, defaultValue: 0m);
b.AddColumn<decimal>(
name: "LoyaltyPointsAccrued", schema: "public", table: "retail_sales",
type: "numeric(18,4)", precision: 18, scale: 4, nullable: false, defaultValue: 0m);
b.AddColumn<Guid>(
name: "LoyaltyCardId", schema: "public", table: "retail_sales",
type: "uuid", nullable: true);
b.AddColumn<decimal>(
name: "PromotionDiscount", schema: "public", table: "retail_sales",
type: "numeric(18,4)", precision: 18, scale: 4, nullable: false, defaultValue: 0m);
b.AddColumn<Guid>(
name: "PromotionId", schema: "public", table: "retail_sales",
type: "uuid", nullable: true);
b.AddColumn<string>(
name: "PromotionCode", schema: "public", table: "retail_sales",
type: "character varying(40)", maxLength: 40, nullable: true);
}
protected override void Down(MigrationBuilder b)
{
b.DropColumn("PromotionCode", "public", "retail_sales");
b.DropColumn("PromotionId", "public", "retail_sales");
b.DropColumn("PromotionDiscount", "public", "retail_sales");
b.DropColumn("LoyaltyCardId", "public", "retail_sales");
b.DropColumn("LoyaltyPointsAccrued", "public", "retail_sales");
b.DropColumn("LoyaltyBonusApplied", "public", "retail_sales");
b.DropTable("promotions", "public");
b.DropTable("loyalty_cards", "public");
b.DropTable("loyalty_programs", "public");
}
}
}

View file

@ -40,6 +40,9 @@ import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage'
import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage'
import { DemandsPage } from '@/pages/DemandsPage' import { DemandsPage } from '@/pages/DemandsPage'
import { DemandEditPage } from '@/pages/DemandEditPage' import { DemandEditPage } from '@/pages/DemandEditPage'
import { LoyaltyProgramsPage } from '@/pages/LoyaltyProgramsPage'
import { LoyaltyCardsPage } from '@/pages/LoyaltyCardsPage'
import { PromotionsPage } from '@/pages/PromotionsPage'
import { OrgAuditLogPage } from '@/pages/OrgAuditLogPage' import { OrgAuditLogPage } from '@/pages/OrgAuditLogPage'
import { SalesReportPage } from '@/pages/SalesReportPage' import { SalesReportPage } from '@/pages/SalesReportPage'
import { StockReportPage } from '@/pages/StockReportPage' import { StockReportPage } from '@/pages/StockReportPage'
@ -157,6 +160,9 @@ export default function App() {
<Route path="/sales/demands" element={<DemandsPage />} /> <Route path="/sales/demands" element={<DemandsPage />} />
<Route path="/sales/demands/new" element={<DemandEditPage />} /> <Route path="/sales/demands/new" element={<DemandEditPage />} />
<Route path="/sales/demands/:id" element={<DemandEditPage />} /> <Route path="/sales/demands/:id" element={<DemandEditPage />} />
<Route path="/loyalty/programs" element={<RoleGuard roles={['Admin']}><LoyaltyProgramsPage /></RoleGuard>} />
<Route path="/loyalty/cards" element={<RoleGuard roles={['Admin']}><LoyaltyCardsPage /></RoleGuard>} />
<Route path="/promotions" element={<RoleGuard roles={['Admin']}><PromotionsPage /></RoleGuard>} />
<Route path="/audit-log" element={<RoleGuard roles={['Admin']}><OrgAuditLogPage /></RoleGuard>} /> <Route path="/audit-log" element={<RoleGuard roles={['Admin']}><OrgAuditLogPage /></RoleGuard>} />
<Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} /> <Route path="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} />
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} /> <Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} />

View file

@ -110,7 +110,12 @@ function buildNav(roles: string[]): NavSection[] {
if (isAdmin || isCashier) { if (isAdmin || isCashier) {
sections.push({ group: 'nav.section_sales', items: [ sections.push({ group: 'nav.section_sales', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'nav.retailSales' }, { to: '/sales/retail', icon: ShoppingCart, label: 'nav.retailSales' },
...(isAdmin ? [{ to: '/sales/demands', icon: Send, label: 'nav.demands' }] : []), ...(isAdmin ? [
{ to: '/sales/demands', icon: Send, label: 'nav.demands' },
{ to: '/promotions', icon: Tag, label: 'nav.promotions' },
{ to: '/loyalty/programs', icon: ShieldCheck, label: 'nav.loyaltyPrograms' },
{ to: '/loyalty/cards', icon: ShieldCheck, label: 'nav.loyaltyCards' },
] : []),
]}) ]})
} }

View file

@ -69,6 +69,9 @@
"supplierReturns": "Supplier returns", "supplierReturns": "Supplier returns",
"retailSales": "Retail sales", "retailSales": "Retail sales",
"demands": "Wholesale", "demands": "Wholesale",
"promotions": "Promotions",
"loyaltyPrograms": "Loyalty programs",
"loyaltyCards": "Loyalty cards",
"reportSales": "Sales", "reportSales": "Sales",
"reportStock": "Stock on date", "reportStock": "Stock on date",
"reportProfit": "Profit", "reportProfit": "Profit",

View file

@ -69,6 +69,9 @@
"supplierReturns": "Возвраты поставщикам", "supplierReturns": "Возвраты поставщикам",
"retailSales": "Розничные чеки", "retailSales": "Розничные чеки",
"demands": "Оптовые отгрузки", "demands": "Оптовые отгрузки",
"promotions": "Акции и промокоды",
"loyaltyPrograms": "Программы лояльности",
"loyaltyCards": "Карты лояльности",
"reportSales": "Продажи", "reportSales": "Продажи",
"reportStock": "Остатки на дату", "reportStock": "Остатки на дату",
"reportProfit": "Прибыль", "reportProfit": "Прибыль",

View file

@ -0,0 +1,153 @@
import { useEffect, useState } from 'react'
import { Plus } from 'lucide-react'
import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, AsyncSelect, Select } from '@/components/Field'
import { EmptyState } from '@/components/EmptyState'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
const URL = '/api/loyalty/cards'
interface CardDto {
id: string
programId: string; programName: string; programType: 1 | 2 | 3; programRate: number
counterpartyId: string; counterpartyName: string
cardNumber: string; balance: number; issuedAt: string; isBlocked: boolean
}
interface ProgramOption { id: string; name: string }
const TYPE_LABEL: Record<number, string> = { 1: 'Скидка %', 2: 'Фикс. скидка', 3: 'Баллы' }
interface Form {
programId: string
counterpartyId: string
cardNumber: string
}
const blank = (): Form => ({ programId: '', counterpartyId: '', cardNumber: '' })
export function LoyaltyCardsPage() {
const list = useCatalogList<CardDto>(URL)
const { remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const [programs, setPrograms] = useState<ProgramOption[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (form) {
// подгружаем активные программы при открытии диалога
api.get<{ items: { id: string; name: string; isActive: boolean }[] }>('/api/loyalty/programs?pageSize=200')
.then((r) => setPrograms(r.data.items.filter((p) => p.isActive).map((p) => ({ id: p.id, name: p.name }))))
.catch(() => {})
}
}, [form?.programId === undefined ? null : 'open'])
const issue = async () => {
if (!form) return
try {
await api.post(`${URL}/issue`, form)
setForm(null); setError(null)
list.refetch?.()
} catch (e) {
setError((e as Error).message)
}
}
const isEmpty = !list.isLoading && (list.data?.items.length ?? 0) === 0 && !list.search
return (
<ListPageShell
title="Карты лояльности"
description="Выпущенные карты постоянных покупателей."
actions={
<>
<SearchBar value={list.search} onChange={list.setSearch} placeholder="Поиск: номер, ФИО…" />
<Button onClick={() => { setForm(blank()); setError(null) }}>
<Plus className="w-4 h-4" /> Выпустить
</Button>
</>
}
footer={list.data && list.data.total > 0 && (
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
)}
>
{isEmpty ? (
<EmptyState
icon={Plus}
title="Карт пока нет"
description="Выпустите первую карту: выберите программу и контрагента, придумайте уникальный номер."
actionLabel="Выпустить карту"
onAction={() => { setForm(blank()); setError(null) }}
/>
) : (
<DataTable
rows={list.data?.items ?? []}
isLoading={list.isLoading}
rowKey={(r) => r.id}
columns={[
{ header: 'Номер', sortKey: 'cardNumber', cell: (r) => (
<div>
<div className="font-mono font-medium">{r.cardNumber}</div>
<div className="text-xs text-slate-400">{new Date(r.issuedAt).toLocaleDateString('ru')}</div>
</div>
)},
{ header: 'Владелец', cell: (r) => r.counterpartyName },
{ header: 'Программа', cell: (r) => (
<div>
<div>{r.programName}</div>
<div className="text-xs text-slate-400">{TYPE_LABEL[r.programType]} · {r.programType === 2 ? `${r.programRate}` : `${r.programRate}%`}</div>
</div>
)},
{ header: 'Баланс', width: '120px', className: 'text-right font-mono', cell: (r) => r.balance.toLocaleString('ru', { maximumFractionDigits: 0 }) },
{ header: 'Статус', width: '120px', cell: (r) => r.isBlocked
? <span className="text-xs px-1.5 py-0.5 rounded bg-rose-100 text-rose-700">Заблокирована</span>
: <span className="text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700">Активна</span> },
]}
/>
)}
<Modal
open={!!form}
onClose={() => { setForm(null); setError(null) }}
title="Выпустить карту лояльности"
footer={form && (
<>
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={issue} disabled={!form.programId || !form.counterpartyId || !form.cardNumber.trim()}>
Выпустить
</Button>
</>
)}
>
{form && (
<div className="space-y-3">
<Field label="Программа *">
<Select value={form.programId} onChange={(e) => setForm({ ...form, programId: e.target.value })}>
<option value=""></option>
{programs.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</Select>
</Field>
<Field label="Контрагент *">
<AsyncSelect
url="/api/catalog/counterparties"
value={form.counterpartyId}
onChange={(v) => setForm({ ...form, counterpartyId: v })}
placeholder="Выберите контрагента"
/>
</Field>
<Field label="Номер карты *">
<TextInput value={form.cardNumber} onChange={(e) => setForm({ ...form, cardNumber: e.target.value })}
placeholder="например, VIP-001" />
</Field>
{error && <div className="text-sm text-red-600 p-2 rounded bg-red-50">{error}</div>}
</div>
)}
</Modal>
</ListPageShell>
)
}

View file

@ -0,0 +1,187 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Select, MoneyInput, Checkbox } from '@/components/Field'
import { EmptyState } from '@/components/EmptyState'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
const URL = '/api/loyalty/programs'
type ProgramType = 1 | 2 | 3
const TYPE_LABEL: Record<ProgramType, string> = {
1: 'Скидка %',
2: 'Фикс. скидка',
3: 'Бонусные баллы',
}
interface ProgramDto {
id: string
name: string
type: ProgramType
rate: number
minSubtotal: number
isActive: boolean
description: string | null
cardsCount: number
}
interface Form {
id?: string
name: string
type: ProgramType
rate: number
minSubtotal: number
isActive: boolean
description: string
}
const blank = (): Form => ({ name: '', type: 1, rate: 5, minSubtotal: 0, isActive: true, description: '' })
export function LoyaltyProgramsPage() {
const list = useCatalogList<ProgramDto>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form | null>(null)
const [error, setError] = useState<string | null>(null)
const save = async () => {
if (!form) return
const payload = {
name: form.name.trim(), type: form.type, rate: form.rate,
minSubtotal: form.minSubtotal, isActive: form.isActive,
description: form.description.trim() || null,
}
try {
if (form.id) await update.mutateAsync({ id: form.id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
setError(null)
} catch (e) {
setError((e as Error).message)
}
}
const isEmpty = !list.isLoading && (list.data?.items.length ?? 0) === 0 && !list.search
return (
<>
<ListPageShell
title="Программы лояльности"
description="Скидки и бонусные баллы для постоянных покупателей."
actions={
<>
<SearchBar value={list.search} onChange={list.setSearch} />
<Button onClick={() => { setForm(blank()); setError(null) }}>
<Plus className="w-4 h-4" /> Добавить
</Button>
</>
}
footer={list.data && list.data.total > 0 && (
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
)}
>
{isEmpty ? (
<EmptyState
icon={Plus}
title="Программ лояльности пока нет"
description="Программа определяет правило скидки или начисления баллов постоянному покупателю при выбивании чека."
actionLabel="Создать программу"
onAction={() => { setForm(blank()); setError(null) }}
/>
) : (
<DataTable
rows={list.data?.items ?? []}
isLoading={list.isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, type: r.type, rate: r.rate,
minSubtotal: r.minSubtotal, isActive: r.isActive,
description: r.description ?? '',
})}
columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => (
<div>
<div className="font-medium">{r.name}</div>
{r.description && <div className="text-xs text-slate-400 truncate max-w-md">{r.description}</div>}
</div>
)},
{ header: 'Тип', width: '170px', cell: (r) => TYPE_LABEL[r.type] },
{ header: 'Ставка', width: '110px', className: 'text-right font-mono', cell: (r) =>
r.type === 2 ? `${r.rate.toLocaleString('ru')}` : `${r.rate}%` },
{ header: 'Мин. чек', width: '120px', className: 'text-right font-mono', cell: (r) =>
r.minSubtotal > 0 ? `${r.minSubtotal.toLocaleString('ru')}` : '—' },
{ header: 'Карт', width: '90px', className: 'text-right', cell: (r) => r.cardsCount },
{ header: 'Статус', width: '110px', cell: (r) => r.isActive
? <span className="text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700">Активна</span>
: <span className="text-xs px-1.5 py-0.5 rounded bg-slate-200 text-slate-600">Выключена</span> },
]}
/>
)}
</ListPageShell>
<Modal
open={!!form}
onClose={() => { setForm(null); setError(null) }}
title={form?.id ? 'Изменить программу' : 'Новая программа'}
footer={form && (
<>
{form.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (await confirm({
title: 'Удалить программу?',
description: <>«{form.name}»? Это возможно если ни одна карта не связана.</>,
confirmLabel: 'Удалить',
})) {
try {
await remove.mutateAsync(form.id!)
setForm(null); setError(null)
} catch (e) { setError((e as Error).message) }
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name?.trim()}>Сохранить</Button>
</>
)}
>
{form && (
<div className="space-y-3">
<Field label="Название *">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Тип">
<Select value={String(form.type)} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as ProgramType })}>
<option value="1">Скидка % от суммы чека</option>
<option value="2">Фиксированная скидка в </option>
<option value="3">Начисление бонусных баллов %</option>
</Select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={form.type === 2 ? 'Сумма скидки, ₸' : 'Ставка, %'}>
<MoneyInput value={form.rate} onChange={(n) => setForm({ ...form, rate: n ?? 0 })} allowFractional />
</Field>
<Field label="Минимум чека, ₸">
<MoneyInput value={form.minSubtotal} onChange={(n) => setForm({ ...form, minSubtotal: n ?? 0 })} allowFractional />
</Field>
</div>
<Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
{error && <div className="text-sm text-red-600 p-2 rounded bg-red-50">{error}</div>}
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -0,0 +1,218 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Select, MoneyInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { EmptyState } from '@/components/EmptyState'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
const URL = '/api/promotions'
type Type = 1 | 2
const TYPE_LABEL: Record<Type, string> = { 1: 'Процент %', 2: 'Фикс. ₸' }
interface PromotionDto {
id: string
name: string
description: string | null
code: string | null
type: Type
value: number
scope: 1 | 2 | 3
minSaleAmount: number
startsAt: string
endsAt: string | null
isActive: boolean
productGroupIds: string[]
productIds: string[]
}
interface Form {
id?: string
name: string
description: string
code: string
type: Type
value: number
scope: 1 | 2 | 3
minSaleAmount: number
startsAt: string
endsAt: string
isActive: boolean
}
const todayIso = () => new Date().toISOString().slice(0, 10)
const blank = (): Form => ({
name: '', description: '', code: '', type: 1, value: 10, scope: 1,
minSaleAmount: 0, startsAt: todayIso(), endsAt: '', isActive: true,
})
export function PromotionsPage() {
const list = useCatalogList<PromotionDto>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form | null>(null)
const [error, setError] = useState<string | null>(null)
const save = async () => {
if (!form) return
const payload = {
name: form.name.trim(),
description: form.description.trim() || null,
code: form.code.trim() || null,
type: form.type, value: form.value, scope: form.scope,
minSaleAmount: form.minSaleAmount,
startsAt: new Date(form.startsAt).toISOString(),
endsAt: form.endsAt ? new Date(form.endsAt).toISOString() : null,
isActive: form.isActive,
productGroupIds: [],
productIds: [],
}
try {
if (form.id) await update.mutateAsync({ id: form.id, input: payload })
else await create.mutateAsync(payload)
setForm(null); setError(null)
} catch (e) {
setError((e as Error).message)
}
}
const isEmpty = !list.isLoading && (list.data?.items.length ?? 0) === 0 && !list.search
return (
<>
<ListPageShell
title="Акции и промокоды"
description="Скидки на чек по коду или по периоду."
actions={
<>
<SearchBar value={list.search} onChange={list.setSearch} placeholder="Поиск: код, название…" />
<Button onClick={() => { setForm(blank()); setError(null) }}>
<Plus className="w-4 h-4" /> Добавить
</Button>
</>
}
footer={list.data && list.data.total > 0 && (
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
)}
>
{isEmpty ? (
<EmptyState
icon={Plus}
title="Акций пока нет"
description="Создайте акцию — постоянную скидку по коду или сезонную распродажу по периоду."
actionLabel="Создать акцию"
onAction={() => { setForm(blank()); setError(null) }}
/>
) : (
<DataTable
rows={list.data?.items ?? []}
isLoading={list.isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, description: r.description ?? '', code: r.code ?? '',
type: r.type, value: r.value, scope: r.scope, minSaleAmount: r.minSaleAmount,
startsAt: r.startsAt.slice(0, 10),
endsAt: r.endsAt ? r.endsAt.slice(0, 10) : '',
isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => (
<div>
<div className="font-medium">{r.name}</div>
{r.code && <div className="text-xs font-mono text-emerald-700">«{r.code}»</div>}
</div>
)},
{ header: 'Тип', width: '130px', cell: (r) => `${TYPE_LABEL[r.type]} ${r.type === 1 ? r.value + '%' : r.value.toLocaleString('ru') + ' ₸'}` },
{ header: 'Период', width: '180px', cell: (r) => (
<span className="text-xs">
{new Date(r.startsAt).toLocaleDateString('ru')}{r.endsAt ? ` ${new Date(r.endsAt).toLocaleDateString('ru')}` : ' ∞'}
</span>
)},
{ header: 'Мин. чек', width: '110px', className: 'text-right font-mono', cell: (r) => r.minSaleAmount > 0 ? `${r.minSaleAmount.toLocaleString('ru')}` : '—' },
{ header: 'Статус', width: '110px', cell: (r) => r.isActive
? <span className="text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700">Активна</span>
: <span className="text-xs px-1.5 py-0.5 rounded bg-slate-200 text-slate-600">Выключена</span> },
]}
/>
)}
</ListPageShell>
<Modal
open={!!form}
onClose={() => { setForm(null); setError(null) }}
title={form?.id ? 'Изменить акцию' : 'Новая акция'}
width="max-w-2xl"
footer={form && (
<>
{form.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (await confirm({
title: 'Удалить акцию?',
description: <>«{form.name}». История применений в чеках сохранится (snapshot).</>,
confirmLabel: 'Удалить',
})) {
try { await remove.mutateAsync(form.id!); setForm(null); setError(null) }
catch (e) { setError((e as Error).message) }
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name?.trim()}>Сохранить</Button>
</>
)}
>
{form && (
<div className="space-y-3">
<Field label="Название *">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Код">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })}
placeholder="оставьте пустым для авто-применения" />
</Field>
<Field label="Тип">
<Select value={String(form.type)} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as Type })}>
<option value="1">Процент % от чека</option>
<option value="2">Фиксированная скидка в </option>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label={form.type === 2 ? 'Сумма скидки, ₸' : 'Размер, %'}>
<MoneyInput value={form.value} onChange={(n) => setForm({ ...form, value: n ?? 0 })} allowFractional />
</Field>
<Field label="Минимум чека, ₸">
<MoneyInput value={form.minSaleAmount} onChange={(n) => setForm({ ...form, minSaleAmount: n ?? 0 })} allowFractional />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="С *">
<DateField value={form.startsAt || null} onChange={(iso) => setForm({ ...form, startsAt: iso ?? '' })} />
</Field>
<Field label="По (опционально)">
<DateField value={form.endsAt || null} onChange={(iso) => setForm({ ...form, endsAt: iso ?? '' })} />
</Field>
</div>
<Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
{error && <div className="text-sm text-red-600 p-2 rounded bg-red-50">{error}</div>}
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -0,0 +1,217 @@
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()!);
}
}

View file

@ -0,0 +1,137 @@
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()!);
}
}