/** * 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; `); };