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
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:
parent
a5314b5be9
commit
91128a7ed0
30
docs/sprint9-progress.md
Normal file
30
docs/sprint9-progress.md
Normal 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).
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
src/food-market.domain/Sales/LoyaltyCard.cs
Normal file
33
src/food-market.domain/Sales/LoyaltyCard.cs
Normal 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; }
|
||||||
|
}
|
||||||
49
src/food-market.domain/Sales/LoyaltyProgram.cs
Normal file
49
src/food-market.domain/Sales/LoyaltyProgram.cs
Normal 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 > 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>();
|
||||||
|
}
|
||||||
64
src/food-market.domain/Sales/Promotion.cs
Normal file
64
src/food-market.domain/Sales/Promotion.cs
Normal 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"/> <= sale.Date < <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();
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>} />
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
] : []),
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@
|
||||||
"supplierReturns": "Возвраты поставщикам",
|
"supplierReturns": "Возвраты поставщикам",
|
||||||
"retailSales": "Розничные чеки",
|
"retailSales": "Розничные чеки",
|
||||||
"demands": "Оптовые отгрузки",
|
"demands": "Оптовые отгрузки",
|
||||||
|
"promotions": "Акции и промокоды",
|
||||||
|
"loyaltyPrograms": "Программы лояльности",
|
||||||
|
"loyaltyCards": "Карты лояльности",
|
||||||
"reportSales": "Продажи",
|
"reportSales": "Продажи",
|
||||||
"reportStock": "Остатки на дату",
|
"reportStock": "Остатки на дату",
|
||||||
"reportProfit": "Прибыль",
|
"reportProfit": "Прибыль",
|
||||||
|
|
|
||||||
153
src/food-market.web/src/pages/LoyaltyCardsPage.tsx
Normal file
153
src/food-market.web/src/pages/LoyaltyCardsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
187
src/food-market.web/src/pages/LoyaltyProgramsPage.tsx
Normal file
187
src/food-market.web/src/pages/LoyaltyProgramsPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
218
src/food-market.web/src/pages/PromotionsPage.tsx
Normal file
218
src/food-market.web/src/pages/PromotionsPage.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
217
tests/food-market.IntegrationTests/LoyaltyFlowTests.cs
Normal file
217
tests/food-market.IntegrationTests/LoyaltyFlowTests.cs
Normal 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()!);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
tests/food-market.IntegrationTests/PromotionFlowTests.cs
Normal file
137
tests/food-market.IntegrationTests/PromotionFlowTests.cs
Normal 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()!);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue