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