diff --git a/Controllers/BillingController.cs b/Controllers/BillingController.cs index d0315fc..55d8aac 100644 --- a/Controllers/BillingController.cs +++ b/Controllers/BillingController.cs @@ -43,11 +43,20 @@ namespace line_gestao_api.Controllers var s = search.Trim(); var hasNumberSearch = TryParseSearchDecimal(s, out var searchNumber); var hasIntSearch = int.TryParse(new string(s.Where(char.IsDigit).ToArray()), out var searchInt); + var matchingClientsByLineOrChip = _db.MobileLines.AsNoTracking() + .Where(m => + EF.Functions.ILike(m.Linha ?? "", $"%{s}%") || + EF.Functions.ILike(m.Chip ?? "", $"%{s}%")) + .Where(m => m.Cliente != null && m.Cliente != "") + .Select(m => m.Cliente!) + .Distinct(); + q = q.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || EF.Functions.ILike(x.Tipo ?? "", $"%{s}%") || EF.Functions.ILike(x.Item.ToString(), $"%{s}%") || EF.Functions.ILike(x.Aparelho ?? "", $"%{s}%") || EF.Functions.ILike(x.FormaPagamento ?? "", $"%{s}%") + || (x.Cliente != null && matchingClientsByLineOrChip.Contains(x.Cliente)) || (hasIntSearch && (x.QtdLinhas ?? 0) == searchInt) || (hasNumberSearch && (((x.FranquiaVivo ?? 0m) == searchNumber) || diff --git a/Controllers/HistoricoController.cs b/Controllers/HistoricoController.cs index 101b793..4e37612 100644 --- a/Controllers/HistoricoController.cs +++ b/Controllers/HistoricoController.cs @@ -26,7 +26,7 @@ public class HistoricoController : ControllerBase [FromQuery] string? pageName, [FromQuery] string? action, [FromQuery] string? entity, - [FromQuery] Guid? userId, + [FromQuery] string? user, [FromQuery] string? search, [FromQuery] DateTime? dateFrom, [FromQuery] DateTime? dateTo, @@ -60,15 +60,17 @@ public class HistoricoController : ControllerBase q = q.Where(x => EF.Functions.ILike(x.EntityName, $"%{e}%")); } - if (userId.HasValue) + if (!string.IsNullOrWhiteSpace(user)) { - q = q.Where(x => x.UserId == userId.Value); + var u = user.Trim(); + q = q.Where(x => + EF.Functions.ILike(x.UserName ?? "", $"%{u}%") || + EF.Functions.ILike(x.UserEmail ?? "", $"%{u}%")); } if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim(); - var hasGuidSearch = Guid.TryParse(s, out var searchGuid); var hasDateSearch = TryParseSearchDateUtcStart(s, out var searchDateStartUtc); var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; q = q.Where(x => @@ -83,7 +85,6 @@ public class HistoricoController : ControllerBase EF.Functions.ILike(x.RequestMethod ?? "", $"%{s}%") || EF.Functions.ILike(x.IpAddress ?? "", $"%{s}%") || // ChangesJson is stored as jsonb; applying ILIKE directly causes PostgreSQL 42883. - (hasGuidSearch && (x.Id == searchGuid || x.UserId == searchGuid)) || (hasDateSearch && x.OccurredAtUtc >= searchDateStartUtc && x.OccurredAtUtc < searchDateEndUtc)); diff --git a/Controllers/LinesController.cs b/Controllers/LinesController.cs index 5ae8b27..fc3c89a 100644 --- a/Controllers/LinesController.cs +++ b/Controllers/LinesController.cs @@ -98,8 +98,8 @@ namespace line_gestao_api.Controllers reservaFilter = true; query = query.Where(x => EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || - (EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA") && - EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA"))); + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); } else query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); @@ -225,8 +225,8 @@ namespace line_gestao_api.Controllers reservaFilter = true; query = query.Where(x => EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || - (EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA") && - EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA"))); + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); } else query = query.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); @@ -461,8 +461,8 @@ namespace line_gestao_api.Controllers reservaFilter = true; q = q.Where(x => EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || - (EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA") && - EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA"))); + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); } else q = q.Where(x => EF.Functions.ILike(x.Skil ?? "", $"%{sSkil}%")); @@ -711,6 +711,13 @@ namespace line_gestao_api.Controllers if (exists) return Conflict(new { message = $"A linha {req.Linha} já está cadastrada no sistema." }); + if (!string.IsNullOrWhiteSpace(chipLimpo)) + { + var chipExists = await _db.MobileLines.AsNoTracking().AnyAsync(x => x.Chip == chipLimpo); + if (chipExists) + return Conflict(new { message = $"O Chip (ICCID) {req.Chip} já está cadastrado no sistema." }); + } + var maxItem = await _db.MobileLines.MaxAsync(x => (int?)x.Item) ?? 0; var nextItem = maxItem + 1; @@ -805,6 +812,12 @@ namespace line_gestao_api.Controllers .Distinct(StringComparer.Ordinal) .ToList(); + var requestedChips = requests + .Select(x => OnlyDigits(x?.Chip)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.Ordinal) + .ToList(); + var existingLinhas = requestedLinhas.Count == 0 ? new HashSet(StringComparer.Ordinal) : (await _db.MobileLines.AsNoTracking() @@ -813,12 +826,21 @@ namespace line_gestao_api.Controllers .ToListAsync()) .ToHashSet(StringComparer.Ordinal); + var existingChips = requestedChips.Count == 0 + ? new HashSet(StringComparer.Ordinal) + : (await _db.MobileLines.AsNoTracking() + .Where(x => x.Chip != null && requestedChips.Contains(x.Chip)) + .Select(x => x.Chip!) + .ToListAsync()) + .ToHashSet(StringComparer.Ordinal); + await using var tx = await _db.Database.BeginTransactionAsync(); try { var nextItem = (await _db.MobileLines.MaxAsync(x => (int?)x.Item) ?? 0); var seenBatchLinhas = new HashSet(StringComparer.Ordinal); + var seenBatchChips = new HashSet(StringComparer.Ordinal); var createdLines = new List<(MobileLine line, VigenciaLine? vigencia)>(requests.Count); for (var i = 0; i < requests.Count; i++) @@ -853,6 +875,15 @@ namespace line_gestao_api.Controllers if (existingLinhas.Contains(linhaLimpa)) return Conflict(new { message = $"A linha {entry.Linha} já está cadastrada no sistema (registro #{lineNo})." }); + if (!string.IsNullOrWhiteSpace(chipLimpo)) + { + if (!seenBatchChips.Add(chipLimpo)) + return Conflict(new { message = $"O Chip (ICCID) {entry.Chip} está duplicado dentro do lote (registro #{lineNo})." }); + + if (existingChips.Contains(chipLimpo)) + return Conflict(new { message = $"O Chip (ICCID) {entry.Chip} já está cadastrado no sistema (registro #{lineNo})." }); + } + nextItem++; var planSuggestion = await AutoFillRules.ResolvePlanSuggestionAsync(_db, entry.PlanoContrato); @@ -941,6 +972,281 @@ namespace line_gestao_api.Controllers } } + // ========================================================== + // ✅ 5.2. PREVIEW IMPORTAÇÃO EXCEL PARA LOTE (ABA GERAL) + // ========================================================== + [HttpPost("batch/import-preview")] + [Authorize(Roles = "admin,gestor")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(20_000_000)] + public async Task> PreviewBatchImportExcel([FromForm] ImportExcelForm form) + { + var tenantId = _tenantProvider.TenantId; + if (!tenantId.HasValue || tenantId.Value == Guid.Empty) + return Unauthorized("Tenant inválido."); + + var file = form.File; + if (file == null || file.Length == 0) + return BadRequest(new { message = "Arquivo inválido." }); + + try + { + using var stream = file.OpenReadStream(); + using var wb = new XLWorkbook(stream); + var preview = await BuildLinesBatchExcelPreviewAsync(wb, file.FileName); + return Ok(preview); + } + catch (Exception ex) + { + return BadRequest(new { message = $"Falha ao ler planilha: {ex.Message}" }); + } + } + + // ========================================================== + // ✅ 5.3. ATRIBUIR LINHAS DA RESERVA PARA CLIENTE + // ========================================================== + [HttpPost("reserva/assign-client")] + [Authorize(Roles = "admin,gestor")] + public async Task> AssignReservaLinesToClient([FromBody] AssignReservaLinesRequestDto req) + { + var ids = (req?.LineIds ?? new List()) + .Where(x => x != Guid.Empty) + .Distinct() + .ToList(); + + if (ids.Count == 0) + return BadRequest(new { message = "Selecione ao menos uma linha da Reserva." }); + + if (ids.Count > 1000) + return BadRequest(new { message = "Limite de 1000 linhas por atribuição em lote." }); + + var clienteDestino = (req?.ClienteDestino ?? "").Trim(); + if (string.IsNullOrWhiteSpace(clienteDestino)) + return BadRequest(new { message = "Informe o cliente de destino." }); + + if (IsReservaValue(clienteDestino)) + return BadRequest(new { message = "O cliente de destino não pode ser RESERVA." }); + + var usuarioDestino = string.IsNullOrWhiteSpace(req?.UsuarioDestino) ? null : req!.UsuarioDestino!.Trim(); + var skilDestinoSolicitado = string.IsNullOrWhiteSpace(req?.SkilDestino) ? null : req!.SkilDestino!.Trim(); + var clienteDestinoUpper = clienteDestino.ToUpperInvariant(); + + var linhas = await _db.MobileLines + .Where(x => ids.Contains(x.Id)) + .OrderBy(x => x.Item) + .ToListAsync(); + + var byId = linhas.ToDictionary(x => x.Id, x => x); + + var inferSkilDestino = await _db.MobileLines.AsNoTracking() + .Where(x => x.Cliente != null && EF.Functions.ILike(x.Cliente, clienteDestino)) + .Where(x => x.Skil != null && x.Skil != "" && !EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA")) + .OrderByDescending(x => x.UpdatedAt) + .Select(x => x.Skil) + .FirstOrDefaultAsync(); + + var result = new AssignReservaLinesResultDto + { + Requested = ids.Count + }; + + foreach (var id in ids) + { + if (!byId.TryGetValue(id, out var line)) + { + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = id, + Success = false, + Message = "Linha não encontrada." + }); + result.Failed++; + continue; + } + + if (!IsReservaLineForTransfer(line)) + { + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = line.Id, + Item = line.Item, + Linha = line.Linha, + Chip = line.Chip, + ClienteAnterior = line.Cliente, + ClienteNovo = line.Cliente, + Success = false, + Message = "A linha não está apta para transferência (não está na Reserva)." + }); + result.Failed++; + continue; + } + + var clienteAnterior = line.Cliente; + line.Cliente = clienteDestinoUpper; + + if (IsReservaValue(line.Usuario)) + { + line.Usuario = usuarioDestino; + } + else if (!string.IsNullOrWhiteSpace(usuarioDestino)) + { + line.Usuario = usuarioDestino; + } + + var skilDestinoEfetivo = skilDestinoSolicitado; + if (string.IsNullOrWhiteSpace(skilDestinoEfetivo) && IsReservaValue(line.Skil)) + { + skilDestinoEfetivo = inferSkilDestino; + } + + if (!string.IsNullOrWhiteSpace(skilDestinoEfetivo)) + { + line.Skil = skilDestinoEfetivo; + } + else if (IsReservaValue(line.Skil)) + { + line.Skil = null; + } + + line.UpdatedAt = DateTime.UtcNow; + + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = line.Id, + Item = line.Item, + Linha = line.Linha, + Chip = line.Chip, + ClienteAnterior = clienteAnterior, + ClienteNovo = line.Cliente, + Success = true, + Message = "Linha atribuída com sucesso." + }); + result.Updated++; + } + + if (result.Updated <= 0) + { + return Ok(result); + } + + try + { + await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); + result.Failed = result.Requested - result.Updated; + return Ok(result); + } + catch (DbUpdateException) + { + return StatusCode(500, new { message = "Erro ao atribuir linhas da Reserva." }); + } + } + + // ========================================================== + // ✅ 5.4. MOVER LINHAS DE CLIENTE PARA RESERVA + // ========================================================== + [HttpPost("move-to-reserva")] + [Authorize(Roles = "admin,gestor")] + public async Task> MoveLinesToReserva([FromBody] MoveLinesToReservaRequestDto req) + { + var ids = (req?.LineIds ?? new List()) + .Where(x => x != Guid.Empty) + .Distinct() + .ToList(); + + if (ids.Count == 0) + return BadRequest(new { message = "Selecione ao menos uma linha para enviar à Reserva." }); + + if (ids.Count > 1000) + return BadRequest(new { message = "Limite de 1000 linhas por movimentação em lote." }); + + var linhas = await _db.MobileLines + .Where(x => ids.Contains(x.Id)) + .OrderBy(x => x.Item) + .ToListAsync(); + + var byId = linhas.ToDictionary(x => x.Id, x => x); + var result = new AssignReservaLinesResultDto + { + Requested = ids.Count + }; + + foreach (var id in ids) + { + if (!byId.TryGetValue(id, out var line)) + { + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = id, + Success = false, + Message = "Linha não encontrada." + }); + result.Failed++; + continue; + } + + if (IsReservaLineForTransfer(line)) + { + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = line.Id, + Item = line.Item, + Linha = line.Linha, + Chip = line.Chip, + ClienteAnterior = line.Cliente, + ClienteNovo = line.Cliente, + Success = false, + Message = "A linha já está disponível na Reserva." + }); + result.Failed++; + continue; + } + + var clienteAnterior = line.Cliente; + var clienteOrigemNormalizado = string.IsNullOrWhiteSpace(clienteAnterior) + ? null + : clienteAnterior.Trim(); + + // Mantém o cliente de origem para que o agrupamento no filtro "Reserva" + // continue exibindo o cliente correto após o envio. + line.Cliente = string.IsNullOrWhiteSpace(clienteOrigemNormalizado) + ? "RESERVA" + : clienteOrigemNormalizado; + line.Usuario = "RESERVA"; + line.Skil = "RESERVA"; + ApplyReservaRule(line); + line.UpdatedAt = DateTime.UtcNow; + + result.Items.Add(new AssignReservaLineItemResultDto + { + Id = line.Id, + Item = line.Item, + Linha = line.Linha, + Chip = line.Chip, + ClienteAnterior = clienteAnterior, + ClienteNovo = "RESERVA", + Success = true, + Message = "Linha enviada para a Reserva com sucesso." + }); + result.Updated++; + } + + if (result.Updated <= 0) + return Ok(result); + + try + { + await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); + result.Failed = result.Requested - result.Updated; + return Ok(result); + } + catch (DbUpdateException) + { + return StatusCode(500, new { message = "Erro ao mover linhas para a Reserva." }); + } + } + // ========================================================== // ✅ 6. UPDATE // ========================================================== @@ -951,6 +1257,7 @@ namespace line_gestao_api.Controllers var x = await _db.MobileLines.FirstOrDefaultAsync(a => a.Id == id); if (x == null) return NotFound(); var previousLinha = x.Linha; + var previousCliente = x.Cliente; var newLinha = OnlyDigits(req.Linha); if (!string.IsNullOrWhiteSpace(newLinha) && !string.Equals((x.Linha ?? ""), newLinha, StringComparison.Ordinal)) @@ -994,6 +1301,26 @@ namespace line_gestao_api.Controllers x.VencConta = req.VencConta?.Trim(); x.TipoDeChip = req.TipoDeChip?.Trim(); + var previousClienteNormalized = string.IsNullOrWhiteSpace(previousCliente) ? null : previousCliente.Trim(); + var clienteAtualIsReserva = IsReservaValue(x.Cliente); + var usuarioAtualIsReserva = IsReservaValue(x.Usuario); + var skilAtualIsReserva = IsReservaValue(x.Skil); + var reserveContextRequested = clienteAtualIsReserva || usuarioAtualIsReserva || skilAtualIsReserva; + + // Quando uma linha é enviada para Reserva por edição, preserva o cliente de origem para + // o agrupamento no filtro "Reservas" (desde que o cliente anterior não fosse RESERVA). + if (reserveContextRequested && (string.IsNullOrWhiteSpace(x.Cliente) || clienteAtualIsReserva)) + { + if (!string.IsNullOrWhiteSpace(previousClienteNormalized) && !IsReservaValue(previousClienteNormalized)) + { + x.Cliente = previousClienteNormalized; + } + else if (string.IsNullOrWhiteSpace(x.Cliente)) + { + x.Cliente = "RESERVA"; + } + } + ApplyReservaRule(x); await UpsertVigenciaFromMobileLineAsync( @@ -3331,6 +3658,385 @@ namespace line_gestao_api.Controllers return hasItem && hasNumeroChip && hasObs; } + private async Task BuildLinesBatchExcelPreviewAsync(XLWorkbook wb, string? fileName) + { + var result = new LinesBatchExcelPreviewResultDto + { + FileName = fileName + }; + + static LinesBatchExcelIssueDto Issue(string? column, string message) => new() + { + Column = column, + Message = message + }; + + static string? NullIfWhite(string? v) + => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); + + static bool LooksLikeBatchGeralHeader(IXLRow row) + { + if (row == null) return false; + + var map = BuildHeaderMap(row); + if (map.Count == 0) return false; + + var hits = 0; + if (GetColAny(map, "CONTA") > 0) hits++; + if (GetColAny(map, "LINHA") > 0) hits++; + if (GetColAny(map, "CHIP") > 0) hits++; + if (GetColAny(map, "CLIENTE") > 0) hits++; + if (GetColAny(map, "USUARIO") > 0) hits++; + if (GetColAny(map, "PLANO CONTRATO") > 0) hits++; + if (GetColAny(map, "STATUS") > 0) hits++; + if (GetColAny(map, "TIPO DE CHIP", "TIPO CHIP") > 0) hits++; + + // Assinatura mínima para reconhecer uma planilha nova com cabeçalhos da GERAL + return hits >= 6; + } + + var ws = wb.Worksheets.FirstOrDefault(w => w.Name.Trim().Equals("GERAL", StringComparison.OrdinalIgnoreCase)); + IXLRow? headerRow = null; + var usedFallbackSheet = false; + + if (ws != null) + { + headerRow = ws.RowsUsed() + .Take(100) + .FirstOrDefault(LooksLikeBatchGeralHeader); + } + + if (ws == null || headerRow == null || !LooksLikeBatchGeralHeader(headerRow)) + { + foreach (var candidateWs in wb.Worksheets) + { + var candidateHeader = candidateWs.RowsUsed() + .Take(100) + .FirstOrDefault(LooksLikeBatchGeralHeader); + + if (candidateHeader == null) continue; + + ws = candidateWs; + headerRow = candidateHeader; + usedFallbackSheet = true; + break; + } + } + + if (ws == null || headerRow == null) + { + result.HeaderErrors.Add(Issue( + "ABA/CABEÇALHO", + "Nenhuma aba compatível encontrada. A planilha deve conter os cabeçalhos da GERAL (ex.: CONTA, LINHA, CHIP, CLIENTE...).")); + result.CanProceed = false; + return result; + } + + result.SheetName = ws.Name; + + if (usedFallbackSheet && !ws.Name.Trim().Equals("GERAL", StringComparison.OrdinalIgnoreCase)) + { + result.HeaderWarnings.Add(Issue( + "ABA", + $"Aba 'GERAL' não foi encontrada. Usando a aba '{ws.Name}' por conter cabeçalhos compatíveis.")); + } + + var map = BuildHeaderMap(headerRow); + + var expectedHeaders = new List<(string Display, bool Required, string[] Aliases)> + { + ("CONTA", true, new[] { "CONTA" }), + ("LINHA", true, new[] { "LINHA" }), + ("CHIP", true, new[] { "CHIP" }), + ("CLIENTE", true, new[] { "CLIENTE" }), + ("USUÁRIO", true, new[] { "USUARIO" }), + ("PLANO CONTRATO", true, new[] { "PLANO CONTRATO" }), + ("FRAQUIA", true, new[] { "FRAQUIA", "FRANQUIA", "FRANQUIA VIVO", "FRAQUIA VIVO" }), + ("VALOR DO PLANO R$", true, new[] { "VALOR DO PLANO R$", "VALOR DO PLANO", "VALORPLANO" }), + ("GESTÃO VOZ E DADOS R$", true, new[] { "GESTAO VOZ E DADOS R$", "GESTAO VOZ E DADOS", "GESTAOVOZEDADOS" }), + ("SKEELO", true, new[] { "SKEELO" }), + ("VIVO NEWS PLUS", true, new[] { "VIVO NEWS PLUS" }), + ("VIVO TRAVEL MUNDO", true, new[] { "VIVO TRAVEL MUNDO" }), + ("VIVO SYNC", true, new[] { "VIVO SYNC" }), + ("VIVO GESTÃO DISPOSITIVO", true, new[] { "VIVO GESTAO DISPOSITIVO" }), + ("VALOR CONTRATO VIVO", true, new[] { "VALOR CONTRATO VIVO", "VALOR DO CONTRATO VIVO" }), + ("FRANQUIA LINE", true, new[] { "FRANQUIA LINE", "FRAQUIA LINE" }), + ("FRANQUIA GESTÃO", true, new[] { "FRANQUIA GESTAO", "FRAQUIA GESTAO" }), + ("LOCAÇÃO AP.", true, new[] { "LOCACAO AP.", "LOCACAO AP", "LOCACAOAP" }), + ("VALOR CONTRATO LINE", true, new[] { "VALOR CONTRATO LINE", "VALOR DO CONTRATO LINE" }), + ("DESCONTO", true, new[] { "DESCONTO" }), + ("LUCRO", true, new[] { "LUCRO" }), + ("STATUS", true, new[] { "STATUS" }), + ("DATA DO BLOQUEIO", true, new[] { "DATA DO BLOQUEIO" }), + ("SKIL", true, new[] { "SKIL" }), + ("MODALIDADE", true, new[] { "MODALIDADE" }), + ("CEDENTE", true, new[] { "CEDENTE" }), + ("SOLICITANTE", true, new[] { "SOLICITANTE" }), + ("DATA DA ENTREGA OPERA.", true, new[] { "DATA DA ENTREGA OPERA." }), + ("DATA DA ENTREGA CLIENTE", true, new[] { "DATA DA ENTREGA CLIENTE" }), + ("VENC. DA CONTA", true, new[] { "VENC. DA CONTA", "VENC DA CONTA", "VENCIMENTO DA CONTA" }), + ("TIPO DE CHIP", true, new[] { "TIPO DE CHIP", "TIPO CHIP" }) + }; + + foreach (var spec in expectedHeaders) + { + if (GetColAny(map, spec.Aliases) > 0) continue; + var issue = Issue(spec.Display, $"Coluna obrigatória ausente: '{spec.Display}'."); + if (spec.Required) result.HeaderErrors.Add(issue); + else result.HeaderWarnings.Add(issue); + } + + var colDtEfetivacao = GetColAny(map, + "DT EFETIVACAO SERVICO", + "DT. EFETIVACAO SERVICO", + "DT. EFETIVAÇÃO SERVIÇO", + "DT EFETIVAÇÃO SERVIÇO"); + var colDtTermino = GetColAny(map, + "DT TERMINO FIDELIZACAO", + "DT. TERMINO FIDELIZACAO", + "DT. TÉRMINO FIDELIZAÇÃO", + "DT TÉRMINO FIDELIZAÇÃO"); + + if (colDtEfetivacao == 0 || colDtTermino == 0) + { + result.HeaderWarnings.Add(Issue( + "DT. EFETIVAÇÃO / DT. TÉRMINO", + "As colunas de vigência não foram encontradas na aba GERAL. Elas continuam obrigatórias para salvar o lote e podem ser preenchidas no modal (detalhes/parâmetros padrão).")); + } + + var expectedCols = expectedHeaders + .Select(x => GetColAny(map, x.Aliases)) + .Where(c => c > 0) + .Distinct() + .ToList(); + if (colDtEfetivacao > 0) expectedCols.Add(colDtEfetivacao); + if (colDtTermino > 0) expectedCols.Add(colDtTermino); + + var startRow = headerRow.RowNumber() + 1; + var lastRow = ws.LastRowUsed()?.RowNumber() ?? startRow; + var rows = new List(); + + for (var r = startRow; r <= lastRow; r++) + { + var rowHasAnyValue = expectedCols.Any(c => !string.IsNullOrWhiteSpace(GetCellString(ws, r, c))); + if (!rowHasAnyValue) + { + if (rows.Count > 0) break; + continue; + } + + var rowDto = new LinesBatchExcelPreviewRowDto + { + SourceRowNumber = r, + Data = new CreateMobileLineDto { Item = 0 } + }; + + void AddRowError(string? column, string message) => rowDto.Errors.Add(Issue(column, message)); + void AddRowWarning(string? column, string message) => rowDto.Warnings.Add(Issue(column, message)); + + string CellBy(params string[] headers) => GetCellByHeaderAny(ws, r, map, headers); + + decimal? ParseDecimalField(string columnDisplay, params string[] headers) + { + var raw = CellBy(headers); + if (string.IsNullOrWhiteSpace(raw)) return null; + var parsed = TryDecimal(raw); + if (!parsed.HasValue) + AddRowError(columnDisplay, $"Valor numérico inválido: '{raw}'."); + return parsed; + } + + DateTime? ParseDateField(string columnDisplay, params string[] headers) + { + var col = GetColAny(map, headers); + if (col <= 0) return null; + var raw = GetCellString(ws, r, col); + if (string.IsNullOrWhiteSpace(raw) && ws.Cell(r, col).IsEmpty()) return null; + var parsed = TryDateCell(ws, r, col); + if (!parsed.HasValue && !string.IsNullOrWhiteSpace(raw)) + AddRowError(columnDisplay, $"Data inválida: '{raw}'."); + return parsed; + } + + var itemRaw = CellBy("ITEM"); + if (!string.IsNullOrWhiteSpace(itemRaw)) + { + var parsedItem = TryNullableInt(itemRaw); + rowDto.SourceItem = parsedItem; + AddRowWarning("ITÉM", "Valor informado será ignorado. O sistema gera a sequência automaticamente."); + } + + var linhaRaw = CellBy("LINHA"); + var linhaDigits = OnlyDigits(linhaRaw); + if (string.IsNullOrWhiteSpace(linhaRaw)) + AddRowError("LINHA", "Campo obrigatório."); + else if (string.IsNullOrWhiteSpace(linhaDigits)) + AddRowError("LINHA", $"Valor inválido: '{linhaRaw}'."); + rowDto.Data.Linha = string.IsNullOrWhiteSpace(linhaDigits) ? NullIfWhite(linhaRaw) : linhaDigits; + + var chipRaw = CellBy("CHIP"); + var chipDigits = OnlyDigits(chipRaw); + if (string.IsNullOrWhiteSpace(chipRaw)) + AddRowError("CHIP", "Campo obrigatório."); + else if (string.IsNullOrWhiteSpace(chipDigits)) + AddRowError("CHIP", $"Valor inválido: '{chipRaw}'."); + rowDto.Data.Chip = string.IsNullOrWhiteSpace(chipDigits) ? NullIfWhite(chipRaw) : chipDigits; + + rowDto.Data.Conta = NullIfWhite(CellBy("CONTA")); + if (string.IsNullOrWhiteSpace(rowDto.Data.Conta)) + AddRowError("CONTA", "Campo obrigatório."); + + rowDto.Data.Cliente = NullIfWhite(CellBy("CLIENTE")); + rowDto.Data.Usuario = NullIfWhite(CellBy("USUARIO")); + rowDto.Data.PlanoContrato = NullIfWhite(CellBy("PLANO CONTRATO")); + if (string.IsNullOrWhiteSpace(rowDto.Data.PlanoContrato)) + AddRowError("PLANO CONTRATO", "Campo obrigatório."); + + rowDto.Data.Status = NullIfWhite(CellBy("STATUS")); + if (string.IsNullOrWhiteSpace(rowDto.Data.Status)) + AddRowError("STATUS", "Campo obrigatório."); + + rowDto.Data.Skil = NullIfWhite(CellBy("SKIL")); + rowDto.Data.Modalidade = NullIfWhite(CellBy("MODALIDADE")); + rowDto.Data.Cedente = NullIfWhite(CellBy("CEDENTE")); + rowDto.Data.Solicitante = NullIfWhite(CellBy("SOLICITANTE")); + rowDto.Data.VencConta = NullIfWhite(CellBy("VENC. DA CONTA", "VENC DA CONTA", "VENCIMENTO DA CONTA")); + rowDto.Data.TipoDeChip = NullIfWhite(CellBy("TIPO DE CHIP", "TIPO CHIP")); + + rowDto.Data.FranquiaVivo = ParseDecimalField("FRAQUIA", "FRAQUIA", "FRANQUIA", "FRANQUIA VIVO", "FRAQUIA VIVO"); + rowDto.Data.ValorPlanoVivo = ParseDecimalField("VALOR DO PLANO R$", "VALOR DO PLANO R$", "VALOR DO PLANO", "VALORPLANO"); + rowDto.Data.GestaoVozDados = ParseDecimalField("GESTÃO VOZ E DADOS R$", "GESTAO VOZ E DADOS R$", "GESTAO VOZ E DADOS", "GESTAOVOZEDADOS"); + rowDto.Data.Skeelo = ParseDecimalField("SKEELO", "SKEELO"); + rowDto.Data.VivoNewsPlus = ParseDecimalField("VIVO NEWS PLUS", "VIVO NEWS PLUS"); + rowDto.Data.VivoTravelMundo = ParseDecimalField("VIVO TRAVEL MUNDO", "VIVO TRAVEL MUNDO"); + rowDto.Data.VivoSync = ParseDecimalField("VIVO SYNC", "VIVO SYNC"); + rowDto.Data.VivoGestaoDispositivo = ParseDecimalField("VIVO GESTÃO DISPOSITIVO", "VIVO GESTAO DISPOSITIVO"); + rowDto.Data.ValorContratoVivo = ParseDecimalField("VALOR CONTRATO VIVO", "VALOR CONTRATO VIVO", "VALOR DO CONTRATO VIVO"); + rowDto.Data.FranquiaLine = ParseDecimalField("FRANQUIA LINE", "FRANQUIA LINE", "FRAQUIA LINE"); + rowDto.Data.FranquiaGestao = ParseDecimalField("FRANQUIA GESTÃO", "FRANQUIA GESTAO", "FRAQUIA GESTAO"); + rowDto.Data.LocacaoAp = ParseDecimalField("LOCAÇÃO AP.", "LOCACAO AP.", "LOCACAO AP", "LOCACAOAP"); + rowDto.Data.ValorContratoLine = ParseDecimalField("VALOR CONTRATO LINE", "VALOR CONTRATO LINE", "VALOR DO CONTRATO LINE"); + rowDto.Data.Desconto = ParseDecimalField("DESCONTO", "DESCONTO"); + rowDto.Data.Lucro = ParseDecimalField("LUCRO", "LUCRO"); + + rowDto.Data.DataBloqueio = ParseDateField("DATA DO BLOQUEIO", "DATA DO BLOQUEIO"); + rowDto.Data.DataEntregaOpera = ParseDateField("DATA DA ENTREGA OPERA.", "DATA DA ENTREGA OPERA."); + rowDto.Data.DataEntregaCliente = ParseDateField("DATA DA ENTREGA CLIENTE", "DATA DA ENTREGA CLIENTE"); + rowDto.Data.DtEfetivacaoServico = colDtEfetivacao > 0 ? ParseDateField("DT. EFETIVAÇÃO SERVIÇO", + "DT EFETIVACAO SERVICO", "DT. EFETIVACAO SERVICO", "DT. EFETIVAÇÃO SERVIÇO", "DT EFETIVAÇÃO SERVIÇO") : null; + rowDto.Data.DtTerminoFidelizacao = colDtTermino > 0 ? ParseDateField("DT. TÉRMINO FIDELIZAÇÃO", + "DT TERMINO FIDELIZACAO", "DT. TERMINO FIDELIZACAO", "DT. TÉRMINO FIDELIZAÇÃO", "DT TÉRMINO FIDELIZAÇÃO") : null; + + if (colDtEfetivacao == 0 || !rowDto.Data.DtEfetivacaoServico.HasValue) + AddRowWarning("DT. EFETIVAÇÃO SERVIÇO", "Campo não preenchido pela aba GERAL; complete no modal antes de salvar."); + if (colDtTermino == 0 || !rowDto.Data.DtTerminoFidelizacao.HasValue) + AddRowWarning("DT. TÉRMINO FIDELIZAÇÃO", "Campo não preenchido pela aba GERAL; complete no modal antes de salvar."); + + rows.Add(rowDto); + } + + var linhasArquivo = rows + .Where(x => !string.IsNullOrWhiteSpace(x.Data.Linha)) + .GroupBy(x => x.Data.Linha!, StringComparer.Ordinal) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(StringComparer.Ordinal); + + var chipsArquivo = rows + .Where(x => !string.IsNullOrWhiteSpace(x.Data.Chip)) + .GroupBy(x => x.Data.Chip!, StringComparer.Ordinal) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(StringComparer.Ordinal); + + var linhasParaConsultar = rows + .Where(x => !string.IsNullOrWhiteSpace(x.Data.Linha)) + .Select(x => x.Data.Linha!) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var chipsParaConsultar = rows + .Where(x => !string.IsNullOrWhiteSpace(x.Data.Chip)) + .Select(x => x.Data.Chip!) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var linhasExistentes = linhasParaConsultar.Count == 0 + ? new HashSet(StringComparer.Ordinal) + : (await _db.MobileLines.AsNoTracking() + .Where(x => x.Linha != null && linhasParaConsultar.Contains(x.Linha)) + .Select(x => x.Linha!) + .ToListAsync()) + .ToHashSet(StringComparer.Ordinal); + + var chipsExistentes = chipsParaConsultar.Count == 0 + ? new HashSet(StringComparer.Ordinal) + : (await _db.MobileLines.AsNoTracking() + .Where(x => x.Chip != null && chipsParaConsultar.Contains(x.Chip)) + .Select(x => x.Chip!) + .ToListAsync()) + .ToHashSet(StringComparer.Ordinal); + + var maxItem = await _db.MobileLines.AsNoTracking().MaxAsync(x => (int?)x.Item) ?? 0; + result.NextItemStart = maxItem + 1; + var previewNextItem = maxItem; + + foreach (var row in rows) + { + var linha = row.Data.Linha; + var chip = row.Data.Chip; + + if (!string.IsNullOrWhiteSpace(linha) && linhasArquivo.Contains(linha)) + { + row.DuplicateLinhaInFile = true; + row.Errors.Add(Issue("LINHA", $"Linha duplicada dentro da planilha: {linha}.")); + } + + if (!string.IsNullOrWhiteSpace(chip) && chipsArquivo.Contains(chip)) + { + row.DuplicateChipInFile = true; + row.Errors.Add(Issue("CHIP", $"Chip (ICCID) duplicado dentro da planilha: {chip}.")); + } + + if (!string.IsNullOrWhiteSpace(linha) && linhasExistentes.Contains(linha)) + { + row.DuplicateLinhaInSystem = true; + row.Errors.Add(Issue("LINHA", $"Linha já cadastrada no sistema: {linha}.")); + } + + if (!string.IsNullOrWhiteSpace(chip) && chipsExistentes.Contains(chip)) + { + row.DuplicateChipInSystem = true; + row.Errors.Add(Issue("CHIP", $"Chip (ICCID) já cadastrado no sistema: {chip}.")); + } + + row.Valid = row.Errors.Count == 0; + if (row.Valid) + { + previewNextItem++; + row.GeneratedItemPreview = previewNextItem; + } + } + + result.Rows = rows; + result.TotalRows = rows.Count; + result.ValidRows = rows.Count(x => x.Valid); + result.InvalidRows = rows.Count(x => !x.Valid); + result.DuplicateRows = rows.Count(x => + x.DuplicateLinhaInFile || x.DuplicateChipInFile || x.DuplicateLinhaInSystem || x.DuplicateChipInSystem); + result.CanProceed = result.HeaderErrors.Count == 0 && result.ValidRows > 0; + + return result; + } + + private static bool IsReservaLineForTransfer(MobileLine line) + { + if (line == null) return false; + return IsReservaValue(line.Usuario) + || IsReservaValue(line.Skil) + || IsReservaValue(line.Cliente); + } + // ========================================================== // HELPERS (SEUS) // ========================================================== diff --git a/Controllers/NotificationsController.cs b/Controllers/NotificationsController.cs index a9f2317..adffa76 100644 --- a/Controllers/NotificationsController.cs +++ b/Controllers/NotificationsController.cs @@ -45,7 +45,9 @@ public class NotificationsController : ControllerBase DiasParaVencer = notification.DiasParaVencer, Lida = notification.Lida, LidaEm = notification.LidaEm, - VigenciaLineId = notification.VigenciaLineId, + VigenciaLineId = notification.VigenciaLineId + ?? (vigencia != null ? (Guid?)vigencia.Id : null) + ?? (vigenciaByLinha != null ? (Guid?)vigenciaByLinha.Id : null), Cliente = notification.Cliente ?? (vigencia != null ? vigencia.Cliente : null) ?? (vigenciaByLinha != null ? vigenciaByLinha.Cliente : null), diff --git a/Controllers/RelatoriosController.cs b/Controllers/RelatoriosController.cs index bf4827e..f17c415 100644 --- a/Controllers/RelatoriosController.cs +++ b/Controllers/RelatoriosController.cs @@ -32,6 +32,7 @@ namespace line_gestao_api.Controllers // GERAL (MobileLines) // ========================= var qLines = _db.MobileLines.AsNoTracking(); + var qLinesWithClient = qLines.Where(x => x.Cliente != null && x.Cliente != ""); var totalLinhas = await qLines.CountAsync(); @@ -44,27 +45,35 @@ namespace line_gestao_api.Controllers var ativos = await qLines.CountAsync(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%ativo%")); - var bloqueadosPerdaRoubo = await qLines.CountAsync(x => + var bloqueadosPerdaRoubo = await qLinesWithClient.CountAsync(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%")); - var bloqueados120Dias = await qLines.CountAsync(x => + var bloqueados120Dias = await qLinesWithClient.CountAsync(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") && EF.Functions.ILike((x.Status ?? "").Trim(), "%120%") && - EF.Functions.ILike((x.Status ?? "").Trim(), "%dia%")); + EF.Functions.ILike((x.Status ?? "").Trim(), "%dia%") && + !(EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || + EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%"))); - var bloqueadosOutros = await qLines.CountAsync(x => + var bloqueadosOutros = await qLinesWithClient.CountAsync(x => EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") && !(EF.Functions.ILike((x.Status ?? "").Trim(), "%120%") && EF.Functions.ILike((x.Status ?? "").Trim(), "%dia%")) && !(EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%")) ); - var bloqueados = bloqueadosPerdaRoubo + bloqueados120Dias + bloqueadosOutros; + // Regra do KPI "Bloqueadas" alinhada ao critério da página Geral: + // status contendo "bloque", "perda" ou "roubo". + var bloqueados = await qLinesWithClient.CountAsync(x => + EF.Functions.ILike((x.Status ?? "").Trim(), "%bloque%") || + EF.Functions.ILike((x.Status ?? "").Trim(), "%perda%") || + EF.Functions.ILike((x.Status ?? "").Trim(), "%roubo%")); + // Regra alinhada ao filtro "Reservas" da página Geral. var reservas = await qLines.CountAsync(x => - EF.Functions.ILike((x.Cliente ?? "").Trim(), "%RESERVA%") || - EF.Functions.ILike((x.Usuario ?? "").Trim(), "%RESERVA%") || - EF.Functions.ILike((x.Skil ?? "").Trim(), "%RESERVA%")); + EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") || + EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA")); var topClientes = await qLines .Where(x => x.Cliente != null && x.Cliente != "") diff --git a/Controllers/TemplatesController.cs b/Controllers/TemplatesController.cs new file mode 100644 index 0000000..98a7e6a --- /dev/null +++ b/Controllers/TemplatesController.cs @@ -0,0 +1,33 @@ +using line_gestao_api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace line_gestao_api.Controllers +{ + [ApiController] + [Route("api/templates")] + [Authorize(Roles = "admin,gestor")] + public class TemplatesController : ControllerBase + { + private readonly GeralSpreadsheetTemplateService _geralSpreadsheetTemplateService; + + public TemplatesController(GeralSpreadsheetTemplateService geralSpreadsheetTemplateService) + { + _geralSpreadsheetTemplateService = geralSpreadsheetTemplateService; + } + + [HttpGet("planilha-geral")] + public IActionResult DownloadPlanilhaGeral() + { + Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; + Response.Headers["Pragma"] = "no-cache"; + Response.Headers["Expires"] = "0"; + + var bytes = _geralSpreadsheetTemplateService.BuildPlanilhaGeralTemplate(); + return File( + bytes, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "MODELO_GERAL_LINEGESTAO.xlsx"); + } + } +} diff --git a/Controllers/UserDataController.cs b/Controllers/UserDataController.cs index 40ad72c..58dbfc8 100644 --- a/Controllers/UserDataController.cs +++ b/Controllers/UserDataController.cs @@ -56,6 +56,7 @@ namespace line_gestao_api.Controllers var searchDateEndUtc = hasDateSearch ? searchDateStartUtc.AddDays(1) : DateTime.MinValue; q = q.Where(x => EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || + (x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) || EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || EF.Functions.ILike(x.TipoPessoa ?? "", $"%{s}%") || EF.Functions.ILike(x.Nome ?? "", $"%{s}%") || @@ -151,6 +152,7 @@ namespace line_gestao_api.Controllers q = q.Where(x => EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || + (x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) || EF.Functions.ILike(x.TipoPessoa ?? "", $"%{s}%") || EF.Functions.ILike(x.Nome ?? "", $"%{s}%") || EF.Functions.ILike(x.RazaoSocial ?? "", $"%{s}%") || diff --git a/Controllers/VigenciaController.cs b/Controllers/VigenciaController.cs index 5c1d4ed..4b700e8 100644 --- a/Controllers/VigenciaController.cs +++ b/Controllers/VigenciaController.cs @@ -56,6 +56,7 @@ namespace line_gestao_api.Controllers q = q.Where(x => EF.Functions.ILike(x.Conta ?? "", $"%{s}%") || EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || + (x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) || EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || EF.Functions.ILike(x.Usuario ?? "", $"%{s}%") || EF.Functions.ILike(x.PlanoContrato ?? "", $"%{s}%") || @@ -98,6 +99,10 @@ namespace line_gestao_api.Controllers PlanoContrato = x.PlanoContrato, DtEfetivacaoServico = x.DtEfetivacaoServico, DtTerminoFidelizacao = x.DtTerminoFidelizacao, + AutoRenewYears = x.AutoRenewYears, + AutoRenewReferenceEndDate = x.AutoRenewReferenceEndDate, + AutoRenewConfiguredAt = x.AutoRenewConfiguredAt, + LastAutoRenewedAt = x.LastAutoRenewedAt, Total = x.Total }) .ToListAsync(); @@ -142,6 +147,7 @@ namespace line_gestao_api.Controllers q = q.Where(x => EF.Functions.ILike(x.Conta ?? "", $"%{s}%") || EF.Functions.ILike(x.Linha ?? "", $"%{s}%") || + (x.Linha != null && _db.MobileLines.Any(m => m.Linha == x.Linha && EF.Functions.ILike(m.Chip ?? "", $"%{s}%"))) || EF.Functions.ILike(x.Cliente ?? "", $"%{s}%") || EF.Functions.ILike(x.Usuario ?? "", $"%{s}%") || EF.Functions.ILike(x.PlanoContrato ?? "", $"%{s}%") || @@ -239,6 +245,10 @@ namespace line_gestao_api.Controllers PlanoContrato = x.PlanoContrato, DtEfetivacaoServico = x.DtEfetivacaoServico, DtTerminoFidelizacao = x.DtTerminoFidelizacao, + AutoRenewYears = x.AutoRenewYears, + AutoRenewReferenceEndDate = x.AutoRenewReferenceEndDate, + AutoRenewConfiguredAt = x.AutoRenewConfiguredAt, + LastAutoRenewedAt = x.LastAutoRenewedAt, Total = x.Total, CreatedAt = x.CreatedAt, UpdatedAt = x.UpdatedAt @@ -333,6 +343,10 @@ namespace line_gestao_api.Controllers PlanoContrato = e.PlanoContrato, DtEfetivacaoServico = e.DtEfetivacaoServico, DtTerminoFidelizacao = e.DtTerminoFidelizacao, + AutoRenewYears = e.AutoRenewYears, + AutoRenewReferenceEndDate = e.AutoRenewReferenceEndDate, + AutoRenewConfiguredAt = e.AutoRenewConfiguredAt, + LastAutoRenewedAt = e.LastAutoRenewedAt, Total = e.Total, CreatedAt = e.CreatedAt, UpdatedAt = e.UpdatedAt @@ -346,6 +360,9 @@ namespace line_gestao_api.Controllers var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id); if (x == null) return NotFound(); + var previousEfetivacao = x.DtEfetivacaoServico; + var previousTermino = x.DtTerminoFidelizacao; + if (req.Item.HasValue) x.Item = req.Item.Value; if (req.Conta != null) x.Conta = TrimOrNull(req.Conta); if (req.Linha != null) x.Linha = TrimOrNull(req.Linha); @@ -358,6 +375,13 @@ namespace line_gestao_api.Controllers if (req.Total.HasValue) x.Total = req.Total.Value; + var efetivacaoChanged = req.DtEfetivacaoServico.HasValue && !IsSameUtcDate(previousEfetivacao, x.DtEfetivacaoServico); + var terminoChanged = req.DtTerminoFidelizacao.HasValue && !IsSameUtcDate(previousTermino, x.DtTerminoFidelizacao); + if (efetivacaoChanged || terminoChanged) + { + ClearAutoRenewSchedule(x); + } + x.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); @@ -365,6 +389,39 @@ namespace line_gestao_api.Controllers return NoContent(); } + [HttpPost("{id:guid}/renew")] + public async Task ConfigureAutoRenew(Guid id, [FromBody] ConfigureVigenciaRenewalRequest req) + { + if (req.Years != 2) + { + return BadRequest(new { message = "A renovação automática permite somente 2 anos." }); + } + + var x = await _db.VigenciaLines.FirstOrDefaultAsync(a => a.Id == id); + if (x == null) return NotFound(); + + if (!x.DtTerminoFidelizacao.HasValue) + { + return BadRequest(new { message = "A linha não possui data de término de fidelização para programar renovação." }); + } + + var now = DateTime.UtcNow; + x.AutoRenewYears = 2; + x.AutoRenewReferenceEndDate = DateTime.SpecifyKind(x.DtTerminoFidelizacao.Value.Date, DateTimeKind.Utc); + x.AutoRenewConfiguredAt = now; + x.UpdatedAt = now; + + await _db.SaveChangesAsync(); + await _vigenciaNotificationSyncService.SyncCurrentTenantAsync(); + + return Ok(new + { + message = "Renovação automática de +2 anos programada para o vencimento.", + autoRenewYears = x.AutoRenewYears, + autoRenewReferenceEndDate = x.AutoRenewReferenceEndDate + }); + } + [HttpDelete("{id:guid}")] [Authorize(Roles = "admin")] public async Task Delete(Guid id) @@ -390,6 +447,20 @@ namespace line_gestao_api.Controllers : (dt.Kind == DateTimeKind.Local ? dt.ToUniversalTime() : DateTime.SpecifyKind(dt, DateTimeKind.Utc)); } + private static void ClearAutoRenewSchedule(VigenciaLine line) + { + line.AutoRenewYears = null; + line.AutoRenewReferenceEndDate = null; + line.AutoRenewConfiguredAt = null; + } + + private static bool IsSameUtcDate(DateTime? a, DateTime? b) + { + if (!a.HasValue && !b.HasValue) return true; + if (!a.HasValue || !b.HasValue) return false; + return DateTime.SpecifyKind(a.Value.Date, DateTimeKind.Utc) == DateTime.SpecifyKind(b.Value.Date, DateTimeKind.Utc); + } + private static string OnlyDigits(string? s) { if (string.IsNullOrWhiteSpace(s)) return ""; diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 0ce9824..0c8f76a 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -170,6 +170,7 @@ public class AppDbContext : IdentityDbContext x.Cliente); e.HasIndex(x => x.Linha); e.HasIndex(x => x.DtTerminoFidelizacao); + e.HasIndex(x => x.AutoRenewReferenceEndDate); e.HasIndex(x => x.TenantId); }); diff --git a/Dtos/LinesBatchExcelPreviewDtos.cs b/Dtos/LinesBatchExcelPreviewDtos.cs new file mode 100644 index 0000000..7356198 --- /dev/null +++ b/Dtos/LinesBatchExcelPreviewDtos.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +namespace line_gestao_api.Dtos +{ + public sealed class LinesBatchExcelPreviewResultDto + { + public string? FileName { get; set; } + public string? SheetName { get; set; } + public int NextItemStart { get; set; } + public int TotalRows { get; set; } + public int ValidRows { get; set; } + public int InvalidRows { get; set; } + public int DuplicateRows { get; set; } + public bool CanProceed { get; set; } + public List HeaderErrors { get; set; } = new(); + public List HeaderWarnings { get; set; } = new(); + public List Rows { get; set; } = new(); + } + + public sealed class LinesBatchExcelPreviewRowDto + { + public int SourceRowNumber { get; set; } + public int? SourceItem { get; set; } + public int? GeneratedItemPreview { get; set; } + public bool Valid { get; set; } + public bool DuplicateLinhaInFile { get; set; } + public bool DuplicateChipInFile { get; set; } + public bool DuplicateLinhaInSystem { get; set; } + public bool DuplicateChipInSystem { get; set; } + public CreateMobileLineDto Data { get; set; } = new(); + public List Errors { get; set; } = new(); + public List Warnings { get; set; } = new(); + } + + public sealed class LinesBatchExcelIssueDto + { + public string? Column { get; set; } + public string Message { get; set; } = string.Empty; + } + + public sealed class AssignReservaLinesRequestDto + { + public string? ClienteDestino { get; set; } + public string? UsuarioDestino { get; set; } + public string? SkilDestino { get; set; } + public List LineIds { get; set; } = new(); + } + + public sealed class MoveLinesToReservaRequestDto + { + public List LineIds { get; set; } = new(); + } + + public sealed class AssignReservaLinesResultDto + { + public int Requested { get; set; } + public int Updated { get; set; } + public int Failed { get; set; } + public List Items { get; set; } = new(); + } + + public sealed class AssignReservaLineItemResultDto + { + public Guid Id { get; set; } + public int Item { get; set; } + public string? Linha { get; set; } + public string? Chip { get; set; } + public string? ClienteAnterior { get; set; } + public string? ClienteNovo { get; set; } + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + } +} diff --git a/Dtos/VigenciaDtos.cs b/Dtos/VigenciaDtos.cs index ccadec0..ce64cfa 100644 --- a/Dtos/VigenciaDtos.cs +++ b/Dtos/VigenciaDtos.cs @@ -14,6 +14,10 @@ namespace line_gestao_api.Dtos public string? PlanoContrato { get; set; } public DateTime? DtEfetivacaoServico { get; set; } public DateTime? DtTerminoFidelizacao { get; set; } + public int? AutoRenewYears { get; set; } + public DateTime? AutoRenewReferenceEndDate { get; set; } + public DateTime? AutoRenewConfiguredAt { get; set; } + public DateTime? LastAutoRenewedAt { get; set; } public decimal? Total { get; set; } } @@ -49,6 +53,11 @@ namespace line_gestao_api.Dtos public decimal? Total { get; set; } } + public class ConfigureVigenciaRenewalRequest + { + public int Years { get; set; } + } + public class VigenciaClientGroupDto { public string Cliente { get; set; } = ""; diff --git a/Migrations/20260227120000_AddVigenciaAutoRenewal.cs b/Migrations/20260227120000_AddVigenciaAutoRenewal.cs new file mode 100644 index 0000000..571fbfd --- /dev/null +++ b/Migrations/20260227120000_AddVigenciaAutoRenewal.cs @@ -0,0 +1,68 @@ +using line_gestao_api.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace line_gestao_api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260227120000_AddVigenciaAutoRenewal")] + public partial class AddVigenciaAutoRenewal : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AutoRenewConfiguredAt", + table: "VigenciaLines", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "AutoRenewReferenceEndDate", + table: "VigenciaLines", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "AutoRenewYears", + table: "VigenciaLines", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastAutoRenewedAt", + table: "VigenciaLines", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_VigenciaLines_AutoRenewReferenceEndDate", + table: "VigenciaLines", + column: "AutoRenewReferenceEndDate"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_VigenciaLines_AutoRenewReferenceEndDate", + table: "VigenciaLines"); + + migrationBuilder.DropColumn( + name: "AutoRenewConfiguredAt", + table: "VigenciaLines"); + + migrationBuilder.DropColumn( + name: "AutoRenewReferenceEndDate", + table: "VigenciaLines"); + + migrationBuilder.DropColumn( + name: "AutoRenewYears", + table: "VigenciaLines"); + + migrationBuilder.DropColumn( + name: "LastAutoRenewedAt", + table: "VigenciaLines"); + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index 0d13ba2..2b375cf 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -1510,6 +1510,15 @@ namespace line_gestao_api.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("AutoRenewConfiguredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AutoRenewReferenceEndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("AutoRenewYears") + .HasColumnType("integer"); + b.Property("Cliente") .HasColumnType("text"); @@ -1528,6 +1537,9 @@ namespace line_gestao_api.Migrations b.Property("Item") .HasColumnType("integer"); + b.Property("LastAutoRenewedAt") + .HasColumnType("timestamp with time zone"); + b.Property("Linha") .HasColumnType("text"); @@ -1550,6 +1562,8 @@ namespace line_gestao_api.Migrations b.HasIndex("Cliente"); + b.HasIndex("AutoRenewReferenceEndDate"); + b.HasIndex("DtTerminoFidelizacao"); b.HasIndex("Item"); diff --git a/Models/VigenciaLine.cs b/Models/VigenciaLine.cs index f8c37c9..165300c 100644 --- a/Models/VigenciaLine.cs +++ b/Models/VigenciaLine.cs @@ -20,6 +20,10 @@ namespace line_gestao_api.Models public DateTime? DtEfetivacaoServico { get; set; } public DateTime? DtTerminoFidelizacao { get; set; } + public int? AutoRenewYears { get; set; } + public DateTime? AutoRenewReferenceEndDate { get; set; } + public DateTime? AutoRenewConfiguredAt { get; set; } + public DateTime? LastAutoRenewedAt { get; set; } public decimal? Total { get; set; } diff --git a/Program.cs b/Program.cs index 5be119e..5ad43ac 100644 --- a/Program.cs +++ b/Program.cs @@ -94,6 +94,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddIdentityCore(options => diff --git a/Services/GeralSpreadsheetTemplateService.cs b/Services/GeralSpreadsheetTemplateService.cs new file mode 100644 index 0000000..2f4ca25 --- /dev/null +++ b/Services/GeralSpreadsheetTemplateService.cs @@ -0,0 +1,180 @@ +using ClosedXML.Excel; + +namespace line_gestao_api.Services +{ + public class GeralSpreadsheetTemplateService + { + private const string WorksheetName = "GERAL"; + private const int HeaderRow = 1; + private const int FirstDataRow = 2; + private const int LastColumn = 31; // A..AE + + private static readonly string[] Headers = + { + "CONTA", + "LINHA", + "CHIP", + "CLIENTE", + "USUÁRIO", + "PLANO CONTRATO", + "FRAQUIA", + "VALOR DO PLANO R$", + "GESTÃO VOZ E DADOS R$", + "SKEELO", + "VIVO NEWS PLUS", + "VIVO TRAVEL MUNDO", + "VIVO SYNC", + "VIVO GESTÃO DISPOSITIVO", + "VALOR CONTRATO VIVO", + "FRANQUIA LINE", + "FRANQUIA GESTÃO", + "LOCAÇÃO AP.", + "VALOR CONTRATO LINE", + "DESCONTO", + "LUCRO", + "STATUS", + "DATA DO BLOQUEIO", + "SKIL", + "MODALIDADE", + "CEDENTE", + "SOLICITANTE", + "DATA DA ENTREGA OPERA.", + "DATA DA ENTREGA CLIENTE", + "VENC. DA CONTA", + "TIPO DE CHIP" + }; + + private static readonly double[] ColumnWidths = + { + 11.00, 12.00, 21.43, 70.14, 58.29, 27.71, 11.00, 18.14, + 20.71, 11.00, 14.57, 18.14, 11.00, 19.57, 21.43, 14.57, + 16.29, 13.00, 20.71, 13.00, 13.00, 14.57, 16.29, 13.00, + 16.29, 39.43, 27.86, 25.00, 27.71, 16.29, 13.00 + }; + + private static readonly int[] TextColumns = + { + 1, 2, 3, 4, 5, 6, 7, 16, 17, 22, 24, 25, 26, 27, 31 + }; + + private static readonly int[] CurrencyColumns = + { + 8, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21 + }; + + private static readonly int[] DateColumns = + { + 23, 28, 29, 30 + }; + + public byte[] BuildPlanilhaGeralTemplate() + { + using var workbook = new XLWorkbook(); + var ws = workbook.Worksheets.Add(WorksheetName); + + BuildHeader(ws); + ConfigureColumns(ws); + ConfigureDataFormatting(ws); + ConfigureSheetView(ws); + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + + private static void BuildHeader(IXLWorksheet ws) + { + for (var i = 0; i < Headers.Length; i++) + { + ws.Cell(HeaderRow, i + 1).Value = Headers[i]; + } + + var headerRange = ws.Range(HeaderRow, 1, HeaderRow, LastColumn); + headerRange.Style.Font.FontName = "Calibri"; + headerRange.Style.Font.FontSize = 11; + headerRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + headerRange.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + headerRange.Style.Border.TopBorder = XLBorderStyleValues.Thin; + headerRange.Style.Border.BottomBorder = XLBorderStyleValues.Thin; + headerRange.Style.Border.LeftBorder = XLBorderStyleValues.Thin; + headerRange.Style.Border.RightBorder = XLBorderStyleValues.Thin; + + ws.Row(HeaderRow).Height = 14.25; + + var navy = XLColor.FromHtml("#002060"); + var purple = XLColor.FromHtml("#7030A0"); + var orange = XLColor.FromHtml("#D9A87E"); + var red = XLColor.FromHtml("#FF0000"); + var yellow = XLColor.FromHtml("#FFFF00"); + var white = XLColor.White; + var black = XLColor.Black; + + ApplyHeaderBlock(ws, 1, 6, navy, white, bold: true); // A-F + ApplyHeaderBlock(ws, 22, 31, navy, white, bold: true); // V-AE + ApplyHeaderBlock(ws, 7, 15, purple, white, bold: true); // G-O + ApplyHeaderBlock(ws, 16, 19, orange, white, bold: true);// P-S + ApplyHeaderBlock(ws, 20, 20, red, white, bold: true); // T + ApplyHeaderBlock(ws, 21, 21, yellow, black, bold: true);// U + + // Exceções no bloco azul (sem negrito): CHIP, CLIENTE, USUÁRIO => C, D, E + ws.Cell(1, 3).Style.Font.Bold = false; + ws.Cell(1, 4).Style.Font.Bold = false; + ws.Cell(1, 5).Style.Font.Bold = false; + } + + private static void ApplyHeaderBlock( + IXLWorksheet ws, + int startCol, + int endCol, + XLColor bgColor, + XLColor fontColor, + bool bold) + { + var range = ws.Range(HeaderRow, startCol, HeaderRow, endCol); + range.Style.Fill.BackgroundColor = bgColor; + range.Style.Font.FontColor = fontColor; + range.Style.Font.Bold = bold; + } + + private static void ConfigureColumns(IXLWorksheet ws) + { + for (var i = 0; i < ColumnWidths.Length; i++) + { + ws.Column(i + 1).Width = ColumnWidths[i]; + } + } + + private static void ConfigureDataFormatting(IXLWorksheet ws) + { + // Prepara um range vazio com estilo base para facilitar preenchimento manual + var dataPreviewRange = ws.Range(FirstDataRow, 1, 1000, LastColumn); + dataPreviewRange.Style.Font.FontName = "Calibri"; + dataPreviewRange.Style.Font.FontSize = 11; + dataPreviewRange.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + + foreach (var col in TextColumns) + { + ws.Column(col).Style.NumberFormat.Format = "@"; + } + + foreach (var col in CurrencyColumns) + { + ws.Column(col).Style.NumberFormat.Format = "\"R$\" #,##0.00"; + } + + foreach (var col in DateColumns) + { + ws.Column(col).Style.DateFormat.Format = "dd/MM/yyyy"; + } + + // O campo ITÉM é gerado internamente pelo sistema e não faz parte do template. + } + + private static void ConfigureSheetView(IXLWorksheet ws) + { + ws.ShowGridLines = false; + ws.SheetView.FreezeRows(1); // Freeze em A2 (mantém linha 1 fixa) + ws.Range(1, 1, 1, LastColumn).SetAutoFilter(); + } + } +} diff --git a/Services/VigenciaNotificationSyncService.cs b/Services/VigenciaNotificationSyncService.cs index 45b4f44..63ab080 100644 --- a/Services/VigenciaNotificationSyncService.cs +++ b/Services/VigenciaNotificationSyncService.cs @@ -83,6 +83,8 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService .Where(u => !string.IsNullOrWhiteSpace(u.Email)) .ToDictionary(u => u.Email!.Trim().ToLowerInvariant(), u => u.Id); + await ApplyAutoRenewalsAsync(tenantId, today, userByName, userByEmail, cancellationToken); + var vigencias = await _db.VigenciaLines.AsNoTracking() .Where(v => v.DtTerminoFidelizacao != null) .ToListAsync(cancellationToken); @@ -213,6 +215,112 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService await _db.SaveChangesAsync(cancellationToken); } + private async Task ApplyAutoRenewalsAsync( + Guid tenantId, + DateTime todayUtc, + IReadOnlyDictionary userByName, + IReadOnlyDictionary userByEmail, + CancellationToken cancellationToken) + { + var scheduledLines = await _db.VigenciaLines + .Where(v => + v.AutoRenewYears != null && + v.AutoRenewReferenceEndDate != null && + v.DtTerminoFidelizacao != null) + .ToListAsync(cancellationToken); + + if (scheduledLines.Count == 0) + { + return; + } + + var changed = false; + var autoRenewNotifications = new List(); + var nowUtc = DateTime.UtcNow; + + foreach (var vigencia in scheduledLines) + { + var years = NormalizeAutoRenewYears(vigencia.AutoRenewYears); + if (!years.HasValue || !vigencia.DtTerminoFidelizacao.HasValue || !vigencia.AutoRenewReferenceEndDate.HasValue) + { + if (vigencia.AutoRenewYears.HasValue || vigencia.AutoRenewReferenceEndDate.HasValue || vigencia.AutoRenewConfiguredAt.HasValue) + { + ClearAutoRenewSchedule(vigencia); + vigencia.UpdatedAt = nowUtc; + changed = true; + } + continue; + } + + var currentEndUtc = ToUtcDate(vigencia.DtTerminoFidelizacao.Value); + var referenceEndUtc = ToUtcDate(vigencia.AutoRenewReferenceEndDate.Value); + + // As datas de vigência foram alteradas manualmente após o agendamento: + // não renova automaticamente e limpa o agendamento. + if (currentEndUtc != referenceEndUtc) + { + ClearAutoRenewSchedule(vigencia); + vigencia.UpdatedAt = nowUtc; + changed = true; + continue; + } + + // Só executa a renovação no vencimento (ou se já passou e segue sem alteração manual). + if (currentEndUtc > todayUtc) + { + continue; + } + + var newStartUtc = currentEndUtc.AddDays(1); + var newEndUtc = currentEndUtc.AddYears(years.Value); + + vigencia.DtEfetivacaoServico = newStartUtc; + vigencia.DtTerminoFidelizacao = newEndUtc; + vigencia.LastAutoRenewedAt = nowUtc; + ClearAutoRenewSchedule(vigencia); + vigencia.UpdatedAt = nowUtc; + changed = true; + + autoRenewNotifications.Add(BuildAutoRenewNotification( + vigencia, + years.Value, + currentEndUtc, + newEndUtc, + ResolveUserId(vigencia.Usuario, userByName, userByEmail), + tenantId)); + } + + if (!changed && autoRenewNotifications.Count == 0) + { + return; + } + + if (autoRenewNotifications.Count > 0) + { + var dedupKeys = autoRenewNotifications + .Select(n => n.DedupKey) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var existingDedupKeys = await _db.Notifications.AsNoTracking() + .Where(n => dedupKeys.Contains(n.DedupKey)) + .Select(n => n.DedupKey) + .ToListAsync(cancellationToken); + + var existingSet = existingDedupKeys.ToHashSet(StringComparer.Ordinal); + autoRenewNotifications = autoRenewNotifications + .Where(n => !existingSet.Contains(n.DedupKey)) + .ToList(); + + if (autoRenewNotifications.Count > 0) + { + await _db.Notifications.AddRangeAsync(autoRenewNotifications, cancellationToken); + } + } + + await _db.SaveChangesAsync(cancellationToken); + } + private async Task CleanupOutdatedNotificationsAsync( IReadOnlyCollection vigencias, bool notifyAllFutureDates, @@ -349,6 +457,38 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService }; } + private static Notification BuildAutoRenewNotification( + VigenciaLine vigencia, + int years, + DateTime previousEndUtc, + DateTime newEndUtc, + Guid? userId, + Guid tenantId) + { + var linha = vigencia.Linha?.Trim(); + var cliente = vigencia.Cliente?.Trim(); + var usuario = vigencia.Usuario?.Trim(); + var dedupKey = BuildAutoRenewDedupKey(tenantId, vigencia.Id, previousEndUtc, years); + + return new Notification + { + Tipo = "RenovacaoAutomatica", + Titulo = $"Renovação automática concluída{FormatLinha(linha)}", + Mensagem = $"A linha{FormatLinha(linha)} do cliente {cliente ?? "(sem cliente)"} foi renovada automaticamente por {years} ano(s): {previousEndUtc:dd/MM/yyyy} → {newEndUtc:dd/MM/yyyy}.", + Data = DateTime.UtcNow, + ReferenciaData = newEndUtc, + DiasParaVencer = null, + Lida = false, + DedupKey = dedupKey, + UserId = userId, + Usuario = usuario, + Cliente = cliente, + Linha = linha, + VigenciaLineId = vigencia.Id, + TenantId = tenantId + }; + } + private static string BuildDedupKey( string tipo, DateTime referenciaData, @@ -370,6 +510,59 @@ public class VigenciaNotificationSyncService : IVigenciaNotificationSyncService return string.Join('|', parts); } + private static string BuildAutoRenewDedupKey(Guid tenantId, Guid vigenciaLineId, DateTime referenceEndDateUtc, int years) + { + return string.Join('|', new[] + { + "renovacaoautomatica", + tenantId.ToString("N"), + vigenciaLineId.ToString("N"), + referenceEndDateUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + years.ToString(CultureInfo.InvariantCulture) + }); + } + + private static Guid? ResolveUserId( + string? usuario, + IReadOnlyDictionary userByName, + IReadOnlyDictionary userByEmail) + { + var key = usuario?.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(key)) + { + return null; + } + + if (userByEmail.TryGetValue(key, out var byEmail)) + { + return byEmail; + } + + if (userByName.TryGetValue(key, out var byName)) + { + return byName; + } + + return null; + } + + private static int? NormalizeAutoRenewYears(int? years) + { + return years == 2 ? years : null; + } + + private static DateTime ToUtcDate(DateTime value) + { + return DateTime.SpecifyKind(value.Date, DateTimeKind.Utc); + } + + private static void ClearAutoRenewSchedule(VigenciaLine vigencia) + { + vigencia.AutoRenewYears = null; + vigencia.AutoRenewReferenceEndDate = null; + vigencia.AutoRenewConfiguredAt = null; + } + private static string FormatLinha(string? linha) { return string.IsNullOrWhiteSpace(linha) ? string.Empty : $" {linha}";