feat(loyalty+promotions): P2-12 + P2-13 — лояльность и промокоды (Sprint 9 п.1-2)
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled

Domain:
- LoyaltyProgram { Type=Percentage|FixedAmount|PointsAccrual, Rate,
  MinSubtotal, IsActive } — org-scoped.
- LoyaltyCard { ProgramId, CounterpartyId, CardNumber unique per org,
  Balance, IsBlocked }.
- Promotion { Type=Percent|FixedDiscount, Value, Scope=All|ProductGroups|
  Products, Code unique per org, period, ProductGroupIds/ProductIds (jsonb) }.
- RetailSale: LoyaltyCardId, LoyaltyBonusApplied, LoyaltyPointsAccrued,
  PromotionId, PromotionCode (snapshot), PromotionDiscount.

EF:
- SalesConfigurations: indexes, FK Restrict, jsonb-converters для Guid-
  списков Promotion (ValueComparer для change-tracker).
- Phase9b миграция: 3 таблицы + 6 колонок на retail_sales.
- RolePermissions: LoyaltyManage, PromotionsManage добавлены (попадают
  в All() для Admin).

API:
- /api/loyalty/programs CRUD (Get/List/Create/Update/Delete; запрет delete
  при существующих картах → 409).
- /api/loyalty/cards CRUD + /issue + /{id}/block + /{id}/unblock + /lookup
  (POS использует при оплате — 404 если нет, 409 если blocked/inactive).
- /api/promotions CRUD; код уникален per org (БД-индекс + 23505 → 409).
- RetailSale.Create/Update: новые поля input.LoyaltyCardNumber +
  input.PromotionCode. Метод ApplyLoyaltyAndPromotionAsync:
  • Lookup карты, проверка active/blocked/MinSubtotal.
  • Расчёт скидки или баллов в зависимости от Type.
  • Lookup промокода, проверка периода/MinSaleAmount/scope.
  • MatchingSubtotal для Scope=ProductGroups/Products считаем по
    input.Lines (sale.Lines ещё пустой в этот момент).
  • Финальный Total = Subtotal - DiscountTotal - LoyaltyBonusApplied
    - PromotionDiscount, max(0).
- RetailSale.Post: начисление баллов на LoyaltyCard.Balance (внутри
  транзакции, чтобы rollback не оставил orphan баллы).

UI:
- /loyalty/programs — list + create/edit modal с Type/Rate/MinSubtotal.
- /loyalty/cards — list + issue modal (Program select + AsyncSelect
  counterparty + CardNumber).
- /promotions — list + create/edit modal (Type/Value/период/MinSaleAmount/Code).
- Sidebar: новый блок «Продажи» с пунктами Промокоды/Программы/Карты
  (Admin-only).
- i18n: ru.json + en.json пополнены nav-ключами.

Тесты:
- LoyaltyFlowTests (3/3 ✓): percentage уменьшает Total на 10%, points-accrual
  пополняет Balance после Post, multi-tenant lookup→404 чужой org.
- PromotionFlowTests (2/2 ✓): SALE20 уменьшает Total на 20%, невалидный
  код→400 с понятной message и field=promotionCode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-31 21:06:10 +05:00
parent a5314b5be9
commit 91128a7ed0
22 changed files with 1983 additions and 5 deletions

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

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

View file

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

View file

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

View file

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

View file

@ -55,7 +55,15 @@ public record RetailSaleDto(
PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
string? Notes, DateTime? PostedAt,
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(
Guid ProductId,
@ -71,7 +79,16 @@ public record RetailSaleInput(
string? Notes,
IReadOnlyList<RetailSaleLineInput> Lines,
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);
@ -247,12 +264,134 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null,
};
ApplyLines(sale, input.Lines, allowFractional);
// Sprint 9: лояльность + промокод. Возвращает 400 если код невалидный.
if (await ApplyLoyaltyAndPromotionAsync(sale, input, allowFractional, ct) is { } loyErr)
return loyErr;
_db.RetailSales.Add(sale);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(sale.Id, ct);
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).
/// Возвращает 400 с указанием поля если FK не сошёлся (например, StoreId
/// или 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);
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;
return NoContent();
@ -419,6 +563,18 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
sale.Status = RetailSaleStatus.Posted;
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 tx.CommitAsync(ct);
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.Notes, row.s.PostedAt,
row.s.IsReturn, row.s.ReferenceSaleId, refNumber,
lines);
lines,
// Sprint 9
row.s.LoyaltyCardId, row.s.LoyaltyBonusApplied, row.s.LoyaltyPointsAccrued,
row.s.PromotionId, row.s.PromotionCode, row.s.PromotionDiscount);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -42,7 +42,30 @@ public class RetailSale : TenantEntity, IVersionedEntity
public decimal Subtotal { get; set; } // sum of LineTotal before discount
public decimal 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 decimal PaidCash { get; set; }

View file

@ -63,6 +63,11 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();
public DbSet<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<DemandLine> DemandLines => Set<DemandLine>();

View file

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

View file

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

View file

@ -40,6 +40,9 @@ import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage'
import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage'
import { DemandsPage } from '@/pages/DemandsPage'
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 { SalesReportPage } from '@/pages/SalesReportPage'
import { StockReportPage } from '@/pages/StockReportPage'
@ -157,6 +160,9 @@ export default function App() {
<Route path="/sales/demands" element={<DemandsPage />} />
<Route path="/sales/demands/new" 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="/admin/import/moysklad" element={<RoleGuard roles={['Admin']}><MoySkladImportPage /></RoleGuard>} />
<Route path="/settings/organization" element={<RoleGuard roles={['Admin']}><OrganizationSettingsPage /></RoleGuard>} />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,217 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>End-to-end лояльности: создание программы Percentage 10% →
/// выпуск карты → создание чека с loyaltyCardNumber → проверка Total
/// уменьшился ровно на 10% от Subtotal.
///
/// Multi-tenant: B не видит программу/карту A.</summary>
[Collection(ApiCollection.Name)]
public class LoyaltyFlowTests
{
private readonly ApiFactory _factory;
public LoyaltyFlowTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Percentage_program_discounts_total_in_retail_sale()
{
var actor = new ApiActor(_factory.CreateClient());
var email = $"loy-{Guid.NewGuid():N}@example.kz";
(await actor.SignupAsync(email, "Passw0rd!", "Loy Org")).EnsureSuccessStatusCode();
actor.UseToken(await actor.TokenAsync(email, "Passw0rd!"));
// Программа Percentage 10%
var progResp = await actor.Http.PostAsJsonAsync("/api/loyalty/programs", new
{
name = "Постоянник 10%", type = 1 /* Percentage */, rate = 10m,
minSubtotal = 0m, isActive = true, description = (string?)null,
});
progResp.EnsureSuccessStatusCode();
var program = await progResp.Content.ReadFromJsonAsync<JsonElement>();
var programId = program.GetProperty("id").GetString()!;
// Контрагент-покупатель
var cpResp = await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "VIP покупатель", type = 1 /* Individual */ });
cpResp.EnsureSuccessStatusCode();
var cp = await cpResp.Content.ReadFromJsonAsync<JsonElement>();
var counterpartyId = cp.GetProperty("id").GetString()!;
// Карта
var cardResp = await actor.Http.PostAsJsonAsync("/api/loyalty/cards/issue", new
{
programId, counterpartyId, cardNumber = "VIP-001",
});
cardResp.EnsureSuccessStatusCode();
// Lookup карты должен вернуть данные
var lookupResp = await actor.Http.GetAsync("/api/loyalty/cards/lookup?number=VIP-001");
lookupResp.EnsureSuccessStatusCode();
var lookup = await lookupResp.Content.ReadFromJsonAsync<JsonElement>();
lookup.GetProperty("programType").GetInt32().Should().Be(1); // Percentage
// Сидим товар + остаток через приёмку
var (productId, storeId, retailPointId, currencyId) = await SeedProductAsync(actor);
// Создаём чек с loyaltyCardNumber=VIP-001, Subtotal=1000
var saleResp = await actor.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow,
storeId, retailPointId, currencyId,
payment = 0, isReturn = false,
lines = new[] { new { productId, quantity = 10m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
subtotal = 1000m, discountTotal = 0m, total = 1000m,
paidCash = 900m, paidCard = 0m,
loyaltyCardNumber = "VIP-001",
});
saleResp.EnsureSuccessStatusCode();
var sale = await saleResp.Content.ReadFromJsonAsync<JsonElement>();
// 10% от 1000 = 100 → loyaltyBonusApplied=100, total=900
sale.GetProperty("loyaltyBonusApplied").GetDecimal().Should().Be(100m);
sale.GetProperty("total").GetDecimal().Should().Be(900m);
sale.GetProperty("loyaltyCardId").GetString().Should().NotBeNullOrEmpty();
}
[Fact]
public async Task Points_accrual_credits_card_balance_on_post()
{
var actor = new ApiActor(_factory.CreateClient());
var email = $"loy2-{Guid.NewGuid():N}@example.kz";
(await actor.SignupAsync(email, "Passw0rd!", "Loy2 Org")).EnsureSuccessStatusCode();
actor.UseToken(await actor.TokenAsync(email, "Passw0rd!"));
// Программа PointsAccrual 5% → 5% от чека идёт в Balance
var progResp = await actor.Http.PostAsJsonAsync("/api/loyalty/programs", new
{
name = "Баллы 5%", type = 3 /* PointsAccrual */, rate = 5m,
minSubtotal = 0m, isActive = true, description = (string?)null,
});
progResp.EnsureSuccessStatusCode();
var programId = (await progResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
var cpResp = await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "Bonus Buyer", type = 1 });
cpResp.EnsureSuccessStatusCode();
var counterpartyId = (await cpResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
var cardResp = await actor.Http.PostAsJsonAsync("/api/loyalty/cards/issue", new
{
programId, counterpartyId, cardNumber = "BNS-001",
});
cardResp.EnsureSuccessStatusCode();
var cardId = (await cardResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
var (productId, storeId, retailPointId, currencyId) = await SeedProductAsync(actor);
// Создаём чек: Subtotal=2000, total остаётся 2000 (баллы не уменьшают)
var saleResp = await actor.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow,
storeId, retailPointId, currencyId,
payment = 0, isReturn = false,
lines = new[] { new { productId, quantity = 20m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
subtotal = 2000m, discountTotal = 0m, total = 2000m,
paidCash = 2000m, paidCard = 0m,
loyaltyCardNumber = "BNS-001",
});
saleResp.EnsureSuccessStatusCode();
var sale = await saleResp.Content.ReadFromJsonAsync<JsonElement>();
sale.GetProperty("loyaltyPointsAccrued").GetDecimal().Should().Be(100m); // 5% от 2000
sale.GetProperty("total").GetDecimal().Should().Be(2000m); // не уменьшилось
var saleId = sale.GetProperty("id").GetString()!;
// Post чек → баланс карты должен стать 100
(await actor.Http.PostAsync($"/api/sales/retail/{saleId}/post", null)).EnsureSuccessStatusCode();
var cardListResp = await actor.Http.GetAsync("/api/loyalty/cards?pageSize=10");
cardListResp.EnsureSuccessStatusCode();
var cardList = await cardListResp.Content.ReadFromJsonAsync<JsonElement>();
var card = cardList.GetProperty("items").EnumerateArray().First(x => x.GetProperty("id").GetString() == cardId);
card.GetProperty("balance").GetDecimal().Should().Be(100m);
}
[Fact]
public async Task Multi_tenant_isolation_lookup_returns_404_for_other_org_card()
{
var actorA = new ApiActor(_factory.CreateClient());
var emailA = $"loyA-{Guid.NewGuid():N}@example.kz";
(await actorA.SignupAsync(emailA, "Passw0rd!", "LoyA Org")).EnsureSuccessStatusCode();
actorA.UseToken(await actorA.TokenAsync(emailA, "Passw0rd!"));
var actorB = new ApiActor(_factory.CreateClient());
var emailB = $"loyB-{Guid.NewGuid():N}@example.kz";
(await actorB.SignupAsync(emailB, "Passw0rd!", "LoyB Org")).EnsureSuccessStatusCode();
actorB.UseToken(await actorB.TokenAsync(emailB, "Passw0rd!"));
// A создаёт программу + карту ISOL-A-001
var progA = await (await actorA.Http.PostAsJsonAsync("/api/loyalty/programs", new
{
name = "p", type = 1, rate = 10m, minSubtotal = 0m, isActive = true, description = (string?)null,
})).Content.ReadFromJsonAsync<JsonElement>();
var cpA = await (await actorA.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "cp", type = 1 })).Content.ReadFromJsonAsync<JsonElement>();
var cardResp = await actorA.Http.PostAsJsonAsync("/api/loyalty/cards/issue", new
{
programId = progA.GetProperty("id").GetString(),
counterpartyId = cpA.GetProperty("id").GetString(),
cardNumber = "ISOL-A-001",
});
cardResp.EnsureSuccessStatusCode();
// B пытается lookup → 404
var lookupB = await actorB.Http.GetAsync("/api/loyalty/cards/lookup?number=ISOL-A-001");
lookupB.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
private async Task<(string ProductId, string StoreId, string RetailPointId, string CurrencyId)>
SeedProductAsync(ApiActor actor)
{
var units = (await actor.GetJsonAsync("/api/catalog/units-of-measure?pageSize=200"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "796");
var groups = (await actor.GetJsonAsync("/api/catalog/product-groups"))
.GetProperty("items").EnumerateArray().First();
var pts = (await actor.GetJsonAsync("/api/catalog/price-types"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("isRetail").GetBoolean());
var curs = (await actor.GetJsonAsync("/api/catalog/currencies"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "KZT");
var stores = (await actor.GetJsonAsync("/api/catalog/stores"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("isMain").GetBoolean());
var retailPoints = (await actor.GetJsonAsync("/api/catalog/retail-points"))
.GetProperty("items").EnumerateArray().First();
var prodResp = await actor.Http.PostAsJsonAsync("/api/catalog/products", new
{
name = "Loy test", article = $"LOY-{Guid.NewGuid():N}",
unitOfMeasureId = units.GetProperty("id").GetString(),
vat = 12, vatEnabled = true,
productGroupId = groups.GetProperty("id").GetString(),
packaging = 1,
prices = new[] { new { priceTypeId = pts.GetProperty("id").GetString(), amount = 100m, currencyId = curs.GetProperty("id").GetString() } },
barcodes = new[] { new { code = $"600000{Guid.NewGuid().GetHashCode():X}".Replace("-", "").Substring(0, 12) + "0", type = 1, isPrimary = true } },
});
prodResp.EnsureSuccessStatusCode();
var prod = await prodResp.Content.ReadFromJsonAsync<JsonElement>();
var productId = prod.GetProperty("id").GetString()!;
var supplier = await (await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "Sup", type = 2 })).Content.ReadFromJsonAsync<JsonElement>();
var supply = await (await actor.Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow,
supplierId = supplier.GetProperty("id").GetString(),
storeId = stores.GetProperty("id").GetString(),
currencyId = curs.GetProperty("id").GetString(),
lines = new[] { new { productId, quantity = 100m, unitPrice = 50m } },
})).Content.ReadFromJsonAsync<JsonElement>();
(await actor.Http.PostAsync($"/api/purchases/supplies/{supply.GetProperty("id").GetString()}/post", null))
.EnsureSuccessStatusCode();
return (productId,
stores.GetProperty("id").GetString()!,
retailPoints.GetProperty("id").GetString()!,
curs.GetProperty("id").GetString()!);
}
}

View file

@ -0,0 +1,137 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Промокод: создание + применение к чеку (Percent от Subtotal,
/// scope=All) + проверка что Total уменьшился ровно на промо-скидку.
/// Проверка валидации: невалидный код → 400 с понятным сообщением.</summary>
[Collection(ApiCollection.Name)]
public class PromotionFlowTests
{
private readonly ApiFactory _factory;
public PromotionFlowTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Promocode_percent_discounts_total()
{
var actor = new ApiActor(_factory.CreateClient());
var email = $"pro-{Guid.NewGuid():N}@example.kz";
(await actor.SignupAsync(email, "Passw0rd!", "Pro Org")).EnsureSuccessStatusCode();
actor.UseToken(await actor.TokenAsync(email, "Passw0rd!"));
// Промокод SALE20 = 20%
var promoResp = await actor.Http.PostAsJsonAsync("/api/promotions", new
{
name = "Скидка 20%",
description = (string?)null,
code = "SALE20",
type = 1 /* Percent */,
value = 20m,
scope = 1 /* All */,
minSaleAmount = 0m,
startsAt = DateTime.UtcNow.AddDays(-1),
endsAt = (DateTime?)null,
isActive = true,
productGroupIds = Array.Empty<Guid>(),
productIds = Array.Empty<Guid>(),
});
promoResp.EnsureSuccessStatusCode();
var (productId, storeId, retailPointId, currencyId) = await SeedAsync(actor);
// Чек на 500, промокод SALE20 → total = 400
var saleResp = await actor.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow,
storeId, retailPointId, currencyId,
payment = 0, isReturn = false,
lines = new[] { new { productId, quantity = 5m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
subtotal = 500m, discountTotal = 0m, total = 500m,
paidCash = 500m, paidCard = 0m,
promotionCode = "SALE20",
});
saleResp.EnsureSuccessStatusCode();
var sale = await saleResp.Content.ReadFromJsonAsync<JsonElement>();
sale.GetProperty("promotionDiscount").GetDecimal().Should().Be(100m); // 20% от 500
sale.GetProperty("total").GetDecimal().Should().Be(400m);
sale.GetProperty("promotionCode").GetString().Should().Be("SALE20");
}
[Fact]
public async Task Invalid_promocode_returns_400_with_message()
{
var actor = new ApiActor(_factory.CreateClient());
var email = $"proi-{Guid.NewGuid():N}@example.kz";
(await actor.SignupAsync(email, "Passw0rd!", "ProI Org")).EnsureSuccessStatusCode();
actor.UseToken(await actor.TokenAsync(email, "Passw0rd!"));
var (productId, storeId, retailPointId, currencyId) = await SeedAsync(actor);
var saleResp = await actor.Http.PostAsJsonAsync("/api/sales/retail", new
{
date = DateTime.UtcNow,
storeId, retailPointId, currencyId,
payment = 0, isReturn = false,
lines = new[] { new { productId, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
subtotal = 100m, discountTotal = 0m, total = 100m,
paidCash = 100m, paidCard = 0m,
promotionCode = "NOPE-NOEXIST",
});
saleResp.StatusCode.Should().Be(System.Net.HttpStatusCode.BadRequest);
var body = await saleResp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString().Should().MatchRegex("(NOPE|не активен|не найден)");
body.GetProperty("field").GetString().Should().Be("promotionCode");
}
private async Task<(string ProductId, string StoreId, string RetailPointId, string CurrencyId)>
SeedAsync(ApiActor actor)
{
var units = (await actor.GetJsonAsync("/api/catalog/units-of-measure?pageSize=200"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "796");
var groups = (await actor.GetJsonAsync("/api/catalog/product-groups"))
.GetProperty("items").EnumerateArray().First();
var pts = (await actor.GetJsonAsync("/api/catalog/price-types"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("isRetail").GetBoolean());
var curs = (await actor.GetJsonAsync("/api/catalog/currencies"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("code").GetString() == "KZT");
var stores = (await actor.GetJsonAsync("/api/catalog/stores"))
.GetProperty("items").EnumerateArray().First(x => x.GetProperty("isMain").GetBoolean());
var retailPoints = (await actor.GetJsonAsync("/api/catalog/retail-points"))
.GetProperty("items").EnumerateArray().First();
var prodResp = await actor.Http.PostAsJsonAsync("/api/catalog/products", new
{
name = "Promo test", article = $"PRM-{Guid.NewGuid():N}",
unitOfMeasureId = units.GetProperty("id").GetString(),
vat = 12, vatEnabled = true,
productGroupId = groups.GetProperty("id").GetString(),
packaging = 1,
prices = new[] { new { priceTypeId = pts.GetProperty("id").GetString(), amount = 100m, currencyId = curs.GetProperty("id").GetString() } },
barcodes = new[] { new { code = "7000000000017", type = 1, isPrimary = true } },
});
prodResp.EnsureSuccessStatusCode();
var prod = await prodResp.Content.ReadFromJsonAsync<JsonElement>();
var productId = prod.GetProperty("id").GetString()!;
var supplier = await (await actor.Http.PostAsJsonAsync("/api/catalog/counterparties",
new { name = "Sup", type = 2 })).Content.ReadFromJsonAsync<JsonElement>();
var supply = await (await actor.Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow,
supplierId = supplier.GetProperty("id").GetString(),
storeId = stores.GetProperty("id").GetString(),
currencyId = curs.GetProperty("id").GetString(),
lines = new[] { new { productId, quantity = 50m, unitPrice = 50m } },
})).Content.ReadFromJsonAsync<JsonElement>();
(await actor.Http.PostAsync($"/api/purchases/supplies/{supply.GetProperty("id").GetString()}/post", null))
.EnsureSuccessStatusCode();
return (productId,
stores.GetProperty("id").GetString()!,
retailPoints.GetProperty("id").GetString()!,
curs.GetProperty("id").GetString()!);
}
}