-
Serviços
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Todos os Clientes
+
+
+
+
+
+
-
diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss
index 38116a6..f3fa7c0 100644
--- a/src/app/pages/geral/geral.scss
+++ b/src/app/pages/geral/geral.scss
@@ -123,11 +123,35 @@
.btn-glass { border-radius: 12px; font-weight: 900; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(3, 15, 170, 0.25); color: var(--blue); &:hover { transform: translateY(-2px); border-color: var(--brand); background: #fff; } }
/* Filtros e Multi-Select */
-.filters-row { display: flex; justify-content: center; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px; }
+.filters-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.filters-row {
+ display: flex;
+ justify-content: center;
+ align-items: flex-end;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-top: 0;
+ position: relative;
+ z-index: 30;
+ overflow: visible;
+}
+
+.filters-row-top {
+ justify-content: center;
+}
+
+.filters-row-bottom {
+ justify-content: center;
+}
.filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; backdrop-filter: blur(8px); }
.filter-tab { border: none; background: transparent; padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } &:disabled { opacity: 0.5; cursor: not-allowed; } }
-.client-filter-wrap { position: relative; }
+.client-filter-wrap { position: relative; z-index: 40; }
.btn-client-filter { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 12px; border: 1px solid rgba(17, 18, 20, 0.08); background: rgba(255, 255, 255, 0.6); color: var(--muted); font-weight: 700; font-size: 0.85rem; backdrop-filter: blur(8px); transition: all 0.2s; min-height: 38px; height: auto; flex-wrap: wrap; &:hover { background: #fff; border-color: var(--blue); color: var(--blue); } &.active, &.has-selection { background: #fff; border-color: var(--brand); } }
.chips-container { display: flex; flex-wrap: wrap; gap: 6px; max-width: 400px; }
.client-chip { display: inline-flex; align-items: center; background: rgba(227, 61, 207, 0.1); color: var(--brand); border: 1px solid rgba(227, 61, 207, 0.2); border-radius: 6px; padding: 2px 6px; font-size: 0.75rem; font-weight: 800; cursor: default; user-select: none; }
@@ -140,6 +164,34 @@
.additional-filter-wrap {
position: relative;
+ z-index: 40;
+}
+
+.operadora-empresa-filters {
+ display: flex;
+ align-items: flex-end;
+ gap: 10px;
+ flex-wrap: wrap;
+ position: relative;
+ z-index: 50;
+}
+
+.filter-select-box {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 190px;
+ position: relative;
+ z-index: 50;
+}
+
+.filter-select-label {
+ font-size: 0.66rem;
+ font-weight: 900;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: rgba(17, 18, 20, 0.58);
+ padding-left: 2px;
}
.btn-additional-filter {
@@ -249,6 +301,18 @@
}
}
+@media (max-width: 768px) {
+ .operadora-empresa-filters {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .filter-select-box {
+ flex: 1 1 220px;
+ min-width: 0;
+ }
+}
+
/* KPIs */
.geral-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 20px; margin-bottom: 16px; width: 100%; @media (max-width: 992px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } }
.geral-kpis.geral-kpis-client {
diff --git a/src/app/pages/geral/geral.spec.ts b/src/app/pages/geral/geral.spec.ts
index 05f32d1..07f461d 100644
--- a/src/app/pages/geral/geral.spec.ts
+++ b/src/app/pages/geral/geral.spec.ts
@@ -73,4 +73,36 @@ describe('Geral', () => {
expect(component.createBatchLines[0].linha).toBe('11888888888');
expect(component.createBatchLines[0]['planoContrato']).toBe('PLANO B');
});
+
+ it('should apply TIM filter in client-side pipeline using conta TIM textual', () => {
+ component.filterOperadora = 'TIM';
+ component.filterContaEmpresa = '';
+ component.filterStatus = 'ALL';
+ component.additionalMode = 'ALL';
+ component.selectedAdditionalServices = [];
+
+ const filtered = (component as any).applyAdditionalFiltersClientSide([
+ { id: '1', item: 1, conta: 'TIM', linha: '11911111111', cliente: 'A', usuario: 'U', vencConta: null, status: 'ATIVO' },
+ { id: '2', item: 2, conta: '455371844', linha: '11922222222', cliente: 'B', usuario: 'U', vencConta: null, status: 'ATIVO' },
+ ]);
+
+ expect(filtered.length).toBe(1);
+ expect(filtered[0].conta).toBe('TIM');
+ });
+
+ it('should combine operadora and empresa filters for VIVO MACROPHONY', () => {
+ component.filterOperadora = 'VIVO';
+ component.filterContaEmpresa = 'VIVO MACROPHONY';
+ component.filterStatus = 'ALL';
+ component.additionalMode = 'ALL';
+ component.selectedAdditionalServices = [];
+
+ const filtered = (component as any).applyAdditionalFiltersClientSide([
+ { id: '1', item: 1, conta: '460161507', linha: '11911111111', cliente: 'A', usuario: 'U', vencConta: null, status: 'ATIVO' },
+ { id: '2', item: 2, conta: '0435288088', linha: '11922222222', cliente: 'B', usuario: 'U', vencConta: null, status: 'ATIVO' },
+ ]);
+
+ expect(filtered.length).toBe(1);
+ expect(filtered[0].conta).toBe('460161507');
+ });
});
diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts
index 0bd8044..2410ed4 100644
--- a/src/app/pages/geral/geral.ts
+++ b/src/app/pages/geral/geral.ts
@@ -45,11 +45,20 @@ import {
buildBatchMassPreview,
mergeMassRows
} from './batch-mass-input.util';
+import {
+ DEFAULT_ACCOUNT_COMPANIES,
+ mergeAccountCompaniesWithDefaults,
+ normalizeConta as normalizeContaValue,
+ resolveEmpresaByConta,
+ resolveOperadoraContext,
+ sameConta as sameContaValue,
+} from '../../utils/account-operator.util';
type SortDir = 'asc' | 'desc';
type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP';
type CreateEntryMode = 'SINGLE' | 'BATCH';
type AdditionalMode = 'ALL' | 'WITH' | 'WITHOUT';
+type OperadoraFilterMode = 'ALL' | 'VIVO' | 'CLARO' | 'TIM';
type AdditionalServiceKey = 'gvd' | 'skeelo' | 'news' | 'travel' | 'sync' | 'dispositivo';
type BlockedStatusMode = 'ALL' | 'PERDA_ROUBO' | 'BLOQUEIO_120';
@@ -79,6 +88,9 @@ interface ApiPagedResult
{
interface ApiLineList {
id: string;
item: number;
+ conta?: string | null;
+ contaEmpresa?: string | null;
+ empresaConta?: string | null;
linha: string | null;
chip?: string | null;
cliente: string | null;
@@ -361,6 +373,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
blockedStatusMode: BlockedStatusMode = 'ALL';
additionalMode: AdditionalMode = 'ALL';
selectedAdditionalServices: AdditionalServiceKey[] = [];
+ filterOperadora: OperadoraFilterMode = 'ALL';
+ filterContaEmpresa = '';
readonly additionalServiceOptions: Array<{ key: AdditionalServiceKey; label: string }> = [
{ key: 'gvd', label: 'Gestão Voz e Dados' },
{ key: 'skeelo', label: 'Skeelo' },
@@ -369,6 +383,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
{ key: 'sync', label: 'Vivo Sync' },
{ key: 'dispositivo', label: 'Vivo Gestão Dispositivo' }
];
+ readonly operadoraFilterOptions: Array<{ label: string; value: OperadoraFilterMode }> = [
+ { label: 'Todas operadoras', value: 'ALL' },
+ { label: 'VIVO', value: 'VIVO' },
+ { label: 'CLARO', value: 'CLARO' },
+ { label: 'TIM', value: 'TIM' },
+ ];
clientsList: string[] = [];
loadingClientsList = false;
@@ -472,12 +492,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
'M2M 50MB'
];
- private readonly fallbackAccountCompanies: AccountCompanyOption[] = [
- { empresa: 'CLARO LINE MÓVEL', contas: ['172593311', '172593840'] },
- { empresa: 'VIVO MACROPHONY', contas: ['0430237019', '0437488125', '0449508564', '0454371844'] },
- { empresa: 'VIVO LINE MÓVEL', contas: ['0435288088'] },
- { empresa: 'TIM LINE MÓVEL', contas: ['0072046192'] }
- ];
+ private readonly fallbackAccountCompanies: AccountCompanyOption[] = DEFAULT_ACCOUNT_COMPANIES.map((group) => ({
+ empresa: group.empresa,
+ contas: [...group.contas],
+ }));
accountCompanies: AccountCompanyOption[] = [...this.fallbackAccountCompanies];
loadingAccountCompanies = false;
@@ -489,6 +507,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return this.accountCompanies.map((x) => x.empresa);
}
+ get contaEmpresaFilterOptions(): Array<{ label: string; value: string }> {
+ const empresas = this.getContaEmpresaOptionsByOperadora(this.filterOperadora);
+ const merged = this.mergeOption(this.filterContaEmpresa, empresas);
+ return [
+ { label: 'Todas empresas', value: '' },
+ ...merged.map((empresa) => ({ label: empresa, value: empresa })),
+ ];
+ }
+
get contaOptionsForCreate(): string[] {
return this.getContasByEmpresa(this.createModel?.contaEmpresa);
}
@@ -740,8 +767,12 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return this.additionalMode !== 'ALL' || this.selectedAdditionalServices.length > 0;
}
+ get hasOperadoraEmpresaFiltersApplied(): boolean {
+ return this.filterOperadora !== 'ALL' || !!this.filterContaEmpresa.trim();
+ }
+
get hasClientSideFiltersApplied(): boolean {
- return this.hasAdditionalFiltersApplied || this.filterStatus === 'BLOCKED';
+ return this.hasAdditionalFiltersApplied || this.filterStatus === 'BLOCKED' || this.hasOperadoraEmpresaFiltersApplied;
}
get additionalModeLabel(): string {
@@ -1042,17 +1073,19 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.http.get(`${this.apiBase}/account-companies`).subscribe({
next: (data) => {
const normalized = this.normalizeAccountCompanies(data);
- this.accountCompanies =
- normalized.length > 0 ? normalized : [...this.fallbackAccountCompanies];
+ const source = normalized.length > 0 ? normalized : this.fallbackAccountCompanies;
+ this.accountCompanies = mergeAccountCompaniesWithDefaults(source);
this.loadingAccountCompanies = false;
+ this.syncContaEmpresaFilterByOperadora();
this.syncContaEmpresaSelection(this.createModel);
this.syncContaEmpresaSelection(this.editModel);
this.cdr.detectChanges();
},
error: () => {
- this.accountCompanies = [...this.fallbackAccountCompanies];
+ this.accountCompanies = mergeAccountCompaniesWithDefaults(this.fallbackAccountCompanies);
this.loadingAccountCompanies = false;
+ this.syncContaEmpresaFilterByOperadora();
this.syncContaEmpresaSelection(this.createModel);
this.syncContaEmpresaSelection(this.editModel);
@@ -1494,6 +1527,32 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.refreshData();
}
+ setOperadoraFilter(mode: OperadoraFilterMode) {
+ if (this.isClientRestricted) return;
+ this.filterOperadora = mode;
+ this.syncContaEmpresaFilterByOperadora();
+ this.expandedGroup = null;
+ this.groupLines = [];
+ this.searchResolvedClient = null;
+ this.page = 1;
+
+ this.loadClients();
+ this.refreshData();
+ }
+
+ setContaEmpresaFilter(empresa: string) {
+ if (this.isClientRestricted) return;
+ const next = (empresa ?? '').toString().trim();
+ this.filterContaEmpresa = next;
+ this.expandedGroup = null;
+ this.groupLines = [];
+ this.searchResolvedClient = null;
+ this.page = 1;
+
+ this.loadClients();
+ this.refreshData();
+ }
+
private applyBaseFilters(params: HttpParams): HttpParams {
let next = params;
@@ -1577,6 +1636,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return false;
}
+ if (!this.matchesOperadoraContaEmpresaFilters(line)) {
+ return false;
+ }
+
const selected = this.selectedAdditionalServices;
const hasSelected = selected.length > 0;
@@ -1600,6 +1663,40 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return true;
}
+ private matchesOperadoraContaEmpresaFilters(line: ApiLineList): boolean {
+ const hasOperadora = this.filterOperadora !== 'ALL';
+ const selectedEmpresa = this.filterContaEmpresa.trim();
+ const hasEmpresa = !!selectedEmpresa;
+ if (!hasOperadora && !hasEmpresa) return true;
+
+ const conta = (line as any)?.conta ?? (line as any)?.Conta ?? '';
+ const empresaConta = (line as any)?.contaEmpresa
+ ?? (line as any)?.empresaConta
+ ?? (line as any)?.ContaEmpresa
+ ?? (line as any)?.EmpresaConta
+ ?? (line as any)?.empresa_conta
+ ?? (line as any)?.Empresa_Conta
+ ?? (line as any)?.['empresa (conta)']
+ ?? (line as any)?.['EMPRESA (CONTA)']
+ ?? '';
+
+ const context = resolveOperadoraContext({
+ conta,
+ empresaConta,
+ accountCompanies: this.accountCompanies,
+ });
+
+ if (hasOperadora && context.operadora !== this.filterOperadora) {
+ return false;
+ }
+
+ if (!hasEmpresa) return true;
+
+ const resolvedEmpresa = (context.empresaConta || this.findEmpresaByConta(conta) || '').toString().trim();
+ if (!resolvedEmpresa) return false;
+ return this.normalizeFilterToken(resolvedEmpresa) === this.normalizeFilterToken(selectedEmpresa);
+ }
+
private applyAdditionalFiltersClientSide(lines: ApiLineList[]): ApiLineList[] {
if (!Array.isArray(lines) || lines.length === 0) return [];
return lines.filter((line) => this.matchesAdditionalFilters(line));
@@ -2411,6 +2508,13 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
parts.push(this.selectedAdditionalServices.join('-'));
}
+ if (this.filterOperadora !== 'ALL') {
+ parts.push(`operadora-${this.filterOperadora.toLowerCase()}`);
+ }
+ if (this.filterContaEmpresa.trim()) {
+ parts.push(`empresa-${this.normalizeFilterToken(this.filterContaEmpresa).toLowerCase()}`);
+ }
+
return parts.join('_');
}
@@ -4538,26 +4642,46 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return found ? [...found.contas] : [];
}
- private findEmpresaByConta(conta: any): string {
- const target = this.normalizeConta(conta);
- if (!target) return '';
+ private getContaEmpresaOptionsByOperadora(mode: OperadoraFilterMode): string[] {
+ const empresas = this.mergeOptionList([], this.accountCompanies.map((group) => group?.empresa ?? ''))
+ .filter((empresa) => !!(empresa ?? '').toString().trim());
- const found = this.accountCompanies.find((group) =>
- (group.contas ?? []).some((c) => this.sameConta(c, target))
- );
- return found?.empresa ?? '';
+ const filtered = mode === 'ALL'
+ ? empresas
+ : empresas.filter((empresa) => {
+ const operadora = resolveOperadoraContext({
+ empresaConta: empresa,
+ accountCompanies: this.accountCompanies,
+ }).operadora;
+ return operadora === mode;
+ });
+
+ return filtered.sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' }));
+ }
+
+ private syncContaEmpresaFilterByOperadora(): void {
+ const selected = this.filterContaEmpresa.trim();
+ if (!selected) return;
+
+ const available = this.getContaEmpresaOptionsByOperadora(this.filterOperadora);
+ const normalizedSelected = this.normalizeFilterToken(selected);
+ const hasSelected = available.some((empresa) => this.normalizeFilterToken(empresa) === normalizedSelected);
+
+ if (!hasSelected) {
+ this.filterContaEmpresa = '';
+ }
+ }
+
+ private findEmpresaByConta(conta: any): string {
+ return resolveEmpresaByConta(conta, this.accountCompanies);
}
private normalizeConta(value: any): string {
- const raw = (value ?? '').toString().trim();
- if (!raw) return '';
- if (!/^\d+$/.test(raw)) return raw.toUpperCase();
- const noLeadingZero = raw.replace(/^0+/, '');
- return noLeadingZero || '0';
+ return normalizeContaValue(value);
}
private sameConta(a: any, b: any): boolean {
- return this.normalizeConta(a) === this.normalizeConta(b);
+ return sameContaValue(a, b);
}
private syncContaEmpresaSelection(model: any) {
diff --git a/src/app/utils/account-operator.util.spec.ts b/src/app/utils/account-operator.util.spec.ts
new file mode 100644
index 0000000..ed1c014
--- /dev/null
+++ b/src/app/utils/account-operator.util.spec.ts
@@ -0,0 +1,80 @@
+import {
+ DEFAULT_ACCOUNT_COMPANIES,
+ mergeAccountCompaniesWithDefaults,
+ normalizeConta,
+ resolveEmpresaByConta,
+ resolveOperadoraContext,
+ sameConta,
+} from './account-operator.util';
+
+describe('account-operator.util', () => {
+ it('normaliza contas removendo zeros a esquerda', () => {
+ expect(normalizeConta('0455371844')).toBe('455371844');
+ expect(normalizeConta('000187890982')).toBe('187890982');
+ });
+
+ it('compara contas normalizadas', () => {
+ expect(sameConta('0435288088', '435288088')).toBeTrue();
+ expect(sameConta('172593311', '172593840')).toBeFalse();
+ });
+
+ it('resolve empresa por conta com regras deterministicas obrigatorias', () => {
+ expect(resolveEmpresaByConta('455371844', [])).toBe('VIVO MACROPHONY');
+ expect(resolveEmpresaByConta('460161507', [])).toBe('VIVO MACROPHONY');
+ expect(resolveEmpresaByConta('187890982', [])).toBe('CLARO LINE MÓVEL');
+ expect(resolveEmpresaByConta('TIM', [])).toBe('TIM LINE MÓVEL');
+ });
+
+ it('mescla lista da API com defaults sem perder contas obrigatorias', () => {
+ const merged = mergeAccountCompaniesWithDefaults([
+ { empresa: 'VIVO MACROPHONY', contas: ['0430237019'] },
+ ]);
+
+ const vivo = merged.find((group) => group.empresa === 'VIVO MACROPHONY');
+ const contas = (vivo?.contas ?? []).map((value) => normalizeConta(value));
+
+ expect(contas).toContain(normalizeConta('455371844'));
+ expect(contas).toContain(normalizeConta('460161507'));
+ expect(contas).toContain(normalizeConta('0430237019'));
+ });
+
+ it('classifica operadora e grupo da vivo por contexto', () => {
+ const vivo = resolveOperadoraContext({
+ conta: '455371844',
+ accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
+ });
+ expect(vivo.operadora).toBe('VIVO');
+ expect(vivo.vivoEmpresaGrupo).toBe('MACROPHONY');
+
+ const claro = resolveOperadoraContext({
+ conta: '187890982',
+ accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
+ });
+ expect(claro.operadora).toBe('CLARO');
+ expect(claro.vivoEmpresaGrupo).toBeNull();
+
+ const tim = resolveOperadoraContext({
+ empresaConta: 'TIM LINE MÓVEL',
+ accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
+ });
+ expect(tim.operadora).toBe('TIM');
+
+ const timByConta = resolveOperadoraContext({
+ conta: 'TIM',
+ accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
+ });
+ expect(timByConta.operadora).toBe('TIM');
+ });
+
+ it('prioriza mapeamento deterministico por conta mesmo com empresa da linha divergente', () => {
+ const vivoDeterministico = resolveOperadoraContext({
+ conta: '455371844',
+ empresaConta: 'VIVO LINE MÓVEL',
+ accountCompanies: DEFAULT_ACCOUNT_COMPANIES,
+ });
+
+ expect(vivoDeterministico.operadora).toBe('VIVO');
+ expect(vivoDeterministico.empresaConta).toBe('VIVO MACROPHONY');
+ expect(vivoDeterministico.vivoEmpresaGrupo).toBe('MACROPHONY');
+ });
+});
diff --git a/src/app/utils/account-operator.util.ts b/src/app/utils/account-operator.util.ts
new file mode 100644
index 0000000..3dab6fc
--- /dev/null
+++ b/src/app/utils/account-operator.util.ts
@@ -0,0 +1,176 @@
+import { normalizeAccentInsensitive } from './text-normalization.util';
+
+export type OperadoraNome = 'VIVO' | 'CLARO' | 'TIM' | 'OUTRA';
+export type OperadoraFiltro = 'TODOS' | 'VIVO' | 'CLARO' | 'TIM';
+export type VivoEmpresaGrupo = 'MACROPHONY' | 'LINE MOVEL' | 'OUTRA';
+
+export interface AccountCompanyOption {
+ empresa: string;
+ contas: string[];
+}
+
+export interface OperadoraResolution {
+ operadora: OperadoraNome;
+ empresaConta: string;
+ vivoEmpresaGrupo: VivoEmpresaGrupo | null;
+}
+
+export const DEFAULT_ACCOUNT_COMPANIES: AccountCompanyOption[] = [
+ { empresa: 'CLARO LINE MÓVEL', contas: ['172593311', '172593840', '187890982'] },
+ { empresa: 'VIVO MACROPHONY', contas: ['0430237019', '0437488125', '0449508564', '0454371844', '455371844', '460161507'] },
+ { empresa: 'VIVO LINE MÓVEL', contas: ['0435288088'] },
+ { empresa: 'TIM LINE MÓVEL', contas: ['TIM'] },
+];
+
+const DEFAULT_EMPRESA_BY_CONTA = buildDefaultEmpresaByConta();
+
+function buildDefaultEmpresaByConta(): Map {
+ const result = new Map();
+
+ DEFAULT_ACCOUNT_COMPANIES.forEach((group) => {
+ (group.contas ?? []).forEach((conta) => {
+ const normalized = normalizeConta(conta);
+ if (!normalized) return;
+ result.set(normalized, group.empresa);
+ });
+ });
+
+ return result;
+}
+
+function normalizeEmpresaKey(value: unknown): string {
+ return normalizeAccentInsensitive(value, 'upper').replace(/[^A-Z0-9]/g, '');
+}
+
+function normalizeContas(contas: unknown): string[] {
+ if (!Array.isArray(contas)) return [];
+
+ const result: string[] = [];
+ const seen = new Set();
+
+ contas.forEach((value) => {
+ const trimmed = String(value ?? '').trim();
+ if (!trimmed) return;
+ const normalized = normalizeConta(trimmed);
+ if (!normalized || seen.has(normalized)) return;
+ seen.add(normalized);
+ result.push(trimmed);
+ });
+
+ return result;
+}
+
+export function normalizeConta(value: unknown): string {
+ const raw = String(value ?? '').trim();
+ if (!raw) return '';
+
+ if (!/^\d+$/.test(raw)) {
+ return normalizeAccentInsensitive(raw, 'upper');
+ }
+
+ const noLeadingZero = raw.replace(/^0+/, '');
+ return noLeadingZero || '0';
+}
+
+export function sameConta(a: unknown, b: unknown): boolean {
+ return normalizeConta(a) === normalizeConta(b);
+}
+
+export function mergeAccountCompaniesWithDefaults(
+ source: AccountCompanyOption[] | null | undefined
+): AccountCompanyOption[] {
+ const merged = new Map();
+ const contaSeenByEmpresa = new Map>();
+
+ const addGroup = (empresaRaw: unknown, contasRaw: unknown) => {
+ const empresa = String(empresaRaw ?? '').trim();
+ if (!empresa) return;
+
+ const key = normalizeEmpresaKey(empresa);
+ const contas = normalizeContas(contasRaw);
+
+ if (!merged.has(key)) {
+ merged.set(key, { empresa, contas: [] });
+ contaSeenByEmpresa.set(key, new Set());
+ }
+
+ const record = merged.get(key);
+ const seen = contaSeenByEmpresa.get(key);
+ if (!record || !seen) return;
+
+ contas.forEach((conta) => {
+ const normalized = normalizeConta(conta);
+ if (!normalized || seen.has(normalized)) return;
+ seen.add(normalized);
+ record.contas.push(conta);
+ });
+ };
+
+ (source ?? []).forEach((group) => addGroup(group?.empresa, group?.contas));
+ DEFAULT_ACCOUNT_COMPANIES.forEach((group) => addGroup(group.empresa, group.contas));
+
+ return Array.from(merged.values());
+}
+
+export function resolveEmpresaByConta(
+ conta: unknown,
+ accountCompanies: AccountCompanyOption[] | null | undefined
+): string {
+ const target = normalizeConta(conta);
+ if (!target) return '';
+
+ const deterministic = DEFAULT_EMPRESA_BY_CONTA.get(target);
+ if (deterministic) return deterministic;
+
+ const found = (accountCompanies ?? []).find((group) =>
+ (group.contas ?? []).some((candidate) => sameConta(candidate, target))
+ );
+ return found?.empresa ?? '';
+}
+
+function resolveOperadoraByEmpresa(empresa: unknown): OperadoraNome {
+ const normalized = normalizeEmpresaKey(empresa);
+ if (!normalized) return 'OUTRA';
+ if (normalized.includes('CLARO')) return 'CLARO';
+ if (normalized.includes('TIM')) return 'TIM';
+ if (normalized.includes('VIVO') || normalized.includes('MACROPHONY')) return 'VIVO';
+ return 'OUTRA';
+}
+
+function resolveVivoEmpresaGrupo(empresa: unknown): VivoEmpresaGrupo {
+ const normalized = normalizeEmpresaKey(empresa);
+ if (!normalized) return 'OUTRA';
+ if (normalized.includes('MACROPHONY')) return 'MACROPHONY';
+ if (normalized.includes('LINEMOVEL') || normalized.includes('LINEMOV')) return 'LINE MOVEL';
+ return 'OUTRA';
+}
+
+export function resolveOperadoraContext(input: {
+ conta?: unknown;
+ empresaConta?: unknown;
+ accountCompanies?: AccountCompanyOption[] | null;
+}): OperadoraResolution {
+ const contaRaw = String(input.conta ?? '').trim();
+ const contaEmpresaRaw = String(input.empresaConta ?? '').trim();
+ const empresaFromConta = resolveEmpresaByConta(input.conta, input.accountCompanies);
+ // Regras por conta (determinísticas) têm prioridade sobre texto livre da linha.
+ const empresaConta = empresaFromConta || contaEmpresaRaw;
+
+ let operadora = resolveOperadoraByEmpresa(empresaConta);
+ if (operadora === 'OUTRA' && empresaFromConta) {
+ operadora = resolveOperadoraByEmpresa(empresaFromConta);
+ }
+ if (operadora === 'OUTRA' && contaRaw) {
+ operadora = resolveOperadoraByEmpresa(contaRaw);
+ }
+
+ const vivoEmpresaGrupo = operadora === 'VIVO'
+ ? resolveVivoEmpresaGrupo(empresaConta || empresaFromConta || contaRaw)
+ : null;
+
+ return {
+ operadora,
+ empresaConta: empresaConta || '',
+ vivoEmpresaGrupo,
+ };
+}