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>
162 lines
7.7 KiB
C#
162 lines
7.7 KiB
C#
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);
|
||
}
|