Feat: Cambios Varios 2

This commit is contained in:
2026-01-05 10:30:04 -03:00
parent 8bc1308bc5
commit 0fa77e4a98
184 changed files with 11098 additions and 6348 deletions

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SIGCM.Domain.Interfaces;
using SIGCM.Application.Interfaces;
@@ -9,7 +10,7 @@ namespace SIGCM.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<IDbConnectionFactory, DbConnectionFactory>();
services.AddSingleton<DbInitializer>();
@@ -25,6 +26,24 @@ public static class DependencyInjection
services.AddScoped<PricingService>();
services.AddScoped<ClientRepository>();
services.AddScoped<AuditRepository>();
//services.AddScoped<CashClosingRepository>();
services.AddScoped<CashSessionRepository>();
services.AddScoped<EditionClosureRepository>();
services.AddScoped<ImageOptimizationService>();
services.AddScoped<IClaimRepository, ClaimRepository>();
services.AddScoped<NotificationRepository>();
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
services.AddScoped<MercadoPagoService>(sp =>
{
return new MercadoPagoService(
configuration["MercadoPago:AccessToken"] ?? "TEST-YOUR-TOKEN",
configuration["MercadoPago:SuccessUrl"] ?? "http://localhost:5173/publicar/exito",
configuration["MercadoPago:FailureUrl"] ?? "http://localhost:5173/publicar/error",
configuration["MercadoPago:NotificationUrl"] ?? "https://yourdomain.com/api/payments/webhook"
);
});
return services;
}
}

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

View 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 });
}
}

View 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 });
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

@@ -8,9 +8,13 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Google.Apis.Auth" Version="1.73.0" />
<PackageReference Include="mercadopago-sdk" Version="2.11.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="QuestPDF" Version="2025.12.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
</ItemGroup>
<PropertyGroup>

View File

@@ -1,5 +1,10 @@
using Google.Apis.Auth;
using OtpNet;
using SIGCM.Application.DTOs;
using SIGCM.Application.Interfaces;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using System.Text;
namespace SIGCM.Infrastructure.Services;
@@ -10,18 +15,149 @@ public class AuthService : IAuthService
public AuthService(IUserRepository userRepo, ITokenService tokenService)
{
_userRepo = userRepo;
_tokenService = tokenService;
_userRepo = userRepo;
_tokenService = tokenService;
}
public async Task<string?> LoginAsync(string username, string password)
// Inicio de sesión estándar con usuario y contraseña
public async Task<AuthResult> LoginAsync(string username, string password)
{
var user = await _userRepo.GetByUsernameAsync(username);
if (user == null) return null;
bool valid = BCrypt.Net.BCrypt.Verify(password, user.PasswordHash);
if (!valid) return null;
if (user == null) return new AuthResult { Success = false, ErrorMessage = "Credenciales inválidas" };
return _tokenService.GenerateToken(user);
// Verificación de bloqueo de cuenta
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
return new AuthResult { Success = false, ErrorMessage = "Cuenta bloqueada temporalmente", IsLockedOut = true };
// Verificación de cuenta activa
if (!user.IsActive)
return new AuthResult { Success = false, ErrorMessage = "Cuenta desactivada" };
// Verificación de contraseña
bool valid = BCrypt.Net.BCrypt.Verify(password, user.PasswordHash);
if (!valid)
{
user.FailedLoginAttempts++;
if (user.FailedLoginAttempts >= 5) user.LockoutEnd = DateTime.UtcNow.AddMinutes(15);
await _userRepo.UpdateAsync(user);
return new AuthResult { Success = false, ErrorMessage = "Credenciales inválidas" };
}
// Si MFA está activo, no devolver token aún, pedir verificación
if (user.IsMfaEnabled)
{
return new AuthResult { Success = true, RequiresMfa = true };
}
// Éxito: Reiniciar intentos y generar token
user.FailedLoginAttempts = 0;
user.LockoutEnd = null;
user.LastLogin = DateTime.UtcNow;
await _userRepo.UpdateAsync(user);
return new AuthResult
{
Success = true,
Token = _tokenService.GenerateToken(user),
RequiresPasswordChange = user.MustChangePassword
};
}
// Registro de nuevos usuarios (Public Web)
public async Task<AuthResult> RegisterAsync(string username, string email, string password)
{
if (await _userRepo.GetByUsernameAsync(username) != null)
return new AuthResult { Success = false, ErrorMessage = "El usuario ya existe" };
if (await _userRepo.GetByEmailAsync(email) != null)
return new AuthResult { Success = false, ErrorMessage = "El email ya está registrado" };
var user = new User
{
Username = username,
Email = email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
Role = "User", // Rol por defecto para la web pública
CreatedAt = DateTime.UtcNow,
MustChangePassword = false
};
await _userRepo.CreateAsync(user);
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user) };
}
// Login mediante Google OAuth
public async Task<AuthResult> GoogleLoginAsync(string idToken)
{
try
{
var payload = await GoogleJsonWebSignature.ValidateAsync(idToken);
var user = await _userRepo.GetByGoogleIdAsync(payload.Subject)
?? await _userRepo.GetByEmailAsync(payload.Email);
if (user == null)
{
// Auto-registro mediante Google
user = new User
{
Username = payload.Email.Split('@')[0],
Email = payload.Email,
GoogleId = payload.Subject,
PasswordHash = "OAUTH_LOGIN_" + Guid.NewGuid().ToString(), // Hash dummy
Role = "User",
CreatedAt = DateTime.UtcNow,
MustChangePassword = false
};
user.Id = await _userRepo.CreateAsync(user);
}
else if (string.IsNullOrEmpty(user.GoogleId))
{
// Vincular cuenta existente con Google
user.GoogleId = payload.Subject;
await _userRepo.UpdateAsync(user);
}
if (user.IsMfaEnabled) return new AuthResult { Success = true, RequiresMfa = true };
return new AuthResult { Success = true, Token = _tokenService.GenerateToken(user) };
}
catch (InvalidJwtException)
{
return new AuthResult { Success = false, ErrorMessage = "Token de Google inválido" };
}
}
// Genera un secreto para configurar MFA con aplicaciones tipo Google Authenticator
public async Task<string> GenerateMfaSecretAsync(int userId)
{
var user = await _userRepo.GetByIdAsync(userId);
if (user == null) throw new Exception("Usuario no encontrado");
var secretBytes = KeyGeneration.GenerateRandomKey(20);
var secret = Base32Encoding.ToString(secretBytes);
user.MfaSecret = secret;
await _userRepo.UpdateAsync(user);
return secret;
}
// Verifica el código TOTP ingresado por el usuario
public async Task<bool> VerifyMfaCodeAsync(int userId, string code)
{
var user = await _userRepo.GetByIdAsync(userId);
if (user == null || string.IsNullOrEmpty(user.MfaSecret)) return false;
var totp = new Totp(Base32Encoding.ToBytes(user.MfaSecret));
return totp.VerifyTotp(code, out _, new VerificationWindow(1, 1));
}
// Activa o desactiva MFA para el usuario
public async Task EnableMfaAsync(int userId, bool enabled)
{
var user = await _userRepo.GetByIdAsync(userId);
if (user == null) return;
user.IsMfaEnabled = enabled;
await _userRepo.UpdateAsync(user);
}
}

View File

@@ -0,0 +1,183 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Formats.Jpeg;
namespace SIGCM.Infrastructure.Services;
public class ImageOptimizationService
{
// Configuración de tamaños
private const int MAX_WIDTH = 1920;
private const int MAX_HEIGHT = 1080;
private const int THUMBNAIL_WIDTH = 400;
private const int THUMBNAIL_HEIGHT = 300;
// Configuración de calidad
private const int WEBP_QUALITY = 85;
private const int JPEG_QUALITY = 90;
/// <summary>
/// Optimiza una imagen: resize, compresión y conversión a WebP
/// </summary>
public async Task<ImageOptimizationResult> OptimizeImageAsync(Stream inputStream, string originalFileName)
{
var result = new ImageOptimizationResult
{
OriginalFileName = originalFileName,
ProcessedAt = DateTime.UtcNow
};
try
{
using var image = await Image.LoadAsync(inputStream);
result.OriginalWidth = image.Width;
result.OriginalHeight = image.Height;
result.OriginalSize = inputStream.Length;
// 1. RESIZE si es necesario
if (image.Width > MAX_WIDTH || image.Height > MAX_HEIGHT)
{
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(MAX_WIDTH, MAX_HEIGHT),
Mode = ResizeMode.Max, // Mantiene aspect ratio
Sampler = KnownResamplers.Lanczos3 // Alta calidad
}));
}
result.OptimizedWidth = image.Width;
result.OptimizedHeight = image.Height;
// 2. CONVERSIÓN A WEBP (imagen principal)
var webpStream = new MemoryStream();
var webpEncoder = new WebpEncoder
{
Quality = WEBP_QUALITY,
Method = WebpEncodingMethod.BestQuality
};
await image.SaveAsync(webpStream, webpEncoder);
webpStream.Position = 0;
result.WebpData = webpStream.ToArray();
result.WebpSize = result.WebpData.Length;
// 3. THUMBNAIL (miniatura para listados)
var thumbnail = image.Clone(x => x.Resize(new ResizeOptions
{
Size = new Size(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT),
Mode = ResizeMode.Max,
Sampler = KnownResamplers.Lanczos3
}));
var thumbStream = new MemoryStream();
await thumbnail.SaveAsync(thumbStream, webpEncoder);
thumbStream.Position = 0;
result.ThumbnailData = thumbStream.ToArray();
result.ThumbnailSize = result.ThumbnailData.Length;
// 4. FALLBACK JPEG (para navegadores antiguos)
var jpegStream = new MemoryStream();
var jpegEncoder = new JpegEncoder { Quality = JPEG_QUALITY };
await image.SaveAsync(jpegStream, jpegEncoder);
jpegStream.Position = 0;
result.JpegData = jpegStream.ToArray();
result.JpegSize = result.JpegData.Length;
// Calcular reducción de tamaño
result.CompressionRatio = (1 - (double)result.WebpSize / result.OriginalSize) * 100;
thumbnail.Dispose();
}
catch (Exception ex)
{
result.Error = ex.Message;
}
return result;
}
/// <summary>
/// Genera solo un thumbnail rápido
/// </summary>
public async Task<byte[]> GenerateThumbnailAsync(Stream inputStream, int width = 200, int height = 150)
{
using var image = await Image.LoadAsync(inputStream);
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(width, height),
Mode = ResizeMode.Crop, // Recorta para mantener dimensiones exactas
Sampler = KnownResamplers.Lanczos3
}));
var stream = new MemoryStream();
var encoder = new WebpEncoder { Quality = 80 };
await image.SaveAsync(stream, encoder);
return stream.ToArray();
}
/// <summary>
/// Valida que el archivo sea realmente una imagen
/// </summary>
public async Task<bool> IsValidImageAsync(Stream stream)
{
try
{
var imageInfo = await Image.IdentifyAsync(stream);
stream.Position = 0; // Reset para uso posterior
return imageInfo != null;
}
catch
{
return false;
}
}
/// <summary>
/// Obtiene información básica de la imagen sin cargarla completamente
/// </summary>
public async Task<(int Width, int Height, string Format)> GetImageInfoAsync(Stream stream)
{
var imageInfo = await Image.IdentifyAsync(stream);
stream.Position = 0;
return (imageInfo.Width, imageInfo.Height, imageInfo.Metadata.DecodedImageFormat?.Name ?? "Unknown");
}
}
// Resultado de la optimización
public class ImageOptimizationResult
{
public string OriginalFileName { get; set; } = string.Empty;
public DateTime ProcessedAt { get; set; }
// Dimensiones originales
public int OriginalWidth { get; set; }
public int OriginalHeight { get; set; }
public long OriginalSize { get; set; }
// Dimensiones optimizadas
public int OptimizedWidth { get; set; }
public int OptimizedHeight { get; set; }
// Archivos generados
public byte[]? WebpData { get; set; }
public long WebpSize { get; set; }
public byte[]? ThumbnailData { get; set; }
public long ThumbnailSize { get; set; }
public byte[]? JpegData { get; set; }
public long JpegSize { get; set; }
// Métricas
public double CompressionRatio { get; set; }
// Error (si hubo)
public string? Error { get; set; }
public bool Success => string.IsNullOrEmpty(Error);
}

View File

@@ -0,0 +1,73 @@
// src/SIGCM.Infrastructure/Services/MercadoPagoService.cs
using MercadoPago.Client.Preference;
using MercadoPago.Config;
using MercadoPago.Resource.Preference;
using SIGCM.Domain.Entities;
namespace SIGCM.Infrastructure.Services;
public class MercadoPagoService
{
private readonly string _accessToken;
private readonly string _successUrl;
private readonly string _failureUrl;
private readonly string _notificationUrl;
public MercadoPagoService(string accessToken, string successUrl, string failureUrl, string notificationUrl)
{
_accessToken = accessToken;
_successUrl = successUrl;
_failureUrl = failureUrl;
_notificationUrl = notificationUrl;
MercadoPagoConfig.AccessToken = _accessToken;
}
public async Task<object> CreatePreferenceAsync(Listing listing, decimal totalAmount)
{
/*
// IMPLEMENTACIÓN REAL (COMENTADA PARA FASE FINAL)
public async Task<Preference> CreatePreferenceAsync(Listing listing, decimal totalAmount)
{
var request = new PreferenceRequest
{
Items = new List<PreferenceItemRequest>
{
new PreferenceItemRequest
{
Id = listing.Id.ToString(),
Title = $"Publicación de Aviso: {listing.Title}",
Quantity = 1,
CurrencyId = "ARS",
UnitPrice = totalAmount
}
},
Payer = new PreferencePayerRequest
{
Email = "test_user_123@testuser.com", // En producción usar email del cliente
},
BackUrls = new PreferenceBackUrlsRequest
{
Success = _successUrl,
Failure = _failureUrl,
Pending = _failureUrl
},
AutoReturn = "approved",
ExternalReference = listing.Id.ToString(),
NotificationUrl = _notificationUrl,
StatementDescriptor = "DIARIO EL DIA"
};
var client = new PreferenceClient();
return await client.CreateAsync(request);
*/
// SIMULACIÓN PARA DESARROLLO
await Task.Delay(500); // Simulamos latencia de red
return new
{
Id = "MOCK_PREFERENCE_ID_12345",
InitPoint = "#",
SandboxInitPoint = "#"
};
}
}

View File

@@ -1,6 +1,7 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Models;
namespace SIGCM.Infrastructure.Services;
@@ -106,4 +107,132 @@ public static class ReportGenerator
});
}).GeneratePdf();
}
public static byte[] GenerateCashSessionPdf(CashSession session)
{
return Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(1.5f, Unit.Centimetre);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Helvetica"));
// --- ENCABEZADO ---
page.Header().Row(row =>
{
row.RelativeItem().Column(col =>
{
col.Item().Text("DIARIO EL DIA").FontSize(22).Black().SemiBold();
col.Item().Text("SISTEMA INTEGRAL DE GESTIÓN (SIG-CM)").FontSize(9).Italic().FontColor(Colors.Grey.Medium);
});
row.RelativeItem().AlignRight().Column(col =>
{
col.Item().Text("ACTA DE CIERRE DE CAJA").FontSize(14).SemiBold().FontColor(Colors.Blue.Medium);
col.Item().Text($"SESIÓN ID: #{session.Id.ToString().PadLeft(6, '0')}").FontSize(10).Bold();
col.Item().Text($"Estado: {session.Status.ToUpper()}").FontSize(8);
});
});
// --- CONTENIDO ---
page.Content().PaddingVertical(20).Column(col =>
{
// Bloque de Información General
col.Item().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text("CAJERO RESPONSABLE").FontSize(8).Bold().FontColor(Colors.Grey.Medium);
c.Item().Text(session.Username?.ToUpper() ?? "N/A").FontSize(12).Black();
});
row.RelativeItem().Column(c =>
{
c.Item().Text("APERTURA").FontSize(8).Bold().FontColor(Colors.Grey.Medium);
c.Item().Text(session.OpeningDate.ToLocalTime().ToString("G")).FontSize(10);
});
row.RelativeItem().Column(c =>
{
c.Item().Text("CIERRE").FontSize(8).Bold().FontColor(Colors.Grey.Medium);
c.Item().Text(session.ClosingDate?.ToLocalTime().ToString("G") ?? "EN CURSO").FontSize(10);
});
});
col.Item().PaddingTop(25).Text("RESUMEN DE VALORES").FontSize(12).SemiBold().Underline();
// Tabla de Liquidación
col.Item().PaddingTop(10).Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn();
columns.ConstantColumn(100);
});
table.Cell().Element(RowStyle).Text("FONDO INICIAL DE APERTURA");
table.Cell().Element(RowStyle).AlignRight().Text($"$ {session.OpeningBalance:N2}");
table.Cell().Element(RowStyle).Text("RECAUDACIÓN EFECTIVO (VENTAS)");
table.Cell().Element(RowStyle).AlignRight().Text($"$ {session.SystemExpectedCash:N2}");
table.Cell().Element(RowStyle).Text("RECAUDACIÓN TARJETAS (DÉBITO/CRÉDITO)");
table.Cell().Element(RowStyle).AlignRight().Text($"$ {session.SystemExpectedCards:N2}");
table.Cell().Element(RowStyle).Text("RECAUDACIÓN TRANSFERENCIAS");
table.Cell().Element(RowStyle).AlignRight().Text($"$ {session.SystemExpectedTransfers:N2}");
// Total Final
table.Cell().PaddingTop(5).Text("TOTAL GENERAL A ENTREGAR").Bold().FontSize(12);
decimal total = session.OpeningBalance + (session.SystemExpectedCash ?? 0) + (session.SystemExpectedCards ?? 0) + (session.SystemExpectedTransfers ?? 0);
table.Cell().PaddingTop(5).AlignRight().Text($"$ {total:N2}").Bold().FontSize(12);
static IContainer RowStyle(IContainer container) => container.PaddingVertical(5).BorderBottom(1).BorderColor(Colors.Grey.Lighten4);
});
// Diferencias (si existen)
if (session.TotalDifference != 0)
{
col.Item().PaddingTop(20).Background(Colors.Grey.Lighten4).Padding(10).Row(row =>
{
row.RelativeItem().Text("DIFERENCIA DETECTADA EN ARQUEO:").Bold();
row.RelativeItem().AlignRight().Text($"$ {session.TotalDifference:N2}").Bold().FontColor(session.TotalDifference > 0 ? Colors.Green.Medium : Colors.Red.Medium);
});
}
// Espacio para Observaciones
col.Item().PaddingTop(30).Column(c =>
{
c.Item().Text("OBSERVACIONES:").FontSize(8).Bold();
c.Item().MinHeight(50).Border(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(5).Text(session.ValidationNotes ?? "Sin observaciones adicionales.");
});
// --- SECCIÓN DE FIRMAS ---
col.Item().PaddingTop(60).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().PaddingTop(10).BorderTop(1).AlignCenter().Text("FIRMA CAJERO").FontSize(9);
c.Item().AlignCenter().Text(session.Username?.ToUpper()).FontSize(7);
});
row.ConstantItem(50);
row.RelativeItem().Column(c =>
{
c.Item().PaddingTop(10).BorderTop(1).AlignCenter().Text("FIRMA SUPERVISOR / TESORERÍA").FontSize(9);
c.Item().AlignCenter().Text(session.ValidatorName?.ToUpper() ?? "ACLARACIÓN").FontSize(7);
});
});
});
// --- PIE DE PÁGINA ---
page.Footer().AlignCenter().Text(x =>
{
x.Span("Documento generado por SIG-CM el ");
x.Span(DateTime.Now.ToString("dd/MM/yyyy HH:mm:ss"));
x.Span(" - Página ");
x.CurrentPageNumber();
});
});
}).GeneratePdf();
}
}

View File

@@ -21,13 +21,14 @@ public class TokenService : ITokenService
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
// Especificamos explícitamente System.Security.Claims.Claim
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Username),
new Claim(ClaimTypes.Role, user.Role),
new Claim("Id", user.Id.ToString())
};
new System.Security.Claims.Claim(JwtRegisteredClaimNames.Sub, user.Username),
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Role, user.Role),
new System.Security.Claims.Claim("Id", user.Id.ToString())
};
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],