Gerenciamento de Filiais (Branch → PDV)
Visão Geral
A hierarquia enterprise distingue entre unidade física (Branch/Filial) e terminal de transação (PDV). Uma filial pode operar 10+ PDVs simultâneos. A relação é:
TENANT (1)
└── BRANCH (N) — unidade física / filial
├── CNPJ, endereço, cerca GPS, taxas IBS/CBS
├── managerId → USER (gerente da filial)
└── PDV (N) — terminal de caixa
├── branch → BRANCH (FK)
└── SHIFT (N) — sessão de caixa
├── branch → BRANCH (denormalizado — agregação rápida)
└── ORDER (N)
└── shift → SHIFT (FK sparse)
Shift.branché denormalizado intencionalmente: o daily-report faz$match { branch: branchId }direto na coleção Shift — O(shifts) sem$lookupnos PDVs.
Schema: Branch
backend/src/branches/schemas/branch.schema.ts
| Campo | Tipo | Descrição |
|---|---|---|
tenant | ObjectId | Tenant pai |
name | string | Nome da filial (max 120) |
slug | string | Identificador único por tenant (lowercase, [a-z0-9-]) |
active | boolean | Se a filial está operacional |
isHQ | boolean | Se é a sede principal |
cnpj | string? | CNPJ desta unidade |
razaoSocial | string? | Razão social |
inscricaoEstadual | string? | IE |
inscricaoMunicipal | string? | IM |
regimeTributario | '1'|'2'|'3' | Simples/Presumido/Real |
focusNfeToken | string? | Token Focus NFe desta filial |
ibsCbsEnabled | boolean | Habilita cálculo IBS/CBS (Reforma Tributária) |
ibsRate | number? | Alíquota IBS (0–100) |
cbsRate | number? | Alíquota CBS (0–100) |
ibgeMunicipioCode | string? | Código IBGE para apuração regional |
address | string? | Logradouro |
addressNumber | string? | Número |
district | string? | Bairro |
city | string? | Cidade |
state | string? | UF (2 chars) |
cep | string? | CEP |
latitude | number? | GPS latitude |
longitude | number? | GPS longitude |
gpsRadiusMeters | number | Raio de validação (default 200m) |
openingHours | [{dayOfWeek, opens, closes}] | Horários por dia da semana |
logo | string? | URL da logo |
primaryColor | string? | Cor primária |
managerId | ObjectId? | Gerente responsável → USER |
evolutionInstance | string? | Instância WhatsApp (Evolution API) |
Índices:
{ slug: 1, tenant: 1 }— unique{ tenant: 1, active: 1 }
Schema: PDV (atualizado)
backend/src/pdvs/schemas/pdv.schema.ts
Campo adicionado nesta feature:
| Campo | Tipo | Descrição |
|---|---|---|
branch | ObjectId? | Branch pai — FK para a filial que contém este terminal |
Schema: Shift (atualizado)
backend/src/shifts/schemas/shift.schema.ts
Campos adicionados nesta feature:
| Campo | Tipo | Descrição |
|---|---|---|
branch | ObjectId? | Branch (denormalizado de pdv.branch no momento da abertura) |
cashDifference | number? | declaredCash - (openingFloat + cashRevenue) — negativo = sobra |
declaredCash | number? | Dinheiro contado pelo operador no fechamento |
reconciledBy | ObjectId? | Gerente que reconciliou |
reconciledAt | Date? | Timestamp da reconciliação |
reconciliationNotes | string? | Observações do gerente |
clockInLatitude | number? | GPS do operador na abertura |
clockInLongitude | number? | GPS do operador na abertura |
Status do turno: open → closed → reconciled
RBAC
Permissões Branch
backend/src/common/enums/permissions.enum.ts
| Permissão | Descrição |
|---|---|
branch.view.reports | Ver relatórios da filial |
branch.manage.pdvs | Gerenciar terminais da filial |
branch.manage.shifts | Abrir/fechar/reconciliar turnos |
branch.manage.config | Editar configurações da filial |
branch.* | Wildcard — todas as permissões da filial |
Role: branch_manager
Definido em backend/src/roles/roles.service.ts (SYSTEM_ROLES).
| Escopo | Acesso |
|---|---|
| JWT payload | assignedBranchIds: [id1, id2] (embutido no sign time) |
BranchScopeGuard | Resolve branch IDs → PDV IDs via Redis (branch:pdvs:{branchId}, TTL 300s) |
| Acesso | Somente filiais/PDVs atribuídos; bypass de revogação por PDV individual |
Guard chain
JwtAuthGuard → TenantGuard → BranchScopeGuard → RolesGuardBranchScopeGuard (backend/src/common/guards/branch-scope.guard.ts):
- Extrai
assignedBranchIdsdo JWT - Para cada branchId, consulta Redis
branch:pdvs:{branchId}ou faz query no MongoDB - Popula
request.assignedBranchIdspara uso nos controllers - Admins/superadmins bypass automaticamente
API Routes
Todas as rotas abaixo são prefixadas com /t/:slug/admin/branches.
Filiais
| Método | Rota | Acesso | Descrição |
|---|---|---|---|
GET | / | Admin | Listar filiais |
POST | / | Admin | Criar filial |
GET | /compare | Admin | Comparativo multi-filial |
GET | /:id | Admin | Detalhe da filial |
PUT | /:id | Admin | Atualizar filial |
DELETE | /:id | Admin | Remover filial |
GET | /:id/stats | Admin | KPIs da filial |
PDVs da Filial
| Método | Rota | Acesso | Descrição |
|---|---|---|---|
GET | /:id/pdvs | Admin/Manager | PDVs desta filial |
Turnos via Filial
| Método | Rota | Acesso | Descrição |
|---|---|---|---|
GET | /:id/shifts/active | Admin/Manager | Turnos abertos em todos os PDVs da filial |
POST | /:id/shifts/open | Admin/Manager | Abrir turno em um PDV desta filial |
GET | /:id/daily-report | Admin/Manager | Relatório diário com breakdown por PDV |
Daily Report
Request:
GET /t/:slug/admin/branches/:id/daily-report?date=2026-04-25Response:
{
"totals": {
"revenue": 3420.50,
"cash": 980.00,
"card": 1840.50,
"pix": 600.00,
"orders": 47,
"shifts": 3
},
"byPdv": [
{
"pdvId": "...",
"pdvName": "Caixa 1",
"revenue": 1240.00,
"cash": 320.00,
"card": 720.00,
"pix": 200.00,
"orders": 18,
"shifts": 1
}
]
}Abrir Turno via Filial
Request:
POST /t/:slug/admin/branches/:id/shifts/open
Body: { "pdvId": "...", "openingFloat": 100.00, "userId": "..." }Validação server-side: pdv.branch === branchId — rejeita se o PDV não pertencer a esta filial.
Lógica de Cash Sessions (Rollup)
Abertura
- Operador abre turno no PDV (
POST /shiftsou viaPOST /branches/:id/shifts/open) ShiftsService.openShift(): fecha qualquer turno aberto anterior do mesmo user+pdv- Busca
pdv.branch→ denormalizabranchno Shift criado - Se a filial tem GPS configurado e o device enviou coordenadas → valida
haversineMetres() ≤ gpsRadiusMeters
Fechamento
ShiftsService.closeShift() usa union query migration-safe:
orders WHERE shift = shiftId // pedidos com FK novo
OR (pdv = pdvId AND shift IS NULL // pedidos legados sem FK
AND createdAt IN [start, end])Agrega: totalRevenue, cashRevenue, cardRevenue, pixRevenue, totalOrders, voidedOrders
Reconciliação: cashDifference = declaredCash - (openingFloat + cashRevenue)
Rollup para Relatório da Filial
ShiftsService.getBranchDailyReport() — pipeline MongoDB:
$match { branch: branchId, startedAt: [dayStart, dayEnd] }
$group by pdv → { revenue, cash, card, pix, orders, shifts: count }
$lookup pdvs → pdvName
$project pdvId, pdvName, revenue, cash, card, pix, orders, shiftsComplexidade: O(shifts) — scan único na coleção Shifts sem joins em Orders.
Frontend
Views
| View | Arquivo | Descrição |
|---|---|---|
BranchManagementView | views/admin/BranchManagementView.tsx | Lista de filiais + criar |
BranchDetailView | views/admin/BranchDetailView.tsx | Detalhe com tabs |
BranchPdvManagementView | views/admin/BranchPdvManagementView.tsx | Grid Quiet Luxury de terminais |
BranchCashSessionsView | views/admin/BranchCashSessionsView.tsx | Sessões de caixa da filial |
BranchOverviewView | views/admin/branch/BranchOverviewView.tsx | Overview na BranchLayout |
BranchSettingsView | views/admin/branch/BranchSettingsView.tsx | Configurações da filial |
Rotas React
/t/:slug/admin/branches → BranchManagementView
/t/:slug/admin/branches/:id → BranchDetailView
/t/:slug/admin/branches/:id/cash-sessions → BranchCashSessionsView
/t/:slug/admin/terminals → BranchPdvManagementView
/t/:slug/admin/b/:branchSlug/overview → BranchOverviewView
/t/:slug/admin/b/:branchSlug/settings → BranchSettingsView
/t/:slug/admin/b/:branchSlug/stock → BranchStockView
/t/:slug/admin/b/:branchSlug/inventory → BranchInventoryView
/t/:slug/admin/b/:branchSlug/orders → BranchOrdersView
/t/:slug/admin/b/:branchSlug/payments → BranchPaymentsView
/t/:slug/admin/b/:branchSlug/tables → BranchTablesView
/t/:slug/admin/b/:branchSlug/balcao → BranchBalcaoViewBranchPdvManagementView — Design Quiet Luxury
Layout two-column:
- Esquerda: stats da filial (receita total, turnos ativos, operadores)
- Direita: grid de
PdvCardcomStatusDot(green=ativo, gray=inativo),MonoNumberpara receita, botão "Abrir Caixa" →FormModal
Modal "Abrir Caixa" envia POST /branches/:id/shifts/open { pdvId, openingFloat }.