Feat: Cambios Varios
This commit is contained in:
@@ -9,10 +9,12 @@ namespace SIGCM.API.Controllers;
|
||||
public class CategoriesController : ControllerBase
|
||||
{
|
||||
private readonly ICategoryRepository _repository;
|
||||
private readonly IListingRepository _listingRepo;
|
||||
|
||||
public CategoriesController(ICategoryRepository repository)
|
||||
public CategoriesController(ICategoryRepository repository, IListingRepository listingRepo)
|
||||
{
|
||||
_repository = repository;
|
||||
_listingRepo = listingRepo;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -33,6 +35,14 @@ public class CategoriesController : ControllerBase
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(Category category)
|
||||
{
|
||||
// Regla: No crear hijos en padres con avisos
|
||||
if (category.ParentId.HasValue)
|
||||
{
|
||||
int adsCount = await _listingRepo.CountByCategoryIdAsync(category.ParentId.Value);
|
||||
if (adsCount > 0)
|
||||
return BadRequest($"El rubro padre contiene {adsCount} avisos. Muévalos antes de crear sub-rubros.");
|
||||
}
|
||||
|
||||
var id = await _repository.AddAsync(category);
|
||||
category.Id = id;
|
||||
return CreatedAtAction(nameof(GetById), new { id }, category);
|
||||
@@ -42,6 +52,15 @@ public class CategoriesController : ControllerBase
|
||||
public async Task<IActionResult> Update(int id, Category category)
|
||||
{
|
||||
if (id != category.Id) return BadRequest();
|
||||
|
||||
// Regla: Drag & Drop (Mover categoría dentro de otra)
|
||||
if (category.ParentId.HasValue)
|
||||
{
|
||||
int adsCount = await _listingRepo.CountByCategoryIdAsync(category.ParentId.Value);
|
||||
if (adsCount > 0)
|
||||
return BadRequest($"El destino contiene {adsCount} avisos. No puede aceptar sub-rubros.");
|
||||
}
|
||||
|
||||
await _repository.UpdateAsync(category);
|
||||
return NoContent();
|
||||
}
|
||||
@@ -53,6 +72,7 @@ public class CategoriesController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// --- Endpoints de Operaciones ---
|
||||
[HttpGet("{id}/operations")]
|
||||
public async Task<IActionResult> GetOperations(int id)
|
||||
{
|
||||
@@ -73,5 +93,32 @@ public class CategoriesController : ControllerBase
|
||||
await _repository.RemoveOperationAsync(id, operationId);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Endpoints Avanzados ---
|
||||
|
||||
[HttpPost("merge")]
|
||||
public async Task<IActionResult> Merge([FromBody] MergeRequest request)
|
||||
{
|
||||
if (request.SourceId == request.TargetId) return BadRequest("Origen y destino iguales.");
|
||||
await _repository.MergeCategoriesAsync(request.SourceId, request.TargetId);
|
||||
return Ok(new { message = "Fusión completada." });
|
||||
}
|
||||
|
||||
[HttpPost("move-content")]
|
||||
public async Task<IActionResult> MoveContent([FromBody] MergeRequest request)
|
||||
{
|
||||
if (request.SourceId == request.TargetId) return BadRequest("Origen y destino iguales.");
|
||||
|
||||
// Regla: No mover avisos a una categoría que tiene hijos (Padre)
|
||||
bool targetHasChildren = await _repository.HasChildrenAsync(request.TargetId);
|
||||
if (targetHasChildren)
|
||||
{
|
||||
return BadRequest("El destino tiene sub-rubros. No puede contener avisos directos.");
|
||||
}
|
||||
|
||||
await _listingRepo.MoveListingsAsync(request.SourceId, request.TargetId);
|
||||
return Ok(new { message = "Avisos movidos correctamente." });
|
||||
}
|
||||
|
||||
public class MergeRequest { public int SourceId { get; set; } public int TargetId { get; set; } }
|
||||
}
|
||||
38
src/SIGCM.API/Controllers/ClientsController.cs
Normal file
38
src/SIGCM.API/Controllers/ClientsController.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
|
||||
namespace SIGCM.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ClientsController : ControllerBase
|
||||
{
|
||||
private readonly ClientRepository _repo;
|
||||
|
||||
public ClientsController(ClientRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<IActionResult> Search([FromQuery] string q)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q)) return Ok(new List<object>());
|
||||
var clients = await _repo.SearchAsync(q);
|
||||
return Ok(clients);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
var clients = await _repo.GetAllWithStatsAsync();
|
||||
return Ok(clients);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/history")]
|
||||
public async Task<IActionResult> GetHistory(int id)
|
||||
{
|
||||
var history = await _repo.GetClientHistoryAsync(id);
|
||||
return Ok(history);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM.Application.DTOs;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
|
||||
namespace SIGCM.API.Controllers;
|
||||
|
||||
@@ -10,15 +12,28 @@ namespace SIGCM.API.Controllers;
|
||||
public class ListingsController : ControllerBase
|
||||
{
|
||||
private readonly IListingRepository _repository;
|
||||
|
||||
public ListingsController(IListingRepository repository)
|
||||
private readonly ClientRepository _clientRepo;
|
||||
private readonly AuditRepository _auditRepo;
|
||||
public ListingsController(IListingRepository repository, ClientRepository clientRepo, AuditRepository auditRepo)
|
||||
{
|
||||
_repository = repository;
|
||||
_clientRepo = clientRepo;
|
||||
_auditRepo = auditRepo;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateListingDto dto)
|
||||
{
|
||||
int? clientId = null;
|
||||
|
||||
// Si viene información de cliente, aseguramos que exista en BD
|
||||
if (!string.IsNullOrWhiteSpace(dto.ClientDni))
|
||||
{
|
||||
// Usamos "Consumidor Final" si no hay nombre pero hay DNI, o el nombre provisto
|
||||
string clientName = string.IsNullOrWhiteSpace(dto.ClientName) ? "Consumidor Final" : dto.ClientName;
|
||||
clientId = await _clientRepo.EnsureClientExistsAsync(clientName, dto.ClientDni);
|
||||
}
|
||||
|
||||
var listing = new Listing
|
||||
{
|
||||
CategoryId = dto.CategoryId,
|
||||
@@ -60,4 +75,63 @@ public class ListingsController : ControllerBase
|
||||
if (listingDetail == null) return NotFound();
|
||||
return Ok(listingDetail);
|
||||
}
|
||||
|
||||
// Búsqueda Facetada (POST para enviar diccionario complejo)
|
||||
[HttpPost("search")]
|
||||
public async Task<IActionResult> Search([FromBody] SearchRequest request)
|
||||
{
|
||||
var results = await _repository.SearchFacetedAsync(request.Query, request.CategoryId, request.Filters);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
// Moderación: Obtener pendientes
|
||||
[HttpGet("pending")]
|
||||
[Authorize(Roles = "Admin,Moderador")]
|
||||
public async Task<IActionResult> GetPending()
|
||||
{
|
||||
var pending = await _repository.GetPendingModerationAsync();
|
||||
return Ok(pending);
|
||||
}
|
||||
|
||||
// Moderación: Cambiar estado
|
||||
[HttpPut("{id}/status")]
|
||||
[Authorize(Roles = "Admin,Moderador")]
|
||||
public async Task<IActionResult> UpdateStatus(int id, [FromBody] string status)
|
||||
{
|
||||
// 1. Obtener ID del usuario desde el Token JWT
|
||||
var userIdStr = User.FindFirst("Id")?.Value;
|
||||
int? currentUserId = !string.IsNullOrEmpty(userIdStr) ? int.Parse(userIdStr) : null;
|
||||
|
||||
// 2. Actualizar el estado del aviso
|
||||
await _repository.UpdateStatusAsync(id, status);
|
||||
|
||||
// 3. Registrar en Auditoría (Si tenemos el repositorio inyectado)
|
||||
if (currentUserId.HasValue)
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = currentUserId.Value,
|
||||
Action = status == "Published" ? "Aprobar" : "Rechazar",
|
||||
EntityId = id,
|
||||
EntityType = "Listing",
|
||||
Details = $"El usuario cambió el estado del aviso #{id} a {status}"
|
||||
});
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
public class SearchRequest
|
||||
{
|
||||
public string? Query { get; set; }
|
||||
public int? CategoryId { get; set; }
|
||||
public Dictionary<string, string>? Filters { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("pending/count")]
|
||||
public async Task<IActionResult> GetPendingCount()
|
||||
{
|
||||
var count = await _repository.GetPendingCountAsync();
|
||||
return Ok(count);
|
||||
}
|
||||
}
|
||||
122
src/SIGCM.API/Controllers/ReportsController.cs
Normal file
122
src/SIGCM.API/Controllers/ReportsController.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
using SIGCM.Infrastructure.Services;
|
||||
|
||||
namespace SIGCM.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Roles = "Cajero,Admin")]
|
||||
public class ReportsController : ControllerBase
|
||||
{
|
||||
private readonly IListingRepository _listingRepo;
|
||||
private readonly AuditRepository _auditRepo;
|
||||
|
||||
public ReportsController(IListingRepository listingRepo, AuditRepository auditRepo)
|
||||
{
|
||||
_listingRepo = listingRepo;
|
||||
_auditRepo = auditRepo;
|
||||
}
|
||||
|
||||
[HttpGet("dashboard")]
|
||||
public async Task<IActionResult> GetDashboard([FromQuery] DateTime? from, [FromQuery] DateTime? to)
|
||||
{
|
||||
var start = from ?? DateTime.UtcNow.Date;
|
||||
var end = to ?? DateTime.UtcNow.Date;
|
||||
var stats = await _listingRepo.GetDashboardStatsAsync(start, end);
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
[HttpGet("sales-by-category")]
|
||||
public async Task<IActionResult> GetSalesByCategory([FromQuery] DateTime? from, [FromQuery] DateTime? to)
|
||||
{
|
||||
var start = from ?? DateTime.UtcNow.AddMonths(-1);
|
||||
var end = to ?? DateTime.UtcNow;
|
||||
var data = await _listingRepo.GetSalesByRootCategoryAsync(start, end);
|
||||
|
||||
var totalAmount = data.Sum(x => x.TotalSales);
|
||||
if (totalAmount > 0)
|
||||
{
|
||||
foreach (var item in data)
|
||||
item.Percentage = Math.Round((item.TotalSales / totalAmount) * 100, 2);
|
||||
}
|
||||
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
[HttpGet("audit")]
|
||||
public async Task<IActionResult> GetAuditLogs()
|
||||
{
|
||||
// Obtenemos los últimos 100 eventos
|
||||
var logs = await _auditRepo.GetRecentLogsAsync(100);
|
||||
return Ok(logs);
|
||||
}
|
||||
|
||||
[HttpGet("cashier")]
|
||||
[Authorize(Roles = "Cajero,Admin")]
|
||||
public async Task<IActionResult> GetCashierDashboard([FromQuery] DateTime? from, [FromQuery] DateTime? to)
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (string.IsNullOrEmpty(userIdClaim)) return Unauthorized();
|
||||
|
||||
int userId = int.Parse(userIdClaim);
|
||||
// Si no vienen fechas, usamos hoy por defecto
|
||||
var start = from ?? DateTime.UtcNow.Date;
|
||||
var end = to ?? DateTime.UtcNow.Date;
|
||||
|
||||
var stats = await _listingRepo.GetCashierStatsAsync(userId, start, end);
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
[HttpGet("export-cierre")]
|
||||
[Authorize(Roles = "Admin,Cajero")]
|
||||
public async Task<IActionResult> ExportCierre([FromQuery] DateTime from, [FromQuery] DateTime to, [FromQuery] int? userId) // <--- Agregamos userId opcional
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
var userRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
|
||||
|
||||
// SEGURIDAD:
|
||||
// Si es Cajero, ignoramos lo que envíe y forzamos su propio ID.
|
||||
// Si es Admin, usamos el userId que venga en la URL (si viene).
|
||||
int? targetUserId = (userRole == "Cajero") ? int.Parse(userIdClaim!) : userId;
|
||||
|
||||
// 1. Obtener datos filtrados
|
||||
var data = await _listingRepo.GetDetailedReportAsync(from, to, targetUserId);
|
||||
|
||||
// 2. Título Dinámico: Si hay un filtro de usuario, no es un cierre global.
|
||||
string title = (targetUserId.HasValue)
|
||||
? $"REPORTE DE ACTIVIDAD: {data.Items.FirstOrDefault()?.Cashier ?? "Usuario Sin Cargas"}"
|
||||
: "CIERRE GLOBAL DE JORNADA";
|
||||
|
||||
// 3. Generar PDF
|
||||
var pdfBytes = ReportGenerator.GenerateSalesPdf(data, title);
|
||||
|
||||
string fileName = targetUserId.HasValue ? $"Actividad_Caja_{targetUserId}" : "Cierre_Global";
|
||||
return File(pdfBytes, "application/pdf", $"{fileName}_{from:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
[HttpGet("audit/user/{userId}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> GetAuditLogsByUser(int userId)
|
||||
{
|
||||
var logs = await _auditRepo.GetLogsByUserAsync(userId);
|
||||
return Ok(logs);
|
||||
}
|
||||
|
||||
[HttpGet("cashier-transactions")]
|
||||
[Authorize(Roles = "Cajero,Admin")]
|
||||
public async Task<IActionResult> GetCashierTransactions()
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (string.IsNullOrEmpty(userIdClaim)) return Unauthorized();
|
||||
|
||||
int userId = int.Parse(userIdClaim);
|
||||
// Usamos el repositorio para traer los avisos de hoy de este usuario
|
||||
var transactions = await _listingRepo.GetDetailedReportAsync(DateTime.UtcNow, DateTime.UtcNow, userId);
|
||||
|
||||
return Ok(transactions);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SIGCM.Infrastructure;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
|
||||
// 1. Agregar servicios al contenedor.
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
||||
<PackageReference Include="QuestPDF" Version="2025.12.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ public class CreateListingDto
|
||||
public string? PrintFontSize { get; set; }
|
||||
public string? PrintAlignment { get; set; }
|
||||
public int PrintDaysCount { get; set; }
|
||||
public string? ClientName { get; set; }
|
||||
public string? ClientDni { get; set; }
|
||||
}
|
||||
|
||||
public class ListingDto : CreateListingDto
|
||||
|
||||
14
src/SIGCM.Domain/Entities/AuditLog.cs
Normal file
14
src/SIGCM.Domain/Entities/AuditLog.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace SIGCM.Domain.Entities;
|
||||
|
||||
public class AuditLog
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public required string Action { get; set; }
|
||||
public int? EntityId { get; set; }
|
||||
public string? EntityType { get; set; }
|
||||
public string? Details { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
// Propiedad auxiliar para el Join
|
||||
public string? Username { get; set; }
|
||||
}
|
||||
11
src/SIGCM.Domain/Entities/Client.cs
Normal file
11
src/SIGCM.Domain/Entities/Client.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace SIGCM.Domain.Entities;
|
||||
|
||||
public class Client
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string DniOrCuit { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
}
|
||||
@@ -8,19 +8,23 @@ public class Listing
|
||||
public required string Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
public decimal AdFee { get; set; }
|
||||
public string Currency { get; set; } = "ARS";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public string Status { get; set; } = "Draft";
|
||||
public int? UserId { get; set; }
|
||||
public int? ClientId { get; set; }
|
||||
|
||||
// Propiedades para impresión
|
||||
public string? PrintText { get; set; }
|
||||
public DateTime? PrintStartDate { get; set; }
|
||||
public int PrintDaysCount { get; set; }
|
||||
public bool IsBold { get; set; }
|
||||
public bool IsFrame { get; set; }
|
||||
public string PrintFontSize { get; set; } = "normal";
|
||||
public string PrintAlignment { get; set; } = "left";
|
||||
|
||||
// Propiedades auxiliares (no están en la tabla Listings, vienen de Joins/Subqueries)
|
||||
// Propiedades auxiliares
|
||||
public string? CategoryName { get; set; }
|
||||
public string? MainImageUrl { get; set; }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
namespace SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Domain.Entities;
|
||||
|
||||
namespace SIGCM.Domain.Interfaces;
|
||||
|
||||
public interface ICategoryRepository
|
||||
{
|
||||
Task<IEnumerable<Category>> GetAllAsync();
|
||||
@@ -12,4 +13,6 @@ public interface ICategoryRepository
|
||||
Task<IEnumerable<Operation>> GetOperationsAsync(int categoryId);
|
||||
Task AddOperationAsync(int categoryId, int operationId);
|
||||
Task RemoveOperationAsync(int categoryId, int operationId);
|
||||
}
|
||||
Task MergeCategoriesAsync(int sourceId, int targetId);
|
||||
Task<bool> HasChildrenAsync(int categoryId);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using SIGCM.Domain.Models;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Application.DTOs;
|
||||
|
||||
namespace SIGCM.Domain.Interfaces;
|
||||
|
||||
@@ -9,6 +10,24 @@ public interface IListingRepository
|
||||
Task<Listing?> GetByIdAsync(int id);
|
||||
Task<ListingDetail?> GetDetailByIdAsync(int id);
|
||||
Task<IEnumerable<Listing>> GetAllAsync();
|
||||
Task<int> CountByCategoryIdAsync(int categoryId);
|
||||
Task MoveListingsAsync(int sourceCategoryId, int targetCategoryId);
|
||||
|
||||
// Búsqueda Simple y Facetada
|
||||
Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId);
|
||||
Task<IEnumerable<Listing>> SearchFacetedAsync(string? query, int? categoryId, Dictionary<string, string>? attributes);
|
||||
|
||||
// Impresión
|
||||
Task<IEnumerable<Listing>> GetListingsForPrintAsync(DateTime date);
|
||||
|
||||
// Moderación
|
||||
Task<IEnumerable<Listing>> GetPendingModerationAsync();
|
||||
Task UpdateStatusAsync(int id, string status);
|
||||
Task<int> GetPendingCountAsync();
|
||||
|
||||
// Estadísticas
|
||||
Task<IEnumerable<CategorySalesReportDto>> GetSalesByRootCategoryAsync(DateTime startDate, DateTime endDate);
|
||||
Task<DashboardStats> GetDashboardStatsAsync(DateTime startDate, DateTime endDate);
|
||||
Task<CashierDashboardDto?> GetCashierStatsAsync(int userId, DateTime startDate, DateTime endDate);
|
||||
Task<GlobalReportDto> GetDetailedReportAsync(DateTime start, DateTime end, int? userId = null);
|
||||
}
|
||||
8
src/SIGCM.Domain/Models/CashierDashboardDto.cs
Normal file
8
src/SIGCM.Domain/Models/CashierDashboardDto.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SIGCM.Domain.Models;
|
||||
|
||||
public class CashierDashboardDto
|
||||
{
|
||||
public decimal MyRevenue { get; set; }
|
||||
public int MyAdsCount { get; set; }
|
||||
public int MyPendingAds { get; set; }
|
||||
}
|
||||
10
src/SIGCM.Domain/Models/CategorySalesReport.cs
Normal file
10
src/SIGCM.Domain/Models/CategorySalesReport.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM.Application.DTOs;
|
||||
|
||||
public class CategorySalesReportDto
|
||||
{
|
||||
public int CategoryId { get; set; }
|
||||
public required string CategoryName { get; set; }
|
||||
public decimal TotalSales { get; set; }
|
||||
public int AdCount { get; set; }
|
||||
public decimal Percentage { get; set; }
|
||||
}
|
||||
23
src/SIGCM.Domain/Models/DashboardStats.cs
Normal file
23
src/SIGCM.Domain/Models/DashboardStats.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace SIGCM.Domain.Models;
|
||||
|
||||
public class DashboardStats
|
||||
{
|
||||
public decimal RevenueToday { get; set; }
|
||||
public int AdsToday { get; set; }
|
||||
public decimal TicketAverage { get; set; }
|
||||
public double PaperOccupation { get; set; }
|
||||
public List<DailyRevenue> WeeklyTrend { get; set; } = new();
|
||||
public List<ChannelStat> ChannelMix { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DailyRevenue
|
||||
{
|
||||
public string Day { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
public class ChannelStat
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public int Value { get; set; }
|
||||
}
|
||||
21
src/SIGCM.Domain/Models/GlobalReportDto.cs
Normal file
21
src/SIGCM.Domain/Models/GlobalReportDto.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace SIGCM.Domain.Models;
|
||||
|
||||
public class GlobalReportDto
|
||||
{
|
||||
public DateTime GeneratedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime FromDate { get; set; }
|
||||
public DateTime ToDate { get; set; }
|
||||
public decimal TotalRevenue { get; set; }
|
||||
public int TotalAds { get; set; }
|
||||
public List<ReportItemDto> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ReportItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
public string Title { get; set; } = "";
|
||||
public string Category { get; set; } = "";
|
||||
public string Cashier { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
@@ -23,6 +23,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<IImageRepository, ImageRepository>();
|
||||
services.AddScoped<PricingRepository>();
|
||||
services.AddScoped<PricingService>();
|
||||
services.AddScoped<ClientRepository>();
|
||||
services.AddScoped<AuditRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
40
src/SIGCM.Infrastructure/Repositories/AuditRepository.cs
Normal file
40
src/SIGCM.Infrastructure/Repositories/AuditRepository.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class AuditRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
public AuditRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
public async Task AddLogAsync(AuditLog log)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"INSERT INTO AuditLogs (UserId, Action, EntityId, EntityType, Details)
|
||||
VALUES (@UserId, @Action, @EntityId, @EntityType, @Details)";
|
||||
await conn.ExecuteAsync(sql, log);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AuditLog>> GetRecentLogsAsync(int limit = 50)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"SELECT TOP (@Limit) a.*, u.Username
|
||||
FROM AuditLogs a
|
||||
JOIN Users u ON a.UserId = u.Id
|
||||
ORDER BY a.CreatedAt DESC";
|
||||
return await conn.QueryAsync<AuditLog>(sql, new { Limit = limit });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AuditLog>> GetLogsByUserAsync(int userId, int limit = 20)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"SELECT TOP (@Limit) a.*, u.Username
|
||||
FROM AuditLogs a
|
||||
JOIN Users u ON a.UserId = u.Id
|
||||
WHERE a.UserId = @UserId
|
||||
ORDER BY a.CreatedAt DESC";
|
||||
return await conn.QueryAsync<AuditLog>(sql, new { UserId = userId, Limit = limit });
|
||||
}
|
||||
}
|
||||
@@ -74,11 +74,11 @@ public class CategoryRepository : ICategoryRepository
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = "INSERT INTO CategoryOperations (CategoryId, OperationId) VALUES (@CategoryId, @OperationId)";
|
||||
try
|
||||
try
|
||||
{
|
||||
await conn.ExecuteAsync(sql, new { CategoryId = categoryId, OperationId = operationId });
|
||||
}
|
||||
catch (SqlException)
|
||||
catch (SqlException)
|
||||
{
|
||||
// Ignore duplicate key errors if it already exists
|
||||
}
|
||||
@@ -88,8 +88,59 @@ public class CategoryRepository : ICategoryRepository
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"DELETE FROM CategoryOperations WHERE CategoryId = @CategoryId AND OperationId = @OperationId",
|
||||
"DELETE FROM CategoryOperations WHERE CategoryId = @CategoryId AND OperationId = @OperationId",
|
||||
new { CategoryId = categoryId, OperationId = operationId });
|
||||
}
|
||||
|
||||
public async Task MergeCategoriesAsync(int sourceId, int targetId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
conn.Open();
|
||||
using var transaction = conn.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Mover Avisos
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE Listings SET CategoryId = @TargetId WHERE CategoryId = @SourceId",
|
||||
new { SourceId = sourceId, TargetId = targetId }, transaction);
|
||||
|
||||
// 2. Mover Subcategorías (Hijos)
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE Categories SET ParentId = @TargetId WHERE ParentId = @SourceId",
|
||||
new { SourceId = sourceId, TargetId = targetId }, transaction);
|
||||
|
||||
// 3. Mover Definiciones de Atributos
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE AttributeDefinitions SET CategoryId = @TargetId WHERE CategoryId = @SourceId",
|
||||
new { SourceId = sourceId, TargetId = targetId }, transaction);
|
||||
|
||||
// 4. Mover Operaciones (evitar duplicados con try/catch o lógica compleja, aquí simplificamos moviendo y borrando)
|
||||
// Borramos las del source de la tabla intermedia
|
||||
await conn.ExecuteAsync(
|
||||
"DELETE FROM CategoryOperations WHERE CategoryId = @SourceId",
|
||||
new { SourceId = sourceId }, transaction);
|
||||
|
||||
// 5. Borrar Categoría Fuente
|
||||
await conn.ExecuteAsync(
|
||||
"DELETE FROM Categories WHERE Id = @SourceId",
|
||||
new { SourceId = sourceId }, transaction);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> HasChildrenAsync(int categoryId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// SELECT 1 es más rápido que COUNT
|
||||
var result = await conn.ExecuteScalarAsync<int?>("SELECT TOP 1 1 FROM Categories WHERE ParentId = @Id", new { Id = categoryId });
|
||||
return result.HasValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
src/SIGCM.Infrastructure/Repositories/ClientRepository.cs
Normal file
71
src/SIGCM.Infrastructure/Repositories/ClientRepository.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class ClientRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
|
||||
public ClientRepository(IDbConnectionFactory db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
// Búsqueda inteligente por Nombre O DNI
|
||||
public async Task<IEnumerable<Client>> SearchAsync(string query)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT TOP 10 * FROM Clients
|
||||
WHERE Name LIKE @Query OR DniOrCuit LIKE @Query
|
||||
ORDER BY Name";
|
||||
return await conn.QueryAsync<Client>(sql, new { Query = $"%{query}%" });
|
||||
}
|
||||
|
||||
// Buscar o Crear (Upsert) al guardar el aviso
|
||||
public async Task<int> EnsureClientExistsAsync(string name, string dni)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
|
||||
// 1. Buscamos por DNI/CUIT (es el identificador único más fiable)
|
||||
var existingId = await conn.ExecuteScalarAsync<int?>(
|
||||
"SELECT Id FROM Clients WHERE DniOrCuit = @Dni", new { Dni = dni });
|
||||
|
||||
if (existingId.HasValue)
|
||||
{
|
||||
// Opcional: Actualizar nombre si cambió
|
||||
await conn.ExecuteAsync("UPDATE Clients SET Name = @Name WHERE Id = @Id", new { Name = name, Id = existingId });
|
||||
return existingId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Crear nuevo
|
||||
var sql = @"
|
||||
INSERT INTO Clients (Name, DniOrCuit) VALUES (@Name, @Dni);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, new { Name = name, Dni = dni });
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<dynamic>> GetAllWithStatsAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT c.*,
|
||||
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = c.Id) as TotalAds,
|
||||
(SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = c.Id) as TotalSpent
|
||||
FROM Clients c
|
||||
ORDER BY c.Name";
|
||||
return await conn.QueryAsync(sql);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Listing>> GetClientHistoryAsync(int clientId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QueryAsync<Listing>(
|
||||
"SELECT * FROM Listings WHERE ClientId = @Id ORDER BY CreatedAt DESC",
|
||||
new { Id = clientId });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Dapper;
|
||||
using SIGCM.Application.DTOs;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Domain.Models;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
@@ -28,12 +29,12 @@ public class ListingRepository : IListingRepository
|
||||
INSERT INTO Listings (
|
||||
CategoryId, OperationId, Title, Description, Price, Currency,
|
||||
CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount,
|
||||
IsBold, IsFrame, PrintFontSize, PrintAlignment
|
||||
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee
|
||||
)
|
||||
VALUES (
|
||||
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
|
||||
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
|
||||
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment
|
||||
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee
|
||||
);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
@@ -67,30 +68,56 @@ public class ListingRepository : IListingRepository
|
||||
return await conn.QuerySingleOrDefaultAsync<Listing>("SELECT * FROM Listings WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<SIGCM.Domain.Models.ListingDetail?> GetDetailByIdAsync(int id)
|
||||
public async Task<ListingDetail?> GetDetailByIdAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// Mejoramos el SQL para asegurar que los nulos se conviertan en 0 (false) desde el motor
|
||||
var sql = @"
|
||||
SELECT * FROM Listings WHERE Id = @Id;
|
||||
|
||||
SELECT lav.*, ad.Name as AttributeName
|
||||
FROM ListingAttributeValues lav
|
||||
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
|
||||
WHERE lav.ListingId = @Id;
|
||||
SELECT
|
||||
l.Id, l.CategoryId, l.OperationId, l.Title, l.Description, l.Price, l.AdFee,
|
||||
l.Currency, l.CreatedAt, l.Status, l.UserId, l.PrintText, l.PrintDaysCount,
|
||||
ISNULL(l.IsBold, 0) as IsBold,
|
||||
ISNULL(l.IsFrame, 0) as IsFrame,
|
||||
l.PrintFontSize, l.PrintAlignment, l.ClientId,
|
||||
c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni
|
||||
FROM Listings l
|
||||
LEFT JOIN Categories c ON l.CategoryId = c.Id
|
||||
LEFT JOIN Clients cl ON l.ClientId = cl.Id
|
||||
WHERE l.Id = @Id;
|
||||
|
||||
SELECT lav.*, ad.Name as AttributeName
|
||||
FROM ListingAttributeValues lav
|
||||
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
|
||||
WHERE lav.ListingId = @Id;
|
||||
|
||||
SELECT * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
|
||||
";
|
||||
SELECT * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
|
||||
";
|
||||
|
||||
using var multi = await conn.QueryMultipleAsync(sql, new { Id = id });
|
||||
var listing = await multi.ReadSingleOrDefaultAsync<Listing>();
|
||||
var listing = await multi.ReadSingleOrDefaultAsync<dynamic>();
|
||||
if (listing == null) return null;
|
||||
|
||||
var attributes = await multi.ReadAsync<SIGCM.Domain.Models.ListingAttributeValueWithName>();
|
||||
var attributes = await multi.ReadAsync<ListingAttributeValueWithName>();
|
||||
var images = await multi.ReadAsync<ListingImage>();
|
||||
|
||||
return new SIGCM.Domain.Models.ListingDetail
|
||||
return new ListingDetail
|
||||
{
|
||||
Listing = listing,
|
||||
Listing = new Listing
|
||||
{
|
||||
Id = (int)listing.Id,
|
||||
Title = listing.Title,
|
||||
Description = listing.Description,
|
||||
Price = listing.Price,
|
||||
AdFee = listing.AdFee,
|
||||
Status = listing.Status,
|
||||
CreatedAt = listing.CreatedAt,
|
||||
PrintText = listing.PrintText,
|
||||
IsBold = Convert.ToBoolean(listing.IsBold),
|
||||
IsFrame = Convert.ToBoolean(listing.IsFrame),
|
||||
|
||||
PrintDaysCount = listing.PrintDaysCount,
|
||||
CategoryName = listing.CategoryName
|
||||
},
|
||||
Attributes = attributes,
|
||||
Images = images
|
||||
};
|
||||
@@ -99,37 +126,86 @@ public class ListingRepository : IListingRepository
|
||||
public async Task<IEnumerable<Listing>> GetAllAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// Subquery para obtener la imagen principal
|
||||
var sql = @"
|
||||
SELECT TOP 20 l.*,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
WHERE l.Status = 'Published'
|
||||
ORDER BY l.CreatedAt DESC";
|
||||
|
||||
return await conn.QueryAsync<Listing>(sql);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId)
|
||||
{
|
||||
return await SearchFacetedAsync(query, categoryId, null);
|
||||
}
|
||||
|
||||
// Búsqueda Avanzada Facetada
|
||||
public async Task<IEnumerable<Listing>> SearchFacetedAsync(string? query, int? categoryId, Dictionary<string, string>? attributes)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT l.*,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
WHERE 1=1";
|
||||
|
||||
var parameters = new DynamicParameters();
|
||||
string sql;
|
||||
|
||||
// Construcción Dinámica de la Query con CTE
|
||||
if (categoryId.HasValue && categoryId.Value > 0)
|
||||
{
|
||||
sql = @"
|
||||
WITH CategoryTree AS (
|
||||
SELECT Id FROM Categories WHERE Id = @CategoryId
|
||||
UNION ALL
|
||||
SELECT c.Id FROM Categories c
|
||||
INNER JOIN CategoryTree ct ON c.ParentId = ct.Id
|
||||
)
|
||||
SELECT l.*,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
WHERE l.Status = 'Published'
|
||||
AND l.CategoryId IN (SELECT Id FROM CategoryTree)";
|
||||
|
||||
parameters.Add("CategoryId", categoryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Sin filtro de categoría (o todas)
|
||||
sql = @"
|
||||
SELECT l.*,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
WHERE l.Status = 'Published'";
|
||||
}
|
||||
|
||||
// Filtro de Texto
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query)";
|
||||
parameters.Add("Query", $"%{query}%");
|
||||
}
|
||||
|
||||
if (categoryId.HasValue)
|
||||
// Filtros de Atributos (Igual que antes)
|
||||
if (attributes != null && attributes.Any())
|
||||
{
|
||||
sql += " AND l.CategoryId = @CategoryId";
|
||||
parameters.Add("CategoryId", categoryId);
|
||||
int i = 0;
|
||||
foreach (var attr in attributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attr.Value)) continue;
|
||||
string paramName = $"@Val{i}";
|
||||
string paramKey = $"@Key{i}";
|
||||
|
||||
sql += $@" AND EXISTS (
|
||||
SELECT 1 FROM ListingAttributeValues lav
|
||||
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
|
||||
WHERE lav.ListingId = l.Id
|
||||
AND ad.Name = {paramKey}
|
||||
AND lav.Value LIKE {paramName}
|
||||
)";
|
||||
|
||||
parameters.Add($"Val{i}", $"%{attr.Value}%");
|
||||
parameters.Add($"Key{i}", attr.Key);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
sql += " ORDER BY l.CreatedAt DESC";
|
||||
@@ -140,8 +216,6 @@ public class ListingRepository : IListingRepository
|
||||
public async Task<IEnumerable<Listing>> GetListingsForPrintAsync(DateTime targetDate)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// La lógica: El aviso debe haber empezado antes o en la fecha target
|
||||
// Y la fecha target debe ser menor a la fecha de inicio + duración
|
||||
var sql = @"
|
||||
SELECT l.*, c.Name as CategoryName
|
||||
FROM Listings l
|
||||
@@ -149,8 +223,190 @@ public class ListingRepository : IListingRepository
|
||||
WHERE l.PrintStartDate IS NOT NULL
|
||||
AND @TargetDate >= CAST(l.PrintStartDate AS DATE)
|
||||
AND @TargetDate < DATEADD(day, l.PrintDaysCount, CAST(l.PrintStartDate AS DATE))
|
||||
ORDER BY c.Name, l.Title"; // Ordenado por Rubro y luego alfabético
|
||||
ORDER BY c.Name, l.Title";
|
||||
|
||||
return await conn.QueryAsync<Listing>(sql, new { TargetDate = targetDate.Date });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Listing>> GetPendingModerationAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// Avisos que NO están publicados ni rechazados ni borrados.
|
||||
// Asumimos 'Pending' o 'Draft' si vienen del Wizard y requieren revisión.
|
||||
// Para este ejemplo, buscamos 'Pending'.
|
||||
return await conn.QueryAsync<Listing>("SELECT * FROM Listings WHERE Status = 'Pending' ORDER BY CreatedAt ASC");
|
||||
}
|
||||
|
||||
public async Task UpdateStatusAsync(int id, string status)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync("UPDATE Listings SET Status = @Status WHERE Id = @Id", new { Id = id, Status = status });
|
||||
}
|
||||
|
||||
public async Task<int> CountByCategoryIdAsync(int categoryId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Listings WHERE CategoryId = @Id", new { Id = categoryId });
|
||||
}
|
||||
|
||||
public async Task MoveListingsAsync(int sourceCategoryId, int targetCategoryId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// Solo movemos los avisos, no tocamos la estructura de categorías
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE Listings SET CategoryId = @TargetId WHERE CategoryId = @SourceId",
|
||||
new { SourceId = sourceCategoryId, TargetId = targetCategoryId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CategorySalesReportDto>> GetSalesByRootCategoryAsync(DateTime startDate, DateTime endDate)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
|
||||
// SQL con CTE Recursiva:
|
||||
// 1. Mapeamos TODA la jerarquía para saber cuál es el ID raíz de cada subcategoría.
|
||||
// 2. Unimos con Listings y sumamos.
|
||||
var sql = @"
|
||||
WITH CategoryHierarchy AS (
|
||||
-- Caso base: Categorías Raíz (donde ParentId es NULL)
|
||||
SELECT Id, Id as RootId, Name
|
||||
FROM Categories
|
||||
WHERE ParentId IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Caso recursivo: Hijos que se vinculan a su padre
|
||||
SELECT c.Id, ch.RootId, ch.Name
|
||||
FROM Categories c
|
||||
INNER JOIN CategoryHierarchy ch ON c.ParentId = ch.Id
|
||||
)
|
||||
SELECT
|
||||
ch.RootId as CategoryId,
|
||||
ch.Name as CategoryName,
|
||||
SUM(l.Price) as TotalSales,
|
||||
COUNT(l.Id) as AdCount
|
||||
FROM Listings l
|
||||
INNER JOIN CategoryHierarchy ch ON l.CategoryId = ch.Id
|
||||
WHERE l.CreatedAt >= @StartDate AND l.CreatedAt <= @EndDate
|
||||
AND l.Status = 'Published'
|
||||
GROUP BY ch.RootId, ch.Name
|
||||
ORDER BY TotalSales DESC";
|
||||
|
||||
return await conn.QueryAsync<CategorySalesReportDto>(sql, new { StartDate = startDate, EndDate = endDate });
|
||||
}
|
||||
|
||||
public async Task<int> GetPendingCountAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// Contamos solo avisos en estado 'Pending'
|
||||
return await conn.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Listings WHERE Status = 'Pending'");
|
||||
}
|
||||
|
||||
public async Task<DashboardStats> GetDashboardStatsAsync(DateTime startDate, DateTime endDate)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var stats = new DashboardStats();
|
||||
|
||||
// 1. KPIs del periodo seleccionado
|
||||
var kpisSql = @"
|
||||
SELECT
|
||||
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as RevenueToday,
|
||||
COUNT(Id) as AdsToday
|
||||
FROM Listings
|
||||
WHERE CAST(CreatedAt AS DATE) >= @StartDate
|
||||
AND CAST(CreatedAt AS DATE) <= @EndDate
|
||||
AND Status = 'Published'";
|
||||
|
||||
var kpis = await conn.QueryFirstOrDefaultAsync(kpisSql, new { StartDate = startDate.Date, EndDate = endDate.Date });
|
||||
stats.RevenueToday = kpis != null ? (decimal)kpis.RevenueToday : 0;
|
||||
stats.AdsToday = kpis != null ? (int)kpis.AdsToday : 0;
|
||||
stats.TicketAverage = stats.AdsToday > 0 ? stats.RevenueToday / stats.AdsToday : 0;
|
||||
|
||||
// 2. Ocupación (basada en el último día del rango)
|
||||
stats.PaperOccupation = Math.Min(100, (stats.AdsToday * 100.0) / 100.0);
|
||||
|
||||
// 3. Tendencia del periodo
|
||||
// Si el rango es mayor a 10 días, agrupamos diferente, pero por ahora mantenemos ddd
|
||||
var trendSql = @"
|
||||
SELECT
|
||||
FORMAT(CreatedAt, 'dd/MM') as Day,
|
||||
SUM(AdFee) as Amount
|
||||
FROM Listings
|
||||
WHERE CAST(CreatedAt AS DATE) >= @StartDate
|
||||
AND CAST(CreatedAt AS DATE) <= @EndDate
|
||||
AND Status = 'Published'
|
||||
GROUP BY FORMAT(CreatedAt, 'dd/MM'), CAST(CreatedAt AS DATE)
|
||||
ORDER BY CAST(CreatedAt AS DATE) ASC";
|
||||
|
||||
var trendResult = await conn.QueryAsync<DailyRevenue>(trendSql, new { StartDate = startDate.Date, EndDate = endDate.Date });
|
||||
stats.WeeklyTrend = trendResult.ToList();
|
||||
|
||||
// 4. Mix de Canales del periodo
|
||||
var channelsSql = @"
|
||||
SELECT
|
||||
CASE WHEN UserId IS NULL THEN 'Web' ELSE 'Mostrador' END as Name,
|
||||
COUNT(Id) as Value
|
||||
FROM Listings
|
||||
WHERE CAST(CreatedAt AS DATE) >= @StartDate
|
||||
AND CAST(CreatedAt AS DATE) <= @EndDate
|
||||
AND Status = 'Published'
|
||||
GROUP BY CASE WHEN UserId IS NULL THEN 'Web' ELSE 'Mostrador' END";
|
||||
|
||||
var channelResult = await conn.QueryAsync<ChannelStat>(channelsSql, new { StartDate = startDate.Date, EndDate = endDate.Date });
|
||||
stats.ChannelMix = channelResult.ToList();
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
public async Task<CashierDashboardDto?> GetCashierStatsAsync(int userId, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
|
||||
// Filtramos tanto la recaudación como los pendientes por el rango seleccionado
|
||||
var sql = @"
|
||||
SELECT
|
||||
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as MyRevenue,
|
||||
COUNT(Id) as MyAdsCount,
|
||||
(SELECT COUNT(1) FROM Listings
|
||||
WHERE UserId = @UserId AND Status = 'Pending'
|
||||
AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End) as MyPendingAds
|
||||
FROM Listings
|
||||
WHERE UserId = @UserId
|
||||
AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND Status = 'Published'";
|
||||
|
||||
return await conn.QueryFirstOrDefaultAsync<CashierDashboardDto>(sql,
|
||||
new { UserId = userId, Start = startDate.Date, End = endDate.Date });
|
||||
}
|
||||
|
||||
public async Task<GlobalReportDto> GetDetailedReportAsync(DateTime start, DateTime end, int? userId = null)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var report = new GlobalReportDto { FromDate = start, ToDate = end };
|
||||
|
||||
// Filtro inteligente: Si @UserId es NULL, devuelve todo. Si no, filtra por ese usuario.
|
||||
var sql = @"
|
||||
SELECT
|
||||
l.Id, l.CreatedAt as Date, l.Title,
|
||||
c.Name as Category, u.Username as Cashier, l.AdFee as Amount
|
||||
FROM Listings l
|
||||
JOIN Categories c ON l.CategoryId = c.Id
|
||||
LEFT JOIN Users u ON l.UserId = u.Id
|
||||
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND l.Status = 'Published'
|
||||
AND (@UserId IS NULL OR l.UserId = @UserId) -- <--- FILTRO DINÁMICO
|
||||
ORDER BY l.CreatedAt ASC";
|
||||
|
||||
var items = await conn.QueryAsync<ReportItemDto>(sql, new
|
||||
{
|
||||
Start = start.Date,
|
||||
End = end.Date,
|
||||
UserId = userId // Dapper pasará null si el parámetro es null
|
||||
});
|
||||
|
||||
report.Items = items.ToList();
|
||||
report.TotalRevenue = report.Items.Sum(x => x.Amount);
|
||||
report.TotalAds = report.Items.Count;
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="QuestPDF" Version="2025.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using SIGCM.Application.DTOs; // Asegúrate de crear este DTO (ver abajo)
|
||||
using SIGCM.Application.DTOs;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -27,14 +27,19 @@ public class PricingService
|
||||
};
|
||||
|
||||
// 2. Análisis del Texto
|
||||
var words = request.Text.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
int realWordCount = words.Length;
|
||||
|
||||
// Contar caracteres especiales configurados en BD (ej: "!")
|
||||
// Escapamos los caracteres por seguridad en Regex
|
||||
// A. Contar caracteres especiales PRIMERO (antes de borrarlos)
|
||||
string escapedSpecialChars = Regex.Escape(pricing.SpecialChars ?? "!");
|
||||
int specialCharCount = Regex.Matches(request.Text, $"[{escapedSpecialChars}]").Count;
|
||||
|
||||
// B. Normalizar el texto para contar palabras
|
||||
// Reemplazamos los caracteres especiales por espacios para que no cuenten como palabras,
|
||||
// ni unan palabras (ej: "Hola!Chau" -> "Hola Chau")
|
||||
string cleanText = Regex.Replace(request.Text, $"[{escapedSpecialChars}]", " ");
|
||||
|
||||
// C. Contar palabras reales (ignorando los signos que ahora son espacios)
|
||||
var words = cleanText.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
int realWordCount = words.Length;
|
||||
|
||||
// 3. Costo Base y Excedente
|
||||
decimal currentCost = pricing.BasePrice; // Precio base incluye N palabras
|
||||
|
||||
@@ -99,13 +104,13 @@ public class PricingService
|
||||
return new CalculatePriceResponse
|
||||
{
|
||||
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
|
||||
BaseCost = pricing.BasePrice,
|
||||
ExtraCost = extraWordCost + specialCharCost,
|
||||
Surcharges = (request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0),
|
||||
BaseCost = pricing.BasePrice * request.Days,
|
||||
ExtraCost = (extraWordCost + specialCharCost) * request.Days,
|
||||
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0)) * request.Days,
|
||||
Discount = totalDiscount,
|
||||
WordCount = realWordCount,
|
||||
SpecialCharCount = specialCharCount,
|
||||
Details = $"Base: ${pricing.BasePrice} | Extras: ${extraWordCost + specialCharCost} | Desc: -${totalDiscount} ({string.Join(", ", appliedPromos)})"
|
||||
Details = $"Tarifa Diaria: ${currentCost} x {request.Days} días. (Extras diarios: ${extraWordCost + specialCharCost})"
|
||||
};
|
||||
}
|
||||
}
|
||||
109
src/SIGCM.Infrastructure/Services/ReportGenerator.cs
Normal file
109
src/SIGCM.Infrastructure/Services/ReportGenerator.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using SIGCM.Domain.Models;
|
||||
|
||||
namespace SIGCM.Infrastructure.Services;
|
||||
|
||||
public static class ReportGenerator
|
||||
{
|
||||
public static byte[] GenerateSalesPdf(GlobalReportDto data, string title)
|
||||
{
|
||||
return Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(1, Unit.Centimetre);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Helvetica"));
|
||||
|
||||
// --- ENCABEZADO ---
|
||||
page.Header().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(col =>
|
||||
{
|
||||
col.Item().Text("DIARIO EL DIA").FontSize(20).SemiBold().FontColor(Colors.Blue.Medium);
|
||||
col.Item().Text("Sistema de Gestión de Avisos").FontSize(9).Italic();
|
||||
});
|
||||
|
||||
row.RelativeItem().AlignRight().Column(col =>
|
||||
{
|
||||
col.Item().Text(title).FontSize(14).SemiBold();
|
||||
col.Item().Text($"Periodo: {data.FromDate:dd/MM/yyyy} - {data.ToDate:dd/MM/yyyy}").FontSize(9);
|
||||
col.Item().Text($"Generado: {DateTime.Now:dd/MM/yyyy HH:mm}").FontSize(8).FontColor(Colors.Grey.Medium);
|
||||
});
|
||||
});
|
||||
|
||||
// --- CONTENIDO ---
|
||||
page.Content().PaddingVertical(20).Column(col =>
|
||||
{
|
||||
// Resumen de KPIs en el PDF
|
||||
col.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(c =>
|
||||
{
|
||||
c.Item().Text("TOTAL RECAUDADO").FontSize(8).SemiBold();
|
||||
c.Item().Text($"${data.TotalRevenue:N2}").FontSize(16).Black();
|
||||
});
|
||||
row.ConstantItem(10);
|
||||
row.RelativeItem().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(c =>
|
||||
{
|
||||
c.Item().Text("CANTIDAD DE AVISOS").FontSize(8).SemiBold();
|
||||
c.Item().Text($"{data.TotalAds}").FontSize(16).Black();
|
||||
});
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(20);
|
||||
|
||||
// Tabla de detalles
|
||||
col.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.ConstantColumn(40); // ID
|
||||
columns.ConstantColumn(80); // Fecha
|
||||
columns.RelativeColumn(); // Titulo
|
||||
columns.RelativeColumn(); // Rubro
|
||||
columns.RelativeColumn(); // Cajero
|
||||
columns.ConstantColumn(70); // Monto
|
||||
});
|
||||
|
||||
// Encabezado de tabla
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().Element(CellStyle).Text("ID");
|
||||
header.Cell().Element(CellStyle).Text("FECHA");
|
||||
header.Cell().Element(CellStyle).Text("TITULO");
|
||||
header.Cell().Element(CellStyle).Text("RUBRO");
|
||||
header.Cell().Element(CellStyle).Text("CAJERO");
|
||||
header.Cell().Element(CellStyle).AlignRight().Text("MONTO");
|
||||
|
||||
static IContainer CellStyle(IContainer container) => container.DefaultTextStyle(x => x.SemiBold()).PaddingVertical(5).BorderBottom(1).BorderColor(Colors.Black);
|
||||
});
|
||||
|
||||
// Filas
|
||||
foreach (var item in data.Items)
|
||||
{
|
||||
table.Cell().Element(RowStyle).Text(item.Id.ToString());
|
||||
table.Cell().Element(RowStyle).Text(item.Date.ToString("dd/MM HH:mm"));
|
||||
table.Cell().Element(RowStyle).Text(item.Title);
|
||||
table.Cell().Element(RowStyle).Text(item.Category);
|
||||
table.Cell().Element(RowStyle).Text(item.Cashier);
|
||||
table.Cell().Element(RowStyle).AlignRight().Text($"${item.Amount:N2}");
|
||||
|
||||
static IContainer RowStyle(IContainer container) => container.PaddingVertical(5).BorderBottom(1).BorderColor(Colors.Grey.Lighten4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- PIE DE PAGINA ---
|
||||
page.Footer().AlignCenter().Text(x =>
|
||||
{
|
||||
x.Span("Página ");
|
||||
x.CurrentPageNumber();
|
||||
});
|
||||
});
|
||||
}).GeneratePdf();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user