Leva grande, pedida como "pelo menos mais 20 etapas, use subagentes":
fecha a pendência de MFA/TOTP da Fase 1 (feito diretamente, autenticação é
sensível demais para delegar) e lança 5 subagentes em paralelo em 2 ondas
(eden-inventory, eden-support, eden-hr-timeclock; depois eden-finance,
eden-fiscal, eden-frontend), cada um em worktree isolado, com integração
seletiva manual (barrel, app.module.ts, migrations renomeadas/aplicadas,
build/typecheck/teste ao vivo antes de cada merge).
## Backend
- MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `crypto/field-encryption.service.ts`):
login em duas etapas quando `mfa_enabled=true`, `FieldEncryptionService`
genérico (AES-256-GCM versionado, ADR-0005) reaproveitado por outros
segredos reversíveis (ex. credencial de equipamento Control iD).
- Inventory: warehouses, stock_movements (ledger append-only), serialized_assets,
asset_assignments, stock_reservations — invariante "serial/patrimônio/MAC
nunca duplicado entre ativos ativos" reforçada por índices únicos parciais
no banco (Master Prompt §15.3).
- Support/Service Desk: tickets com máquina de estados, SLA com pausa/retomada
em `waiting_customer`/`waiting_third_party` (funções puras testáveis).
- HR-Timeclock (fundação): dispositivos Control iD (senha cifrada), import de
AFD com parser + CRC-16/KERMIT, `afd_records` imutável sem exceção nem
para super_admin (sem válvula de escape, diferente de audit_log).
- Fiscal: NCM/CFOP/municípios/naturezas de operação (eden.md módulo 5, MVP de
5 das ~19 tabelas), product_fiscal_profiles com invariante de único
profile ativo por billing_component.
- Billing: subscription→billing_run→invoice idempotente (regenerar em draft
substitui, nunca duplica) e imutável após consolidar (ADR-0008: nunca
confunde faturamento/documento fiscal/recebimento).
- FKs cruzadas fechadas: quotes/client_registrations/contracts.
## Frontend — primeira leva
- `packages/ui`: Design System real extraído do tema DreamsERP comprado —
achado que mudou o escopo: o tema já vem em React+Tailwind (não só
Angular), tokens visuais reais portados (cores, Geist como fonte real,
divergindo da doc antiga que citava Nunito/Poppins).
- `apps/core-web`: login real (com suporte a MFA), sessão, shell autenticado
via `AppShell` — substitui a tela estática "Bootstrap da Fase 1".
## Bugs reais encontrados e corrigidos (todos via teste ao vivo, não leitura)
1. **Segurança**: o token de desafio MFA era aceito como access token normal
pelo `JwtAuthGuard` (mesma assinatura, nenhuma checagem da claim
`purpose`) — bypass total do segundo fator. Explorado e fechado ao vivo
contra o container real; teste de regressão em `test/mfa.spec.ts`.
2. Crase solta dentro de comentário SQL na migration de hr-timeclock quebrava
o template literal JS (`SyntaxError` ao rodar a migration).
3. CORS ausente por completo na API.
4. `turbo.json` não declarava `VITE_API_BASE_URL` como input de build — o
Turborepo cacheava o bundle do frontend ignorando mudança na env var.
5. `compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`, que colidia com a
variável de mesmo nome do `.env` local — o default nunca era aplicado.
6. Não existia `.dockerignore` — todo build copiava node_modules/.git/
worktrees de subagentes (665MB) para dentro do contexto Docker.
Decisão do operador incorporada: a API nunca tem domínio público próprio
(só IP interno) — os 3 frontends chamam por caminho relativo `/api/v1`,
proxy externo roteia same-origin; CORS só é relevante para dev local.
Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter
@eden/api test` (30/30, cresceu de 15), todo o lote testado ao vivo via
curl contra os containers reais reconstruídos, incluindo os 6 bugs acima
já fechados e confirmados, e a trilha de auditoria conferida no Postgres.
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
246 lines
18 KiB
JavaScript
246 lines
18 KiB
JavaScript
/**
|
|
* HR/Timeclock (Ponto Eletrônico) — eden.md módulo 6, foundation leva.
|
|
* Master Prompt §6.14. Ported faithfully from the legacy OrçaFácil
|
|
* (`eden.md` módulo 6 §1 for the data model, §2 for the Control iD
|
|
* integration shape, §4 for the AFD format) — this is dense legal/
|
|
* compliance logic already validated in production, not redesigned here.
|
|
*
|
|
* SCOPE OF THIS LEVA (deliberately narrow — see the task brief):
|
|
* - timeclock_devices — Control iD REP iDClass device registry
|
|
* (data model only; no live sync yet)
|
|
* - timeclock_employees — separate cadastro from `users` (see note
|
|
* below on why)
|
|
* - afd_import_batches — one row per AFD file import (manual upload)
|
|
* - afd_records — one row per AFD file LINE, IMMUTABLE
|
|
*
|
|
* Explicitly DEFERRED to a future leva, not forgotten (eden.md §5-§10):
|
|
* - timeclock_employee_devices — per-device sync/biometry status link
|
|
* table (eden.md §1.3); needs the live-sync workflow to be meaningful.
|
|
* - timeclock_work_schedules / timeclock_work_schedule_days (§1.6-1.7)
|
|
* - timeclock_holidays (§1.8)
|
|
* - timeclock_punch_adjustments (§1.9) — administrative treatments
|
|
* - timeclock_daily_calculations (§1.10) — apuração engine output
|
|
* - timeclock_time_bank_entries (§1.11) — banco de horas ledger
|
|
* - timeclock_period_closures (§1.12) — fechamento de período
|
|
* - time_audit_logs (§1.13) — this leva uses the shared `audit_log`
|
|
* hash-chain (via auditRepo.appendAuditEvent) instead; the module-
|
|
* specific structured log is deferred with the workflows that need it
|
|
* (approvals, reopenings) to render nicely.
|
|
*
|
|
* ── employees vs. users (explicit decision, per task brief) ─────────────
|
|
* eden.md §1.2 is unambiguous: `timeclock_employees` is a cadastro
|
|
* SEPARATE from `users` (with an optional `user_id` link), because (a) a
|
|
* punch-clock employee often has no ERP login at all, and (b) the
|
|
* Control iD device identifies a person BY CPF (eden.md §1.2's "Importante
|
|
* — identidade no relógio": "no modo 671 da API Control iD, o CPF é o
|
|
* próprio identificador do usuário no equipamento"), and `users` has no
|
|
* CPF column. Reusing `users` would mean inventing a CPF column on a
|
|
* table owned by Identity/Auth, and forcing every punch-clock employee to
|
|
* have an ERP login they may not need. So: `timeclock_employees` it is,
|
|
* ported faithfully.
|
|
*
|
|
* ── naming deviation from eden.md, per task brief ────────────────────────
|
|
* The task brief names the import-batch table `afd_import_batches`
|
|
* (this migration) rather than eden.md's `afd_imports`. Kept as briefed.
|
|
* Also simplified vs. eden.md's richer version: no S3 storage of the
|
|
* original file bytes (the file is never persisted outside afd_records'
|
|
* raw_line reconstruction — see afd-imports.controller.ts (CreateAfdImportDto) for why: this
|
|
* leva's import endpoint accepts the file as a JSON string, not a
|
|
* multipart upload, so there's no original file artifact worth archiving
|
|
* separately from the parsed lines themselves; a real multipart upload +
|
|
* S3 archival, matching eden.md's `file_s3_key`, is future work), and no
|
|
* `source` distinction (`manual_upload` only — `live_sync` is deferred
|
|
* along with the Control iD live-communication layer, eden.md §2-3).
|
|
*
|
|
* ── crc_valid / validation_status as TEXT enums, not a plain boolean ────
|
|
* The task brief's own summary suggested a boolean `crc_valid`, but
|
|
* eden.md §1.5/§4.2 documents a 4-value enum (VALID/INVALID/
|
|
* NOT_APPLICABLE/NOT_VALIDATED) because "no CRC field for this record
|
|
* type" (types 1/5/6/9) and "CRC field present but not checked" are
|
|
* different, useful facts from a real CRC mismatch (type 7's own hash
|
|
* chain) — collapsing that into a boolean would lose information the
|
|
* legacy explicitly modeled. Followed eden.md here per this task's own
|
|
* instruction to prefer the full read over the summary when they diverge.
|
|
*/
|
|
|
|
exports.shorthands = undefined;
|
|
|
|
exports.up = (pgm) => {
|
|
pgm.sql(`
|
|
-- =====================================================================
|
|
-- timeclock_devices — Control iD REP iDClass equipment registry
|
|
-- =====================================================================
|
|
CREATE TABLE timeclock_devices (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
created_by UUID REFERENCES users(id),
|
|
updated_by UUID REFERENCES users(id),
|
|
company_id UUID,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
branch TEXT,
|
|
host TEXT NOT NULL,
|
|
port INTEGER NOT NULL DEFAULT 443 CHECK (port > 0 AND port <= 65535),
|
|
use_https BOOLEAN NOT NULL DEFAULT true,
|
|
username TEXT NOT NULL,
|
|
password_encrypted TEXT NOT NULL,
|
|
serial_number TEXT,
|
|
model TEXT,
|
|
firmware_version TEXT,
|
|
timezone TEXT NOT NULL DEFAULT 'America/Sao_Paulo',
|
|
status TEXT NOT NULL DEFAULT 'UNKNOWN' CHECK (status IN ('UNKNOWN', 'ONLINE', 'OFFLINE', 'ERROR')),
|
|
last_seen_at TIMESTAMPTZ,
|
|
last_sync_at TIMESTAMPTZ,
|
|
last_error TEXT,
|
|
last_afd_nsr BIGINT,
|
|
active BOOLEAN NOT NULL DEFAULT true
|
|
);
|
|
COMMENT ON TABLE timeclock_devices IS
|
|
'Relógios de ponto Control iD REP iDClass — eden.md módulo 6 §1.1/§2. company_id nullable = equipamento "sem empresa" (compartilhado). DELIBERADAMENTE sem FK para companies: essa tabela (Organization bounded context) não existe neste worktree isolado no momento em que esta migration foi escrita — mesma situação de contracts.cjs (contracts.quote_id/client_registration_id) neste monorepo. A FK é trabalho de integração/merge, não desta migration.';
|
|
COMMENT ON COLUMN timeclock_devices.password_encrypted IS
|
|
'AES-256-GCM reversível via FieldEncryptionService (apps/api/src/crypto/field-encryption.service.ts, formato v1:iv:tag:ciphertext) — NUNCA hash, porque a aplicação precisa autenticar no equipamento sob demanda a cada operação (eden.md §2.2/§2.3). NUNCA retornada em texto puro por nenhuma rota GET — a API expõe apenas um flag hasCredential.';
|
|
COMMENT ON COLUMN timeclock_devices.timezone IS
|
|
'Metadado informativo do cadastro do equipamento. A interpretação de negócio das datas/horas do AFD SEMPRE usa o offset literal embutido no próprio campo de data/hora do arquivo (fixo -03:00 na prática, América/São_Paulo sem horário de verão desde 2019) — esta coluna não é consultada pelo parser (afd-parser.ts), que nunca assume o timezone do servidor nem de nenhuma configuração.';
|
|
COMMENT ON COLUMN timeclock_devices.last_afd_nsr IS
|
|
'Cursor de sincronização incremental do AFD (eden.md §3) — reservado para a leva de sync ao vivo; não é escrito por esta leva (só import manual de arquivo completo).';
|
|
|
|
CREATE TRIGGER trg_timeclock_devices_updated_at BEFORE UPDATE ON timeclock_devices
|
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
|
CREATE INDEX idx_timeclock_devices_company ON timeclock_devices(company_id);
|
|
|
|
-- =====================================================================
|
|
-- timeclock_employees — separate from 'users' (see migration header)
|
|
-- =====================================================================
|
|
CREATE TABLE timeclock_employees (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
created_by UUID REFERENCES users(id),
|
|
updated_by UUID REFERENCES users(id),
|
|
company_id UUID,
|
|
user_id UUID REFERENCES users(id),
|
|
full_name TEXT NOT NULL,
|
|
cpf TEXT NOT NULL,
|
|
registration TEXT,
|
|
active BOOLEAN NOT NULL DEFAULT true,
|
|
UNIQUE (company_id, cpf)
|
|
);
|
|
COMMENT ON TABLE timeclock_employees IS
|
|
'Cadastro de funcionários do módulo de ponto, SEPARADO de users (eden.md §1.2) — vínculo opcional via user_id. CPF (11 dígitos, sem máscara) é o identificador do usuário no equipamento Control iD no modo 671 — não existe um "controlid_user_id" separado. CPF único por empresa (company_id, cpf), não globalmente. work_schedule_id (jornada vinculada) é DEFERRADO para a leva de apuração — não existe ainda tabela timeclock_work_schedules.';
|
|
COMMENT ON COLUMN timeclock_employees.cpf IS
|
|
'11 dígitos, sem máscara. Validação de dígito verificador é responsabilidade da camada de aplicação (mesma convenção de contract_parties.document/companies.cnpj neste monorepo) — sem CHECK de formato no banco nesta leva (nenhuma rota de CRUD de employees foi implementada ainda; a coluna existe para afd_records.employee_id ter o que referenciar e para a resolução de CPF do parser).';
|
|
|
|
CREATE TRIGGER trg_timeclock_employees_updated_at BEFORE UPDATE ON timeclock_employees
|
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
|
CREATE INDEX idx_timeclock_employees_company ON timeclock_employees(company_id);
|
|
CREATE INDEX idx_timeclock_employees_user ON timeclock_employees(user_id);
|
|
|
|
-- =====================================================================
|
|
-- afd_import_batches — one row per AFD file import (manual upload only
|
|
-- this leva; live_sync source is deferred with the Control iD live
|
|
-- communication layer, eden.md §2-3)
|
|
-- =====================================================================
|
|
CREATE TABLE afd_import_batches (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
device_id UUID NOT NULL REFERENCES timeclock_devices(id),
|
|
company_id UUID,
|
|
source TEXT NOT NULL DEFAULT 'manual_upload' CHECK (source = 'manual_upload'),
|
|
file_hash TEXT NOT NULL UNIQUE,
|
|
file_size INTEGER NOT NULL,
|
|
record_count INTEGER NOT NULL DEFAULT 0,
|
|
inserted_count INTEGER NOT NULL DEFAULT 0,
|
|
valid_count INTEGER NOT NULL DEFAULT 0,
|
|
warning_count INTEGER NOT NULL DEFAULT 0,
|
|
invalid_count INTEGER NOT NULL DEFAULT 0,
|
|
unsupported_count INTEGER NOT NULL DEFAULT 0,
|
|
parse_failure_count INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL CHECK (status IN ('VALID', 'WARNING', 'INVALID')),
|
|
first_nsr BIGINT,
|
|
last_nsr BIGINT,
|
|
summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
imported_by UUID REFERENCES users(id),
|
|
imported_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
COMMENT ON TABLE afd_import_batches IS
|
|
'Metadados de cada importação de arquivo AFD (eden.md §1.4, nomeada afd_import_batches por instrução desta leva em vez de afd_imports do legado). file_hash é a idempotência de arquivo inteiro (SHA-256) — UNIQUE garante que reimportar o mesmo conteúdo retorna already_imported em vez de duplicar. Simplificação desta leva: nenhum S3/armazenamento do arquivo original bruto (o transporte é um JSON string, não multipart — ver afd-imports.controller.ts (CreateAfdImportDto)); o dado legal é preservado integralmente em afd_records.raw_line, uma linha por registro.';
|
|
COMMENT ON COLUMN afd_import_batches.file_hash IS
|
|
'SHA-256 do CONTEÚDO recebido pela API (a string JSON do body, decodificada como texto) — não necessariamente idêntico byte-a-byte ao SHA-256 de um arquivo .afd bruto em disco, já que o transporte desta leva é string JSON, não upload multipart de bytes crus. Suficiente para o propósito de idempotência desta leva; ver nota de simplificação no cabeçalho da migration.';
|
|
COMMENT ON COLUMN afd_import_batches.record_count IS
|
|
'Total de linhas parseadas do arquivo (inclui INVALID/UNSUPPORTED_LAYOUT — nunca descartadas). inserted_count pode ser menor que record_count quando ON CONFLICT (device_id, nsr) DO NOTHING pulou registros já existentes de uma importação anterior (idempotência por registro, eden.md §1.5).';
|
|
|
|
CREATE INDEX idx_afd_import_batches_device ON afd_import_batches(device_id);
|
|
|
|
-- =====================================================================
|
|
-- afd_records — one row per AFD file LINE. IMMUTABLE: no application
|
|
-- route may ever UPDATE/DELETE this table, at any privilege level,
|
|
-- not even super_admin (eden.md módulo 6 §12) — enforced here again at
|
|
-- the DB level via trigger, same pattern as audit_log's
|
|
-- prevent_audit_log_mutation, but WITHOUT that trigger's escape-hatch
|
|
-- GUC (audit_log allows a controlled internal bypass; afd_records
|
|
-- deliberately does not, per this leva's explicit non-negotiable
|
|
-- instruction: "em nenhum nível de privilégio, nem para super_admin").
|
|
-- =====================================================================
|
|
CREATE TABLE afd_records (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
afd_import_batch_id UUID NOT NULL REFERENCES afd_import_batches(id),
|
|
device_id UUID NOT NULL REFERENCES timeclock_devices(id),
|
|
company_id UUID,
|
|
nsr BIGINT,
|
|
record_type TEXT NOT NULL,
|
|
event_datetime TIMESTAMPTZ,
|
|
event_datetime_raw TEXT,
|
|
employee_cpf_raw TEXT,
|
|
employee_id UUID REFERENCES timeclock_employees(id),
|
|
raw_line TEXT NOT NULL,
|
|
crc_received TEXT,
|
|
crc_calculated TEXT,
|
|
crc_valid TEXT NOT NULL DEFAULT 'NOT_APPLICABLE' CHECK (crc_valid IN ('VALID', 'INVALID', 'NOT_APPLICABLE', 'NOT_VALIDATED')),
|
|
parsed_data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
validation_status TEXT NOT NULL CHECK (validation_status IN ('VALID', 'WARNING', 'INVALID', 'UNSUPPORTED_LAYOUT')),
|
|
validation_notes TEXT,
|
|
UNIQUE (device_id, nsr)
|
|
);
|
|
COMMENT ON TABLE afd_records IS
|
|
'Dado bruto de ponto, eden.md módulo 6 §1.5/§12 — IMUTÁVEL por design. Nenhuma rota de UPDATE/DELETE existe na camada de aplicação (apps/api/src/hr-timeclock/afd-records.controller.ts expõe apenas GET) e o banco reforça isso via trigger (trg_afd_records_no_update/no_delete abaixo) SEM nenhuma válvula de escape — nem para super_admin, diferente de audit_log. Preservado mesmo quando inválido: nsr é nullable de propósito (NSR ausente/não-numérico nunca vira 0/autoincrement), e todo registro tem raw_line completa, mesmo os classificados INVALID/UNSUPPORTED_LAYOUT.';
|
|
COMMENT ON COLUMN afd_records.nsr IS
|
|
'Número Sequencial de Registro. NULLABLE DE PROPÓSITO — NSR ausente/não-numérico é inválido mas precisa ser preservado, nunca substituído por 0/autoincrement (eden.md §12). UNIQUE (device_id, nsr): múltiplas linhas com nsr NULL do mesmo device são permitidas (semântica padrão de NULL em índice único do Postgres) — a idempotência por registro só se aplica a NSRs realmente numéricos.';
|
|
COMMENT ON COLUMN afd_records.crc_valid IS
|
|
'VALID/INVALID = CRC-16/KERMIT verificado (tipos 2/3/4). NOT_APPLICABLE = tipo 7, que usa cadeia de hash própria NUNCA recalculada pelo EDEN. NOT_VALIDATED = tipos 1/5/6/9, sem campo de CRC. Enum de 4 valores, não boolean — ver nota no cabeçalho da migration sobre por que.';
|
|
COMMENT ON COLUMN afd_records.parsed_data IS
|
|
'Campos decodificados específicos do tipo de registro, com posição EXATA documentada em eden.md §4.1 — qualquer trecho sem posição documentada vai para a chave "raw_tail" dentro deste JSONB, nunca é inventado/adivinhado (ver apps/api/src/hr-timeclock/afd-parser.ts).';
|
|
COMMENT ON COLUMN afd_records.employee_cpf_raw IS
|
|
'CPF exatamente como veio no campo do AFD (12 caracteres de largura documentada para tipos 3/7, 11 para o cpf_resp do tipo 4) — sempre string, nunca number, nunca perde zero à esquerda. employee_id é resolvido a partir daqui via normalizeAfdCpf (últimos 11 dígitos numéricos), escopado por company_id quando o device tem uma; NULL quando órfão (sem match) — nunca um match é inventado/forçado.';
|
|
|
|
CREATE INDEX idx_afd_records_employee ON afd_records(employee_id);
|
|
CREATE INDEX idx_afd_records_company ON afd_records(company_id);
|
|
CREATE INDEX idx_afd_records_type ON afd_records(record_type);
|
|
CREATE INDEX idx_afd_records_event_datetime ON afd_records(event_datetime);
|
|
CREATE INDEX idx_afd_records_batch ON afd_records(afd_import_batch_id);
|
|
|
|
CREATE OR REPLACE FUNCTION prevent_afd_records_mutation() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
RAISE EXCEPTION 'afd_records é imutável (dado bruto de ponto, eden.md módulo 6 §12) — UPDATE/DELETE bloqueados, sem exceção, mesmo para super_admin';
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER trg_afd_records_no_update BEFORE UPDATE ON afd_records
|
|
FOR EACH ROW EXECUTE FUNCTION prevent_afd_records_mutation();
|
|
CREATE TRIGGER trg_afd_records_no_delete BEFORE DELETE ON afd_records
|
|
FOR EACH ROW EXECUTE FUNCTION prevent_afd_records_mutation();
|
|
`);
|
|
};
|
|
|
|
exports.down = (pgm) => {
|
|
pgm.sql(`
|
|
DROP TRIGGER IF EXISTS trg_afd_records_no_delete ON afd_records;
|
|
DROP TRIGGER IF EXISTS trg_afd_records_no_update ON afd_records;
|
|
DROP FUNCTION IF EXISTS prevent_afd_records_mutation();
|
|
DROP TABLE IF EXISTS afd_records;
|
|
DROP TABLE IF EXISTS afd_import_batches;
|
|
DROP TABLE IF EXISTS timeclock_employees;
|
|
DROP TABLE IF EXISTS timeclock_devices;
|
|
`);
|
|
};
|