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.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
@@ -16,6 +17,7 @@ public class AttributeDefinitionsController : ControllerBase
}
[HttpGet("category/{categoryId}")]
[AllowAnonymous]
public async Task<IActionResult> GetByCategoryId(int categoryId)
{
var attributes = await _repository.GetByCategoryIdAsync(categoryId);
@@ -23,6 +25,7 @@ public class AttributeDefinitionsController : ControllerBase
}
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Create(AttributeDefinition attribute)
{
var id = await _repository.AddAsync(attribute);
@@ -31,6 +34,7 @@ public class AttributeDefinitionsController : ControllerBase
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(int id)
{
await _repository.DeleteAsync(id);

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs;
using SIGCM.Application.Interfaces;
@@ -15,12 +16,60 @@ public class AuthController : ControllerBase
_authService = authService;
}
// Inicio de sesión tradicional
[HttpPost("login")]
public async Task<IActionResult> Login(LoginDto dto)
{
var token = await _authService.LoginAsync(dto.Username, dto.Password);
if (token == null) return Unauthorized("Invalid credentials");
var result = await _authService.LoginAsync(dto.Username, dto.Password);
if (!result.Success) return Unauthorized(new { message = result.ErrorMessage });
return Ok(result);
}
// Registro de nuevos usuarios
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterDto dto)
{
var result = await _authService.RegisterAsync(dto.Username, dto.Email, dto.Password);
if (!result.Success) return BadRequest(new { message = result.ErrorMessage });
return Ok(result);
}
// Inicio de sesión con Google
[HttpPost("google-login")]
public async Task<IActionResult> GoogleLogin([FromBody] string idToken)
{
var result = await _authService.GoogleLoginAsync(idToken);
if (!result.Success) return Unauthorized(new { message = result.ErrorMessage });
return Ok(result);
}
// Flujo MFA: Obtener secreto (QR)
[Authorize]
[HttpGet("mfa/setup")]
public async Task<IActionResult> SetupMfa()
{
var userId = int.Parse(User.FindFirst("Id")?.Value!);
var secret = await _authService.GenerateMfaSecretAsync(userId);
return Ok(new { secret, qrCodeUri = $"otpauth://totp/SIGCM:{User.Identity?.Name}?secret={secret}&issuer=SIGCM" });
}
// Flujo MFA: Verificar y activar
[Authorize]
[HttpPost("mfa/verify")]
public async Task<IActionResult> VerifyMfa([FromBody] string code)
{
var userId = int.Parse(User.FindFirst("Id")?.Value!);
var valid = await _authService.VerifyMfaCodeAsync(userId, code);
if (!valid) return BadRequest(new { message = "Código inválido" });
return Ok(new { token });
await _authService.EnableMfaAsync(userId, true);
return Ok(new { success = true });
}
}
public class RegisterDto
{
public string Username { get; set; } = "";
public string Email { get; set; } = "";
public string Password { get; set; } = "";
}

View File

@@ -0,0 +1,182 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class CashClosingController : ControllerBase
{
private readonly CashClosingRepository _closingRepo;
private readonly AuditRepository _auditRepo;
public CashClosingController(CashClosingRepository closingRepo, AuditRepository auditRepo)
{
_closingRepo = closingRepo;
_auditRepo = auditRepo;
}
/// <summary>
/// Procesar cierre de caja con arqueo ciego
/// El cajero declara sus totales sin ver primero lo que dice el sistema
/// </summary>
[HttpPost("close")]
[Authorize(Roles = "Cajero,Admin")]
public async Task<IActionResult> CloseCash(CashClosingDto dto)
{
// Obtener usuario actual del JWT
var userIdClaim = User.FindFirst("Id")?.Value;
if (!int.TryParse(userIdClaim, out int userId))
{
return Unauthorized("Usuario no identificado");
}
// Obtener los totales reales del sistema para el día actual
var today = DateTime.UtcNow.Date;
var systemTotals = await _closingRepo.GetPaymentTotalsByMethodAsync(userId, today);
// Calcular diferencias
var closing = new CashClosing
{
UserId = userId,
ClosingDate = DateTime.UtcNow,
// Valores declarados por el cajero
DeclaredCash = dto.DeclaredCash,
DeclaredDebit = dto.DeclaredDebit,
DeclaredCredit = dto.DeclaredCredit,
DeclaredTransfer = dto.DeclaredTransfer,
// Valores del sistema
SystemCash = systemTotals["Cash"],
SystemDebit = systemTotals["Debit"],
SystemCredit = systemTotals["Credit"],
SystemTransfer = systemTotals["Transfer"],
// Cálculo de diferencias
CashDifference = dto.DeclaredCash - systemTotals["Cash"],
DebitDifference = dto.DeclaredDebit - systemTotals["Debit"],
CreditDifference = dto.DeclaredCredit - systemTotals["Credit"],
TransferDifference = dto.DeclaredTransfer - systemTotals["Transfer"],
// Totales
TotalDeclared = dto.DeclaredCash + dto.DeclaredDebit + dto.DeclaredCredit + dto.DeclaredTransfer,
TotalSystem = systemTotals["Cash"] + systemTotals["Debit"] + systemTotals["Credit"] + systemTotals["Transfer"],
Notes = dto.Notes
};
// Calcular diferencia total
closing.TotalDifference = closing.TotalDeclared - closing.TotalSystem;
// Detectar discrepancias (tolerancia de $10 pesos)
closing.HasDiscrepancies = Math.Abs(closing.TotalDifference) > 10;
// Guardar el cierre
var closingId = await _closingRepo.CreateAsync(closing);
// Registrar en auditoría
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "CASH_CLOSING",
EntityId = closingId,
EntityType = "CashClosing",
Details = $"Cierre de caja. Diferencia: ${closing.TotalDifference}. Discrepancias: {closing.HasDiscrepancies}",
CreatedAt = DateTime.UtcNow
});
// Preparar respuesta
var result = new CashClosingResultDto
{
ClosingId = closingId,
DeclaredCash = closing.DeclaredCash,
DeclaredDebit = closing.DeclaredDebit,
DeclaredCredit = closing.DeclaredCredit,
DeclaredTransfer = closing.DeclaredTransfer,
TotalDeclared = closing.TotalDeclared,
SystemCash = closing.SystemCash,
SystemDebit = closing.SystemDebit,
SystemCredit = closing.SystemCredit,
SystemTransfer = closing.SystemTransfer,
TotalSystem = closing.TotalSystem,
CashDifference = closing.CashDifference,
DebitDifference = closing.DebitDifference,
CreditDifference = closing.CreditDifference,
TransferDifference = closing.TransferDifference,
TotalDifference = closing.TotalDifference,
HasDiscrepancies = closing.HasDiscrepancies,
Message = closing.HasDiscrepancies
? "⚠️ ATENCIÓN: Se detectaron diferencias en el cierre. Un supervisor revisará tu caja."
: "✅ Cierre correcto. No se detectaron diferencias."
};
return Ok(result);
}
/// <summary>
/// Obtener cierres con discrepancias pendientes de aprobación (Solo Admin)
/// </summary>
[HttpGet("discrepancies")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> GetDiscrepancies()
{
var discrepancies = await _closingRepo.GetDiscrepanciesAsync();
return Ok(discrepancies);
}
/// <summary>
/// Aprobar un cierre de caja con discrepancias (Solo Admin)
/// </summary>
[HttpPost("{id}/approve")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> ApproveClosure(int id)
{
await _closingRepo.ApproveAsync(id);
// Registrar en auditoría
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "APPROVE_CASH_CLOSING",
EntityId = id,
EntityType = "CashClosing",
Details = "Cierre de caja aprobado por administrador",
CreatedAt = DateTime.UtcNow
});
}
return Ok(new { message = "Cierre aprobado exitosamente" });
}
/// <summary>
/// Obtener historial de cierres del usuario actual
/// </summary>
[HttpGet("history")]
[Authorize(Roles = "Cajero,Admin")]
public async Task<IActionResult> GetHistory([FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (!int.TryParse(userIdClaim, out int userId))
{
return Unauthorized();
}
var startDate = from ?? DateTime.UtcNow.AddMonths(-1);
var endDate = to ?? DateTime.UtcNow;
var closings = await _closingRepo.GetByUserAsync(userId, startDate, endDate);
return Ok(closings);
}
}

View File

@@ -0,0 +1,171 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs;
using SIGCM.Infrastructure.Repositories;
using SIGCM.Domain.Entities; // <-- Faltaba para AuditLog
using System.Security.Claims;
using SIGCM.Infrastructure.Services; // <-- Recomendado para claridad en User.FindFirst
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class CashSessionsController : ControllerBase
{
private readonly CashSessionRepository _repo;
private readonly AuditRepository _auditRepo; // <-- AGREGADO: Declaración del campo
// ACTUALIZADO: Inyección de ambos repositorios en el constructor
public CashSessionsController(CashSessionRepository repo, AuditRepository auditRepo)
{
_repo = repo;
_auditRepo = auditRepo;
}
[HttpGet("status")]
public async Task<IActionResult> GetStatus()
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out int userId))
return Unauthorized();
var session = await _repo.GetActiveSessionAsync(userId);
return Ok(new { isOpen = session != null, session });
}
[HttpPost("open")]
public async Task<IActionResult> Open([FromBody] decimal openingBalance)
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
var existing = await _repo.GetActiveSessionAsync(userId);
if (existing != null) return BadRequest("Ya tienes una caja abierta");
var id = await _repo.OpenSessionAsync(userId, openingBalance);
// Opcional: Auditar la apertura
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "CASH_SESSION_OPENED",
EntityId = id,
EntityType = "CashSession",
Details = $"Caja abierta con fondo: ${openingBalance}",
CreatedAt = DateTime.UtcNow
});
return Ok(new { id });
}
[HttpPost("close")]
public async Task<IActionResult> Close(CashClosingDto dto)
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
var session = await _repo.GetActiveSessionAsync(userId);
if (session == null) return BadRequest("No hay una sesión activa para cerrar");
var system = await _repo.GetSystemTotalsAsync(userId, session.OpeningDate, DateTime.UtcNow);
session.DeclaredCash = dto.DeclaredCash;
session.DeclaredCards = dto.DeclaredDebit + dto.DeclaredCredit;
session.DeclaredTransfers = dto.DeclaredTransfer;
session.SystemExpectedCash = (decimal)(system.Cash ?? 0m);
session.SystemExpectedCards = (decimal)(system.Cards ?? 0m);
session.SystemExpectedTransfers = (decimal)(system.Transfers ?? 0m);
decimal totalExpected = session.SystemExpectedCash.Value + session.OpeningBalance +
session.SystemExpectedCards.Value + session.SystemExpectedTransfers.Value;
decimal totalDeclared = dto.DeclaredCash + dto.DeclaredDebit + dto.DeclaredCredit + dto.DeclaredTransfer;
session.TotalDifference = totalDeclared - totalExpected;
await _repo.CloseSessionAsync(session);
// Auditar el cierre
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "CASH_SESSION_CLOSED",
EntityId = session.Id,
EntityType = "CashSession",
Details = $"Caja cerrada por el usuario. Diferencia detectada: ${session.TotalDifference}",
CreatedAt = DateTime.UtcNow
});
return Ok(new
{
message = "Caja cerrada. Pendiente de validación por supervisor.",
difference = session.TotalDifference,
sessionId = session.Id
});
}
[HttpGet("summary")]
public async Task<IActionResult> GetSummary()
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
var session = await _repo.GetActiveSessionAsync(userId);
if (session == null) return BadRequest("No hay sesión activa");
var system = await _repo.GetSystemTotalsAsync(userId, session.OpeningDate, DateTime.UtcNow);
return Ok(new
{
openingBalance = session.OpeningBalance,
cashSales = (decimal)(system.Cash ?? 0m),
cardSales = (decimal)(system.Cards ?? 0m),
transferSales = (decimal)(system.Transfers ?? 0m),
totalExpected = session.OpeningBalance + (decimal)(system.Cash ?? 0m) + (decimal)(system.Cards ?? 0m) + (decimal)(system.Transfers ?? 0m)
});
}
[HttpGet("pending")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> GetPending()
{
var pending = await _repo.GetPendingValidationAsync();
return Ok(pending);
}
[HttpPost("{id}/validate")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Validate(int id, [FromBody] string notes)
{
var adminIdClaim = User.FindFirst("Id")?.Value;
if (!int.TryParse(adminIdClaim, out int adminId)) return Unauthorized();
await _repo.ValidateSessionAsync(id, adminId, notes);
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = adminId,
Action = "CASH_SESSION_VALIDATED",
EntityId = id,
EntityType = "CashSession",
Details = $"Caja #{id} validada y liquidada. Notas: {notes}",
CreatedAt = DateTime.UtcNow
});
return Ok(new { message = "Sesión liquidada correctamente" });
}
[HttpGet("{id}/pdf")]
public async Task<IActionResult> DownloadPdf(int id)
{
var session = await _repo.GetSessionDetailAsync(id);
if (session == null) return NotFound();
var pdfBytes = ReportGenerator.GenerateCashSessionPdf(session);
string fileName = $"Acta_Cierre_{id}_{DateTime.Now:yyyyMMdd}.pdf";
return File(pdfBytes, "application/pdf", fileName);
}
}

View File

@@ -1,7 +1,10 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers;
[ApiController]
@@ -10,14 +13,17 @@ public class CategoriesController : ControllerBase
{
private readonly ICategoryRepository _repository;
private readonly IListingRepository _listingRepo;
private readonly AuditRepository _auditRepo;
public CategoriesController(ICategoryRepository repository, IListingRepository listingRepo)
public CategoriesController(ICategoryRepository repository, IListingRepository listingRepo, AuditRepository auditRepo)
{
_repository = repository;
_listingRepo = listingRepo;
_auditRepo = auditRepo;
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> GetAll()
{
var categories = await _repository.GetAllAsync();
@@ -25,6 +31,7 @@ public class CategoriesController : ControllerBase
}
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<IActionResult> GetById(int id)
{
var category = await _repository.GetByIdAsync(id);
@@ -33,6 +40,7 @@ public class CategoriesController : ControllerBase
}
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Create(Category category)
{
// Regla: No crear hijos en padres con avisos
@@ -49,6 +57,7 @@ public class CategoriesController : ControllerBase
}
[HttpPut("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Update(int id, Category category)
{
if (id != category.Id) return BadRequest();
@@ -66,9 +75,26 @@ public class CategoriesController : ControllerBase
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(int id)
{
await _repository.DeleteAsync(id);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "DELETE_CATEGORY",
EntityId = id,
EntityType = "Category",
Details = "Category deleted via API",
CreatedAt = DateTime.UtcNow
});
}
return NoContent();
}

View File

@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Repositories;
using SIGCM.Domain.Models;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ClaimsController : ControllerBase
{
private readonly IClaimRepository _repository;
private readonly AuditRepository _auditRepo;
public ClaimsController(IClaimRepository repository, AuditRepository auditRepo)
{
_repository = repository;
_auditRepo = auditRepo;
}
[HttpPost]
public async Task<IActionResult> Create(Claim claim)
{
System.Security.Claims.Claim? userIdClaim = User.FindFirst("Id");
var userId = int.Parse(userIdClaim?.Value!);
claim.CreatedByUserId = userId;
claim.CreatedAt = DateTime.UtcNow;
claim.Status = "Open";
var id = await _repository.CreateAsync(claim);
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "CLAIM_CREATED",
EntityId = id,
EntityType = "Claim",
Details = $"Reclamo creado para aviso {claim.ListingId}: {claim.ClaimType}"
});
return Ok(new { id });
}
[HttpGet("listing/{listingId}")]
public async Task<IActionResult> GetByListing(int listingId)
{
var claims = await _repository.GetByListingIdAsync(listingId);
return Ok(claims);
}
[HttpPut("{id}/resolve")]
public async Task<IActionResult> Resolve(int id, [FromBody] ResolveRequest request)
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
// Llamamos al repositorio pasando el objeto completo de solicitud
await _repository.UpdateStatusAsync(id, "Resolved", request, userId);
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "CLAIM_RESOLVED_TECHNICAL",
EntityId = id,
EntityType = "Claim",
Details = $"Reclamo {id} resuelto con ajustes técnicos aplicados."
});
return Ok(new { message = "Reclamo resuelto y aviso actualizado." });
}
}

View File

@@ -1,10 +1,12 @@
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Microsoft.AspNetCore.Authorization.Authorize]
public class ClientsController : ControllerBase
{
private readonly ClientRepository _repo;
@@ -29,10 +31,19 @@ public class ClientsController : ControllerBase
return Ok(clients);
}
[HttpGet("{id}/history")]
public async Task<IActionResult> GetHistory(int id)
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, Client client)
{
var history = await _repo.GetClientHistoryAsync(id);
return Ok(history);
if (id != client.Id) return BadRequest("ID de URL no coincide con el cuerpo");
await _repo.UpdateAsync(client);
return NoContent();
}
[HttpGet("{id}/summary")]
public async Task<IActionResult> GetSummary(int id)
{
var summary = await _repo.GetClientSummaryAsync(id);
if (summary == null) return NotFound();
return Ok(summary);
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Interfaces;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class DashboardController : ControllerBase
{
private readonly IListingRepository _repository;
public DashboardController(IListingRepository repository)
{
_repository = repository;
}
// Obtiene estadísticas básicas para el dashboard principal
[HttpGet("stats")]
public async Task<IActionResult> GetStats([FromQuery] DateTime? start, [FromQuery] DateTime? end)
{
var startDate = start ?? DateTime.UtcNow.AddDays(-7);
var endDate = end ?? DateTime.UtcNow;
var stats = await _repository.GetDashboardStatsAsync(startDate, endDate);
return Ok(stats);
}
// Obtiene analítica avanzada para reportes gerenciales detallados
[HttpGet("analytics")]
[Authorize(Roles = "Admin,Gerente")]
public async Task<IActionResult> GetAdvancedAnalytics([FromQuery] DateTime? start, [FromQuery] DateTime? end)
{
var startDate = start ?? DateTime.UtcNow.AddMonths(-1);
var endDate = end ?? DateTime.UtcNow;
var analytics = await _repository.GetAdvancedAnalyticsAsync(startDate, endDate);
return Ok(analytics);
}
}

View File

@@ -2,7 +2,9 @@ using System.Text;
using System.Xml.Linq;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Repositories;
namespace SIGCM.API.Controllers;
@@ -11,42 +13,294 @@ namespace SIGCM.API.Controllers;
[Authorize(Roles = "Admin,Diagramador")]
public class ExportsController : ControllerBase
{
private readonly IListingRepository _repository;
private readonly IListingRepository _listingRepo;
private readonly ICategoryRepository _categoryRepo;
private readonly EditionClosureRepository _closureRepo;
private readonly AuditRepository _auditRepo;
public ExportsController(IListingRepository repository)
public ExportsController(
IListingRepository listingRepo,
ICategoryRepository categoryRepo,
EditionClosureRepository closureRepo,
AuditRepository auditRepo)
{
_repository = repository;
_listingRepo = listingRepo;
_categoryRepo = categoryRepo;
_closureRepo = closureRepo;
_auditRepo = auditRepo;
}
/// <summary>
/// Exportar XML para diagramación con estructura jerárquica y estilos
/// Compatible con Adobe InDesign y QuarkXPress
/// </summary>
[HttpGet("diagram")]
public async Task<IActionResult> DownloadDiagram([FromQuery] DateTime date)
{
var listings = await _repository.GetListingsForPrintAsync(date);
// Verificar si la edición está cerrada
var isClosed = await _closureRepo.IsEditionClosedAsync(date);
// Obtener avisos para la fecha
var listings = await _listingRepo.GetListingsForPrintAsync(date);
if (!listings.Any())
{
return NotFound(new { message = "No hay avisos para exportar en esa fecha." });
}
// Agrupar por Rubro
var grouped = listings.GroupBy(l => l.CategoryName ?? "Varios");
// Obtener todas las categorías para armar la jerarquía
var allCategories = await _categoryRepo.GetAllAsync();
var categoryDict = allCategories.ToDictionary(c => c.Id, c => c);
// Construir XML usando XDocument (más seguro y limpio que StringBuilder)
// Crear estructura jerárquica con categorías raíz
var rootCategories = allCategories.Where(c => c.ParentId == null).OrderBy(c => c.Name);
// Construir XML con jerarquía completa
var doc = new XDocument(
new XElement("Edition",
new XAttribute("date", date.ToString("yyyy-MM-dd")),
from g in grouped
select new XElement("Category",
new XAttribute("name", g.Key),
from l in g
select new XElement("Ad",
new XAttribute("id", l.Id),
// Usamos CDATA para proteger caracteres especiales en el texto
new XCData(l.PrintText ?? l.Description ?? "")
)
)
)
new XDeclaration("1.0", "utf-8", "yes"),
new XElement("Edition",
new XAttribute("date", date.ToString("yyyy-MM-dd")),
new XAttribute("closed", isClosed.ToString().ToLower()),
new XAttribute("totalAds", listings.Count()),
// Recorrer categorías raíz
from rootCat in rootCategories
select BuildCategoryElement(rootCat, categoryDict, listings)
)
);
// Convertir a bytes
var xmlBytes = Encoding.UTF8.GetBytes(doc.ToString());
var fileName = $"diagrama_{date:yyyy-MM-dd}.xml";
// Registrar exportación en auditoría
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "EXPORT_XML",
EntityType = "Edition",
Details = $"Exportación XML para {date:yyyy-MM-dd}. Avisos: {listings.Count()}",
CreatedAt = DateTime.UtcNow
});
}
// Convertir a bytes con formato indentado
var xmlBytes = Encoding.UTF8.GetBytes(doc.ToString(SaveOptions.None));
var fileName = $"Edicion_{date:yyyy-MM-dd}.xml";
return File(xmlBytes, "application/xml", fileName);
}
// Método recursivo para construir la jerarquía de categorías
private XElement BuildCategoryElement(
Category category,
Dictionary<int, Category> categoryDict,
IEnumerable<Listing> allListings)
{
// Filtrar avisos de esta categoría
var adsInCategory = allListings
.Where(l => l.CategoryId == category.Id)
.OrderBy(l => l.Title) // Orden alfabético
.ToList();
// Crear elemento de categoría
var categoryElement = new XElement("Category",
new XAttribute("id", category.Id),
new XAttribute("name", category.Name),
new XAttribute("adCount", adsInCategory.Count)
);
// Agregar avisos si es una categoría hoja (que tiene avisos)
if (adsInCategory.Any())
{
foreach (var ad in adsInCategory)
{
categoryElement.Add(
new XElement("Ad",
new XAttribute("id", ad.Id),
// Atributos de estilo para InDesign
new XAttribute("style", GetAdStyle(ad)),
new XAttribute("bold", ad.IsBold.ToString().ToLower()),
new XAttribute("frame", ad.IsFrame.ToString().ToLower()),
new XAttribute("fontSize", ad.PrintFontSize),
new XAttribute("alignment", ad.PrintAlignment),
new XAttribute("days", ad.PrintDaysCount),
// Texto del aviso en CDATA para proteger caracteres especiales
new XCData(ad.PrintText ?? ad.Description ?? "")
)
);
}
}
// Buscar sub-categorías recursivamente
var subCategories = categoryDict.Values
.Where(c => c.ParentId == category.Id)
.OrderBy(c => c.Name);
foreach (var subCat in subCategories)
{
categoryElement.Add(BuildCategoryElement(subCat, categoryDict, allListings));
}
return categoryElement;
}
// Generar string de estilo para InDesign
private string GetAdStyle(Listing ad)
{
var styles = new List<string>();
if (ad.IsBold) styles.Add("bold");
if (ad.IsFrame) styles.Add("frame");
if (ad.PrintFontSize != "normal") styles.Add($"size-{ad.PrintFontSize}");
if (ad.PrintAlignment != "left") styles.Add($"align-{ad.PrintAlignment}");
return styles.Any() ? string.Join(" ", styles) : "normal";
}
/// <summary>
/// Obtener avisos huérfanos (pagados pero no incluidos en export)
/// </summary>
[HttpGet("orphan-ads")]
public async Task<IActionResult> GetOrphanAds([FromQuery] DateTime date)
{
// Obtener avisos marcados para esa fecha pero con status incorrecto
var allAds = await _listingRepo.GetListingsForPrintAsync(date);
// Simulación: avisos con estado "Pending" o "Draft" que deberían haberse publicado
var orphans = allAds.Where(a =>
a.Status != "Published" &&
a.AdFee > 0 && // Está pagado
a.PrintStartDate.HasValue &&
a.PrintStartDate.Value.Date <= date.Date
);
return Ok(new {
date = date.ToString("yyyy-MM-dd"),
count = orphans.Count(),
orphans = orphans.Select(a => new {
a.Id,
a.Title,
a.Status,
a.AdFee,
a.PrintStartDate,
CategoryName = a.CategoryName
})
});
}
/// <summary>
/// Cerrar edición de una fecha específica
/// Una vez cerrada, no se pueden modificar avisos de esa fecha
/// </summary>
[HttpPost("close-edition")]
[Authorize(Roles = "Admin,Diagramador")]
public async Task<IActionResult> CloseEdition([FromBody] CloseEditionRequest request)
{
// Verificar si ya está cerrada
var alreadyClosed = await _closureRepo.IsEditionClosedAsync(request.EditionDate);
if (alreadyClosed)
{
return BadRequest(new { message = "La edición ya está cerrada." });
}
// Obtener usuario actual
var userIdClaim = User.FindFirst("Id")?.Value;
if (!int.TryParse(userIdClaim, out int userId))
{
return Unauthorized();
}
// Crear cierre
var closure = new EditionClosure
{
EditionDate = request.EditionDate.Date,
ClosureDateTime = DateTime.UtcNow,
ClosedByUserId = userId,
IsClosed = true,
Notes = request.Notes
};
var closureId = await _closureRepo.CloseEditionAsync(closure);
// Auditoría
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "CLOSE_EDITION",
EntityId = closureId,
EntityType = "EditionClosure",
Details = $"Edición {request.EditionDate:yyyy-MM-dd} cerrada. {request.Notes}",
CreatedAt = DateTime.UtcNow
});
return Ok(new {
message = "Edición cerrada exitosamente.",
closureId,
editionDate = request.EditionDate.ToString("yyyy-MM-dd")
});
}
/// <summary>
/// Reabrir una edición cerrada (solo Admin)
/// </summary>
[HttpPost("reopen-edition")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> ReopenEdition([FromQuery] DateTime date)
{
await _closureRepo.ReopenEditionAsync(date);
// Auditoría
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = userId,
Action = "REOPEN_EDITION",
EntityType = "EditionClosure",
Details = $"Edición {date:yyyy-MM-dd} reabierta",
CreatedAt = DateTime.UtcNow
});
}
return Ok(new { message = "Edición reabierta exitosamente." });
}
/// <summary>
/// Obtener estado de una edición
/// </summary>
[HttpGet("edition-status")]
public async Task<IActionResult> GetEditionStatus([FromQuery] DateTime date)
{
var closure = await _closureRepo.GetClosureByDateAsync(date);
var listings = await _listingRepo.GetListingsForPrintAsync(date);
return Ok(new {
date = date.ToString("yyyy-MM-dd"),
isClosed = closure?.IsClosed ?? false,
closedAt = closure?.ClosureDateTime,
closedBy = closure?.ClosedByUsername,
adCount = listings.Count(),
notes = closure?.Notes
});
}
/// <summary>
/// Historial de cierres
/// </summary>
[HttpGet("closure-history")]
public async Task<IActionResult> GetClosureHistory()
{
var history = await _closureRepo.GetClosureHistoryAsync(30);
return Ok(history);
}
}
// DTO para cerrar edición
public class CloseEditionRequest
{
public DateTime EditionDate { get; set; }
public string? Notes { get; set; }
}

View File

@@ -1,66 +1,173 @@
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Services;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Microsoft.AspNetCore.Authorization.Authorize]
public class ImagesController : ControllerBase
{
private readonly IImageRepository _repository;
private readonly IWebHostEnvironment _env;
private readonly ImageOptimizationService _imageService;
public ImagesController(IImageRepository repository, IWebHostEnvironment env)
public ImagesController(
IImageRepository repository,
IWebHostEnvironment env,
ImageOptimizationService imageService)
{
_repository = repository;
_env = env;
_imageService = imageService;
}
/// <summary>
/// Upload con optimización automática
/// Genera: WebP principal, Thumbnail, Fallback JPEG
/// </summary>
[HttpPost("upload/{listingId}")]
public async Task<IActionResult> Upload(int listingId, IFormFile file)
{
if (file == null || file.Length == 0) return BadRequest("File is empty");
if (file == null || file.Length == 0)
return BadRequest("File is empty");
// Validaciones básicas
var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".webp" };
// Validar extensiones permitidas
var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".webp", ".gif" };
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowedExtensions.Contains(ext)) return BadRequest("Invalid file type");
if (!allowedExtensions.Contains(ext))
return BadRequest("Invalid file type. Allowed: JPG, PNG, WebP, GIF");
// Si WebRootPath es nulo, construimos la ruta manualmente apuntando a la raíz del contenido + wwwroot
// Validar que sea realmente una imagen
using var validationStream = file.OpenReadStream();
var isValidImage = await _imageService.IsValidImageAsync(validationStream);
if (!isValidImage)
return BadRequest("File is not a valid image");
// Configurar rutas
string webRootPath = _env.WebRootPath ?? Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
// Ruta física donde se guarda
var uploadDir = Path.Combine(webRootPath, "uploads", "listings", listingId.ToString());
// Asegurar que la carpeta exista (esto crea toda la estructura si falta)
if (!Directory.Exists(uploadDir))
{
Directory.CreateDirectory(uploadDir);
}
// Guardar archivo
var fileName = $"{Guid.NewGuid()}{ext}";
var filePath = Path.Combine(uploadDir, fileName);
var baseFilename = Guid.NewGuid().ToString();
using (var stream = new FileStream(filePath, FileMode.Create))
// OPTIMIZAR LA IMAGEN
using var stream = file.OpenReadStream();
var optimization = await _imageService.OptimizeImageAsync(stream, file.FileName);
if (!optimization.Success)
{
await file.CopyToAsync(stream);
return StatusCode(500, new { Error = "Image optimization failed", Details = optimization.Error });
}
// Guardar metadata en BD
// Ojo: La URL que guardamos en BD debe ser relativa para que el navegador la entienda
var relativeUrl = $"/uploads/listings/{listingId}/{fileName}";
// 1. Guardar WebP (principal)
var webpFilename = $"{baseFilename}.webp";
var webpPath = Path.Combine(uploadDir, webpFilename);
await System.IO.File.WriteAllBytesAsync(webpPath, optimization.WebpData!);
// 2. Guardar Thumbnail
var thumbFilename = $"{baseFilename}_thumb.webp";
var thumbPath = Path.Combine(uploadDir, thumbFilename);
await System.IO.File.WriteAllBytesAsync(thumbPath, optimization.ThumbnailData!);
// 3. Guardar JPEG fallback
var jpegFilename = $"{baseFilename}.jpg";
var jpegPath = Path.Combine(uploadDir, jpegFilename);
await System.IO.File.WriteAllBytesAsync(jpegPath, optimization.JpegData!);
// URLs relativas
var webpUrl = $"/uploads/listings/{listingId}/{webpFilename}";
var thumbUrl = $"/uploads/listings/{listingId}/{thumbFilename}";
var jpegUrl = $"/uploads/listings/{listingId}/{jpegFilename}";
// Guardar metadata en BD (solo la imagen principal WebP)
var image = new ListingImage
{
ListingId = listingId,
Url = relativeUrl,
Url = webpUrl,
IsMainInfo = false,
DisplayOrder = 0
};
await _repository.AddAsync(image);
return Ok(new { Url = relativeUrl });
// Retornar todas las versiones y estadísticas
return Ok(new {
webpUrl,
thumbUrl,
jpegUrl,
optimization = new {
originalSize = optimization.OriginalSize,
webpSize = optimization.WebpSize,
compressionRatio = $"{optimization.CompressionRatio:F1}%",
dimensions = $"{optimization.OptimizedWidth}x{optimization.OptimizedHeight}",
thumbnailSize = optimization.ThumbnailSize
}
});
}
/// <summary>
/// Obtener información de una imagen sin descargarla
/// </summary>
[HttpHead("{listingId}/{filename}")]
public async Task<IActionResult> GetImageInfo(int listingId, string filename)
{
// Seguridad: Solo permitimos el nombre del archivo, no rutas
string sanitizedFilename = Path.GetFileName(filename);
string webRootPath = _env.WebRootPath ?? Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
var imagePath = Path.Combine(webRootPath, "uploads", "listings", listingId.ToString(), sanitizedFilename);
if (!System.IO.File.Exists(imagePath))
return NotFound();
var fileInfo = new FileInfo(imagePath);
Response.Headers["Content-Length"] = fileInfo.Length.ToString();
Response.Headers["Content-Type"] = "image/webp";
return Ok();
}
/// <summary>
/// Eliminar imagen y todas sus versiones
/// </summary>
[HttpDelete("{imageId}")]
public async Task<IActionResult> Delete(int imageId)
{
var image = await _repository.GetByIdAsync(imageId);
if (image == null) return NotFound();
// Extraer información de la URL
var parts = image.Url.Split('/');
var listingId = parts[3];
var filename = Path.GetFileNameWithoutExtension(parts[4]);
string webRootPath = _env.WebRootPath ?? Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
var uploadDir = Path.Combine(webRootPath, "uploads", "listings", listingId);
// Eliminar todas las versiones
var filesToDelete = new[]
{
Path.Combine(uploadDir, $"{filename}.webp"),
Path.Combine(uploadDir, $"{filename}_thumb.webp"),
Path.Combine(uploadDir, $"{filename}.jpg")
};
foreach (var file in filesToDelete)
{
if (System.IO.File.Exists(file))
{
System.IO.File.Delete(file);
}
}
// Eliminar de BD
await _repository.DeleteAsync(imageId);
return Ok(new { message = "Image and all variants deleted successfully" });
}
}

View File

@@ -1,3 +1,4 @@
// src/SIGCM.API/Controllers/ListingsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Application.DTOs;
@@ -14,6 +15,7 @@ public class ListingsController : ControllerBase
private readonly IListingRepository _repository;
private readonly ClientRepository _clientRepo;
private readonly AuditRepository _auditRepo;
public ListingsController(IListingRepository repository, ClientRepository clientRepo, AuditRepository auditRepo)
{
_repository = repository;
@@ -22,14 +24,23 @@ public class ListingsController : ControllerBase
}
[HttpPost]
[Authorize]
public async Task<IActionResult> Create(CreateListingDto dto)
{
// SEGURIDAD: Forzamos la obtención del ID desde el Token JWT
var userIdClaim = User.FindFirst("Id")?.Value;
// Si por alguna razón el claim no está (configuración de JWT), no permitimos la creación
if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out int currentUserId))
{
return Unauthorized(new { message = "No se pudo identificar al usuario desde el token de seguridad." });
}
int? clientId = null;
// Si viene información de cliente, aseguramos que exista en BD
// Lógica de Cliente (asegurar existencia en BD)
if (!string.IsNullOrWhiteSpace(dto.ClientDni))
{
// Usamos "Consumidor Final" si no hay nombre pero hay DNI, o el nombre provisto
string clientName = string.IsNullOrWhiteSpace(dto.ClientName) ? "Consumidor Final" : dto.ClientName;
clientId = await _clientRepo.EnsureClientExistsAsync(clientName, dto.ClientDni);
}
@@ -41,13 +52,50 @@ public class ListingsController : ControllerBase
Title = dto.Title,
Description = dto.Description,
Price = dto.Price,
Currency = dto.Currency,
UserId = dto.UserId,
Status = "Published", // Auto publish for now
CreatedAt = DateTime.UtcNow
AdFee = dto.AdFee,
Currency = "ARS",
UserId = currentUserId,
ClientId = clientId,
Origin = dto.Origin,
Status = dto.Status,
ImagesToClone = dto.ImagesToClone,
CreatedAt = DateTime.UtcNow,
PrintText = dto.PrintText,
PrintDaysCount = dto.PrintDaysCount,
PrintStartDate = dto.PrintStartDate,
IsBold = dto.IsBold,
IsFrame = dto.IsFrame,
PrintFontSize = dto.PrintFontSize,
PrintAlignment = dto.PrintAlignment
};
var id = await _repository.CreateAsync(listing, dto.Attributes);
// Conversión de Pagos
List<Payment>? payments = null;
if (dto.Payments != null && dto.Payments.Any())
{
payments = dto.Payments.Select(p => new Payment
{
Amount = p.Amount,
PaymentMethod = p.PaymentMethod,
CardPlan = p.CardPlan,
Surcharge = p.Surcharge,
PaymentDate = DateTime.UtcNow
}).ToList();
}
var id = await _repository.CreateAsync(listing, dto.Attributes, payments);
// Registro de Auditoría
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = currentUserId,
Action = "CREATE_LISTING",
EntityId = id,
EntityType = "Listing",
Details = $"Aviso creado por usuario autenticado. Total: ${dto.AdFee}",
CreatedAt = DateTime.UtcNow
});
return Ok(new { id });
}
@@ -57,10 +105,6 @@ public class ListingsController : ControllerBase
if (string.IsNullOrEmpty(q) && !categoryId.HasValue)
{
var listings = await _repository.GetAllAsync();
// Populate images for list view (simplified N+1 for now, fix later with JOIN)
// Ideally Repo returns a DTO with MainImage
// We will fetch images separately in frontend or update repo later for performance.
return Ok(listings);
}
@@ -68,23 +112,43 @@ public class ListingsController : ControllerBase
return Ok(results);
}
[HttpGet("my")]
[Authorize]
public async Task<IActionResult> GetMyListings()
{
// SEGURIDAD: Forzamos el ID del usuario desde el Claim
var userIdStr = User.FindFirst("Id")?.Value;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int userId))
return Unauthorized();
var listings = await _repository.GetByUserIdAsync(userId);
return Ok(listings);
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
await _repository.IncrementViewCountAsync(id);
var listingDetail = await _repository.GetDetailByIdAsync(id);
if (listingDetail == null) return NotFound();
return Ok(listingDetail);
}
// Búsqueda Facetada (POST para enviar diccionario complejo)
[HttpPost("search")]
public async Task<IActionResult> Search([FromBody] SearchRequest request)
{
var results = await _repository.SearchFacetedAsync(request.Query, request.CategoryId, request.Filters);
var results = await _repository.SearchFacetedAsync(
request.Query,
request.CategoryId,
request.Filters,
request.From,
request.To,
request.Origin,
request.Status
);
return Ok(results);
}
// Moderación: Obtener pendientes
[HttpGet("pending")]
[Authorize(Roles = "Admin,Moderador")]
public async Task<IActionResult> GetPending()
@@ -93,30 +157,26 @@ public class ListingsController : ControllerBase
return Ok(pending);
}
// Moderación: Cambiar estado
[HttpPut("{id}/status")]
[Authorize(Roles = "Admin,Moderador")]
public async Task<IActionResult> UpdateStatus(int id, [FromBody] string status)
{
// 1. Obtener ID del usuario desde el Token JWT
// Obtener el ID de quien realiza la acción (Administrador/Moderador)
var userIdStr = User.FindFirst("Id")?.Value;
int? currentUserId = !string.IsNullOrEmpty(userIdStr) ? int.Parse(userIdStr) : null;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId))
return Unauthorized();
// 2. Actualizar el estado del aviso
await _repository.UpdateStatusAsync(id, status);
// 3. Registrar en Auditoría (Si tenemos el repositorio inyectado)
if (currentUserId.HasValue)
await _auditRepo.AddLogAsync(new AuditLog
{
await _auditRepo.AddLogAsync(new AuditLog
{
UserId = currentUserId.Value,
Action = status == "Published" ? "Aprobar" : "Rechazar",
EntityId = id,
EntityType = "Listing",
Details = $"El usuario cambió el estado del aviso #{id} a {status}"
});
}
UserId = currentUserId,
Action = status == "Published" ? "APPROVE_LISTING" : "REJECT_LISTING",
EntityId = id,
EntityType = "Listing",
Details = $"El usuario {currentUserId} cambió el estado del aviso #{id} a {status}",
CreatedAt = DateTime.UtcNow
});
return Ok();
}
@@ -126,6 +186,10 @@ public class ListingsController : ControllerBase
public string? Query { get; set; }
public int? CategoryId { get; set; }
public Dictionary<string, string>? Filters { get; set; }
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public string? Origin { get; set; }
public string? Status { get; set; }
}
[HttpGet("pending/count")]
@@ -134,4 +198,19 @@ public class ListingsController : ControllerBase
var count = await _repository.GetPendingCountAsync();
return Ok(count);
}
[HttpPatch("{id}/overlay")]
[Authorize]
public async Task<IActionResult> UpdateOverlay(int id, [FromBody] string? status)
{
var userIdClaim = User.FindFirst("Id")?.Value;
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
// Validar estados permitidos
var allowed = new[] { "Vendido", "Alquilado", "Reservado", null };
if (!allowed.Contains(status)) return BadRequest("Estado no permitido");
await _repository.UpdateOverlayStatusAsync(id, userId, status);
return NoContent();
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Infrastructure.Repositories;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class NotificationsController : ControllerBase
{
private readonly NotificationRepository _repo;
public NotificationsController(NotificationRepository repo) => _repo = repo;
[HttpGet]
public async Task<IActionResult> GetMyNotifications()
{
var userId = int.Parse(User.FindFirst("Id")?.Value!);
var notifications = await _repo.GetByUserAsync(userId);
var unreadCount = await _repo.GetUnreadCountAsync(userId);
return Ok(new { items = notifications, unreadCount });
}
[HttpPut("{id}/read")]
public async Task<IActionResult> MarkAsRead(int id)
{
await _repo.MarkAsReadAsync(id);
return Ok();
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
@@ -16,6 +17,7 @@ public class OperationsController : ControllerBase
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> GetAll()
{
var operations = await _repository.GetAllAsync();
@@ -23,6 +25,7 @@ public class OperationsController : ControllerBase
}
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<IActionResult> GetById(int id)
{
var operation = await _repository.GetByIdAsync(id);
@@ -31,6 +34,7 @@ public class OperationsController : ControllerBase
}
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Create(Operation operation)
{
var id = await _repository.AddAsync(operation);
@@ -39,6 +43,7 @@ public class OperationsController : ControllerBase
}
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(int id)
{
await _repository.DeleteAsync(id);

View File

@@ -0,0 +1,116 @@
// src/SIGCM.API/Controllers/PaymentsController.cs
using MercadoPago.Client.Payment;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Services;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class PaymentsController : ControllerBase
{
private readonly MercadoPagoService _mpService;
private readonly IListingRepository _listingRepo;
public PaymentsController(MercadoPagoService mpService, IListingRepository listingRepo)
{
_mpService = mpService;
_listingRepo = listingRepo;
}
/*
IMPLEMENTACIÓN REAL (COMENTADA PARA FASE FINAL)
[HttpPost("create-preference/{listingId}")]
public async Task<IActionResult> CreatePreference(int listingId, [FromBody] decimal amount)
{
var listing = await _listingRepo.GetByIdAsync(listingId);
if (listing == null) return NotFound("Aviso no encontrado");
var preference = await _mpService.CreatePreferenceAsync(listing, amount);
return Ok(new {
id = preference.Id,
initPoint = preference.InitPoint, // Para redirección
sandboxInitPoint = preference.SandboxInitPoint // Para testing
});
}
*/
[HttpPost("create-preference/{listingId}")]
public async Task<IActionResult> CreatePreference(int listingId, [FromBody] decimal amount)
{
var listing = await _listingRepo.GetByIdAsync(listingId);
if (listing == null) return NotFound("Aviso no encontrado");
// Llamamos al servicio (que ahora devuelve el objeto simulado)
var preference = await _mpService.CreatePreferenceAsync(listing, amount);
return Ok(preference);
}
public class SimulationRequest { public decimal Amount { get; set; } }
[HttpPost("confirm-simulation/{listingId}")]
public async Task<IActionResult> ConfirmSimulation(int listingId, [FromBody] SimulationRequest request)
{
var listing = await _listingRepo.GetByIdAsync(listingId);
if (listing == null) return NotFound();
// 1. El aviso queda en 'Pending' (Moderación), no 'Published'
await _listingRepo.UpdateStatusAsync(listingId, "Pending");
// 2. Registrar el pago
var paymentRecord = new Payment
{
ListingId = listingId,
Amount = request.Amount,
PaymentMethod = "MercadoPago (Simulado)",
PaymentDate = DateTime.UtcNow,
ExternalReference = listingId.ToString(),
ExternalId = "SIM-" + Guid.NewGuid().ToString().Substring(0, 8),
Status = "Approved"
};
await _listingRepo.AddPaymentAsync(paymentRecord);
return Ok(new { message = "Pago registrado. Aviso enviado a moderación." });
}
[HttpPost("webhook")]
public async Task<IActionResult> Webhook([FromQuery] string id, [FromQuery] string topic)
{
if (topic == "payment")
{
var client = new PaymentClient();
var mpPayment = await client.GetAsync(long.Parse(id));
if (mpPayment.Status == "approved")
{
var listingId = int.Parse(mpPayment.ExternalReference);
var listing = await _listingRepo.GetByIdAsync(listingId);
if (listing != null && listing.Status != "Published")
{
// 1. Cambiar estado a PUBLISHED
await _listingRepo.UpdateStatusAsync(listingId, "Pending");
// 2. Registrar el pago en la tabla Payments
var paymentRecord = new Payment
{
ListingId = listingId,
Amount = mpPayment.TransactionAmount ?? 0,
PaymentMethod = "MercadoPago",
PaymentDate = DateTime.UtcNow,
ExternalReference = mpPayment.ExternalReference,
ExternalId = mpPayment.Id.ToString(),
Status = "Approved"
};
await _listingRepo.AddPaymentAsync(paymentRecord);
}
}
}
return Ok();
}
}

View File

@@ -14,11 +14,13 @@ public class PricingController : ControllerBase
{
private readonly PricingService _service;
private readonly PricingRepository _repository;
private readonly AuditRepository _auditRepo;
public PricingController(PricingService service, PricingRepository repository)
public PricingController(PricingService service, PricingRepository repository, AuditRepository auditRepo)
{
_service = service;
_repository = repository;
_auditRepo = auditRepo;
}
// Usado por: Panel Mostrador (Cajero) y Admin
@@ -48,6 +50,22 @@ public class PricingController : ControllerBase
if (pricing.CategoryId == 0) return BadRequest("CategoryId es requerido");
await _repository.UpsertPricingAsync(pricing);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new Domain.Entities.AuditLog
{
UserId = userId,
Action = "SAVE_PRICING",
EntityId = pricing.CategoryId,
EntityType = "CategoryPricing",
Details = $"Pricing updated for Category {pricing.CategoryId}",
CreatedAt = DateTime.UtcNow
});
}
return Ok(new { message = "Configuración guardada exitosamente" });
}
@@ -66,6 +84,22 @@ public class PricingController : ControllerBase
public async Task<IActionResult> CreatePromotion(Promotion promo)
{
var id = await _repository.CreatePromotionAsync(promo);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new Domain.Entities.AuditLog
{
UserId = userId,
Action = "CREATE_PROMOTION",
EntityId = id,
EntityType = "Promotion",
Details = $"Promotion {promo.Name} created",
CreatedAt = DateTime.UtcNow
});
}
return Ok(new { id });
}
@@ -75,6 +109,22 @@ public class PricingController : ControllerBase
{
if (id != promo.Id) return BadRequest();
await _repository.UpdatePromotionAsync(promo);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new Domain.Entities.AuditLog
{
UserId = userId,
Action = "UPDATE_PROMOTION",
EntityId = id,
EntityType = "Promotion",
Details = $"Promotion {id} updated",
CreatedAt = DateTime.UtcNow
});
}
return NoContent();
}
@@ -83,6 +133,22 @@ public class PricingController : ControllerBase
public async Task<IActionResult> DeletePromotion(int id)
{
await _repository.DeletePromotionAsync(id);
// Audit Log
var userIdClaim = User.FindFirst("Id")?.Value;
if (int.TryParse(userIdClaim, out int userId))
{
await _auditRepo.AddLogAsync(new Domain.Entities.AuditLog
{
UserId = userId,
Action = "DELETE_PROMOTION",
EntityId = id,
EntityType = "Promotion",
Details = $"Promotion {id} deleted",
CreatedAt = DateTime.UtcNow
});
}
return NoContent();
}
}

View File

@@ -22,6 +22,7 @@ public class ReportsController : ControllerBase
}
[HttpGet("dashboard")]
[Authorize(Roles = "Admin,GerenteVentas")]
public async Task<IActionResult> GetDashboard([FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var start = from ?? DateTime.UtcNow.Date;
@@ -31,6 +32,7 @@ public class ReportsController : ControllerBase
}
[HttpGet("sales-by-category")]
[Authorize(Roles = "Admin,GerenteVentas")]
public async Task<IActionResult> GetSalesByCategory([FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var start = from ?? DateTime.UtcNow.AddMonths(-1);
@@ -48,6 +50,7 @@ public class ReportsController : ControllerBase
}
[HttpGet("audit")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> GetAuditLogs()
{
// Obtenemos los últimos 100 eventos
@@ -108,15 +111,39 @@ public class ReportsController : ControllerBase
[HttpGet("cashier-transactions")]
[Authorize(Roles = "Cajero,Admin")]
public async Task<IActionResult> GetCashierTransactions()
public async Task<IActionResult> GetCashierTransactions(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? userId)
{
// 1. Obtener datos del usuario logueado
var userIdClaim = User.FindFirst("Id")?.Value;
var userRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
if (string.IsNullOrEmpty(userIdClaim)) return Unauthorized();
int userId = int.Parse(userIdClaim);
// Usamos el repositorio para traer los avisos de hoy de este usuario
var transactions = await _listingRepo.GetDetailedReportAsync(DateTime.UtcNow, DateTime.UtcNow, userId);
// 2. Lógica de seguridad para el filtro de usuario:
// Si es Admin, puede ver a cualquier cajero (usa el userId que viene por query).
// Si es Cajero, SOLO puede verse a sí mismo (forzamos su propio ID).
int? targetUserId = (userRole == "Admin") ? userId : int.Parse(userIdClaim);
// 3. Manejo de fechas:
// Si no vienen, usamos el rango de hoy.
// Pero el frontend enviará los valores de los selectores.
var startDate = from ?? DateTime.UtcNow.Date;
var endDate = to ?? DateTime.UtcNow.Date;
// Llamamos al repositorio con las fechas REALES enviadas desde el frontend
var transactions = await _listingRepo.GetDetailedReportAsync(startDate, endDate, targetUserId);
return Ok(transactions);
}
[HttpGet("cajeros")]
[Authorize(Roles = "Cajero,Admin")]
public async Task<IActionResult> GetCajeros()
{
var cajeros = await _listingRepo.GetActiveCashiersAsync();
return Ok(cajeros);
}
}

View File

@@ -0,0 +1,61 @@
// src/SIGCM.API/Middleware/ExceptionMiddleware.cs
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace SIGCM.API.Middleware;
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger, IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (FluentValidation.ValidationException valEx)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
var errors = valEx.Errors.Select(e => new { e.PropertyName, e.ErrorMessage });
var response = new {
StatusCode = context.Response.StatusCode,
Message = "Error de validación",
Errors = errors
};
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
await context.Response.WriteAsync(JsonSerializer.Serialize(response, options));
}
catch (Exception ex)
{
_logger.LogError(ex, ex.Message);
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var response = _env.IsDevelopment()
? new ErrorDetails(context.Response.StatusCode, ex.Message, ex.StackTrace?.ToString())
: new ErrorDetails(context.Response.StatusCode, "Ocurrió un error interno en el servidor", "Consulte con soporte.");
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var json = JsonSerializer.Serialize(response, options);
await context.Response.WriteAsync(json);
}
}
}
public record ErrorDetails(int StatusCode, string Message, string? Details);

View File

@@ -0,0 +1,57 @@
// src/SIGCM.API/Middleware/SecurityHeadersMiddleware.cs
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace SIGCM.API.Middleware
{
public class SecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
public SecurityHeadersMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// 1. Content-Security-Policy (CSP)
// Permitimos scripts de dominio propio, estilos, imágenes y fuentes.
// Ajustamos para permitir Mercado Pago y fuentes de Google si es necesario.
context.Response.Headers.Append("Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://sdk.mercadopago.com; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com data:; " +
"img-src 'self' data: https://*.mercadopago.com; " +
"frame-src 'self' https://*.mercadopago.com; " +
"connect-src 'self' https://api.mercadopago.com;");
// 2. Strict-Transport-Security (HSTS)
// Indica que el sitio solo debe ser accedido vía HTTPS (1 año)
context.Response.Headers.Append("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
// 3. X-Content-Type-Options
// Previene que el navegador intente "adivinar" el tipo MIME (MIME sniffing)
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
// 4. X-Frame-Options
// Protege contra Clickjacking al prevenir que el sitio sea embebido en iframes externos
context.Response.Headers.Append("X-Frame-Options", "SAMEORIGIN");
// 5. Referrer-Policy
// Controla cuánta información de referencia se envía en las peticiones
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
// 6. X-XSS-Protection
// Filtro contra XSS (aunque CSP es el estándar moderno, esto es para navegadores antiguos)
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
// 7. Permissions-Policy
// Restringe el acceso a APIs del navegador (Cámara, Micrófono, etc.)
context.Response.Headers.Append("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
await _next(context);
}
}
}

View File

@@ -4,6 +4,9 @@ using Microsoft.IdentityModel.Tokens;
using SIGCM.Infrastructure;
using SIGCM.Infrastructure.Data;
using QuestPDF.Infrastructure;
using FluentValidation;
using FluentValidation.AspNetCore;
using SIGCM.API.Middleware;
var builder = WebApplication.CreateBuilder(args);
@@ -11,9 +14,19 @@ QuestPDF.Settings.License = LicenseType.Community;
// 1. Agregar servicios al contenedor.
builder.Services.AddControllers();
builder.Services.AddFluentValidationAutoValidation()
.AddFluentValidationClientsideAdapters();
// Registrar validadores manualmente si es necesario o por asamblea
builder.Services.AddValidatorsFromAssemblyContaining<SIGCM.Application.Validators.CreateListingDtoValidator>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
});
// 2. Configurar Autenticación JWT
var key = Encoding.ASCII.GetBytes(builder.Configuration["Jwt:Key"]!);
@@ -40,7 +53,7 @@ builder.Services.AddAuthentication(options =>
});
// 3. Agregar Capa de Infraestructura
builder.Services.AddInfrastructure();
builder.Services.AddInfrastructure(builder.Configuration);
// 4. Configurar CORS
builder.Services.AddCors(options =>
@@ -49,7 +62,7 @@ builder.Services.AddCors(options =>
policy =>
{
policy.WithOrigins(
"http://localhost:5173",
"http://localhost:5173",
"http://localhost:5174",
"http://localhost:5175",
"http://localhost:5177")
@@ -58,10 +71,14 @@ builder.Services.AddCors(options =>
});
});
var app = builder.Build();
// --- Configuración del Pipeline HTTP ---
app.UseMiddleware<ExceptionMiddleware>();
app.UseMiddleware<SecurityHeadersMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();

View File

@@ -7,8 +7,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Google.Apis.Auth" Version="1.73.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="QuestPDF" Version="2025.12.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
</ItemGroup>

View File

@@ -13,5 +13,11 @@
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
"Issuer": "SIGCMApi",
"Audience": "SIGCMAdmin"
},
"MercadoPago": {
"AccessToken": "TEST-71539281-2291-443b-873b-eb8647021589-122610-86ec037f07067d55d7b5b31cb9c1069b-1375354",
"SuccessUrl": "http://localhost:5173/publicar/exito",
"FailureUrl": "http://localhost:5173/publicar/error",
"NotificationUrl": "https://your-webhook-proxy.com/api/payments/webhook"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,11 @@
namespace SIGCM.Application.DTOs;
public class AuthResult
{
public bool Success { get; set; }
public string? Token { get; set; }
public string? ErrorMessage { get; set; }
public bool IsLockedOut { get; set; }
public bool RequiresPasswordChange { get; set; }
public bool RequiresMfa { get; set; }
}

View File

@@ -0,0 +1,42 @@
namespace SIGCM.Application.DTOs;
// DTO para iniciar el cierre de caja (arqueo ciego)
public class CashClosingDto
{
public decimal DeclaredCash { get; set; }
public decimal DeclaredDebit { get; set; }
public decimal DeclaredCredit { get; set; }
public decimal DeclaredTransfer { get; set; }
public string? Notes { get; set; }
}
// DTO de respuesta con el resultado del cierre
public class CashClosingResultDto
{
public int ClosingId { get; set; }
// Valores declarados por el cajero
public decimal DeclaredCash { get; set; }
public decimal DeclaredDebit { get; set; }
public decimal DeclaredCredit { get; set; }
public decimal DeclaredTransfer { get; set; }
public decimal TotalDeclared { get; set; }
// Valores reales del sistema
public decimal SystemCash { get; set; }
public decimal SystemDebit { get; set; }
public decimal SystemCredit { get; set; }
public decimal SystemTransfer { get; set; }
public decimal TotalSystem { get; set; }
// Diferencias detectadas
public decimal CashDifference { get; set; }
public decimal DebitDifference { get; set; }
public decimal CreditDifference { get; set; }
public decimal TransferDifference { get; set; }
public decimal TotalDifference { get; set; }
// Banderas de estado
public bool HasDiscrepancies { get; set; }
public string? Message { get; set; }
}

View File

@@ -1,28 +1,43 @@
// src/SIGCM.Application/DTOs/ListingDtos.cs
namespace SIGCM.Application.DTOs;
public class CreateListingDto
{
public int CategoryId { get; set; }
public int OperationId { get; set; }
public required string Title { get; set; }
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public decimal Price { get; set; }
public decimal AdFee { get; set; }
public string Status { get; set; } = "Draft";
public string Currency { get; set; } = "ARS";
public int? UserId { get; set; }
public Dictionary<int, string> Attributes { get; set; } = new();
public string? PrintText { get; set; }
public string? PrintFontSize { get; set; }
public string? PrintAlignment { get; set; }
public int PrintDaysCount { get; set; }
public int? ClientId { get; set; }
public string? ClientName { get; set; }
public string? ClientDni { get; set; }
public string Origin { get; set; } = "Mostrador";
public List<string>? ImagesToClone { get; set; }
// Impresión
public string? PrintText { get; set; }
public DateTime? PrintStartDate { get; set; }
public int PrintDaysCount { get; set; }
public bool IsBold { get; set; }
public bool IsFrame { get; set; }
public string PrintFontSize { get; set; } = "normal";
public string PrintAlignment { get; set; } = "left";
// Atributos Dinámicos
public Dictionary<int, string> Attributes { get; set; } = new();
// Pagos
public List<PaymentDto>? Payments { get; set; }
}
public class ListingDto : CreateListingDto
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public required string Status { get; set; }
}
public class ListingDetailDto : ListingDto
@@ -35,14 +50,22 @@ public class ListingAttributeDto
{
public int ListingId { get; set; }
public int AttributeDefinitionId { get; set; }
public required string AttributeName { get; set; }
public required string Value { get; set; }
public string AttributeName { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
}
public class ListingImageDto
{
public int Id { get; set; }
public required string Url { get; set; }
public string Url { get; set; } = string.Empty;
public bool IsMainInfo { get; set; }
public int DisplayOrder { get; set; }
}
}
public class PaymentDto
{
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = string.Empty;
public string? CardPlan { get; set; }
public decimal Surcharge { get; set; }
}

View File

@@ -2,5 +2,15 @@ namespace SIGCM.Application.Interfaces;
public interface IAuthService
{
Task<string?> LoginAsync(string username, string password);
// Autenticación estándar
Task<DTOs.AuthResult> LoginAsync(string username, string password);
Task<DTOs.AuthResult> RegisterAsync(string username, string email, string password);
// Autenticación social (Google)
Task<DTOs.AuthResult> GoogleLoginAsync(string idToken);
// Seguridad avanzada (MFA)
Task<string> GenerateMfaSecretAsync(int userId);
Task<bool> VerifyMfaCodeAsync(int userId, string code);
Task EnableMfaAsync(int userId, bool enabled);
}

View File

@@ -4,6 +4,10 @@
<ProjectReference Include="..\SIGCM.Domain\SIGCM.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="12.1.1" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -0,0 +1,53 @@
using FluentValidation;
using SIGCM.Application.DTOs;
namespace SIGCM.Application.Validators;
public class CreateListingDtoValidator : AbstractValidator<CreateListingDto>
{
public CreateListingDtoValidator()
{
RuleFor(x => x.CategoryId)
.GreaterThan(0)
.WithMessage("Debe seleccionar un rubro válido.");
RuleFor(x => x.OperationId)
.GreaterThan(0)
.WithMessage("Debe seleccionar una operación válida.");
RuleFor(x => x.PrintText)
.NotEmpty()
.WithMessage("El texto del aviso no puede estar vacío.")
.MinimumLength(10)
.WithMessage("El texto del aviso es demasiado corto (mínimo 10 caracteres).");
RuleFor(x => x.PrintDaysCount)
.InclusiveBetween(1, 365)
.WithMessage("La duración debe ser entre 1 y 365 días.");
RuleFor(x => x.AdFee)
.GreaterThanOrEqualTo(0)
.WithMessage("El costo del aviso no puede ser negativo.");
RuleFor(x => x.ClientDni)
.NotEmpty()
.When(x => !string.IsNullOrEmpty(x.ClientName))
.WithMessage("Si especifica un nombre de cliente, el DNI/CUIT es obligatorio.");
RuleForEach(x => x.Payments).SetValidator(new PaymentDtoValidator());
}
}
public class PaymentDtoValidator : AbstractValidator<PaymentDto>
{
public PaymentDtoValidator()
{
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("El monto del pago debe ser mayor a 0.");
RuleFor(x => x.PaymentMethod)
.NotEmpty()
.WithMessage("Debe especificar un medio de pago.");
}
}

View File

@@ -0,0 +1,38 @@
namespace SIGCM.Domain.Entities;
public class CashClosing
{
public int Id { get; set; }
public int UserId { get; set; }
public DateTime ClosingDate { get; set; } = DateTime.UtcNow;
// Declaración del cajero (arqueo ciego)
public decimal DeclaredCash { get; set; }
public decimal DeclaredDebit { get; set; }
public decimal DeclaredCredit { get; set; }
public decimal DeclaredTransfer { get; set; }
// Valores del sistema (calculados)
public decimal SystemCash { get; set; }
public decimal SystemDebit { get; set; }
public decimal SystemCredit { get; set; }
public decimal SystemTransfer { get; set; }
// Diferencias
public decimal CashDifference { get; set; }
public decimal DebitDifference { get; set; }
public decimal CreditDifference { get; set; }
public decimal TransferDifference { get; set; }
// Totales
public decimal TotalDeclared { get; set; }
public decimal TotalSystem { get; set; }
public decimal TotalDifference { get; set; }
// Notas del cajero
public string? Notes { get; set; }
// Estado
public bool HasDiscrepancies { get; set; }
public bool IsApproved { get; set; }
}

View File

@@ -0,0 +1,34 @@
namespace SIGCM.Domain.Entities;
public class CashSession
{
public int Id { get; set; }
public int UserId { get; set; }
public DateTime OpeningDate { get; set; }
public DateTime? ClosingDate { get; set; }
public string Status { get; set; } = "Open"; // Open, PendingValidation, Closed
// Apertura
public decimal OpeningBalance { get; set; }
// Declaración del Cajero
public decimal? DeclaredCash { get; set; }
public decimal? DeclaredCards { get; set; }
public decimal? DeclaredTransfers { get; set; }
// Valores calculados por Sistema
public decimal? SystemExpectedCash { get; set; }
public decimal? SystemExpectedCards { get; set; }
public decimal? SystemExpectedTransfers { get; set; }
public decimal? TotalDifference { get; set; }
// Auditoría de Validación
public int? ValidatedByUserId { get; set; }
public DateTime? ValidationDate { get; set; }
public string? ValidationNotes { get; set; }
// Auxiliares para UI
public string? Username { get; set; }
public string? ValidatorName { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace SIGCM.Domain.Entities;
public class Claim
{
public int Id { get; set; }
public int ListingId { get; set; }
public int CreatedByUserId { get; set; }
public required string ClaimType { get; set; }
public required string Description { get; set; }
public string Status { get; set; } = "Open";
public string? SolutionDescription { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ResolvedAt { get; set; }
public int? ResolvedByUserId { get; set; }
public string? OriginalValues { get; set; }
// Propiedades auxiliares para mostrar en UI
public string? CreatedByUsername { get; set; }
public string? ResolvedByUsername { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace SIGCM.Domain.Entities;
public class EditionClosure
{
public int Id { get; set; }
public DateTime EditionDate { get; set; } // Fecha de la edición que se cierra
public DateTime ClosureDateTime { get; set; } = DateTime.UtcNow; // Cuándo se cerró
public int ClosedByUserId { get; set; } // Quién cerró la edición
public bool IsClosed { get; set; } = true;
public string? Notes { get; set; } // Notas del cierre
// Nombre del usuario (auxiliar para JOINs)
public string? ClosedByUsername { get; set; }
}

View File

@@ -14,6 +14,10 @@ public class Listing
public string Status { get; set; } = "Draft";
public int? UserId { get; set; }
public int? ClientId { get; set; }
public int ViewCount { get; set; }
public string Origin { get; set; } = "Mostrador";
public string? OverlayStatus { get; set; }
public List<string>? ImagesToClone { get; set; }
// Propiedades para impresión
public string? PrintText { get; set; }

View File

@@ -0,0 +1,12 @@
namespace SIGCM.Domain.Entities;
public class Notification
{
public int Id { get; set; }
public int UserId { get; set; }
public required string Title { get; set; }
public required string Message { get; set; }
public required string Type { get; set; }
public bool IsRead { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM.Domain.Entities;
public class Payment
{
public int Id { get; set; }
public int ListingId { get; set; }
public decimal Amount { get; set; }
public required string PaymentMethod { get; set; } // Cash, Debit, Credit, Transfer, MercadoPago, Stripe
public string? CardPlan { get; set; }
public decimal Surcharge { get; set; }
public DateTime PaymentDate { get; set; } = DateTime.UtcNow;
// Campos para pagos externos (MP/Stripe)
public string? ExternalReference { get; set; } // Nuestro ID de preferencia
public string? ExternalId { get; set; } // ID transaccional de la pasarela
public string? Status { get; set; } // Pending, Approved, Rejected
}

View File

@@ -8,4 +8,16 @@ public class User
public required string Role { get; set; }
public string? Email { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public int FailedLoginAttempts { get; set; } = 0;
public DateTime? LockoutEnd { get; set; }
public bool MustChangePassword { get; set; } = true;
public bool IsActive { get; set; } = true;
// Campos de seguridad adicional
public DateTime? LastLogin { get; set; }
// Soporte para OAuth y MFA
public string? GoogleId { get; set; }
public bool IsMfaEnabled { get; set; }
public string? MfaSecret { get; set; }
}

View File

@@ -0,0 +1,13 @@
using SIGCM.Domain.Entities;
using SIGCM.Domain.Models;
namespace SIGCM.Domain.Interfaces;
public interface IClaimRepository
{
Task<int> CreateAsync(Claim claim);
Task<IEnumerable<Claim>> GetByListingIdAsync(int listingId);
Task<IEnumerable<Claim>> GetAllActiveAsync();
Task UpdateStatusAsync(int claimId, string status, ResolveRequest request, int resolvedByUserId);
Task<Claim?> GetByIdAsync(int id);
}

View File

@@ -5,5 +5,7 @@ namespace SIGCM.Domain.Interfaces;
public interface IImageRepository
{
Task AddAsync(ListingImage image);
Task<ListingImage?> GetByIdAsync(int id);
Task<IEnumerable<ListingImage>> GetByListingIdAsync(int listingId);
Task DeleteAsync(int id);
}

View File

@@ -1,3 +1,4 @@
// src/SIGCM.Domain/Interfaces/IListingRepository.cs
using SIGCM.Domain.Models;
using SIGCM.Domain.Entities;
using SIGCM.Application.DTOs;
@@ -6,16 +7,28 @@ namespace SIGCM.Domain.Interfaces;
public interface IListingRepository
{
Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes);
Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes, List<Payment>? payments = null);
Task<Listing?> GetByIdAsync(int id);
Task<ListingDetail?> GetDetailByIdAsync(int id);
Task<IEnumerable<Listing>> GetAllAsync();
Task<IEnumerable<Listing>> GetByUserIdAsync(int userId);
Task<int> CountByCategoryIdAsync(int categoryId);
Task MoveListingsAsync(int sourceCategoryId, int targetCategoryId);
Task IncrementViewCountAsync(int id);
Task<IEnumerable<dynamic>> GetActiveCashiersAsync();
Task UpdateOverlayStatusAsync(int id, int userId, string? status);
// Búsqueda Simple y Facetada
Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId);
Task<IEnumerable<Listing>> SearchFacetedAsync(string? query, int? categoryId, Dictionary<string, string>? attributes);
Task<IEnumerable<Listing>> SearchFacetedAsync(
string? query,
int? categoryId,
Dictionary<string, string>? attributes,
DateTime? from = null,
DateTime? to = null,
string? origin = null,
string? status = null
);
// Impresión
Task<IEnumerable<Listing>> GetListingsForPrintAsync(DateTime date);
@@ -28,6 +41,10 @@ public interface IListingRepository
// Estadísticas
Task<IEnumerable<CategorySalesReportDto>> GetSalesByRootCategoryAsync(DateTime startDate, DateTime endDate);
Task<DashboardStats> GetDashboardStatsAsync(DateTime startDate, DateTime endDate);
Task<AdvancedAnalyticsDto> GetAdvancedAnalyticsAsync(DateTime startDate, DateTime endDate);
Task<CashierDashboardDto?> GetCashierStatsAsync(int userId, DateTime startDate, DateTime endDate);
Task<GlobalReportDto> GetDetailedReportAsync(DateTime start, DateTime end, int? userId = null);
// Pagos
Task AddPaymentAsync(Payment payment);
}

View File

@@ -3,7 +3,12 @@ using SIGCM.Domain.Entities;
public interface IUserRepository
{
// Métodos de búsqueda
Task<User?> GetByUsernameAsync(string username);
Task<User?> GetByEmailAsync(string email);
Task<User?> GetByGoogleIdAsync(string googleId);
// Gestión básica
Task<IEnumerable<User>> GetAllAsync();
Task<User?> GetByIdAsync(int id);
Task<int> CreateAsync(User user);

View File

@@ -0,0 +1,58 @@
using System.Collections.Generic;
namespace SIGCM.Domain.Models;
public class AdvancedAnalyticsDto
{
// Resumen Comparativo
public decimal TotalRevenue { get; set; }
public decimal PreviousPeriodRevenue { get; set; }
public double RevenueGrowth { get; set; }
public int TotalAds { get; set; }
public int PreviousPeriodAds { get; set; }
public double AdsGrowth { get; set; }
// Distribución de Pagos
public List<PaymentMethodStat> PaymentsDistribution { get; set; } = new();
// Rendimiento por Categoría
public List<CategoryPerformanceStat> CategoryPerformance { get; set; } = new();
// Análisis Horario (Peak Hours)
public List<HourlyStat> HourlyActivity { get; set; } = new();
// Tendencia de Recaudación (Día a Día)
public List<DailyRevenue> DailyTrends { get; set; } = new();
public SourceMixDto SourceMix { get; set; } = new();
}
public class SourceMixDto
{
public int MostradorCount { get; set; }
public int WebCount { get; set; }
public double MostradorPercent { get; set; }
public double WebPercent { get; set; }
}
public class PaymentMethodStat
{
public string Method { get; set; } = "";
public decimal Total { get; set; }
public int Count { get; set; }
}
public class CategoryPerformanceStat
{
public string CategoryName { get; set; } = "";
public decimal Revenue { get; set; }
public int AdsCount { get; set; }
public double Share { get; set; }
}
public class HourlyStat
{
public int Hour { get; set; }
public int Count { get; set; }
}

View File

@@ -1,3 +1,4 @@
// src/SIGCM.Domain/Models/GlobalReportDto.cs
namespace SIGCM.Domain.Models;
public class GlobalReportDto
@@ -8,6 +9,12 @@ public class GlobalReportDto
public decimal TotalRevenue { get; set; }
public int TotalAds { get; set; }
public List<ReportItemDto> Items { get; set; } = new();
// Desglose por método de pago
public decimal TotalCash { get; set; }
public decimal TotalDebit { get; set; }
public decimal TotalCredit { get; set; }
public decimal TotalTransfer { get; set; }
}
public class ReportItemDto
@@ -18,4 +25,5 @@ public class ReportItemDto
public string Category { get; set; } = "";
public string Cashier { get; set; } = "";
public decimal Amount { get; set; }
public string Source { get; set; } = "";
}

View File

@@ -7,6 +7,7 @@ public class ListingDetail
public required Listing Listing { get; set; }
public required IEnumerable<ListingAttributeValueWithName> Attributes { get; set; }
public required IEnumerable<ListingImage> Images { get; set; }
public required IEnumerable<Payment> Payments { get; set; }
}
public class ListingAttributeValueWithName : ListingAttributeValue

View File

@@ -0,0 +1,19 @@
using System;
namespace SIGCM.Domain.Models;
public class ResolveRequest
{
// La explicación de lo que hizo el cajero (obligatorio)
public string Solution { get; set; } = string.Empty;
// --- Campos de Ajuste Técnico ---
// Si el cajero corrige el texto del aviso
public string? NewPrintText { get; set; }
// Si el cajero reprograma la fecha de salida
public DateTime? NewStartDate { get; set; }
// Si el cajero extiende o acorta la cantidad de días
public int? NewDaysCount { get; set; }
}

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"],