Feat: Cambios Varios 2
@@ -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);
|
||||
|
||||
@@ -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; } = "";
|
||||
}
|
||||
|
||||
182
src/SIGCM.API/Controllers/CashClosingController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
171
src/SIGCM.API/Controllers/CashSessionsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
74
src/SIGCM.API/Controllers/ClaimsController.cs
Normal 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." });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
41
src/SIGCM.API/Controllers/DashboardController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
28
src/SIGCM.API/Controllers/NotificationsController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
116
src/SIGCM.API/Controllers/PaymentsController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
61
src/SIGCM.API/Middleware/ExceptionMiddleware.cs
Normal 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);
|
||||
57
src/SIGCM.API/Middleware/SecurityHeadersMiddleware.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 38 KiB |
11
src/SIGCM.Application/DTOs/AuthResult.cs
Normal 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; }
|
||||
}
|
||||
42
src/SIGCM.Application/DTOs/CashClosingDto.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
38
src/SIGCM.Domain/Entities/CashClosing.cs
Normal 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; }
|
||||
}
|
||||
34
src/SIGCM.Domain/Entities/CashSession.cs
Normal 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; }
|
||||
}
|
||||
20
src/SIGCM.Domain/Entities/Claim.cs
Normal 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; }
|
||||
}
|
||||
14
src/SIGCM.Domain/Entities/EditionClosure.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
12
src/SIGCM.Domain/Entities/Notification.cs
Normal 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; }
|
||||
}
|
||||
17
src/SIGCM.Domain/Entities/Payment.cs
Normal 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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
13
src/SIGCM.Domain/Interfaces/IClaimRepository.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
58
src/SIGCM.Domain/Models/AdvancedAnalyticsDto.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
19
src/SIGCM.Domain/Models/ResolveRequest.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
107
src/SIGCM.Infrastructure/Repositories/CashClosingRepository.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class CashClosingRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
|
||||
public CashClosingRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
// Crear un nuevo cierre de caja
|
||||
public async Task<int> CreateAsync(CashClosing closing)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO CashClosings (
|
||||
UserId, ClosingDate,
|
||||
DeclaredCash, DeclaredDebit, DeclaredCredit, DeclaredTransfer,
|
||||
SystemCash, SystemDebit, SystemCredit, SystemTransfer,
|
||||
CashDifference, DebitDifference, CreditDifference, TransferDifference,
|
||||
TotalDeclared, TotalSystem, TotalDifference,
|
||||
Notes, HasDiscrepancies, IsApproved
|
||||
)
|
||||
VALUES (
|
||||
@UserId, @ClosingDate,
|
||||
@DeclaredCash, @DeclaredDebit, @DeclaredCredit, @DeclaredTransfer,
|
||||
@SystemCash, @SystemDebit, @SystemCredit, @SystemTransfer,
|
||||
@CashDifference, @DebitDifference, @CreditDifference, @TransferDifference,
|
||||
@TotalDeclared, @TotalSystem, @TotalDifference,
|
||||
@Notes, @HasDiscrepancies, @IsApproved
|
||||
);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
return await conn.QuerySingleAsync<int>(sql, closing);
|
||||
}
|
||||
|
||||
// Obtener cierres de un usuario en un rango de fechas
|
||||
public async Task<IEnumerable<CashClosing>> GetByUserAsync(int userId, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT * FROM CashClosings
|
||||
WHERE UserId = @UserId
|
||||
AND CAST(ClosingDate AS DATE) BETWEEN @StartDate AND @EndDate
|
||||
ORDER BY ClosingDate DESC";
|
||||
|
||||
return await conn.QueryAsync<CashClosing>(sql, new { UserId = userId, StartDate = startDate.Date, EndDate = endDate.Date });
|
||||
}
|
||||
|
||||
// Obtener todos los cierres con discrepancias (para administradores)
|
||||
public async Task<IEnumerable<CashClosing>> GetDiscrepanciesAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT c.*, u.Username
|
||||
FROM CashClosings c
|
||||
JOIN Users u ON c.UserId = u.Id
|
||||
WHERE c.HasDiscrepancies = 1 AND c.IsApproved = 0
|
||||
ORDER BY c.ClosingDate DESC";
|
||||
|
||||
return await conn.QueryAsync<CashClosing>(sql);
|
||||
}
|
||||
|
||||
// Aprobar un cierre de caja
|
||||
public async Task ApproveAsync(int closingId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
await conn.ExecuteAsync("UPDATE CashClosings SET IsApproved = 1 WHERE Id = @Id", new { Id = closingId });
|
||||
}
|
||||
|
||||
// Calcular totales de pagos por método del día actual para un usuario
|
||||
public async Task<Dictionary<string, decimal>> GetPaymentTotalsByMethodAsync(int userId, DateTime date)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT p.PaymentMethod, SUM(p.Amount + p.Surcharge) as Total
|
||||
FROM Payments p
|
||||
INNER JOIN Listings l ON p.ListingId = l.Id
|
||||
WHERE l.UserId = @UserId
|
||||
AND CAST(l.CreatedAt AS DATE) = @Date
|
||||
GROUP BY p.PaymentMethod";
|
||||
|
||||
var results = await conn.QueryAsync<dynamic>(sql, new { UserId = userId, Date = date.Date });
|
||||
|
||||
var totals = new Dictionary<string, decimal>
|
||||
{
|
||||
["Cash"] = 0,
|
||||
["Debit"] = 0,
|
||||
["Credit"] = 0,
|
||||
["Transfer"] = 0
|
||||
};
|
||||
|
||||
foreach (var row in results)
|
||||
{
|
||||
string method = row.PaymentMethod;
|
||||
decimal total = row.Total;
|
||||
if (totals.ContainsKey(method))
|
||||
{
|
||||
totals[method] = total;
|
||||
}
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
}
|
||||
105
src/SIGCM.Infrastructure/Repositories/CashSessionRepository.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class CashSessionRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
public CashSessionRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
// 1. Verificar si el usuario ya tiene una caja abierta
|
||||
public async Task<CashSession?> GetActiveSessionAsync(int userId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = "SELECT * FROM CashSessions WHERE UserId = @userId AND Status = 'Open'";
|
||||
return await conn.QuerySingleOrDefaultAsync<CashSession>(sql, new { userId });
|
||||
}
|
||||
|
||||
// 2. Abrir Caja
|
||||
public async Task<int> OpenSessionAsync(int userId, decimal openingBalance)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO CashSessions (UserId, OpeningBalance, Status, OpeningDate)
|
||||
VALUES (@userId, @openingBalance, 'Open', GETUTCDATE());
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, new { userId, openingBalance });
|
||||
}
|
||||
|
||||
// 3. Obtener totales del sistema para un rango de tiempo (El turno)
|
||||
public async Task<dynamic> GetSystemTotalsAsync(int userId, DateTime start, DateTime end)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT
|
||||
SUM(CASE WHEN PaymentMethod = 'Cash' THEN Amount + Surcharge ELSE 0 END) as Cash,
|
||||
SUM(CASE WHEN PaymentMethod IN ('Debit', 'Credit') THEN Amount + Surcharge ELSE 0 END) as Cards,
|
||||
SUM(CASE WHEN PaymentMethod = 'Transfer' THEN Amount + Surcharge ELSE 0 END) as Transfers
|
||||
FROM Payments p
|
||||
JOIN Listings l ON p.ListingId = l.Id
|
||||
WHERE l.UserId = @userId AND p.PaymentDate BETWEEN @start AND @end";
|
||||
|
||||
return await conn.QuerySingleAsync<dynamic>(sql, new { userId, start, end });
|
||||
}
|
||||
|
||||
// 4. Cerrar Caja (Arqueo Ciego)
|
||||
public async Task CloseSessionAsync(CashSession session)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
UPDATE CashSessions
|
||||
SET Status = 'PendingValidation',
|
||||
ClosingDate = GETUTCDATE(),
|
||||
DeclaredCash = @DeclaredCash,
|
||||
DeclaredCards = @DeclaredCards,
|
||||
DeclaredTransfers = @DeclaredTransfers,
|
||||
SystemExpectedCash = @SystemExpectedCash,
|
||||
SystemExpectedCards = @SystemExpectedCards,
|
||||
SystemExpectedTransfers = @SystemExpectedTransfers,
|
||||
TotalDifference = @TotalDifference
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, session);
|
||||
}
|
||||
|
||||
// Obtener todas las sesiones que requieren validación (para el Supervisor)
|
||||
public async Task<IEnumerable<CashSession>> GetPendingValidationAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT cs.*, u.Username
|
||||
FROM CashSessions cs
|
||||
JOIN Users u ON cs.UserId = u.Id
|
||||
WHERE cs.Status = 'PendingValidation'
|
||||
ORDER BY cs.ClosingDate DESC";
|
||||
return await conn.QueryAsync<CashSession>(sql);
|
||||
}
|
||||
|
||||
// Validar / Liquidar una sesión
|
||||
public async Task ValidateSessionAsync(int sessionId, int adminId, string notes)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
UPDATE CashSessions
|
||||
SET Status = 'Closed',
|
||||
ValidatedByUserId = @adminId,
|
||||
ValidationDate = GETUTCDATE(),
|
||||
ValidationNotes = @notes
|
||||
WHERE Id = @sessionId";
|
||||
await conn.ExecuteAsync(sql, new { sessionId, adminId, notes });
|
||||
}
|
||||
|
||||
public async Task<CashSession?> GetSessionDetailAsync(int sessionId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT cs.*, u.Username, v.Username as ValidatorName
|
||||
FROM CashSessions cs
|
||||
JOIN Users u ON cs.UserId = u.Id
|
||||
LEFT JOIN Users v ON cs.ValidatedByUserId = v.Id
|
||||
WHERE cs.Id = @sessionId";
|
||||
return await conn.QuerySingleOrDefaultAsync<CashSession>(sql, new { sessionId });
|
||||
}
|
||||
}
|
||||
126
src/SIGCM.Infrastructure/Repositories/ClaimRepository.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
using SIGCM.Domain.Models;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class ClaimRepository : IClaimRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
public ClaimRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
public async Task<int> CreateAsync(Claim claim)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO Claims (ListingId, CreatedByUserId, ClaimType, Description, Status)
|
||||
VALUES (@ListingId, @CreatedByUserId, @ClaimType, @Description, @Status);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, claim);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Claim>> GetByListingIdAsync(int listingId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT c.*, u.Username as CreatedByUsername, r.Username as ResolvedByUsername
|
||||
FROM Claims c
|
||||
JOIN Users u ON c.CreatedByUserId = u.Id
|
||||
LEFT JOIN Users r ON c.ResolvedByUserId = r.Id
|
||||
WHERE c.ListingId = @ListingId
|
||||
ORDER BY c.CreatedAt DESC";
|
||||
return await conn.QueryAsync<Claim>(sql, new { ListingId = listingId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Claim>> GetAllActiveAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT c.*, u.Username as CreatedByUsername
|
||||
FROM Claims c
|
||||
JOIN Users u ON c.CreatedByUserId = u.Id
|
||||
WHERE c.Status <> 'Resolved'
|
||||
ORDER BY c.CreatedAt ASC";
|
||||
return await conn.QueryAsync<Claim>(sql);
|
||||
}
|
||||
|
||||
public async Task UpdateStatusAsync(int claimId, string status, ResolveRequest request, int resolvedByUserId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
conn.Open();
|
||||
using var transaction = conn.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Buscamos el aviso y sus valores ACTUALES antes de cambiarlos
|
||||
var currentDataSql = @"
|
||||
SELECT l.Id, l.PrintText, l.PrintStartDate, l.PrintDaysCount
|
||||
FROM Listings l
|
||||
JOIN Claims c ON c.ListingId = l.Id
|
||||
WHERE c.Id = @claimId";
|
||||
|
||||
var oldValues = await conn.QuerySingleOrDefaultAsync<dynamic>(currentDataSql, new { claimId }, transaction);
|
||||
|
||||
if (oldValues == null)
|
||||
throw new InvalidOperationException("Reclamo o Aviso no encontrado.");
|
||||
|
||||
// 2. Construimos un string descriptivo del estado anterior
|
||||
string originalValuesLog = $"Texto: {oldValues.PrintText} | " +
|
||||
$"Fecha: {oldValues.PrintStartDate:yyyy-MM-dd} | " +
|
||||
$"Días: {oldValues.PrintDaysCount}";
|
||||
|
||||
// 3. Actualizar el Reclamo (Guardando los valores originales)
|
||||
var sqlClaim = @"
|
||||
UPDATE Claims
|
||||
SET Status = @status,
|
||||
SolutionDescription = @solution,
|
||||
OriginalValues = @originalValuesLog,
|
||||
ResolvedAt = GETUTCDATE(),
|
||||
ResolvedByUserId = @resolvedByUserId
|
||||
WHERE Id = @claimId";
|
||||
|
||||
await conn.ExecuteAsync(sqlClaim, new
|
||||
{
|
||||
claimId,
|
||||
status,
|
||||
solution = request.Solution,
|
||||
originalValuesLog,
|
||||
resolvedByUserId
|
||||
}, transaction);
|
||||
|
||||
// 4. Aplicar los nuevos ajustes técnicos al aviso
|
||||
if (!string.IsNullOrEmpty(request.NewPrintText) || request.NewStartDate.HasValue || request.NewDaysCount.HasValue)
|
||||
{
|
||||
var sqlListing = @"
|
||||
UPDATE Listings
|
||||
SET PrintText = ISNULL(@NewPrintText, PrintText),
|
||||
PrintStartDate = ISNULL(@NewStartDate, PrintStartDate),
|
||||
PrintDaysCount = ISNULL(@NewDaysCount, PrintDaysCount)
|
||||
WHERE Id = @listingId";
|
||||
|
||||
await conn.ExecuteAsync(sqlListing, new
|
||||
{
|
||||
listingId = (int)oldValues.Id,
|
||||
request.NewPrintText,
|
||||
request.NewStartDate,
|
||||
request.NewDaysCount
|
||||
}, transaction);
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Claim?> GetByIdAsync(int id)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<Claim>("SELECT * FROM Claims WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -13,35 +13,37 @@ public class ClientRepository
|
||||
_db = db;
|
||||
}
|
||||
|
||||
// Búsqueda inteligente por Nombre O DNI
|
||||
// Búsqueda inteligente con protección de nulos
|
||||
public async Task<IEnumerable<Client>> SearchAsync(string query)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT TOP 10 * FROM Clients
|
||||
SELECT TOP 10
|
||||
Id,
|
||||
ISNULL(Name, 'Sin Nombre') as Name,
|
||||
ISNULL(DniOrCuit, '') as DniOrCuit,
|
||||
Email, Phone, Address
|
||||
FROM Clients
|
||||
WHERE Name LIKE @Query OR DniOrCuit LIKE @Query
|
||||
ORDER BY Name";
|
||||
return await conn.QueryAsync<Client>(sql, new { Query = $"%{query}%" });
|
||||
}
|
||||
|
||||
// Buscar o Crear (Upsert) al guardar el aviso
|
||||
// Asegurar existencia (Upsert)
|
||||
public async Task<int> EnsureClientExistsAsync(string name, string dni)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
|
||||
// 1. Buscamos por DNI/CUIT (es el identificador único más fiable)
|
||||
var existingId = await conn.ExecuteScalarAsync<int?>(
|
||||
"SELECT Id FROM Clients WHERE DniOrCuit = @Dni", new { Dni = dni });
|
||||
|
||||
if (existingId.HasValue)
|
||||
{
|
||||
// Opcional: Actualizar nombre si cambió
|
||||
await conn.ExecuteAsync("UPDATE Clients SET Name = @Name WHERE Id = @Id", new { Name = name, Id = existingId });
|
||||
return existingId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Crear nuevo
|
||||
var sql = @"
|
||||
INSERT INTO Clients (Name, DniOrCuit) VALUES (@Name, @Dni);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
@@ -49,23 +51,59 @@ public class ClientRepository
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener todos con estadísticas (ISNULL agregado para seguridad)
|
||||
public async Task<IEnumerable<dynamic>> GetAllWithStatsAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT c.*,
|
||||
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = c.Id) as TotalAds,
|
||||
(SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = c.Id) as TotalSpent
|
||||
SELECT
|
||||
c.Id as id,
|
||||
ISNULL(c.Name, 'Sin Nombre') as name,
|
||||
ISNULL(c.DniOrCuit, 'S/D') as dniOrCuit,
|
||||
ISNULL(c.Email, 'Sin correo') as email,
|
||||
ISNULL(c.Phone, 'Sin teléfono') as phone,
|
||||
(SELECT COUNT(1) FROM Listings l WHERE l.ClientId = c.Id) as totalAds,
|
||||
ISNULL((SELECT SUM(AdFee) FROM Listings l WHERE l.ClientId = c.Id), 0) as totalSpent
|
||||
FROM Clients c
|
||||
ORDER BY c.Name";
|
||||
return await conn.QueryAsync(sql);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Listing>> GetClientHistoryAsync(int clientId)
|
||||
public async Task UpdateAsync(Client client)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QueryAsync<Listing>(
|
||||
"SELECT * FROM Listings WHERE ClientId = @Id ORDER BY CreatedAt DESC",
|
||||
new { Id = clientId });
|
||||
var sql = @"
|
||||
UPDATE Clients
|
||||
SET Name = @Name,
|
||||
DniOrCuit = @DniOrCuit,
|
||||
Email = @Email,
|
||||
Phone = @Phone,
|
||||
Address = @Address
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, client);
|
||||
}
|
||||
|
||||
public async Task<dynamic?> GetClientSummaryAsync(int clientId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT
|
||||
c.Id, c.Name, c.DniOrCuit, c.Email, c.Phone, c.Address,
|
||||
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id) as TotalAds,
|
||||
ISNULL((SELECT SUM(AdFee) FROM Listings WHERE ClientId = c.Id), 0) as TotalInvested,
|
||||
(SELECT MAX(CreatedAt) FROM Listings WHERE ClientId = c.Id) as LastAdDate,
|
||||
(SELECT COUNT(1) FROM Listings WHERE ClientId = c.Id AND Status = 'Published') as ActiveAds,
|
||||
ISNULL((
|
||||
SELECT TOP 1 cat.Name
|
||||
FROM Listings l
|
||||
JOIN Categories cat ON l.CategoryId = cat.Id
|
||||
WHERE l.ClientId = c.Id
|
||||
GROUP BY cat.Name
|
||||
ORDER BY COUNT(l.Id) DESC
|
||||
), 'N/A') as PreferredCategory
|
||||
FROM Clients c
|
||||
WHERE c.Id = @Id";
|
||||
|
||||
return await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { Id = clientId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class EditionClosureRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
|
||||
public EditionClosureRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
// Cerrar una edición específica
|
||||
public async Task<int> CloseEditionAsync(EditionClosure closure)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO EditionClosures (EditionDate, ClosureDateTime, ClosedByUserId, IsClosed, Notes)
|
||||
VALUES (@EditionDate, @ClosureDateTime, @ClosedByUserId, @IsClosed, @Notes);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
return await conn.QuerySingleAsync<int>(sql, closure);
|
||||
}
|
||||
|
||||
// Verificar si una edición está cerrada
|
||||
public async Task<bool> IsEditionClosedAsync(DateTime editionDate)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = "SELECT COUNT(1) FROM EditionClosures WHERE EditionDate = @Date AND IsClosed = 1";
|
||||
var count = await conn.ExecuteScalarAsync<int>(sql, new { Date = editionDate.Date });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
// Reabrir una edición
|
||||
public async Task ReopenEditionAsync(DateTime editionDate)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE EditionClosures SET IsClosed = 0 WHERE EditionDate = @Date",
|
||||
new { Date = editionDate.Date });
|
||||
}
|
||||
|
||||
// Obtener historial de cierres
|
||||
public async Task<IEnumerable<EditionClosure>> GetClosureHistoryAsync(int limit = 30)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT TOP (@Limit) ec.*, u.Username as ClosedByUsername
|
||||
FROM EditionClosures ec
|
||||
JOIN Users u ON ec.ClosedByUserId = u.Id
|
||||
ORDER BY ec.EditionDate DESC";
|
||||
|
||||
return await conn.QueryAsync<EditionClosure>(sql, new { Limit = limit });
|
||||
}
|
||||
|
||||
// Obtener el cierre de una fecha específica
|
||||
public async Task<EditionClosure?> GetClosureByDateAsync(DateTime editionDate)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT ec.*, u.Username as ClosedByUsername
|
||||
FROM EditionClosures ec
|
||||
JOIN Users u ON ec.ClosedByUserId = u.Id
|
||||
WHERE ec.EditionDate = @Date";
|
||||
|
||||
return await conn.QuerySingleOrDefaultAsync<EditionClosure>(sql, new { Date = editionDate.Date });
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,16 @@ public class ImageRepository : IImageRepository
|
||||
await conn.ExecuteAsync(sql, image);
|
||||
}
|
||||
|
||||
// Obtiene una imagen por su ID
|
||||
public async Task<ListingImage?> GetByIdAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ListingImage>(
|
||||
"SELECT * FROM ListingImages WHERE Id = @Id",
|
||||
new { Id = id });
|
||||
}
|
||||
|
||||
// Obtiene las imágenes de un aviso ordenadas
|
||||
public async Task<IEnumerable<ListingImage>> GetByListingIdAsync(int listingId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
@@ -30,4 +40,11 @@ public class ImageRepository : IImageRepository
|
||||
"SELECT * FROM ListingImages WHERE ListingId = @ListingId ORDER BY DisplayOrder",
|
||||
new { ListingId = listingId });
|
||||
}
|
||||
|
||||
// Elimina el registro de una imagen de la base de datos
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync("DELETE FROM ListingImages WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Data;
|
||||
// src/SIGCM.Infrastructure/Repositories/ListingRepository.cs
|
||||
using Dapper;
|
||||
using SIGCM.Application.DTOs;
|
||||
using SIGCM.Domain.Entities;
|
||||
@@ -17,7 +17,7 @@ public class ListingRepository : IListingRepository
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes)
|
||||
public async Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes, List<Payment>? payments = null)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
conn.Open();
|
||||
@@ -29,12 +29,12 @@ public class ListingRepository : IListingRepository
|
||||
INSERT INTO Listings (
|
||||
CategoryId, OperationId, Title, Description, Price, Currency,
|
||||
CreatedAt, Status, UserId, PrintText, PrintStartDate, PrintDaysCount,
|
||||
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee
|
||||
IsBold, IsFrame, PrintFontSize, PrintAlignment, AdFee, ClientId
|
||||
)
|
||||
VALUES (
|
||||
@CategoryId, @OperationId, @Title, @Description, @Price, @Currency,
|
||||
@CreatedAt, @Status, @UserId, @PrintText, @PrintStartDate, @PrintDaysCount,
|
||||
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee
|
||||
@IsBold, @IsFrame, @PrintFontSize, @PrintAlignment, @AdFee, @ClientId
|
||||
);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
@@ -52,6 +52,34 @@ public class ListingRepository : IListingRepository
|
||||
}
|
||||
}
|
||||
|
||||
if (listing.ImagesToClone != null && listing.ImagesToClone.Any())
|
||||
{
|
||||
foreach (var url in listing.ImagesToClone)
|
||||
{
|
||||
// Validamos que la URL sea válida antes de insertar
|
||||
if (string.IsNullOrEmpty(url)) continue;
|
||||
|
||||
var sqlImg = @"INSERT INTO ListingImages (ListingId, Url, IsMainInfo, DisplayOrder)
|
||||
VALUES (@listingId, @url, 0, 0)";
|
||||
|
||||
// Usamos el ID del nuevo aviso (listingId) y la URL del viejo
|
||||
await conn.ExecuteAsync(sqlImg, new { listingId, url }, transaction);
|
||||
}
|
||||
}
|
||||
|
||||
if (payments != null && payments.Any())
|
||||
{
|
||||
var sqlPayment = @"
|
||||
INSERT INTO Payments (ListingId, Amount, PaymentMethod, CardPlan, Surcharge, PaymentDate)
|
||||
VALUES (@ListingId, @Amount, @PaymentMethod, @CardPlan, @Surcharge, @PaymentDate)";
|
||||
|
||||
foreach (var pay in payments)
|
||||
{
|
||||
pay.ListingId = listingId;
|
||||
await conn.ExecuteAsync(sqlPayment, pay, transaction);
|
||||
}
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
return listingId;
|
||||
}
|
||||
@@ -71,55 +99,57 @@ public class ListingRepository : IListingRepository
|
||||
public async Task<ListingDetail?> GetDetailByIdAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// Mejoramos el SQL para asegurar que los nulos se conviertan en 0 (false) desde el motor
|
||||
var sql = @"
|
||||
SELECT
|
||||
l.Id, l.CategoryId, l.OperationId, l.Title, l.Description, l.Price, l.AdFee,
|
||||
l.Currency, l.CreatedAt, l.Status, l.UserId, l.PrintText, l.PrintDaysCount,
|
||||
ISNULL(l.IsBold, 0) as IsBold,
|
||||
ISNULL(l.IsFrame, 0) as IsFrame,
|
||||
l.PrintFontSize, l.PrintAlignment, l.ClientId,
|
||||
c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni
|
||||
FROM Listings l
|
||||
LEFT JOIN Categories c ON l.CategoryId = c.Id
|
||||
LEFT JOIN Clients cl ON l.ClientId = cl.Id
|
||||
WHERE l.Id = @Id;
|
||||
|
||||
SELECT lav.*, ad.Name as AttributeName
|
||||
FROM ListingAttributeValues lav
|
||||
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
|
||||
WHERE lav.ListingId = @Id;
|
||||
|
||||
SELECT * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
|
||||
";
|
||||
SELECT
|
||||
l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni,
|
||||
u.Username as CashierName
|
||||
FROM Listings l
|
||||
LEFT JOIN Categories c ON l.CategoryId = c.Id
|
||||
LEFT JOIN Clients cl ON l.ClientId = cl.Id
|
||||
LEFT JOIN Users u ON l.UserId = u.Id
|
||||
WHERE l.Id = @Id;
|
||||
|
||||
SELECT lav.*, ad.Name as AttributeName
|
||||
FROM ListingAttributeValues lav
|
||||
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
|
||||
WHERE lav.ListingId = @Id;
|
||||
|
||||
SELECT * FROM ListingImages WHERE ListingId = @Id ORDER BY DisplayOrder;
|
||||
|
||||
SELECT * FROM Payments WHERE ListingId = @Id;
|
||||
";
|
||||
|
||||
using var multi = await conn.QueryMultipleAsync(sql, new { Id = id });
|
||||
var listing = await multi.ReadSingleOrDefaultAsync<dynamic>();
|
||||
if (listing == null) return null;
|
||||
var listingData = await multi.ReadSingleOrDefaultAsync<dynamic>();
|
||||
if (listingData == null) return null;
|
||||
|
||||
var attributes = await multi.ReadAsync<ListingAttributeValueWithName>();
|
||||
var images = await multi.ReadAsync<ListingImage>();
|
||||
var payments = await multi.ReadAsync<Payment>();
|
||||
|
||||
return new ListingDetail
|
||||
{
|
||||
Listing = new Listing
|
||||
{
|
||||
Id = (int)listing.Id,
|
||||
Title = listing.Title,
|
||||
Description = listing.Description,
|
||||
Price = listing.Price,
|
||||
AdFee = listing.AdFee,
|
||||
Status = listing.Status,
|
||||
CreatedAt = listing.CreatedAt,
|
||||
PrintText = listing.PrintText,
|
||||
IsBold = Convert.ToBoolean(listing.IsBold),
|
||||
IsFrame = Convert.ToBoolean(listing.IsFrame),
|
||||
|
||||
PrintDaysCount = listing.PrintDaysCount,
|
||||
CategoryName = listing.CategoryName
|
||||
Id = (int)listingData.Id,
|
||||
CategoryId = (int)(listingData.CategoryId ?? 0),
|
||||
OperationId = (int)(listingData.OperationId ?? 0),
|
||||
Title = listingData.Title ?? "",
|
||||
Description = listingData.Description ?? "",
|
||||
Price = listingData.Price ?? 0m,
|
||||
AdFee = listingData.AdFee ?? 0m,
|
||||
Status = listingData.Status ?? "Draft",
|
||||
CreatedAt = listingData.CreatedAt,
|
||||
UserId = listingData.UserId,
|
||||
CategoryName = listingData.CategoryName,
|
||||
PrintDaysCount = (int)(listingData.PrintDaysCount ?? 0),
|
||||
PrintText = listingData.PrintText,
|
||||
IsBold = listingData.IsBold != null && Convert.ToBoolean(listingData.IsBold),
|
||||
IsFrame = listingData.IsFrame != null && Convert.ToBoolean(listingData.IsFrame),
|
||||
},
|
||||
Attributes = attributes,
|
||||
Images = images
|
||||
Images = images,
|
||||
Payments = payments
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,85 +157,100 @@ public class ListingRepository : IListingRepository
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT TOP 20 l.*,
|
||||
SELECT l.*, c.Name as CategoryName,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
JOIN Categories c ON l.CategoryId = c.Id
|
||||
WHERE l.Status = 'Published'
|
||||
ORDER BY l.CreatedAt DESC";
|
||||
|
||||
return await conn.QueryAsync<Listing>(sql);
|
||||
}
|
||||
|
||||
// Implementación de incremento
|
||||
public async Task IncrementViewCountAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.ExecuteAsync("UPDATE Listings SET ViewCount = ViewCount + 1 WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Listing>> GetByUserIdAsync(int userId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT l.*, c.Name as CategoryName,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
JOIN Categories c ON l.CategoryId = c.Id
|
||||
WHERE l.UserId = @UserId
|
||||
ORDER BY l.CreatedAt DESC";
|
||||
|
||||
return await conn.QueryAsync<Listing>(sql, new { UserId = userId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Listing>> SearchAsync(string? query, int? categoryId)
|
||||
{
|
||||
return await SearchFacetedAsync(query, categoryId, null);
|
||||
}
|
||||
|
||||
// Búsqueda Avanzada Facetada
|
||||
public async Task<IEnumerable<Listing>> SearchFacetedAsync(string? query, int? categoryId, Dictionary<string, string>? attributes)
|
||||
public async Task<IEnumerable<Listing>> SearchFacetedAsync(
|
||||
string? query,
|
||||
int? categoryId,
|
||||
Dictionary<string, string>? attributes,
|
||||
DateTime? from = null,
|
||||
DateTime? to = null,
|
||||
string? origin = null,
|
||||
string? status = null)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
|
||||
var parameters = new DynamicParameters();
|
||||
string sql;
|
||||
|
||||
// Construcción Dinámica de la Query con CTE
|
||||
if (categoryId.HasValue && categoryId.Value > 0)
|
||||
{
|
||||
sql = @"
|
||||
WITH CategoryTree AS (
|
||||
SELECT Id FROM Categories WHERE Id = @CategoryId
|
||||
UNION ALL
|
||||
SELECT c.Id FROM Categories c
|
||||
INNER JOIN CategoryTree ct ON c.ParentId = ct.Id
|
||||
)
|
||||
SELECT l.*,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
WHERE l.Status = 'Published'
|
||||
AND l.CategoryId IN (SELECT Id FROM CategoryTree)";
|
||||
string sql = @"
|
||||
SELECT l.*, c.Name as CategoryName, cl.Name as ClientName, cl.DniOrCuit as ClientDni,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
JOIN Categories c ON l.CategoryId = c.Id
|
||||
LEFT JOIN Clients cl ON l.ClientId = cl.Id
|
||||
WHERE 1=1";
|
||||
|
||||
parameters.Add("CategoryId", categoryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Sin filtro de categoría (o todas)
|
||||
sql = @"
|
||||
SELECT l.*,
|
||||
(SELECT TOP 1 Url FROM ListingImages li WHERE li.ListingId = l.Id ORDER BY IsMainInfo DESC, DisplayOrder ASC) as MainImageUrl
|
||||
FROM Listings l
|
||||
WHERE l.Status = 'Published'";
|
||||
}
|
||||
|
||||
// Filtro de Texto
|
||||
// --- FILTROS EXISTENTES ---
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query)";
|
||||
sql += " AND (l.Title LIKE @Query OR l.Description LIKE @Query OR cl.DniOrCuit = @ExactQuery)";
|
||||
parameters.Add("Query", $"%{query}%");
|
||||
parameters.Add("ExactQuery", query);
|
||||
}
|
||||
|
||||
// Filtros de Atributos (Igual que antes)
|
||||
if (attributes != null && attributes.Any())
|
||||
if (categoryId.HasValue && categoryId.Value > 0)
|
||||
{
|
||||
int i = 0;
|
||||
foreach (var attr in attributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attr.Value)) continue;
|
||||
string paramName = $"@Val{i}";
|
||||
string paramKey = $"@Key{i}";
|
||||
sql += " AND l.CategoryId = @CategoryId";
|
||||
parameters.Add("CategoryId", categoryId);
|
||||
}
|
||||
|
||||
sql += $@" AND EXISTS (
|
||||
SELECT 1 FROM ListingAttributeValues lav
|
||||
JOIN AttributeDefinitions ad ON lav.AttributeDefinitionId = ad.Id
|
||||
WHERE lav.ListingId = l.Id
|
||||
AND ad.Name = {paramKey}
|
||||
AND lav.Value LIKE {paramName}
|
||||
)";
|
||||
if (from.HasValue)
|
||||
{
|
||||
sql += " AND l.CreatedAt >= @From";
|
||||
parameters.Add("From", from.Value.Date);
|
||||
}
|
||||
|
||||
parameters.Add($"Val{i}", $"%{attr.Value}%");
|
||||
parameters.Add($"Key{i}", attr.Key);
|
||||
i++;
|
||||
}
|
||||
if (to.HasValue)
|
||||
{
|
||||
sql += " AND l.CreatedAt <= @To";
|
||||
parameters.Add("To", to.Value.Date.AddDays(1).AddSeconds(-1));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(origin) && origin != "All")
|
||||
{
|
||||
sql += " AND l.Origin = @Origin";
|
||||
parameters.Add("Origin", origin);
|
||||
}
|
||||
|
||||
// --- FILTRO DE ESTADO ---
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
sql += " AND l.Status = @Status";
|
||||
parameters.Add("Status", status);
|
||||
}
|
||||
|
||||
sql += " ORDER BY l.CreatedAt DESC";
|
||||
@@ -317,11 +362,11 @@ public class ListingRepository : IListingRepository
|
||||
AND Status = 'Published'";
|
||||
|
||||
var kpis = await conn.QueryFirstOrDefaultAsync(kpisSql, new { StartDate = startDate.Date, EndDate = endDate.Date });
|
||||
stats.RevenueToday = kpis != null ? (decimal)kpis.RevenueToday : 0;
|
||||
stats.AdsToday = kpis != null ? (int)kpis.AdsToday : 0;
|
||||
stats.RevenueToday = kpis?.RevenueToday ?? 0;
|
||||
stats.AdsToday = kpis?.AdsToday ?? 0;
|
||||
stats.TicketAverage = stats.AdsToday > 0 ? stats.RevenueToday / stats.AdsToday : 0;
|
||||
|
||||
// 2. Ocupación (basada en el último día del rango)
|
||||
// 2. Occupation (based on the last day of the range)
|
||||
stats.PaperOccupation = Math.Min(100, (stats.AdsToday * 100.0) / 100.0);
|
||||
|
||||
// 3. Tendencia del periodo
|
||||
@@ -357,6 +402,142 @@ public class ListingRepository : IListingRepository
|
||||
return stats;
|
||||
}
|
||||
|
||||
public async Task<AdvancedAnalyticsDto> GetAdvancedAnalyticsAsync(DateTime startDate, DateTime endDate)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var analytics = new AdvancedAnalyticsDto();
|
||||
|
||||
// 1. Calcular Periodo Anterior para comparación
|
||||
var duration = endDate - startDate;
|
||||
var prevStart = startDate.Add(-duration);
|
||||
var prevEnd = startDate.AddSeconds(-1);
|
||||
|
||||
// 2. KPIs del Periodo Actual
|
||||
var currentKpiSql = @"
|
||||
SELECT
|
||||
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as Revenue,
|
||||
COUNT(Id) as Ads
|
||||
FROM Listings
|
||||
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND Status = 'Published'";
|
||||
|
||||
var currentKpis = await conn.QueryFirstOrDefaultAsync(currentKpiSql, new { Start = startDate.Date, End = endDate.Date });
|
||||
analytics.TotalRevenue = currentKpis?.Revenue ?? 0;
|
||||
analytics.TotalAds = currentKpis?.Ads ?? 0;
|
||||
|
||||
// 3. KPIs del Periodo Anterior
|
||||
var prevKpiSql = @"
|
||||
SELECT
|
||||
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as Revenue,
|
||||
COUNT(Id) as Ads
|
||||
FROM Listings
|
||||
WHERE CAST(CreatedAt AS DATE) BETWEEN @PrevStart AND @PrevEnd
|
||||
AND Status = 'Published'";
|
||||
|
||||
var prevKpis = await conn.QueryFirstOrDefaultAsync(prevKpiSql, new { PrevStart = prevStart.Date, PrevEnd = prevEnd.Date });
|
||||
analytics.PreviousPeriodRevenue = prevKpis?.Revenue ?? 0;
|
||||
analytics.PreviousPeriodAds = prevKpis?.Ads ?? 0;
|
||||
|
||||
// 4. Calcular Crecimiento
|
||||
if (analytics.PreviousPeriodRevenue > 0)
|
||||
analytics.RevenueGrowth = (double)((analytics.TotalRevenue - analytics.PreviousPeriodRevenue) / analytics.PreviousPeriodRevenue) * 100;
|
||||
|
||||
if (analytics.PreviousPeriodAds > 0)
|
||||
analytics.AdsGrowth = (double)(analytics.TotalAds - analytics.PreviousPeriodAds) / analytics.PreviousPeriodAds * 100;
|
||||
|
||||
// 5. Distribución de Pagos (Real)
|
||||
var paymentsSql = @"
|
||||
SELECT
|
||||
p.PaymentMethod as Method,
|
||||
SUM(p.Amount + p.Surcharge) as Total,
|
||||
COUNT(p.Id) as Count
|
||||
FROM Payments p
|
||||
INNER JOIN Listings l ON p.ListingId = l.Id
|
||||
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND l.Status = 'Published'
|
||||
GROUP BY p.PaymentMethod";
|
||||
|
||||
analytics.PaymentsDistribution = (await conn.QueryAsync<PaymentMethodStat>(paymentsSql, new { Start = startDate.Date, End = endDate.Date })).ToList();
|
||||
|
||||
// 6. Rendimiento por Categoría
|
||||
var categorySql = @"
|
||||
SELECT
|
||||
c.Name as CategoryName,
|
||||
SUM(l.AdFee) as Revenue,
|
||||
COUNT(l.Id) as AdsCount
|
||||
FROM Listings l
|
||||
JOIN Categories c ON l.CategoryId = c.Id
|
||||
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND l.Status = 'Published'
|
||||
GROUP BY c.Name
|
||||
ORDER BY Revenue DESC";
|
||||
|
||||
var catPerf = (await conn.QueryAsync<CategoryPerformanceStat>(categorySql, new { Start = startDate.Date, End = endDate.Date })).ToList();
|
||||
foreach (var cp in catPerf)
|
||||
{
|
||||
cp.Share = analytics.TotalRevenue > 0 ? (double)(cp.Revenue / analytics.TotalRevenue) * 100 : 0;
|
||||
}
|
||||
analytics.CategoryPerformance = catPerf;
|
||||
|
||||
// 7. Análisis Horario (Peak Hours)
|
||||
var hourlySql = @"
|
||||
SELECT
|
||||
DATEPART(HOUR, CreatedAt) as Hour,
|
||||
COUNT(Id) as Count
|
||||
FROM Listings
|
||||
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND Status = 'Published'
|
||||
GROUP BY DATEPART(HOUR, CreatedAt)
|
||||
ORDER BY Hour";
|
||||
|
||||
analytics.HourlyActivity = (await conn.QueryAsync<HourlyStat>(hourlySql, new { Start = startDate.Date, End = endDate.Date })).ToList();
|
||||
|
||||
// 8. Tendencia Diaria
|
||||
var dailySql = @"
|
||||
SELECT
|
||||
FORMAT(CreatedAt, 'dd/MM') as Day,
|
||||
SUM(AdFee) as Amount
|
||||
FROM Listings
|
||||
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND Status = 'Published'
|
||||
GROUP BY FORMAT(CreatedAt, 'dd/MM'), CAST(CreatedAt AS DATE)
|
||||
ORDER BY CAST(CreatedAt AS DATE) ASC";
|
||||
|
||||
analytics.DailyTrends = (await conn.QueryAsync<DailyRevenue>(dailySql, new { Start = startDate.Date, End = endDate.Date })).ToList();
|
||||
|
||||
var sourceSql = @"
|
||||
SELECT
|
||||
Origin,
|
||||
COUNT(Id) as Count
|
||||
FROM Listings
|
||||
WHERE CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND Status = 'Published'
|
||||
GROUP BY Origin";
|
||||
|
||||
var sources = await conn.QueryAsync<dynamic>(sourceSql, new { Start = startDate.Date, End = endDate.Date });
|
||||
|
||||
int total = 0;
|
||||
int web = 0;
|
||||
int mostrador = 0;
|
||||
|
||||
foreach (var s in sources)
|
||||
{
|
||||
if (s.Origin == "Web") web = (int)s.Count;
|
||||
if (s.Origin == "Mostrador") mostrador = (int)s.Count;
|
||||
total += (int)s.Count;
|
||||
}
|
||||
|
||||
analytics.SourceMix = new SourceMixDto
|
||||
{
|
||||
MostradorCount = mostrador,
|
||||
WebCount = web,
|
||||
MostradorPercent = total > 0 ? (mostrador * 100.0 / total) : 0,
|
||||
WebPercent = total > 0 ? (web * 100.0 / total) : 0
|
||||
};
|
||||
|
||||
return analytics;
|
||||
}
|
||||
|
||||
public async Task<CashierDashboardDto?> GetCashierStatsAsync(int userId, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
@@ -364,18 +545,12 @@ public class ListingRepository : IListingRepository
|
||||
// Filtramos tanto la recaudación como los pendientes por el rango seleccionado
|
||||
var sql = @"
|
||||
SELECT
|
||||
CAST(ISNULL(SUM(AdFee), 0) AS DECIMAL(18,2)) as MyRevenue,
|
||||
COUNT(Id) as MyAdsCount,
|
||||
(SELECT COUNT(1) FROM Listings
|
||||
WHERE UserId = @UserId AND Status = 'Pending'
|
||||
AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End) as MyPendingAds
|
||||
FROM Listings
|
||||
WHERE UserId = @UserId
|
||||
AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND Status = 'Published'";
|
||||
(SELECT ISNULL(SUM(Amount + Surcharge), 0) FROM Payments p INNER JOIN Listings l ON p.ListingId = l.Id
|
||||
WHERE l.UserId = @UserId AND CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End AND l.Status = 'Published') as MyRevenue,
|
||||
(SELECT COUNT(1) FROM Listings WHERE UserId = @UserId AND Status = 'Pending') as MyPendingAds,
|
||||
(SELECT COUNT(1) FROM Listings WHERE UserId = @UserId AND CAST(CreatedAt AS DATE) BETWEEN @Start AND @End AND Status = 'Published') as MyAdsCount";
|
||||
|
||||
return await conn.QueryFirstOrDefaultAsync<CashierDashboardDto>(sql,
|
||||
new { UserId = userId, Start = startDate.Date, End = endDate.Date });
|
||||
return await conn.QueryFirstOrDefaultAsync<CashierDashboardDto>(sql, new { UserId = userId, Start = startDate.Date, End = endDate.Date });
|
||||
}
|
||||
|
||||
public async Task<GlobalReportDto> GetDetailedReportAsync(DateTime start, DateTime end, int? userId = null)
|
||||
@@ -383,30 +558,83 @@ public class ListingRepository : IListingRepository
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var report = new GlobalReportDto { FromDate = start, ToDate = end };
|
||||
|
||||
// Filtro inteligente: Si @UserId es NULL, devuelve todo. Si no, filtra por ese usuario.
|
||||
var sql = @"
|
||||
SELECT
|
||||
l.Id, l.CreatedAt as Date, l.Title,
|
||||
c.Name as Category, u.Username as Cashier, l.AdFee as Amount
|
||||
FROM Listings l
|
||||
JOIN Categories c ON l.CategoryId = c.Id
|
||||
LEFT JOIN Users u ON l.UserId = u.Id
|
||||
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND l.Status = 'Published'
|
||||
AND (@UserId IS NULL OR l.UserId = @UserId) -- <--- FILTRO DINÁMICO
|
||||
ORDER BY l.CreatedAt ASC";
|
||||
|
||||
var items = await conn.QueryAsync<ReportItemDto>(sql, new
|
||||
{
|
||||
Start = start.Date,
|
||||
End = end.Date,
|
||||
UserId = userId // Dapper pasará null si el parámetro es null
|
||||
});
|
||||
SELECT
|
||||
l.Id, l.CreatedAt as Date, l.Title,
|
||||
c.Name as Category, u.Username as Cashier, l.AdFee as Amount,
|
||||
l.Origin as Source
|
||||
FROM Listings l
|
||||
JOIN Categories c ON l.CategoryId = c.Id
|
||||
LEFT JOIN Users u ON l.UserId = u.Id
|
||||
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND l.Status = 'Published'
|
||||
AND (@UserId IS NULL OR l.UserId = @UserId)
|
||||
ORDER BY l.CreatedAt ASC";
|
||||
|
||||
var items = await conn.QueryAsync<ReportItemDto>(sql, new { Start = start.Date, End = end.Date, UserId = userId });
|
||||
report.Items = items.ToList();
|
||||
report.TotalRevenue = report.Items.Sum(x => x.Amount);
|
||||
report.TotalAds = report.Items.Count;
|
||||
|
||||
// TOTALES FÍSICOS (Solo lo que entró por caja)
|
||||
var totalsSql = @"
|
||||
SELECT
|
||||
p.PaymentMethod,
|
||||
SUM(p.Amount + p.Surcharge) as Total
|
||||
FROM Payments p
|
||||
INNER JOIN Listings l ON p.ListingId = l.Id
|
||||
WHERE CAST(l.CreatedAt AS DATE) BETWEEN @Start AND @End
|
||||
AND l.Status = 'Published'
|
||||
AND l.Origin = 'Mostrador'
|
||||
AND (@UserId IS NULL OR l.UserId = @UserId)
|
||||
GROUP BY p.PaymentMethod";
|
||||
|
||||
var paymentTotals = await conn.QueryAsync<dynamic>(totalsSql, new { Start = start.Date, End = end.Date, UserId = userId });
|
||||
|
||||
// Reiniciar totales para el reporte
|
||||
report.TotalCash = 0; report.TotalDebit = 0; report.TotalCredit = 0; report.TotalTransfer = 0;
|
||||
|
||||
foreach (var p in paymentTotals)
|
||||
{
|
||||
string method = p.PaymentMethod;
|
||||
decimal total = (decimal)p.Total;
|
||||
switch (method)
|
||||
{
|
||||
case "Cash": report.TotalCash = total; break;
|
||||
case "Debit": report.TotalDebit = total; break;
|
||||
case "Credit": report.TotalCredit = total; break;
|
||||
case "Transfer": report.TotalTransfer = total; break;
|
||||
}
|
||||
}
|
||||
report.TotalRevenue = report.TotalCash + report.TotalDebit + report.TotalCredit + report.TotalTransfer;
|
||||
return report;
|
||||
}
|
||||
|
||||
public async Task AddPaymentAsync(Payment payment)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO Payments (ListingId, Amount, PaymentMethod, CardPlan, Surcharge, PaymentDate, ExternalReference, ExternalId, Status)
|
||||
VALUES (@ListingId, @Amount, @PaymentMethod, @CardPlan, @Surcharge, @PaymentDate, @ExternalReference, @ExternalId, @Status)";
|
||||
|
||||
await conn.ExecuteAsync(sql, payment);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<dynamic>> GetActiveCashiersAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// Traemos usuarios que tengan el rol adecuado para filtrar en el historial
|
||||
var sql = @"SELECT Id, Username FROM Users
|
||||
WHERE Role IN ('Cajero', 'Admin')
|
||||
AND IsActive = 1
|
||||
ORDER BY Username ASC";
|
||||
|
||||
return await conn.QueryAsync(sql);
|
||||
}
|
||||
|
||||
public async Task UpdateOverlayStatusAsync(int id, int userId, string? status)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
// Seguridad: Filtramos por Id del aviso Y Id del usuario dueño
|
||||
var sql = "UPDATE Listings SET OverlayStatus = @status WHERE Id = @id AND UserId = @userId";
|
||||
await conn.ExecuteAsync(sql, new { id, userId, status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class NotificationRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
public NotificationRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
public async Task<IEnumerable<Notification>> GetByUserAsync(int userId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QueryAsync<Notification>(
|
||||
"SELECT TOP 10 * FROM Notifications WHERE UserId = @userId ORDER BY CreatedAt DESC",
|
||||
new { userId });
|
||||
}
|
||||
|
||||
public async Task MarkAsReadAsync(int notificationId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
await conn.ExecuteAsync("UPDATE Notifications SET IsRead = 1 WHERE Id = @Id", new { Id = notificationId });
|
||||
}
|
||||
|
||||
public async Task<int> GetUnreadCountAsync(int userId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(1) FROM Notifications WHERE UserId = @userId AND IsRead = 0",
|
||||
new { userId });
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public class UserRepository : IUserRepository
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
// Busca usuario por nombre de usuario
|
||||
public async Task<User?> GetByUsernameAsync(string username)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
@@ -22,38 +23,64 @@ public class UserRepository : IUserRepository
|
||||
new { Username = username });
|
||||
}
|
||||
|
||||
// Busca usuario por correo electrónico
|
||||
public async Task<User?> GetByEmailAsync(string email)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<User>(
|
||||
"SELECT * FROM Users WHERE Email = @Email",
|
||||
new { Email = email });
|
||||
}
|
||||
|
||||
// Busca usuario por identificador único de Google
|
||||
public async Task<User?> GetByGoogleIdAsync(string googleId)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<User>(
|
||||
"SELECT * FROM Users WHERE GoogleId = @GoogleId",
|
||||
new { GoogleId = googleId });
|
||||
}
|
||||
|
||||
// Crea un nuevo usuario en el sistema
|
||||
public async Task<int> CreateAsync(User user)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO Users (Username, PasswordHash, Role, Email)
|
||||
VALUES (@Username, @PasswordHash, @Role, @Email);
|
||||
INSERT INTO Users (Username, PasswordHash, Role, Email, FailedLoginAttempts, LockoutEnd, MustChangePassword, IsActive, LastLogin, GoogleId, IsMfaEnabled, MfaSecret)
|
||||
VALUES (@Username, @PasswordHash, @Role, @Email, @FailedLoginAttempts, @LockoutEnd, @MustChangePassword, @IsActive, @LastLogin, @GoogleId, @IsMfaEnabled, @MfaSecret);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, user);
|
||||
}
|
||||
|
||||
// Obtiene todos los usuarios
|
||||
public async Task<IEnumerable<User>> GetAllAsync()
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QueryAsync<User>("SELECT Id, Username, Role, Email, CreatedAt, PasswordHash FROM Users");
|
||||
return await conn.QueryAsync<User>("SELECT * FROM Users");
|
||||
}
|
||||
|
||||
// Obtiene un usuario por su ID
|
||||
public async Task<User?> GetByIdAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<User>("SELECT * FROM Users WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
|
||||
// Actualiza los datos de un usuario existente
|
||||
public async Task UpdateAsync(User user)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
var sql = @"
|
||||
UPDATE Users
|
||||
SET Username = @Username, Role = @Role, Email = @Email, PasswordHash = @PasswordHash
|
||||
SET Username = @Username, Role = @Role, Email = @Email, PasswordHash = @PasswordHash,
|
||||
FailedLoginAttempts = @FailedLoginAttempts, LockoutEnd = @LockoutEnd,
|
||||
MustChangePassword = @MustChangePassword, IsActive = @IsActive, LastLogin = @LastLogin,
|
||||
GoogleId = @GoogleId, IsMfaEnabled = @IsMfaEnabled, MfaSecret = @MfaSecret
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, user);
|
||||
}
|
||||
|
||||
// Elimina un usuario por su ID
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
183
src/SIGCM.Infrastructure/Services/ImageOptimizationService.cs
Normal 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);
|
||||
}
|
||||
73
src/SIGCM.Infrastructure/Services/MercadoPagoService.cs
Normal 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 = "#"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||