Compare commits

...

4 Commits

Author SHA1 Message Date
Leon Nascimento Moreira 1f255888b0
Merge pull request #26 from eduardolopesx03/adicao-linhas-lote
Adicao linhas lote
2026-02-27 17:07:32 -03:00
Eduardo 024b7d299d Merge branch 'dev' into adicao-linhas-lote 2026-02-27 16:58:41 -03:00
Eduardo 242f8bc707 Feat: Corrigindo merge 2026-02-27 16:54:20 -03:00
Eduardo 7a7b5db73e Feat: Adição Lote de Linhas 2026-02-27 14:55:05 -03:00
18 changed files with 1417 additions and 20 deletions

3
.gitignore vendored
View File

@ -6,6 +6,9 @@
# dotenv files
.env
appsettings.Local.json
appsettings*.json
line-gestao-api.csproj
line-gestao-api.http
# User-specific files
*.rsuser

View File

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

View File

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

View File

@ -98,13 +98,16 @@ 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}%"));
}
if (!reservaFilter)
query = ExcludeReservaContext(query);
query = ApplyAdditionalFilters(query, additionalMode, additionalServices);
IQueryable<ClientGroupDto> groupedQuery;
@ -225,13 +228,16 @@ 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}%"));
}
if (!reservaFilter)
query = ExcludeReservaContext(query);
query = ApplyAdditionalFilters(query, additionalMode, additionalServices);
List<string> clients;
@ -461,13 +467,16 @@ 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}%"));
}
if (!reservaFilter)
q = ExcludeReservaContext(q);
q = ApplyAdditionalFilters(q, additionalMode, additionalServices);
var sb = (sortBy ?? "item").Trim().ToLowerInvariant();
@ -711,6 +720,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 +821,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<string>(StringComparer.Ordinal)
: (await _db.MobileLines.AsNoTracking()
@ -813,12 +835,21 @@ namespace line_gestao_api.Controllers
.ToListAsync())
.ToHashSet(StringComparer.Ordinal);
var existingChips = requestedChips.Count == 0
? new HashSet<string>(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<string>(StringComparer.Ordinal);
var seenBatchChips = new HashSet<string>(StringComparer.Ordinal);
var createdLines = new List<(MobileLine line, VigenciaLine? vigencia)>(requests.Count);
for (var i = 0; i < requests.Count; i++)
@ -853,6 +884,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 +981,281 @@ namespace line_gestao_api.Controllers
}
}
// ==========================================================
// ✅ 5.2. PREVIEW IMPORTAÇÃO EXCEL PARA LOTE (ABA GERAL)
// ==========================================================
[HttpPost("batch/import-preview")]
[Authorize(Roles = "sysadmin,gestor")]
[Consumes("multipart/form-data")]
[RequestSizeLimit(20_000_000)]
public async Task<ActionResult<LinesBatchExcelPreviewResultDto>> 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 = "sysadmin,gestor")]
public async Task<ActionResult<AssignReservaLinesResultDto>> AssignReservaLinesToClient([FromBody] AssignReservaLinesRequestDto req)
{
var ids = (req?.LineIds ?? new List<Guid>())
.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 = "sysadmin,gestor")]
public async Task<ActionResult<AssignReservaLinesResultDto>> MoveLinesToReserva([FromBody] MoveLinesToReservaRequestDto req)
{
var ids = (req?.LineIds ?? new List<Guid>())
.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 +1266,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 +1310,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 +3667,385 @@ namespace line_gestao_api.Controllers
return hasItem && hasNumeroChip && hasObs;
}
private async Task<LinesBatchExcelPreviewResultDto> 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<LinesBatchExcelPreviewRowDto>();
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<string>(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<string>(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)
// ==========================================================
@ -3630,6 +4345,14 @@ namespace line_gestao_api.Controllers
});
}
private static IQueryable<MobileLine> ExcludeReservaContext(IQueryable<MobileLine> query)
{
return query.Where(x =>
!EF.Functions.ILike((x.Usuario ?? "").Trim(), "RESERVA") &&
!EF.Functions.ILike((x.Skil ?? "").Trim(), "RESERVA") &&
!EF.Functions.ILike((x.Cliente ?? "").Trim(), "RESERVA"));
}
private static IQueryable<MobileLine> ApplyAdditionalFilters(
IQueryable<MobileLine> query,
string? additionalMode,

View File

@ -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),

View File

@ -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 != "")

View File

@ -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 = "sysadmin,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");
}
}
}

View File

@ -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}%") ||

View File

@ -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<IActionResult> 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 = "sysadmin")]
public async Task<IActionResult> 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 "";

View File

@ -179,6 +179,7 @@ public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid
e.HasIndex(x => x.Cliente);
e.HasIndex(x => x.Linha);
e.HasIndex(x => x.DtTerminoFidelizacao);
e.HasIndex(x => x.AutoRenewReferenceEndDate);
e.HasIndex(x => x.TenantId);
});

View File

@ -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<LinesBatchExcelIssueDto> HeaderErrors { get; set; } = new();
public List<LinesBatchExcelIssueDto> HeaderWarnings { get; set; } = new();
public List<LinesBatchExcelPreviewRowDto> 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<LinesBatchExcelIssueDto> Errors { get; set; } = new();
public List<LinesBatchExcelIssueDto> 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<Guid> LineIds { get; set; } = new();
}
public sealed class MoveLinesToReservaRequestDto
{
public List<Guid> 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<AssignReservaLineItemResultDto> 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;
}
}

View File

@ -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; } = "";

View File

@ -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<DateTime>(
name: "AutoRenewConfiguredAt",
table: "VigenciaLines",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "AutoRenewReferenceEndDate",
table: "VigenciaLines",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "AutoRenewYears",
table: "VigenciaLines",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
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");
}
}
}

View File

@ -1549,6 +1549,15 @@ namespace line_gestao_api.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AutoRenewConfiguredAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("AutoRenewReferenceEndDate")
.HasColumnType("timestamp with time zone");
b.Property<int?>("AutoRenewYears")
.HasColumnType("integer");
b.Property<string>("Cliente")
.HasColumnType("text");
@ -1567,6 +1576,9 @@ namespace line_gestao_api.Migrations
b.Property<int>("Item")
.HasColumnType("integer");
b.Property<DateTime?>("LastAutoRenewedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Linha")
.HasColumnType("text");
@ -1589,6 +1601,8 @@ namespace line_gestao_api.Migrations
b.HasIndex("Cliente");
b.HasIndex("AutoRenewReferenceEndDate");
b.HasIndex("DtTerminoFidelizacao");
b.HasIndex("Item");

View File

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

View File

@ -96,6 +96,7 @@ builder.Services.AddScoped<ISystemAuditService, SystemAuditService>();
builder.Services.AddScoped<IVigenciaNotificationSyncService, VigenciaNotificationSyncService>();
builder.Services.AddScoped<ParcelamentosImportService>();
builder.Services.AddScoped<GeralDashboardInsightsService>();
builder.Services.AddScoped<GeralSpreadsheetTemplateService>();
builder.Services.AddScoped<SpreadsheetImportAuditService>();
builder.Services.AddIdentityCore<ApplicationUser>(options =>

View File

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

View File

@ -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<string, Guid> userByName,
IReadOnlyDictionary<string, Guid> 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<Notification>();
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<VigenciaLine> 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<string, Guid> userByName,
IReadOnlyDictionary<string, Guid> 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}";