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