Feat: Cambios Varios

This commit is contained in:
2025-12-23 15:12:57 -03:00
parent 32663e6324
commit 8bc1308bc5
58 changed files with 4080 additions and 663 deletions

View File

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