// src/SIGCM.Infrastructure/Repositories/ListingRepository.cs 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; public class ListingRepository : IListingRepository { private readonly IDbConnectionFactory _connectionFactory; public ListingRepository(IDbConnectionFactory connectionFactory) { _connectionFactory = connectionFactory; } public async Task CreateAsync(Listing listing, Dictionary attributes, List? payments = null) { using var conn = _connectionFactory.CreateConnection(); conn.Open(); using var transaction = conn.BeginTransaction(); try { var sqlListing = @" INSERT INTO Listings ( CategoryId, OperationId, Title, Description, Price, Currency, CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount, IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId ) VALUES ( @CategoryId, @OperationId, @Title, @Description, @Price, @Currency, @CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount, @IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId ); SELECT CAST(SCOPE_IDENTITY() as int);"; var listingId = await conn.QuerySingleAsync(sqlListing, listing, transaction); if (attributes != null && attributes.Any()) { var sqlAttr = @" INSERT INTO ListingAttributeValues (ListingId, AttributeDefinitionId, Value) VALUES (@ListingId, @AttributeDefinitionId, @Value)"; foreach (var attr in attributes) { await conn.ExecuteAsync(sqlAttr, new { ListingId = listingId, AttributeDefinitionId = attr.Key, Value = attr.Value }, transaction); } } if (listing.ImagesToClone != null && listing.ImagesToClone.Any()) { foreach (var url in listing.ImagesToClone) { // Validamos que la URL sea válida antes de insertar if (string.IsNullOrEmpty(url)) continue; var sqlImg = @"INSERT INTO ListingImages (ListingId, Url, IsMainInfo, DisplayOrder) VALUES (@listingId, @url, 0, 0)"; // Usamos el ID del nuevo aviso (listingId) y la URL del viejo await conn.ExecuteAsync(sqlImg, new { listingId, url }, transaction); } } if (payments != null && payments.Any()) { var sqlPayment = @" INSERT INTO Payments (ListingId, Amount, PaymentMethod, CardPlan, Surcharge, PaymentDate) VALUES (@ListingId, @Amount, @PaymentMethod, @CardPlan, @Surcharge, @PaymentDate)"; foreach (var pay in payments) { pay.ListingId = listingId; await conn.ExecuteAsync(sqlPayment, pay, transaction); } } transaction.Commit(); return listingId; } catch { transaction.Rollback(); throw; } } public async Task GetByIdAsync(int id) { using var conn = _connectionFactory.CreateConnection(); return await conn.QuerySingleOrDefaultAsync("SELECT * FROM Listings WHERE Id = @Id", new { Id = id }); } public async Task GetDetailByIdAsync(int id) { using var conn = _connectionFactory.CreateConnection(); var sql = @" SELECT l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni, u.Username as CashierName FROM Listings l LEFT JOIN Categories c ON l.CategoryId = c.Id LEFT JOIN Clients cl ON l.ClientId = cl.Id LEFT JOIN Users u ON l.UserId = u.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 Payments WHERE ListingId = @Id; "; using var multi = await conn.QueryMultipleAsync(sql, new { Id = id }); var listingData = await multi.ReadSingleOrDefaultAsync(); if (listingData == null) return null; var attributes = await multi.ReadAsync(); var images = await multi.ReadAsync(); var payments = await multi.ReadAsync(); return new ListingDetail { Listing = new Listing { Id = (int)listingData.Id, CategoryId = (int)(listingData.CategoryId ?? 0), OperationId = (int)(listingData.OperationId ?? 0), Title = listingData.Title ?? "", Description = listingData.Description ?? "", Price = listingData.Price ?? 0m, AdFee = listingData.AdFee ?? 0m, Status = listingData.Status ?? "Draft", CreatedAt = listingData.CreatedAt, UserId = listingData.UserId, CategoryName = listingData.CategoryName, PrintDaysCount = (int)(listingData.PrintDaysCount ?? 0), PrintText = listingData.PrintText, IsBold = listingData.IsBold != null && Convert.ToBoolean(listingData.IsBold), IsFrame = listingData.IsFrame != null && Convert.ToBoolean(listingData.IsFrame), }, Attributes = attributes, Images = images, Payments = payments }; } public async Task> GetAllAsync() { using var conn = _connectionFactory.CreateConnection(); var sql = @" SELECT l.*, c.Name as CategoryName, (SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl FROM Listings l JOIN Categories c ON l.CategoryId = c.Id WHERE l.Status = 'Published' ORDER BY l.CreatedAt DESC"; return await conn.QueryAsync(sql); } // Implementación de incremento public async Task IncrementViewCountAsync(int id) { using var conn = _connectionFactory.CreateConnection(); await conn.ExecuteAsync("UPDATE Listings SET ViewCount = ViewCount + 1 WHERE Id = @Id", new { Id = id }); } public async Task> GetByUserIdAsync(int userId) { using var conn = _connectionFactory.CreateConnection(); var sql = @" SELECT l.*, c.Name as CategoryName, (SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl FROM Listings l JOIN Categories c ON l.CategoryId = c.Id WHERE l.UserId = @UserId ORDER BY l.CreatedAt DESC"; return await conn.QueryAsync(sql, new { UserId = userId }); } public async Task> SearchAsync(string? query, int? categoryId) { return await SearchFacetedAsync(query, categoryId, null); } // Búsqueda Avanzada Facetada public async Task> SearchFacetedAsync( string? query, int? categoryId, Dictionary? attributes, DateTime? from = null, DateTime? to = null, string? origin = null, string? status = null) { using var conn = _connectionFactory.CreateConnection(); var parameters = new DynamicParameters(); string sql = @" SELECT l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni, (SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl FROM Listings l JOIN Categories c ON l.CategoryId = c.Id LEFT JOIN Clients cl ON l.ClientId = cl.Id WHERE 1=1"; // --- FILTROS EXISTENTES --- if (!string.IsNullOrEmpty(query)) { sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.DniOrCuit = @ExactQuery)"; parameters.Add("Query", $"%{query}%"); parameters.Add("ExactQuery", query); } if (categoryId.HasValue && categoryId.Value > 0) { sql += " AND l.CategoryId = @CategoryId"; parameters.Add("CategoryId", categoryId); } if (from.HasValue) { sql += " AND l.CreatedAt >= @From"; parameters.Add("From", from.Value.Date); } if (to.HasValue) { sql += " AND l.CreatedAt <= @To"; parameters.Add("To", to.Value.Date.AddDays(1).AddSeconds(-1)); } if (!string.IsNullOrEmpty(origin) && origin != "All") { sql += " AND l.Origin = @Origin"; parameters.Add("Origin", origin); } // --- FILTRO DE ESTADO --- if (!string.IsNullOrEmpty(status)) { sql += " AND l.Status = @Status"; parameters.Add("Status", status); } sql += " ORDER BY l.CreatedAt DESC"; return await conn.QueryAsync(sql, parameters); } public async Task> GetListingsForPrintAsync(DateTime targetDate) { using var conn = _connectionFactory.CreateConnection(); var sql = @" SELECT l.*, c.Name as CategoryName FROM Listings l JOIN Categories c ON l.CategoryId = c.Id 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"; return await conn.QueryAsync(sql, new { TargetDate = targetDate.Date }); } public async Task> 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("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 CountByCategoryIdAsync(int categoryId) { using var conn = _connectionFactory.CreateConnection(); return await conn.ExecuteScalarAsync("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> 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(sql, new { StartDate = startDate, EndDate = endDate }); } public async Task GetPendingCountAsync() { using var conn = _connectionFactory.CreateConnection(); // Contamos solo avisos en estado 'Pending' return await conn.ExecuteScalarAsync("SELECT COUNT(1) FROM Listings WHERE Status = 'Pending'"); } public async Task 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?.RevenueToday ?? 0; stats.AdsToday = kpis?.AdsToday ?? 0; stats.TicketAverage = stats.AdsToday > 0 ? stats.RevenueToday / stats.AdsToday : 0; // 2. Occupation (based on the last day of the range) 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(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(channelsSql, new { StartDate = startDate.Date, EndDate = endDate.Date }); stats.ChannelMix = channelResult.ToList(); return stats; } public async Task GetAdvancedAnalyticsAsync(DateTime startDate, DateTime endDate) { using var conn = _connectionFactory.CreateConnection(); var analytics = new AdvancedAnalyticsDto(); // 1. Calcular Periodo Anterior para comparación var duration = endDate - startDate; var prevStart = startDate.Add(-duration); var prevEnd = startDate.AddSeconds(-1); // 2. KPIs del Periodo Actual var currentKpiSql = @" SELECT CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as Revenue, COUNT(Id) as Ads FROM Listings WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End AND Status = 'Published'"; var currentKpis = await conn.QueryFirstOrDefaultAsync(currentKpiSql, new { Start = startDate.Date, End = endDate.Date }); analytics.TotalRevenue = currentKpis?.Revenue ?? 0; analytics.TotalAds = currentKpis?.Ads ?? 0; // 3. KPIs del Periodo Anterior var prevKpiSql = @" SELECT CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as Revenue, COUNT(Id) as Ads FROM Listings WHERE CAST(CreatedAt AS DATE) BETWEEN @PrevStart AND @PrevEnd AND Status = 'Published'"; var prevKpis = await conn.QueryFirstOrDefaultAsync(prevKpiSql, new { PrevStart = prevStart.Date, PrevEnd = prevEnd.Date }); analytics.PreviousPeriodRevenue = prevKpis?.Revenue ?? 0; analytics.PreviousPeriodAds = prevKpis?.Ads ?? 0; // 4. Calcular Crecimiento if (analytics.PreviousPeriodRevenue > 0) analytics.RevenueGrowth = (double)((analytics.TotalRevenue - analytics.PreviousPeriodRevenue) / analytics.PreviousPeriodRevenue) * 100; if (analytics.PreviousPeriodAds > 0) analytics.AdsGrowth = (double)(analytics.TotalAds - analytics.PreviousPeriodAds) / analytics.PreviousPeriodAds * 100; // 5. Distribución de Pagos (Real) var paymentsSql = @" SELECT p.PaymentMethod as Method, SUM(p.Amount + p.Surcharge) as Total, COUNT(p.Id) as Count FROM Payments p INNER JOIN Listings l ON p.ListingId = l.Id WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End AND l.Status = 'Published' GROUP BY p.PaymentMethod"; analytics.PaymentsDistribution = (await conn.QueryAsync(paymentsSql, new { Start = startDate.Date, End = endDate.Date })).ToList(); // 6. Rendimiento por Categoría var categorySql = @" SELECT c.Name as CategoryName, SUM(l.AdFee) as Revenue, COUNT(l.Id) as AdsCount FROM Listings l JOIN Categories c ON l.CategoryId = c.Id WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End AND l.Status = 'Published' GROUP BY c.Name ORDER BY Revenue DESC"; var catPerf = (await conn.QueryAsync(categorySql, new { Start = startDate.Date, End = endDate.Date })).ToList(); foreach (var cp in catPerf) { cp.Share = analytics.TotalRevenue > 0 ? (double)(cp.Revenue / analytics.TotalRevenue) * 100 : 0; } analytics.CategoryPerformance = catPerf; // 7. Análisis Horario (Peak Hours) var hourlySql = @" SELECT DATEPART(HOUR, CreatedAt) as Hour, COUNT(Id) as Count FROM Listings WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End AND Status = 'Published' GROUP BY DATEPART(HOUR, CreatedAt) ORDER BY Hour"; analytics.HourlyActivity = (await conn.QueryAsync(hourlySql, new { Start = startDate.Date, End = endDate.Date })).ToList(); // 8. Tendencia Diaria var dailySql = @" SELECT FORMAT(CreatedAt, 'dd/MM') as Day, SUM(AdFee) as Amount FROM Listings WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End AND Status = 'Published' GROUP BY FORMAT(CreatedAt, 'dd/MM'), CAST(CreatedAt AS DATE) ORDER BY CAST(CreatedAt AS DATE) ASC"; analytics.DailyTrends = (await conn.QueryAsync(dailySql, new { Start = startDate.Date, End = endDate.Date })).ToList(); var sourceSql = @" SELECT Origin, COUNT(Id) as Count FROM Listings WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End AND Status = 'Published' GROUP BY Origin"; var sources = await conn.QueryAsync(sourceSql, new { Start = startDate.Date, End = endDate.Date }); int total = 0; int web = 0; int mostrador = 0; foreach (var s in sources) { if (s.Origin == "Web") web = (int)s.Count; if (s.Origin == "Mostrador") mostrador = (int)s.Count; total += (int)s.Count; } analytics.SourceMix = new SourceMixDto { MostradorCount = mostrador, WebCount = web, MostradorPercent = total > 0 ? (mostrador * 100.0 / total) : 0, WebPercent = total > 0 ? (web * 100.0 / total) : 0 }; return analytics; } public async Task 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 (SELECT ISNULL(SUM(Amount + Surcharge), 0) FROM Payments p INNER JOIN Listings l ON p.ListingId = l.Id WHERE l.UserId = @UserId AND CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End AND l.Status = 'Published') as MyRevenue, (SELECT COUNT(1) FROM Listings WHERE UserId = @UserId AND Status = 'Pending') as MyPendingAds, (SELECT COUNT(1) FROM Listings WHERE UserId = @UserId AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End AND Status = 'Published') as MyAdsCount"; return await conn.QueryFirstOrDefaultAsync(sql, new { UserId = userId, Start = startDate.Date, End = endDate.Date }); } public async Task GetDetailedReportAsync(DateTime start, DateTime end, int? userId = null) { using var conn = _connectionFactory.CreateConnection(); var report = new GlobalReportDto { FromDate = start, ToDate = end }; var sql = @" SELECT l.Id, l.CreatedAt as Date, l.Title, c.Name as Category, u.Username as Cashier, l.AdFee as Amount, l.Origin as Source 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) ORDER BY l.CreatedAt ASC"; var items = await conn.QueryAsync(sql, new { Start = start.Date, End = end.Date, UserId = userId }); report.Items = items.ToList(); // TOTALES FÍSICOS (Solo lo que entró por caja) var totalsSql = @" SELECT p.PaymentMethod, SUM(p.Amount + p.Surcharge) as Total FROM Payments p INNER JOIN Listings l ON p.ListingId = l.Id WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End AND l.Status = 'Published' AND l.Origin = 'Mostrador' AND (@UserId IS NULL OR l.UserId = @UserId) GROUP BY p.PaymentMethod"; var paymentTotals = await conn.QueryAsync(totalsSql, new { Start = start.Date, End = end.Date, UserId = userId }); // Reiniciar totales para el reporte report.TotalCash = 0; report.TotalDebit = 0; report.TotalCredit = 0; report.TotalTransfer = 0; foreach (var p in paymentTotals) { string method = p.PaymentMethod; decimal total = (decimal)p.Total; switch (method) { case "Cash": report.TotalCash = total; break; case "Debit": report.TotalDebit = total; break; case "Credit": report.TotalCredit = total; break; case "Transfer": report.TotalTransfer = total; break; } } report.TotalRevenue = report.TotalCash + report.TotalDebit + report.TotalCredit + report.TotalTransfer; return report; } public async Task AddPaymentAsync(Payment payment) { using var conn = _connectionFactory.CreateConnection(); var sql = @" INSERT INTO Payments (ListingId, Amount, PaymentMethod, CardPlan, Surcharge, PaymentDate, ExternalReference, ExternalId, Status) VALUES (@ListingId, @Amount, @PaymentMethod, @CardPlan, @Surcharge, @PaymentDate, @ExternalReference, @ExternalId, @Status)"; await conn.ExecuteAsync(sql, payment); } public async Task> GetActiveCashiersAsync() { using var conn = _connectionFactory.CreateConnection(); // Traemos usuarios que tengan el rol adecuado para filtrar en el historial var sql = @"SELECT Id, Username FROM Users WHERE Role IN ('Cajero', 'Admin') AND IsActive = 1 ORDER BY Username ASC"; return await conn.QueryAsync(sql); } public async Task UpdateOverlayStatusAsync(int id, int userId, string? status) { using var conn = _connectionFactory.CreateConnection(); // Seguridad: Filtramos por Id del aviso Y Id del usuario dueño var sql = "UPDATE Listings SET OverlayStatus = @status WHERE Id = @id AND UserId = @userId"; await conn.ExecuteAsync(sql, new { id, userId, status }); } }