2026-01-05 10:30:04 -03:00
|
|
|
// src/SIGCM.Infrastructure/Repositories/ListingRepository.cs
|
2025-12-17 13:51:48 -03:00
|
|
|
using Dapper;
|
|
|
|
|
using SIGCM.Application.DTOs;
|
|
|
|
|
using SIGCM.Domain.Entities;
|
|
|
|
|
using SIGCM.Domain.Interfaces;
|
2025-12-23 15:12:57 -03:00
|
|
|
using SIGCM.Domain.Models;
|
2025-12-17 13:51:48 -03:00
|
|
|
using SIGCM.Infrastructure.Data;
|
|
|
|
|
|
|
|
|
|
namespace SIGCM.Infrastructure.Repositories;
|
|
|
|
|
|
|
|
|
|
public class ListingRepository : IListingRepository
|
|
|
|
|
{
|
|
|
|
|
private readonly IDbConnectionFactory _connectionFactory;
|
|
|
|
|
|
|
|
|
|
public ListingRepository(IDbConnectionFactory connectionFactory)
|
|
|
|
|
{
|
|
|
|
|
_connectionFactory = connectionFactory;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
public async Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes, List<Payment>? payments = null)
|
2025-12-17 13:51:48 -03:00
|
|
|
{
|
|
|
|
|
using var conn = _connectionFactory.CreateConnection();
|
|
|
|
|
conn.Open();
|
|
|
|
|
using var transaction = conn.BeginTransaction();
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var sqlListing = @"
|
2025-12-18 13:32:50 -03:00
|
|
|
INSERT INTO Listings (
|
|
|
|
|
CategoryId, OperationId, Title, Description, Price, Currency,
|
|
|
|
|
CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount,
|
2026-01-05 10:30:04 -03:00
|
|
|
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId
|
2025-12-18 13:32:50 -03:00
|
|
|
)
|
|
|
|
|
VALUES (
|
|
|
|
|
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
|
|
|
|
|
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
|
2026-01-05 10:30:04 -03:00
|
|
|
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId
|
2025-12-18 13:32:50 -03:00
|
|
|
);
|
2025-12-17 13:51:48 -03:00
|
|
|
SELECT CAST(SCOPE_IDENTITY() as int);";
|
|
|
|
|
|
|
|
|
|
var listingId = await conn.QuerySingleAsync<int>(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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 13:51:48 -03:00
|
|
|
transaction.Commit();
|
|
|
|
|
return listingId;
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
transaction.Rollback();
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<Listing?> GetByIdAsync(int id)
|
|
|
|
|
{
|
|
|
|
|
using var conn = _connectionFactory.CreateConnection();
|
|
|
|
|
return await conn.QuerySingleOrDefaultAsync<Listing>("SELECT * FROM Listings WHERE Id = @Id", new { Id = id });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 15:12:57 -03:00
|
|
|
public async Task<ListingDetail?> GetDetailByIdAsync(int id)
|
2025-12-18 13:32:50 -03:00
|
|
|
{
|
|
|
|
|
using var conn = _connectionFactory.CreateConnection();
|
|
|
|
|
var sql = @"
|
2026-01-05 10:30:04 -03:00
|
|
|
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;
|
|
|
|
|
";
|
2025-12-18 13:32:50 -03:00
|
|
|
|
|
|
|
|
using var multi = await conn.QueryMultipleAsync(sql, new { Id = id });
|
2026-01-05 10:30:04 -03:00
|
|
|
var listingData = await multi.ReadSingleOrDefaultAsync<dynamic>();
|
|
|
|
|
if (listingData == null) return null;
|
2025-12-18 13:32:50 -03:00
|
|
|
|
2025-12-23 15:12:57 -03:00
|
|
|
var attributes = await multi.ReadAsync<ListingAttributeValueWithName>();
|
2025-12-18 13:32:50 -03:00
|
|
|
var images = await multi.ReadAsync<ListingImage>();
|
2026-01-05 10:30:04 -03:00
|
|
|
var payments = await multi.ReadAsync<Payment>();
|
2025-12-18 13:32:50 -03:00
|
|
|
|
2025-12-23 15:12:57 -03:00
|
|
|
return new ListingDetail
|
2025-12-18 13:32:50 -03:00
|
|
|
{
|
2025-12-23 15:12:57 -03:00
|
|
|
Listing = new Listing
|
|
|
|
|
{
|
2026-01-05 10:30:04 -03:00
|
|
|
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),
|
2025-12-23 15:12:57 -03:00
|
|
|
},
|
2025-12-18 13:32:50 -03:00
|
|
|
Attributes = attributes,
|
2026-01-05 10:30:04 -03:00
|
|
|
Images = images,
|
|
|
|
|
Payments = payments
|
2025-12-18 13:32:50 -03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 13:51:48 -03:00
|
|
|
public async Task<IEnumerable<Listing>> GetAllAsync()
|
|
|
|
|
{
|
|
|
|
|
using var conn = _connectionFactory.CreateConnection();
|
|
|
|
|
var sql = @"
|
2026-01-05 10:30:04 -03:00
|
|
|
SELECT l.*, c.Name as CategoryName,
|
2025-12-18 13:32:50 -03:00
|
|
|
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
2025-12-17 13:51:48 -03:00
|
|
|
FROM Listings l
|
2026-01-05 10:30:04 -03:00
|
|
|
JOIN Categories c ON l.CategoryId = c.Id
|
2025-12-23 15:12:57 -03:00
|
|
|
WHERE l.Status = 'Published'
|
2025-12-17 13:51:48 -03:00
|
|
|
ORDER BY l.CreatedAt DESC";
|
2025-12-18 13:32:50 -03:00
|
|
|
|
2025-12-17 13:51:48 -03:00
|
|
|
return await conn.QueryAsync<Listing>(sql);
|
|
|
|
|
}
|
2025-12-18 13:32:50 -03:00
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
// 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<IEnumerable<Listing>> 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<Listing>(sql, new { UserId = userId });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 13:32:50 -03:00
|
|
|
public async Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId)
|
2025-12-23 15:12:57 -03:00
|
|
|
{
|
|
|
|
|
return await SearchFacetedAsync(query, categoryId, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Búsqueda Avanzada Facetada
|
2026-01-05 10:30:04 -03:00
|
|
|
public async Task<IEnumerable<Listing>> SearchFacetedAsync(
|
|
|
|
|
string? query,
|
|
|
|
|
int? categoryId,
|
|
|
|
|
Dictionary<string, string>? attributes,
|
|
|
|
|
DateTime? from = null,
|
|
|
|
|
DateTime? to = null,
|
|
|
|
|
string? origin = null,
|
|
|
|
|
string? status = null)
|
2025-12-18 13:32:50 -03:00
|
|
|
{
|
|
|
|
|
using var conn = _connectionFactory.CreateConnection();
|
|
|
|
|
var parameters = new DynamicParameters();
|
2025-12-23 15:12:57 -03:00
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
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))
|
2025-12-23 15:12:57 -03:00
|
|
|
{
|
2026-01-05 10:30:04 -03:00
|
|
|
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.DniOrCuit = @ExactQuery)";
|
|
|
|
|
parameters.Add("Query", $"%{query}%");
|
|
|
|
|
parameters.Add("ExactQuery", query);
|
|
|
|
|
}
|
2025-12-23 15:12:57 -03:00
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
if (categoryId.HasValue && categoryId.Value > 0)
|
|
|
|
|
{
|
|
|
|
|
sql += " AND l.CategoryId = @CategoryId";
|
2025-12-23 15:12:57 -03:00
|
|
|
parameters.Add("CategoryId", categoryId);
|
|
|
|
|
}
|
2026-01-05 10:30:04 -03:00
|
|
|
|
|
|
|
|
if (from.HasValue)
|
2025-12-23 15:12:57 -03:00
|
|
|
{
|
2026-01-05 10:30:04 -03:00
|
|
|
sql += " AND l.CreatedAt >= @From";
|
|
|
|
|
parameters.Add("From", from.Value.Date);
|
2025-12-23 15:12:57 -03:00
|
|
|
}
|
2025-12-18 13:32:50 -03:00
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
if (to.HasValue)
|
2025-12-18 13:32:50 -03:00
|
|
|
{
|
2026-01-05 10:30:04 -03:00
|
|
|
sql += " AND l.CreatedAt <= @To";
|
|
|
|
|
parameters.Add("To", to.Value.Date.AddDays(1).AddSeconds(-1));
|
2025-12-18 13:32:50 -03:00
|
|
|
}
|
|
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
if (!string.IsNullOrEmpty(origin) && origin != "All")
|
2025-12-18 13:32:50 -03:00
|
|
|
{
|
2026-01-05 10:30:04 -03:00
|
|
|
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);
|
2025-12-18 13:32:50 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sql += " ORDER BY l.CreatedAt DESC";
|
|
|
|
|
|
|
|
|
|
return await conn.QueryAsync<Listing>(sql, parameters);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<IEnumerable<Listing>> 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))
|
2025-12-23 15:12:57 -03:00
|
|
|
ORDER BY c.Name, l.Title";
|
2025-12-18 13:32:50 -03:00
|
|
|
|
|
|
|
|
return await conn.QueryAsync<Listing>(sql, new { TargetDate = targetDate.Date });
|
|
|
|
|
}
|
2025-12-23 15:12:57 -03:00
|
|
|
|
|
|
|
|
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 });
|
2026-01-05 10:30:04 -03:00
|
|
|
stats.RevenueToday = kpis?.RevenueToday ?? 0;
|
|
|
|
|
stats.AdsToday = kpis?.AdsToday ?? 0;
|
2025-12-23 15:12:57 -03:00
|
|
|
stats.TicketAverage = stats.AdsToday > 0 ? stats.RevenueToday / stats.AdsToday : 0;
|
|
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
// 2. Occupation (based on the last day of the range)
|
2025-12-23 15:12:57 -03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
public async Task<AdvancedAnalyticsDto> GetAdvancedAnalyticsAsync(DateTime startDate, DateTime endDate)
|
2025-12-23 15:12:57 -03:00
|
|
|
{
|
|
|
|
|
using var conn = _connectionFactory.CreateConnection();
|
2026-01-05 10:30:04 -03:00
|
|
|
var analytics = new AdvancedAnalyticsDto();
|
2025-12-23 15:12:57 -03:00
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
// 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 = @"
|
2025-12-23 15:12:57 -03:00
|
|
|
SELECT
|
2026-01-05 10:30:04 -03:00
|
|
|
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as Revenue,
|
|
|
|
|
COUNT(Id) as Ads
|
2025-12-23 15:12:57 -03:00
|
|
|
FROM Listings
|
2026-01-05 10:30:04 -03:00
|
|
|
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
|
2025-12-23 15:12:57 -03:00
|
|
|
AND Status = 'Published'";
|
|
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
var currentKpis = await conn.QueryFirstOrDefaultAsync(currentKpiSql, new { Start = startDate.Date, End = endDate.Date });
|
|
|
|
|
analytics.TotalRevenue = currentKpis?.Revenue ?? 0;
|
|
|
|
|
analytics.TotalAds = currentKpis?.Ads ?? 0;
|
2025-12-23 15:12:57 -03:00
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
// 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'";
|
2025-12-23 15:12:57 -03:00
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
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 = @"
|
2025-12-23 15:12:57 -03:00
|
|
|
SELECT
|
2026-01-05 10:30:04 -03:00
|
|
|
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<PaymentMethodStat>(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
|
2025-12-23 15:12:57 -03:00
|
|
|
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'
|
2026-01-05 10:30:04 -03:00
|
|
|
GROUP BY c.Name
|
|
|
|
|
ORDER BY Revenue DESC";
|
|
|
|
|
|
|
|
|
|
var catPerf = (await conn.QueryAsync<CategoryPerformanceStat>(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<HourlyStat>(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";
|
2025-12-23 15:12:57 -03:00
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
analytics.DailyTrends = (await conn.QueryAsync<DailyRevenue>(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<dynamic>(sourceSql, new { Start = startDate.Date, End = endDate.Date });
|
|
|
|
|
|
|
|
|
|
int total = 0;
|
|
|
|
|
int web = 0;
|
|
|
|
|
int mostrador = 0;
|
|
|
|
|
|
|
|
|
|
foreach (var s in sources)
|
2025-12-23 15:12:57 -03:00
|
|
|
{
|
2026-01-05 10:30:04 -03:00
|
|
|
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<CashierDashboardDto?> GetCashierStatsAsync(int userId, DateTime startDate, DateTime endDate)
|
|
|
|
|
{
|
|
|
|
|
using var conn = _connectionFactory.CreateConnection();
|
2025-12-23 15:12:57 -03:00
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
// 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<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 };
|
|
|
|
|
|
|
|
|
|
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<ReportItemDto>(sql, new { Start = start.Date, End = end.Date, UserId = userId });
|
2025-12-23 15:12:57 -03:00
|
|
|
report.Items = items.ToList();
|
|
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
// 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<dynamic>(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;
|
2025-12-23 15:12:57 -03:00
|
|
|
return report;
|
|
|
|
|
}
|
2026-01-05 10:30:04 -03:00
|
|
|
|
|
|
|
|
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<IEnumerable<dynamic>> 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 });
|
|
|
|
|
}
|
2025-12-18 13:32:50 -03:00
|
|
|
}
|