Merge pull request #26 from eduardolopesx03/adicao-linhas-lote
Adicao linhas lote
This commit is contained in:
commit
1f255888b0
|
|
@ -6,6 +6,9 @@
|
|||
# dotenv files
|
||||
.env
|
||||
appsettings.Local.json
|
||||
appsettings*.json
|
||||
line-gestao-api.csproj
|
||||
line-gestao-api.http
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 != "")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}%") ||
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; } = "";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}";
|
||||
|
|
|
|||
Loading…
Reference in New Issue