Feat: Cambios Varios 2
This commit is contained in:
107
src/SIGCM.Infrastructure/Repositories/CashClosingRepository.cs
Normal file
107
src/SIGCM.Infrastructure/Repositories/CashClosingRepository.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class CashClosingRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
|
||||
public CashClosingRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
// Crear un nuevo cierre de caja
|
||||
public async Task<int> CreateAsync(CashClosing closing)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO CashClosings (
|
||||
UserId, ClosingDate,
|
||||
DeclaredCash, DeclaredDebit, DeclaredCredit, DeclaredTransfer,
|
||||
SystemCash, SystemDebit, SystemCredit, SystemTransfer,
|
||||
CashDifference, DebitDifference, CreditDifference, TransferDifference,
|
||||
TotalDeclared, TotalSystem, TotalDifference,
|
||||
Notes, HasDiscrepancies, IsApproved
|
||||
)
|
||||
VALUES (
|
||||
@UserId, @ClosingDate,
|
||||
@DeclaredCash, @DeclaredDebit, @DeclaredCredit, @DeclaredTransfer,
|
||||
@SystemCash, @SystemDebit, @SystemCredit, @SystemTransfer,
|
||||
@CashDifference, @DebitDifference, @CreditDifference, @TransferDifference,
|
||||
@TotalDeclared, @TotalSystem, @TotalDifference,
|
||||
@Notes, @HasDiscrepancies, @IsApproved
|
||||
);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
return await conn.QuerySingleAsync<int>(sql, closing);
|
||||
}
|
||||
|
||||
// Obtener cierres de un usuario en un rango de fechas
|
||||
public async Task<IEnumerable<CashClosing>> GetByUserAsync(int userId, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT * FROM CashClosings
|
||||
WHERE UserId = @UserId
|
||||
AND CAST(ClosingDate AS DATE) BETWEEN @StartDate AND @EndDate
|
||||
ORDER BY ClosingDate DESC";
|
||||
|
||||
return await conn.QueryAsync<CashClosing>(sql, new { UserId = userId, StartDate = startDate.Date, EndDate = endDate.Date });
|
||||
}
|
||||
|
||||
// Obtener todos los cierres con discrepancias (para administradores)
|
||||
public async Task<IEnumerable<CashClosing>> GetDiscrepanciesAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT c.*, u.Username
|
||||
FROM CashClosings c
|
||||
JOIN Users u ON c.UserId = u.Id
|
||||
WHERE c.HasDiscrepancies = 1 AND c.IsApproved = 0
|
||||
ORDER BY c.ClosingDate DESC";
|
||||
|
||||
return await conn.QueryAsync<CashClosing>(sql);
|
||||
}
|
||||
|
||||
// Aprobar un cierre de caja
|
||||
public async Task ApproveAsync(int closingId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
await conn.ExecuteAsync("UPDATE CashClosings SET IsApproved = 1 WHERE Id = @Id", new { Id = closingId });
|
||||
}
|
||||
|
||||
// Calcular totales de pagos por método del día actual para un usuario
|
||||
public async Task<Dictionary<string, decimal>> GetPaymentTotalsByMethodAsync(int userId, DateTime date)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT p.PaymentMethod, SUM(p.Amount + p.Surcharge) as Total
|
||||
FROM Payments p
|
||||
INNER JOIN Listings l ON p.ListingId = l.Id
|
||||
WHERE l.UserId = @UserId
|
||||
AND CAST(l.CreatedAt AS DATE) = @Date
|
||||
GROUP BY p.PaymentMethod";
|
||||
|
||||
var results = await conn.QueryAsync<dynamic>(sql, new { UserId = userId, Date = date.Date });
|
||||
|
||||
var totals = new Dictionary<string, decimal>
|
||||
{
|
||||
["Cash"] = 0,
|
||||
["Debit"] = 0,
|
||||
["Credit"] = 0,
|
||||
["Transfer"] = 0
|
||||
};
|
||||
|
||||
foreach (var row in results)
|
||||
{
|
||||
string method = row.PaymentMethod;
|
||||
decimal total = row.Total;
|
||||
if (totals.ContainsKey(method))
|
||||
{
|
||||
totals[method] = total;
|
||||
}
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
}
|
||||
105
src/SIGCM.Infrastructure/Repositories/CashSessionRepository.cs
Normal file
105
src/SIGCM.Infrastructure/Repositories/CashSessionRepository.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class CashSessionRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
public CashSessionRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
// 1. Verificar si el usuario ya tiene una caja abierta
|
||||
public async Task<CashSession?> GetActiveSessionAsync(int userId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = "SELECT * FROM CashSessions WHERE UserId = @userId AND Status = 'Open'";
|
||||
return await conn.QuerySingleOrDefaultAsync<CashSession>(sql, new { userId });
|
||||
}
|
||||
|
||||
// 2. Abrir Caja
|
||||
public async Task<int> OpenSessionAsync(int userId, decimal openingBalance)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO CashSessions (UserId, OpeningBalance, Status, OpeningDate)
|
||||
VALUES (@userId, @openingBalance, 'Open', GETUTCDATE());
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, new { userId, openingBalance });
|
||||
}
|
||||
|
||||
// 3. Obtener totales del sistema para un rango de tiempo (El turno)
|
||||
public async Task<dynamic> GetSystemTotalsAsync(int userId, DateTime start, DateTime end)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT
|
||||
SUM(CASE WHEN PaymentMethod = 'Cash' THEN Amount + Surcharge ELSE 0 END) as Cash,
|
||||
SUM(CASE WHEN PaymentMethod IN ('Debit', 'Credit') THEN Amount + Surcharge ELSE 0 END) as Cards,
|
||||
SUM(CASE WHEN PaymentMethod = 'Transfer' THEN Amount + Surcharge ELSE 0 END) as Transfers
|
||||
FROM Payments p
|
||||
JOIN Listings l ON p.ListingId = l.Id
|
||||
WHERE l.UserId = @userId AND p.PaymentDate BETWEEN @start AND @end";
|
||||
|
||||
return await conn.QuerySingleAsync<dynamic>(sql, new { userId, start, end });
|
||||
}
|
||||
|
||||
// 4. Cerrar Caja (Arqueo Ciego)
|
||||
public async Task CloseSessionAsync(CashSession session)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
UPDATE CashSessions
|
||||
SET Status = 'PendingValidation',
|
||||
ClosingDate = GETUTCDATE(),
|
||||
DeclaredCash = @DeclaredCash,
|
||||
DeclaredCards = @DeclaredCards,
|
||||
DeclaredTransfers = @DeclaredTransfers,
|
||||
SystemExpectedCash = @SystemExpectedCash,
|
||||
SystemExpectedCards = @SystemExpectedCards,
|
||||
SystemExpectedTransfers = @SystemExpectedTransfers,
|
||||
TotalDifference = @TotalDifference
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, session);
|
||||
}
|
||||
|
||||
// Obtener todas las sesiones que requieren validación (para el Supervisor)
|
||||
public async Task<IEnumerable<CashSession>> GetPendingValidationAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT cs.*, u.Username
|
||||
FROM CashSessions cs
|
||||
JOIN Users u ON cs.UserId = u.Id
|
||||
WHERE cs.Status = 'PendingValidation'
|
||||
ORDER BY cs.ClosingDate DESC";
|
||||
return await conn.QueryAsync<CashSession>(sql);
|
||||
}
|
||||
|
||||
// Validar / Liquidar una sesión
|
||||
public async Task ValidateSessionAsync(int sessionId, int adminId, string notes)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
UPDATE CashSessions
|
||||
SET Status = 'Closed',
|
||||
ValidatedByUserId = @adminId,
|
||||
ValidationDate = GETUTCDATE(),
|
||||
ValidationNotes = @notes
|
||||
WHERE Id = @sessionId";
|
||||
await conn.ExecuteAsync(sql, new { sessionId, adminId, notes });
|
||||
}
|
||||
|
||||
public async Task<CashSession?> GetSessionDetailAsync(int sessionId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT cs.*, u.Username, v.Username as ValidatorName
|
||||
FROM CashSessions cs
|
||||
JOIN Users u ON cs.UserId = u.Id
|
||||
LEFT JOIN Users v ON cs.ValidatedByUserId = v.Id
|
||||
WHERE cs.Id = @sessionId";
|
||||
return await conn.QuerySingleOrDefaultAsync<CashSession>(sql, new { sessionId });
|
||||
}
|
||||
}
|
||||
126
src/SIGCM.Infrastructure/Repositories/ClaimRepository.cs
Normal file
126
src/SIGCM.Infrastructure/Repositories/ClaimRepository.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
using SIGCM.Domain.Models;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class ClaimRepository : IClaimRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
public ClaimRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
public async Task<int> CreateAsync(Claim claim)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO Claims (ListingId, CreatedByUserId, ClaimType, Description, Status)
|
||||
VALUES (@ListingId, @CreatedByUserId, @ClaimType, @Description, @Status);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, claim);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Claim>> GetByListingIdAsync(int listingId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT c.*, u.Username as CreatedByUsername, r.Username as ResolvedByUsername
|
||||
FROM Claims c
|
||||
JOIN Users u ON c.CreatedByUserId = u.Id
|
||||
LEFT JOIN Users r ON c.ResolvedByUserId = r.Id
|
||||
WHERE c.ListingId = @ListingId
|
||||
ORDER BY c.CreatedAt DESC";
|
||||
return await conn.QueryAsync<Claim>(sql, new { ListingId = listingId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Claim>> GetAllActiveAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT c.*, u.Username as CreatedByUsername
|
||||
FROM Claims c
|
||||
JOIN Users u ON c.CreatedByUserId = u.Id
|
||||
WHERE c.Status <> 'Resolved'
|
||||
ORDER BY c.CreatedAt ASC";
|
||||
return await conn.QueryAsync<Claim>(sql);
|
||||
}
|
||||
|
||||
public async Task UpdateStatusAsync(int claimId, string status, ResolveRequest request, int resolvedByUserId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
conn.Open();
|
||||
using var transaction = conn.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Buscamos el aviso y sus valores ACTUALES antes de cambiarlos
|
||||
var currentDataSql = @"
|
||||
SELECT l.Id, l.PrintText, l.PrintStartDate, l.PrintDaysCount
|
||||
FROM Listings l
|
||||
JOIN Claims c ON c.ListingId = l.Id
|
||||
WHERE c.Id = @claimId";
|
||||
|
||||
var oldValues = await conn.QuerySingleOrDefaultAsync<dynamic>(currentDataSql, new { claimId }, transaction);
|
||||
|
||||
if (oldValues == null)
|
||||
throw new InvalidOperationException("Reclamo o Aviso no encontrado.");
|
||||
|
||||
// 2. Construimos un string descriptivo del estado anterior
|
||||
string originalValuesLog = $"Texto: {oldValues.PrintText} | " +
|
||||
$"Fecha: {oldValues.PrintStartDate:yyyy-MM-dd} | " +
|
||||
$"Días: {oldValues.PrintDaysCount}";
|
||||
|
||||
// 3. Actualizar el Reclamo (Guardando los valores originales)
|
||||
var sqlClaim = @"
|
||||
UPDATE Claims
|
||||
SET Status = @status,
|
||||
SolutionDescription = @solution,
|
||||
OriginalValues = @originalValuesLog,
|
||||
ResolvedAt = GETUTCDATE(),
|
||||
ResolvedByUserId = @resolvedByUserId
|
||||
WHERE Id = @claimId";
|
||||
|
||||
await conn.ExecuteAsync(sqlClaim, new
|
||||
{
|
||||
claimId,
|
||||
status,
|
||||
solution = request.Solution,
|
||||
originalValuesLog,
|
||||
resolvedByUserId
|
||||
}, transaction);
|
||||
|
||||
// 4. Aplicar los nuevos ajustes técnicos al aviso
|
||||
if (!string.IsNullOrEmpty(request.NewPrintText) || request.NewStartDate.HasValue || request.NewDaysCount.HasValue)
|
||||
{
|
||||
var sqlListing = @"
|
||||
UPDATE Listings
|
||||
SET PrintText = ISNULL(@NewPrintText, PrintText),
|
||||
PrintStartDate = ISNULL(@NewStartDate, PrintStartDate),
|
||||
PrintDaysCount = ISNULL(@NewDaysCount, PrintDaysCount)
|
||||
WHERE Id = @listingId";
|
||||
|
||||
await conn.ExecuteAsync(sqlListing, new
|
||||
{
|
||||
listingId = (int)oldValues.Id,
|
||||
request.NewPrintText,
|
||||
request.NewStartDate,
|
||||
request.NewDaysCount
|
||||
}, transaction);
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Claim?> GetByIdAsync(int id)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<Claim>("SELECT * FROM Claims WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -13,35 +13,37 @@ public class ClientRepository
|
||||
_db = db;
|
||||
}
|
||||
|
||||
// Búsqueda inteligente por Nombre O DNI
|
||||
// Búsqueda inteligente con protección de nulos
|
||||
public async Task<IEnumerable<Client>> SearchAsync(string query)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT TOP 10 * FROM Clients
|
||||
SELECT TOP 10
|
||||
Id,
|
||||
ISNULL(Name, 'Sin Nombre') as Name,
|
||||
ISNULL(DniOrCuit, '') as DniOrCuit,
|
||||
Email, Phone, Address
|
||||
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
|
||||
// Asegurar existencia (Upsert)
|
||||
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);";
|
||||
@@ -49,23 +51,59 @@ public class ClientRepository
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener todos con estadísticas (ISNULL agregado para seguridad)
|
||||
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
|
||||
SELECT
|
||||
c.Id as id,
|
||||
ISNULL(c.Name, 'Sin Nombre') as name,
|
||||
ISNULL(c.DniOrCuit, 'S/D') as dniOrCuit,
|
||||
ISNULL(c.Email, 'Sin correo') as email,
|
||||
ISNULL(c.Phone, 'Sin teléfono') as phone,
|
||||
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = c.Id) as totalAds,
|
||||
ISNULL((SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = c.Id), 0) as totalSpent
|
||||
FROM Clients c
|
||||
ORDER BY c.Name";
|
||||
return await conn.QueryAsync(sql);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Listing>> GetClientHistoryAsync(int clientId)
|
||||
public async Task UpdateAsync(Client client)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QueryAsync<Listing>(
|
||||
"SELECT * FROM Listings WHERE ClientId = @Id ORDER BY CreatedAt DESC",
|
||||
new { Id = clientId });
|
||||
var sql = @"
|
||||
UPDATE Clients
|
||||
SET Name = @Name,
|
||||
DniOrCuit = @DniOrCuit,
|
||||
Email = @Email,
|
||||
Phone = @Phone,
|
||||
Address = @Address
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, client);
|
||||
}
|
||||
|
||||
public async Task<dynamic?> GetClientSummaryAsync(int clientId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT
|
||||
c.Id, c.Name, c.DniOrCuit, c.Email, c.Phone, c.Address,
|
||||
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id) as TotalAds,
|
||||
ISNULL((SELECT SUM(AdFee) FROM Listings WHERE ClientId = c.Id), 0) as TotalInvested,
|
||||
(SELECT MAX(CreatedAt) FROM Listings WHERE ClientId = c.Id) as LastAdDate,
|
||||
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id AND Status = 'Published') as ActiveAds,
|
||||
ISNULL((
|
||||
SELECT TOP 1 cat.Name
|
||||
FROM Listings l
|
||||
JOIN Categories cat ON l.CategoryId = cat.Id
|
||||
WHERE l.ClientId = c.Id
|
||||
GROUP BY cat.Name
|
||||
ORDER BY COUNT(l.Id) DESC
|
||||
), 'N/A') as PreferredCategory
|
||||
FROM Clients c
|
||||
WHERE c.Id = @Id";
|
||||
|
||||
return await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { Id = clientId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class EditionClosureRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
|
||||
public EditionClosureRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
// Cerrar una edición específica
|
||||
public async Task<int> CloseEditionAsync(EditionClosure closure)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO EditionClosures (EditionDate, ClosureDateTime, ClosedByUserId, IsClosed, Notes)
|
||||
VALUES (@EditionDate, @ClosureDateTime, @ClosedByUserId, @IsClosed, @Notes);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
return await conn.QuerySingleAsync<int>(sql, closure);
|
||||
}
|
||||
|
||||
// Verificar si una edición está cerrada
|
||||
public async Task<bool> IsEditionClosedAsync(DateTime editionDate)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = "SELECT COUNT(1) FROM EditionClosures WHERE EditionDate = @Date AND IsClosed = 1";
|
||||
var count = await conn.ExecuteScalarAsync<int>(sql, new { Date = editionDate.Date });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
// Reabrir una edición
|
||||
public async Task ReopenEditionAsync(DateTime editionDate)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE EditionClosures SET IsClosed = 0 WHERE EditionDate = @Date",
|
||||
new { Date = editionDate.Date });
|
||||
}
|
||||
|
||||
// Obtener historial de cierres
|
||||
public async Task<IEnumerable<EditionClosure>> GetClosureHistoryAsync(int limit = 30)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT TOP (@Limit) ec.*, u.Username as ClosedByUsername
|
||||
FROM EditionClosures ec
|
||||
JOIN Users u ON ec.ClosedByUserId = u.Id
|
||||
ORDER BY ec.EditionDate DESC";
|
||||
|
||||
return await conn.QueryAsync<EditionClosure>(sql, new { Limit = limit });
|
||||
}
|
||||
|
||||
// Obtener el cierre de una fecha específica
|
||||
public async Task<EditionClosure?> GetClosureByDateAsync(DateTime editionDate)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT ec.*, u.Username as ClosedByUsername
|
||||
FROM EditionClosures ec
|
||||
JOIN Users u ON ec.ClosedByUserId = u.Id
|
||||
WHERE ec.EditionDate = @Date";
|
||||
|
||||
return await conn.QuerySingleOrDefaultAsync<EditionClosure>(sql, new { Date = editionDate.Date });
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,16 @@ public class ImageRepository : IImageRepository
|
||||
await conn.ExecuteAsync(sql, image);
|
||||
}
|
||||
|
||||
// Obtiene una imagen por su ID
|
||||
public async Task<ListingImage?> GetByIdAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ListingImage>(
|
||||
"SELECT * FROM ListingImages WHERE Id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
// Obtiene las imágenes de un aviso ordenadas
|
||||
public async Task<IEnumerable<ListingImage>> GetByListingIdAsync(int listingId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
@@ -30,4 +40,11 @@ public class ImageRepository : IImageRepository
|
||||
"SELECT * FROM ListingImages WHERE ListingId = @ListingId ORDER BY DisplayOrder",
|
||||
new { ListingId = listingId });
|
||||
}
|
||||
|
||||
// Elimina el registro de una imagen de la base de datos
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync("DELETE FROM ListingImages WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Data;
|
||||
// src/SIGCM.Infrastructure/Repositories/ListingRepository.cs
|
||||
using Dapper;
|
||||
using SIGCM.Application.DTOs;
|
||||
using SIGCM.Domain.Entities;
|
||||
@@ -17,7 +17,7 @@ public class ListingRepository : IListingRepository
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes)
|
||||
public async Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes, List<Payment>? payments = null)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
conn.Open();
|
||||
@@ -29,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, AdFee
|
||||
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId
|
||||
)
|
||||
VALUES (
|
||||
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
|
||||
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
|
||||
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee
|
||||
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId
|
||||
);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
@@ -52,6 +52,34 @@ public class ListingRepository : IListingRepository
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -71,55 +99,57 @@ public class ListingRepository : IListingRepository
|
||||
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
|
||||
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
|
||||
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 listing = await multi.ReadSingleOrDefaultAsync<dynamic>();
|
||||
if (listing == null) return null;
|
||||
var listingData = await multi.ReadSingleOrDefaultAsync<dynamic>();
|
||||
if (listingData == null) return null;
|
||||
|
||||
var attributes = await multi.ReadAsync<ListingAttributeValueWithName>();
|
||||
var images = await multi.ReadAsync<ListingImage>();
|
||||
var payments = await multi.ReadAsync<Payment>();
|
||||
|
||||
return new ListingDetail
|
||||
{
|
||||
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
|
||||
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
|
||||
Images = images,
|
||||
Payments = payments
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,85 +157,100 @@ public class ListingRepository : IListingRepository
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT TOP 20 l.*,
|
||||
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<Listing>(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<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 });
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
|
||||
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)";
|
||||
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";
|
||||
|
||||
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
|
||||
// --- FILTROS EXISTENTES ---
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query)";
|
||||
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.DniOrCuit = @ExactQuery)";
|
||||
parameters.Add("Query", $"%{query}%");
|
||||
parameters.Add("ExactQuery", query);
|
||||
}
|
||||
|
||||
// Filtros de Atributos (Igual que antes)
|
||||
if (attributes != null && attributes.Any())
|
||||
if (categoryId.HasValue && categoryId.Value > 0)
|
||||
{
|
||||
int i = 0;
|
||||
foreach (var attr in attributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attr.Value)) continue;
|
||||
string paramName = $"@Val{i}";
|
||||
string paramKey = $"@Key{i}";
|
||||
sql += " AND l.CategoryId = @CategoryId";
|
||||
parameters.Add("CategoryId", categoryId);
|
||||
}
|
||||
|
||||
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}
|
||||
)";
|
||||
if (from.HasValue)
|
||||
{
|
||||
sql += " AND l.CreatedAt >= @From";
|
||||
parameters.Add("From", from.Value.Date);
|
||||
}
|
||||
|
||||
parameters.Add($"Val{i}", $"%{attr.Value}%");
|
||||
parameters.Add($"Key{i}", attr.Key);
|
||||
i++;
|
||||
}
|
||||
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";
|
||||
@@ -317,11 +362,11 @@ public class ListingRepository : IListingRepository
|
||||
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.RevenueToday = kpis?.RevenueToday ?? 0;
|
||||
stats.AdsToday = kpis?.AdsToday ?? 0;
|
||||
stats.TicketAverage = stats.AdsToday > 0 ? stats.RevenueToday / stats.AdsToday : 0;
|
||||
|
||||
// 2. Ocupación (basada en el último día del rango)
|
||||
// 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
|
||||
@@ -357,6 +402,142 @@ public class ListingRepository : IListingRepository
|
||||
return stats;
|
||||
}
|
||||
|
||||
public async Task<AdvancedAnalyticsDto> 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<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
|
||||
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<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";
|
||||
|
||||
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)
|
||||
{
|
||||
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();
|
||||
@@ -364,18 +545,12 @@ public class ListingRepository : IListingRepository
|
||||
// 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'";
|
||||
(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 });
|
||||
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)
|
||||
@@ -383,30 +558,83 @@ public class ListingRepository : IListingRepository
|
||||
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
|
||||
});
|
||||
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 });
|
||||
report.Items = items.ToList();
|
||||
report.TotalRevenue = report.Items.Sum(x => x.Amount);
|
||||
report.TotalAds = report.Items.Count;
|
||||
|
||||
// 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;
|
||||
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<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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class NotificationRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
public NotificationRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
public async Task<IEnumerable<Notification>> GetByUserAsync(int userId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QueryAsync<Notification>(
|
||||
"SELECT TOP 10 * FROM Notifications WHERE UserId = @userId ORDER BY CreatedAt DESC",
|
||||
new { userId });
|
||||
}
|
||||
|
||||
public async Task MarkAsReadAsync(int notificationId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
await conn.ExecuteAsync("UPDATE Notifications SET IsRead = 1 WHERE Id = @Id", new { Id = notificationId });
|
||||
}
|
||||
|
||||
public async Task<int> GetUnreadCountAsync(int userId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(1) FROM Notifications WHERE UserId = @userId AND IsRead = 0",
|
||||
new { userId });
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public class UserRepository : IUserRepository
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
// Busca usuario por nombre de usuario
|
||||
public async Task<User?> GetByUsernameAsync(string username)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
@@ -22,38 +23,64 @@ public class UserRepository : IUserRepository
|
||||
new { Username = username });
|
||||
}
|
||||
|
||||
// Busca usuario por correo electrónico
|
||||
public async Task<User?> GetByEmailAsync(string email)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<User>(
|
||||
"SELECT * FROM Users WHERE Email = @Email",
|
||||
new { Email = email });
|
||||
}
|
||||
|
||||
// Busca usuario por identificador único de Google
|
||||
public async Task<User?> GetByGoogleIdAsync(string googleId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<User>(
|
||||
"SELECT * FROM Users WHERE GoogleId = @GoogleId",
|
||||
new { GoogleId = googleId });
|
||||
}
|
||||
|
||||
// Crea un nuevo usuario en el sistema
|
||||
public async Task<int> CreateAsync(User user)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO Users (Username, PasswordHash, Role, Email)
|
||||
VALUES (@Username, @PasswordHash, @Role, @Email);
|
||||
INSERT INTO Users (Username, PasswordHash, Role, Email, FailedLoginAttempts, LockoutEnd, MustChangePassword, IsActive, LastLogin, GoogleId, IsMfaEnabled, MfaSecret)
|
||||
VALUES (@Username, @PasswordHash, @Role, @Email, @FailedLoginAttempts, @LockoutEnd, @MustChangePassword, @IsActive, @LastLogin, @GoogleId, @IsMfaEnabled, @MfaSecret);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, user);
|
||||
}
|
||||
|
||||
// Obtiene todos los usuarios
|
||||
public async Task<IEnumerable<User>> GetAllAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QueryAsync<User>("SELECT Id, Username, Role, Email, CreatedAt, PasswordHash FROM Users");
|
||||
return await conn.QueryAsync<User>("SELECT * FROM Users");
|
||||
}
|
||||
|
||||
// Obtiene un usuario por su ID
|
||||
public async Task<User?> GetByIdAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<User>("SELECT * FROM Users WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
|
||||
// Actualiza los datos de un usuario existente
|
||||
public async Task UpdateAsync(User user)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
UPDATE Users
|
||||
SET Username = @Username, Role = @Role, Email = @Email, PasswordHash = @PasswordHash
|
||||
SET Username = @Username, Role = @Role, Email = @Email, PasswordHash = @PasswordHash,
|
||||
FailedLoginAttempts = @FailedLoginAttempts, LockoutEnd = @LockoutEnd,
|
||||
MustChangePassword = @MustChangePassword, IsActive = @IsActive, LastLogin = @LastLogin,
|
||||
GoogleId = @GoogleId, IsMfaEnabled = @IsMfaEnabled, MfaSecret = @MfaSecret
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, user);
|
||||
}
|
||||
|
||||
// Elimina un usuario por su ID
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
|
||||
Reference in New Issue
Block a user