diff --git a/docs/sprint9-progress.md b/docs/sprint9-progress.md new file mode 100644 index 0000000..ba9449f --- /dev/null +++ b/docs/sprint9-progress.md @@ -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). diff --git a/src/food-market.api/Controllers/Loyalty/LoyaltyCardsController.cs b/src/food-market.api/Controllers/Loyalty/LoyaltyCardsController.cs new file mode 100644 index 0000000..54749b8 --- /dev/null +++ b/src/food-market.api/Controllers/Loyalty/LoyaltyCardsController.cs @@ -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; + +/// Карты лояльности: выпуск/удаление/просмотр баланса. CardNumber +/// уникален в рамках org — это позволяет кассиру сканировать его как +/// идентификатор. Просмотр по номеру (lookup) используется во время оплаты — +/// кассир сканирует, мы возвращаем привязанную программу и Counterparty. +[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>> 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 + { + Items = items, Total = total, Page = page, PageSize = take, + }); + } + + /// Lookup по CardNumber — используется кассой при оплате. Возвращает + /// 404 если карты нет, 409 если карта блокирована. Тенантовое разделение + /// гарантировано query-фильтром AppDbContext. + [HttpGet("lookup")] + public async Task> 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> 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 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 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 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(); + } +} diff --git a/src/food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs b/src/food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs new file mode 100644 index 0000000..50f25af --- /dev/null +++ b/src/food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs @@ -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; + +/// CRUD-программ лояльности. Org-scoped, Admin-only для мутаций; +/// чтение доступно всем tenant-ролям (используется при оформлении чека). +/// Не позволяем удалить программу с существующими картами — это бы оставило +/// orphan-карты и сломало history. +[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>> 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 + { + Items = items, Total = total, Page = page, PageSize = take, + }); + } + + [HttpGet("{id:guid}")] + public async Task> 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> 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 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 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(); + } +} diff --git a/src/food-market.api/Controllers/Promotions/PromotionsController.cs b/src/food-market.api/Controllers/Promotions/PromotionsController.cs new file mode 100644 index 0000000..6633fd5 --- /dev/null +++ b/src/food-market.api/Controllers/Promotions/PromotionsController.cs @@ -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; + +/// CRUD-промокодов/акций. Org-scoped. PromotionsManage permission. +/// Уникальность кода в рамках org — обеспечивается БД-индексом, но и в +/// контроллере отдаём 409 с понятным текстом если дубль. +[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 ProductGroupIds, IReadOnlyList 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 ProductGroupIds, List ProductIds); + + [HttpGet] + public async Task>> 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 + { + Items = items, Total = total, Page = page, PageSize = take, + }); + } + + [HttpGet("{id:guid}")] + public async Task> 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> 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(), + ProductIds = input.ProductIds ?? new List(), + }; + _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 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(); + p.ProductIds = input.ProductIds ?? new List(); + 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 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); +} diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index 6341d88..7b424b9 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -55,7 +55,15 @@ public record RetailSaleDto( PaymentMethod Payment, decimal PaidCash, decimal PaidCard, string? Notes, DateTime? PostedAt, bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber, - IReadOnlyList Lines); + IReadOnlyList 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 Lines, bool IsReturn = false, - Guid? ReferenceSaleId = null); + Guid? ReferenceSaleId = null, + /// Sprint 9: номер карты лояльности. Если задан — сервер + /// сматчит на active-карту, применит программу (скидка %, фикс или + /// начисление баллов), запишет snapshot в LoyaltyCardId / LoyaltyBonusApplied / + /// LoyaltyPointsAccrued. + string? LoyaltyCardNumber = null, + /// Sprint 9: код промокода. Если задан — сервер найдёт active- + /// promotion с этим кодом, проверит период/MinSaleAmount/Scope и + /// применит скидку (PromotionDiscount/PromotionId/PromotionCode). + string? PromotionCode = null); public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions); @@ -247,12 +264,134 @@ public async Task> 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); } + /// Применяет программу лояльности и промокод к чеку. + /// - LoyaltyCardNumber: lookup карты по номеру, проверка active/!blocked/ + /// program.IsActive, проверка MinSubtotal, расчёт скидки или баллов. + /// - PromotionCode: lookup promotion, проверка периода/IsActive/MinSaleAmount/ + /// scope, расчёт скидки. + /// + /// Total чека пересчитываем в конце: Subtotal - DiscountTotal - LoyaltyBonusApplied - + /// PromotionDiscount. Округляем по allowFractional. Если sale.IsReturn=true — + /// лояльность/промо игнорируем (возврат не накручивает баллы). + private async Task 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; + } + /// SaveChanges + перехват PostgresException 23503 (FK violation). /// Возвращает 400 с указанием поля если FK не сошёлся (например, StoreId /// или RetailPointId указывают на несуществующую запись) — это лучше @@ -307,6 +446,11 @@ public async Task 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 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); } } diff --git a/src/food-market.domain/Organizations/RolePermissions.cs b/src/food-market.domain/Organizations/RolePermissions.cs index e9ff968..fbc0c99 100644 --- a/src/food-market.domain/Organizations/RolePermissions.cs +++ b/src/food-market.domain/Organizations/RolePermissions.cs @@ -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; } + /// Полный набор всех true — для системной роли «Администратор». 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, }; } diff --git a/src/food-market.domain/Sales/LoyaltyCard.cs b/src/food-market.domain/Sales/LoyaltyCard.cs new file mode 100644 index 0000000..bc61a2c --- /dev/null +++ b/src/food-market.domain/Sales/LoyaltyCard.cs @@ -0,0 +1,33 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Sales; + +/// Карта лояльности — привязка покупателя (Counterparty) к программе. +/// Один Counterparty может иметь несколько карт (например для разных +/// программ); CardNumber должен быть уникален в рамках org. +/// +/// Balance — накопленные баллы (для ); +/// для Percentage/FixedAmount остаётся 0. Списание баллов планируется в +/// следующих итерациях. +public class LoyaltyCard : TenantEntity +{ + public Guid ProgramId { get; set; } + public LoyaltyProgram? Program { get; set; } + + public Guid CounterpartyId { get; set; } + public Counterparty? Counterparty { get; set; } + + /// Уникальный номер карты в рамках org. Может быть штрихкод + /// или строка-серийник, который кассир сканирует/вводит при оплате. + public string CardNumber { get; set; } = null!; + + /// Накопленные баллы для PointsAccrual-программ. Decimal(18,4). + public decimal Balance { get; set; } + + public DateTime IssuedAt { get; set; } = DateTime.UtcNow; + + /// Заблокирована ли (украдена/просрочена). Заблокированные карты + /// игнорируются при применении лояльности. + public bool IsBlocked { get; set; } +} diff --git a/src/food-market.domain/Sales/LoyaltyProgram.cs b/src/food-market.domain/Sales/LoyaltyProgram.cs new file mode 100644 index 0000000..e6b6d40 --- /dev/null +++ b/src/food-market.domain/Sales/LoyaltyProgram.cs @@ -0,0 +1,49 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Sales; + +/// Программа лояльности в магазине. Org-scoped, может быть несколько +/// (например «Скидка постоянных 5%» + «Бонусные баллы 3%»). +/// +/// Тип определяет что происходит при выписке чека покупателю с привязанной +/// картой: +/// +/// — скидка % +/// от итога чека (применяется на Subtotal до VAT). +/// — фиксированная скидка +/// ₸ с каждого чека (если Subtotal > rate). +/// — начисление баллов: +/// % от суммы чека идёт на баланс карты. Списания баллов +/// пока не делаем (next-iteration; можно использовать +/// карты в дальнейшем). +/// +/// +public enum LoyaltyProgramType +{ + Percentage = 1, + FixedAmount = 2, + PointsAccrual = 3, +} + +public class LoyaltyProgram : TenantEntity +{ + public string Name { get; set; } = null!; + + public LoyaltyProgramType Type { get; set; } + + /// Ставка. Семантика зависит от Type: для Percentage/PointsAccrual + /// — проценты [0..100], для FixedAmount — рубли/тенге. Хранится как + /// decimal(18,4) — точности достаточно. + public decimal Rate { get; set; } + + /// Активна ли. Неактивные программы не применяются к новым + /// чекам; existing-карты ссылаются на programId, история сохраняется. + public bool IsActive { get; set; } = true; + + /// Минимальная сумма чека для применения. 0 = без ограничения. + public decimal MinSubtotal { get; set; } + + public string? Description { get; set; } + + public ICollection Cards { get; set; } = new List(); +} diff --git a/src/food-market.domain/Sales/Promotion.cs b/src/food-market.domain/Sales/Promotion.cs new file mode 100644 index 0000000..1db849e --- /dev/null +++ b/src/food-market.domain/Sales/Promotion.cs @@ -0,0 +1,64 @@ +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Sales; + +/// Тип скидки акции. +public enum PromotionType +{ + /// Процент от Subtotal (или от MatchingSubtotal если ProductGroupIds/ProductIds задан). + Percent = 1, + /// Фиксированная сумма скидки в валюте чека. + FixedDiscount = 2, +} + +/// Область применения акции — на весь чек или только на товары из +/// указанных групп/конкретные товары. +public enum PromotionScope +{ + All = 1, + ProductGroups = 2, + Products = 3, +} + +/// Промокод/акция. Применяется к розничному чеку при выбивании. +/// +/// Применение: либо кассир вводит вручную, либо для +/// акций с пустым применяется автоматически если +/// чек удовлетворяет правилам (период + минимум + scope). +/// +/// Период: <= sale.Date < +/// (если EndsAt = null — бессрочная). позволяет +/// быстро отключить акцию не меняя дат. +public class Promotion : TenantEntity +{ + public string Name { get; set; } = null!; + public string? Description { get; set; } + + /// Код для ручного ввода. null/empty = auto-apply акция (без кода). + /// Уникальный в рамках org через unique-index (`OrganizationId`, `Code`). + /// + public string? Code { get; set; } + + public PromotionType Type { get; set; } + + /// Значение скидки. Для Percent — [0..100], для FixedDiscount — в валюте чека. + /// Хранится как decimal(18,4). + public decimal Value { get; set; } + + public PromotionScope Scope { get; set; } = PromotionScope.All; + + /// Минимальная сумма чека для применения (на Subtotal). 0 = без ограничения. + public decimal MinSaleAmount { get; set; } + + public DateTime StartsAt { get; set; } = DateTime.UtcNow; + public DateTime? EndsAt { get; set; } + + public bool IsActive { get; set; } = true; + + /// Применимые ProductGroupId'ы (для Scope=ProductGroups). Сериализуется + /// как JSONB-массив; пустой массив = ни одной группы (никогда не сматчит). + public List ProductGroupIds { get; set; } = new(); + + /// Применимые ProductId'ы (для Scope=Products). + public List ProductIds { get; set; } = new(); +} diff --git a/src/food-market.domain/Sales/RetailSale.cs b/src/food-market.domain/Sales/RetailSale.cs index 37deb7e..6b8dd09 100644 --- a/src/food-market.domain/Sales/RetailSale.cs +++ b/src/food-market.domain/Sales/RetailSale.cs @@ -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 + + /// Скидка применённой лояльности в валюте чека. Снапшот на + /// момент проведения. Для Percentage/FixedAmount — сумма скидки (вычтена + /// из Total). Для PointsAccrual — 0 (скидки нет, начисляем в + /// ). + public decimal LoyaltyBonusApplied { get; set; } + + /// Начисленные на карту баллы (для PointsAccrual). 0 для + /// остальных типов программ. + public decimal LoyaltyPointsAccrued { get; set; } + + /// Применённая карта лояльности (если была). null = без программы. + public Guid? LoyaltyCardId { get; set; } + + /// Скидка применённой акции/промокода. Снапшот на момент проведения. + public decimal PromotionDiscount { get; set; } + + /// Применённая акция (если была). null = без промокода. + public Guid? PromotionId { get; set; } + + /// Введённый код акции (snapshot, чтобы переименование Promotion + /// не теряло информации в истории). + public string? PromotionCode { get; set; } public PaymentMethod Payment { get; set; } = PaymentMethod.Cash; public decimal PaidCash { get; set; } diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 85c1f1a..8632c60 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -63,6 +63,11 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet RetailSaleLines => Set(); public DbSet PosBatchAcks => Set(); + // Sprint 9: лояльность и акции. + public DbSet LoyaltyPrograms => Set(); + public DbSet LoyaltyCards => Set(); + public DbSet Promotions => Set(); + public DbSet Demands => Set(); public DbSet DemandLines => Set(); diff --git a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs index ab7a3a7..0927578 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs @@ -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(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(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(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 → 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>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List(), + new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer>( + (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>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List(), + new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer>( + (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(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); + }); } } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260601100000_Phase9b_LoyaltyAndPromotions.cs b/src/food-market.infrastructure/Persistence/Migrations/20260601100000_Phase9b_LoyaltyAndPromotions.cs new file mode 100644 index 0000000..6ee1f07 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260601100000_Phase9b_LoyaltyAndPromotions.cs @@ -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 +{ + /// Phase9b — лояльность и промокоды. + /// + /// Таблицы: loyalty_programs, loyalty_cards, promotions. + /// Колонки в retail_sales: LoyaltyBonusApplied, + /// LoyaltyPointsAccrued, LoyaltyCardId, + /// PromotionDiscount, PromotionId, PromotionCode. + /// FK на retail_sales опциональны (Cards/Promotions могут быть удалены — + /// чек remains; но мы ставим ON DELETE SET NULL не явно, EF сам сделает + /// nullable foreign key без cascade). + [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(type: "uuid", nullable: false), + OrganizationId = t.Column(type: "uuid", nullable: false), + Name = t.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = t.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Type = t.Column(type: "integer", nullable: false), + Rate = t.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + MinSubtotal = t.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + IsActive = t.Column(type: "boolean", nullable: false), + CreatedAt = t.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = t.Column(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(type: "uuid", nullable: false), + OrganizationId = t.Column(type: "uuid", nullable: false), + ProgramId = t.Column(type: "uuid", nullable: false), + CounterpartyId = t.Column(type: "uuid", nullable: false), + CardNumber = t.Column(type: "character varying(60)", maxLength: 60, nullable: false), + Balance = t.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + IssuedAt = t.Column(type: "timestamp with time zone", nullable: false), + IsBlocked = t.Column(type: "boolean", nullable: false), + CreatedAt = t.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = t.Column(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(type: "uuid", nullable: false), + OrganizationId = t.Column(type: "uuid", nullable: false), + Name = t.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = t.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Code = t.Column(type: "character varying(40)", maxLength: 40, nullable: true), + Type = t.Column(type: "integer", nullable: false), + Value = t.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + Scope = t.Column(type: "integer", nullable: false), + MinSaleAmount = t.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + StartsAt = t.Column(type: "timestamp with time zone", nullable: false), + EndsAt = t.Column(type: "timestamp with time zone", nullable: true), + IsActive = t.Column(type: "boolean", nullable: false), + ProductGroupIds = t.Column(type: "jsonb", nullable: false, defaultValue: "[]"), + ProductIds = t.Column(type: "jsonb", nullable: false, defaultValue: "[]"), + CreatedAt = t.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = t.Column(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( + name: "LoyaltyBonusApplied", schema: "public", table: "retail_sales", + type: "numeric(18,4)", precision: 18, scale: 4, nullable: false, defaultValue: 0m); + b.AddColumn( + name: "LoyaltyPointsAccrued", schema: "public", table: "retail_sales", + type: "numeric(18,4)", precision: 18, scale: 4, nullable: false, defaultValue: 0m); + b.AddColumn( + name: "LoyaltyCardId", schema: "public", table: "retail_sales", + type: "uuid", nullable: true); + b.AddColumn( + name: "PromotionDiscount", schema: "public", table: "retail_sales", + type: "numeric(18,4)", precision: 18, scale: 4, nullable: false, defaultValue: 0m); + b.AddColumn( + name: "PromotionId", schema: "public", table: "retail_sales", + type: "uuid", nullable: true); + b.AddColumn( + 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"); + } + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index d162fc2..16d85b3 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 33d2226..1502393 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -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' }, + ] : []), ]}) } diff --git a/src/food-market.web/src/locales/en.json b/src/food-market.web/src/locales/en.json index ee02d45..8f842a6 100644 --- a/src/food-market.web/src/locales/en.json +++ b/src/food-market.web/src/locales/en.json @@ -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", diff --git a/src/food-market.web/src/locales/ru.json b/src/food-market.web/src/locales/ru.json index e4049f0..7f09a87 100644 --- a/src/food-market.web/src/locales/ru.json +++ b/src/food-market.web/src/locales/ru.json @@ -69,6 +69,9 @@ "supplierReturns": "Возвраты поставщикам", "retailSales": "Розничные чеки", "demands": "Оптовые отгрузки", + "promotions": "Акции и промокоды", + "loyaltyPrograms": "Программы лояльности", + "loyaltyCards": "Карты лояльности", "reportSales": "Продажи", "reportStock": "Остатки на дату", "reportProfit": "Прибыль", diff --git a/src/food-market.web/src/pages/LoyaltyCardsPage.tsx b/src/food-market.web/src/pages/LoyaltyCardsPage.tsx new file mode 100644 index 0000000..232b64a --- /dev/null +++ b/src/food-market.web/src/pages/LoyaltyCardsPage.tsx @@ -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 = { 1: 'Скидка %', 2: 'Фикс. скидка', 3: 'Баллы' } + +interface Form { + programId: string + counterpartyId: string + cardNumber: string +} +const blank = (): Form => ({ programId: '', counterpartyId: '', cardNumber: '' }) + +export function LoyaltyCardsPage() { + const list = useCatalogList(URL) + const { remove } = useCatalogMutations(URL, URL) + const [form, setForm] = useState
(null) + const [programs, setPrograms] = useState([]) + const [error, setError] = useState(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 ( + + + + + } + footer={list.data && list.data.total > 0 && ( + + )} + > + {isEmpty ? ( + { setForm(blank()); setError(null) }} + /> + ) : ( + r.id} + columns={[ + { header: 'Номер', sortKey: 'cardNumber', cell: (r) => ( +
+
{r.cardNumber}
+
{new Date(r.issuedAt).toLocaleDateString('ru')}
+
+ )}, + { header: 'Владелец', cell: (r) => r.counterpartyName }, + { header: 'Программа', cell: (r) => ( +
+
{r.programName}
+
{TYPE_LABEL[r.programType]} · {r.programType === 2 ? `${r.programRate} ₸` : `${r.programRate}%`}
+
+ )}, + { header: 'Баланс', width: '120px', className: 'text-right font-mono', cell: (r) => r.balance.toLocaleString('ru', { maximumFractionDigits: 0 }) }, + { header: 'Статус', width: '120px', cell: (r) => r.isBlocked + ? Заблокирована + : Активна }, + ]} + /> + )} + + { setForm(null); setError(null) }} + title="Выпустить карту лояльности" + footer={form && ( + <> + + + + )} + > + {form && ( +
+ + + + + setForm({ ...form, counterpartyId: v })} + placeholder="Выберите контрагента" + /> + + + setForm({ ...form, cardNumber: e.target.value })} + placeholder="например, VIP-001" /> + + {error &&
{error}
} +
+ )} +
+
+ ) +} diff --git a/src/food-market.web/src/pages/LoyaltyProgramsPage.tsx b/src/food-market.web/src/pages/LoyaltyProgramsPage.tsx new file mode 100644 index 0000000..9cb9c35 --- /dev/null +++ b/src/food-market.web/src/pages/LoyaltyProgramsPage.tsx @@ -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 = { + 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(URL) + const { create, update, remove } = useCatalogMutations(URL, URL) + const { confirm, dialogProps } = useConfirm() + const [form, setForm] = useState(null) + const [error, setError] = useState(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 ( + <> + + + + + } + footer={list.data && list.data.total > 0 && ( + + )} + > + {isEmpty ? ( + { setForm(blank()); setError(null) }} + /> + ) : ( + 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) => ( +
+
{r.name}
+ {r.description &&
{r.description}
} +
+ )}, + { 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 + ? Активна + : Выключена }, + ]} + /> + )} +
+ + { setForm(null); setError(null) }} + title={form?.id ? 'Изменить программу' : 'Новая программа'} + footer={form && ( + <> + {form.id && ( + + )} + + + + )} + > + {form && ( +
+ + setForm({ ...form, name: e.target.value })} /> + + + + +
+ + setForm({ ...form, rate: n ?? 0 })} allowFractional /> + + + setForm({ ...form, minSubtotal: n ?? 0 })} allowFractional /> + +
+ +