food-market/src/food-market.api/Controllers/Promotions/PromotionsController.cs
nns 91128a7ed0
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
feat(loyalty+promotions): P2-12 + P2-13 — лояльность и промокоды (Sprint 9 п.1-2)
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>
2026-05-31 21:06:10 +05:00

162 lines
7.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}