feat(api,ui): MFA/TOTP, Inventory, Support, HR-Timeclock, Fiscal, Billing e Design System

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>
This commit is contained in:
2026-09-03 17:25:31 -03:00
parent 18838a9031
commit eff515b70d
105 changed files with 8875 additions and 21 deletions

View File

@@ -0,0 +1,264 @@
/**
* Inventory/Warehouse/Assets bounded context — Master Prompt §6.8, ADR-0007.
* Greenfield: the legacy OrçaFácil has no real stock module, only the
* requisições cited in the Master Prompt, so there is no eden.md section
* to port faithfully here — this schema is designed from the Master
* Prompt §6.8 requirements directly.
*
* Master Prompt §6.8 lists 15 possible entities. This migration builds
* the 5 that form one coherent, end-to-end-testable slice (reserve →
* select serial → install/comodato → return), mirroring the
* 4-of-11-tables MVP approach taken by the Contracts module
* (docs/adr/0006, see 1788447802216_contracts.cjs's header comment):
*
* - warehouses (this migration)
* - stock_movements (this migration)
* - serialized_assets (this migration)
* - asset_assignments (this migration)
* - stock_reservations (this migration)
*
* Explicitly DEFERRED, not forgotten — each needs a module/workflow that
* hasn't landed yet:
* - warehouse_locations — bin/shelf-level location inside a warehouse;
* needs a UX for put-away/picking before a bare table is useful.
* - stock_items — a per-(warehouse,product) balance cache row;
* deliberately NOT built — this MVP's invariant is that on-hand
* balance is ALWAYS derived from stock_movements (SUM by sign), never
* a stored/editable column (see stock_movements' comment). A
* materialized cache can be added later purely as a performance
* optimization, refreshed FROM the ledger, never the other way round.
* - stock_lots — batch/lot tracking (expiry, supplier batch);
* no product in the current catalog needs it yet.
* - inventory_counts — physical count / cycle-count workflow with
* its own reconciliation UX; a count's variance would itself need to
* post `adjustment_in`/`adjustment_out` movements once built.
* - transfers — a formal multi-step inter-warehouse transfer
* (request → in-transit → receive) with its own approval workflow;
* `transfer_out`/`transfer_in` movement types are already modeled in
* the ledger vocabulary below so this can be layered on top later
* without a ledger migration.
* - receipts / issues — formal goods-receipt-note / issue-note
* documents (e.g. tied to a purchase order); `purchase_in` already
* covers the ledger side for this MVP without the PO workflow.
* - returns — a formal customer-return workflow distinct
* from the simple `/assets/:id/return` endpoint this MVP ships
* (which only closes the assignment and posts `return_in`); needed
* later for e.g. partial/quantity-based non-serialized returns.
* - rma — formal vendor RMA case tracking (tracking
* number, vendor, resolution); `rma_out` is already modeled in the
* ledger vocabulary below so the case-tracking table can be added
* later without a ledger migration.
* - asset_maintenance — service/repair history for a serialized
* asset; needs its own workflow design, not just a bare log table.
*
* No FK to `contracts` yet: `serialized_assets.current_contract_id` and
* `asset_assignments.contract_id` are deliberately nullable/plain UUID
* with NO REFERENCES clause — same adopted pattern as Quotes/Contracts/
* ClientRegistrations' cross-module FKs while modules land in parallel
* worktrees. Wiring those FKs is integration work for the merge step.
*
* ON DELETE: no table in this migration ever CASCADEs a delete from
* warehouses/products/serialized_assets — an asset or warehouse with
* movement/assignment history is never hard-deleted, only inactivated
* (warehouses.is_active) or status-transitioned (serialized_assets.status
* = 'written_off'). This is a deliberate invariant per the Inventory
* agent's mandate, not an oversight.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- warehouses — armazém/depósito físico, pertence a uma legal entity
-- =====================================================================
CREATE TABLE warehouses (
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),
company_id UUID NOT NULL REFERENCES companies(id),
name TEXT NOT NULL,
code TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
UNIQUE (code)
);
COMMENT ON TABLE warehouses IS
'Armazém/depósito físico — Master Prompt §6.8. Pertence a uma company (legal entity, companies.id). code é único globalmente (não só por company) para servir como identificador humano curto e inequívoco em toda a UI/relatórios. Nunca deletado fisicamente — um armazém desativado é is_active=false, preservando o histórico de stock_movements/serialized_assets que apontam para ele (ver comentário de topo da migration).';
CREATE TRIGGER trg_warehouses_updated_at BEFORE UPDATE ON warehouses
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_warehouses_company ON warehouses(company_id);
-- =====================================================================
-- stock_movements — ledger append-only. Saldo de estoque é SEMPRE
-- derivado daqui (SUM com sinal implícito pelo tipo), nunca uma coluna
-- de saldo editável em nenhuma outra tabela.
-- =====================================================================
CREATE TABLE stock_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
warehouse_id UUID NOT NULL REFERENCES warehouses(id),
product_id UUID NOT NULL REFERENCES products(id),
movement_type TEXT NOT NULL CHECK (movement_type IN (
'purchase_in', 'adjustment_in', 'adjustment_out',
'transfer_out', 'transfer_in',
'reservation', 'reservation_release',
'sale_out', 'install_out', 'comodato_out',
'return_in', 'rma_out', 'write_off'
)),
quantity NUMERIC(14,3) NOT NULL CHECK (quantity > 0),
reference_type TEXT,
reference_id UUID,
notes TEXT
);
COMMENT ON TABLE stock_movements IS
'Ledger append-only de estoque — Master Prompt §6.8. quantity é SEMPRE positivo; o sinal/direção é implícito pelo movement_type (ver COMMENT de movement_type). Uma correção de um movimento passado é sempre um NOVO movimento de estorno (ex.: um purchase_in lançado errado é corrigido com um adjustment_out equivalente), nunca um UPDATE/DELETE da linha original — daí os triggers trg_stock_movements_no_update/no_delete abaixo, mesmo padrão de audit_log (ver baseline-schema). reference_type/reference_id são polimórficos e opcionais (sem FK, por natureza) — ex.: reference_type=''contract''/reference_id=<contracts.id> num install_out, ou reference_type=''serialized_asset''/reference_id=<serialized_assets.id> numa reservation de um ativo específico.';
COMMENT ON COLUMN stock_movements.movement_type IS
'Direção implícita por tipo (usada para calcular o saldo on-hand de um warehouse+product via SUM):
ENTRADA (+ saldo on-hand): purchase_in, adjustment_in, transfer_in, return_in
SAÍDA (- saldo on-hand): adjustment_out, transfer_out, sale_out, install_out, comodato_out, rma_out, write_off
NEUTRO para saldo on-hand, afeta apenas "disponível para prometer"
(on_hand - reservas ativas), não o saldo físico: reservation, reservation_release
Saldo on-hand de um (warehouse_id, product_id) = SUM(quantity) das entradas − SUM(quantity) das saídas, sempre calculado em tempo de consulta a partir desta tabela — nunca cacheado numa coluna editável (ver header da migration, item stock_items deferido).';
CREATE OR REPLACE FUNCTION prevent_stock_movement_mutation() RETURNS TRIGGER AS $$
BEGIN
IF current_setting('eden.allow_stock_movement_mutation', true) IS DISTINCT FROM 'true' THEN
RAISE EXCEPTION 'stock_movements é append-only — correções são novos movimentos de estorno, nunca UPDATE/DELETE (ver Master Prompt §6.8 / .claude/agents/eden-inventory.md)';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_stock_movements_no_update BEFORE UPDATE ON stock_movements
FOR EACH ROW EXECUTE FUNCTION prevent_stock_movement_mutation();
CREATE TRIGGER trg_stock_movements_no_delete BEFORE DELETE ON stock_movements
FOR EACH ROW EXECUTE FUNCTION prevent_stock_movement_mutation();
CREATE INDEX idx_stock_movements_warehouse_product ON stock_movements(warehouse_id, product_id);
CREATE INDEX idx_stock_movements_reference ON stock_movements(reference_type, reference_id);
CREATE INDEX idx_stock_movements_created_at ON stock_movements(created_at);
-- =====================================================================
-- serialized_assets — um ativo físico rastreado individualmente
-- =====================================================================
CREATE TABLE serialized_assets (
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),
product_id UUID NOT NULL REFERENCES products(id),
serial TEXT,
patrimony_tag TEXT,
mac_address TEXT,
warehouse_id UUID REFERENCES warehouses(id),
status TEXT NOT NULL DEFAULT 'in_stock' CHECK (status IN (
'in_stock', 'reserved', 'installed', 'comodato', 'returned', 'written_off'
)),
current_contract_id UUID,
warranty_expires_at TIMESTAMPTZ,
CONSTRAINT chk_serialized_assets_has_identifier CHECK (serial IS NOT NULL OR patrimony_tag IS NOT NULL)
);
COMMENT ON TABLE serialized_assets IS
'Ativo físico rastreado individualmente (serial/patrimônio/MAC) — Master Prompt §6.8. Ao menos um identificador (serial OU patrimony_tag) é obrigatório; produtos que só rastreiam patrimônio (ex.: mobiliário) podem não ter serial de fabricante. warehouse_id é a localização atual e é NULL quando o ativo está instalado/comodato no cliente (status IN (''installed'',''comodato'')) — current_contract_id aponta pra onde ele está nesse caso. current_contract_id NÃO tem FK para contracts ainda (contracts está sendo construído em paralelo por outro agente neste worktree) — mesmo padrão de FK cruzada adiada de Quotes/Contracts/ClientRegistrations; a FK real é trabalho de integração no merge. Nunca deletado fisicamente: um ativo dado de baixa vira status=''written_off'' (ver header da migration).';
COMMENT ON COLUMN serialized_assets.mac_address IS
'MVP: uma única coluna de MAC primário (nullable), não um array nem uma tabela filha. Decisão documentada: o índice único parcial que garante "MAC nunca duplicado entre ativos simultaneamente ativos" (ver abaixo) precisa colocalizar mac_address com status na MESMA tabela — um índice parcial não pode referenciar uma condição de uma tabela filha via JOIN. Se um produto real precisar rastrear múltiplos MACs (ex.: CPE dual-band com MAC de LAN e de WLAN), promover para uma tabela filha então, com a invariante reforçada por trigger em vez de índice parcial — deferido conscientemente, não esquecido.';
COMMENT ON COLUMN serialized_assets.status IS
'in_stock/reserved/installed/comodato contam como "ativo" para a invariante de não-duplicação de identificador (ver índices únicos parciais abaixo). returned/written_off NÃO contam — liberam o serial/patrimônio/MAC para reuso por uma linha nova, ex.: uma correção de cadastro. Nota: o endpoint POST /assets/:id/return desta MVP leva o ativo direto de volta a ''in_stock'' (mesma linha) — o status ''returned'' fica reservado para um fluxo futuro de RMA/inspeção pós-devolução (ainda não implementado, ver header da migration) em que o ativo chega mas ainda não foi conferido/restocado.';
COMMENT ON COLUMN serialized_assets.current_contract_id IS
'Sem FK para contracts.id ainda — deliberado, ver COMMENT da tabela. Preenchido em /assets/:id/install, limpo em /assets/:id/return.';
CREATE TRIGGER trg_serialized_assets_updated_at BEFORE UPDATE ON serialized_assets
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- Invariante obrigatória (Master Prompt §15.3 / §6.8: "nunca reutilizar
-- o mesmo serial/MAC/patrimônio simultaneamente em dois ativos
-- ativos") — reforçada em DB, não só em validação de aplicação.
CREATE UNIQUE INDEX idx_serialized_assets_serial_active ON serialized_assets(serial)
WHERE serial IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato');
CREATE UNIQUE INDEX idx_serialized_assets_patrimony_active ON serialized_assets(patrimony_tag)
WHERE patrimony_tag IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato');
CREATE UNIQUE INDEX idx_serialized_assets_mac_active ON serialized_assets(mac_address)
WHERE mac_address IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato');
CREATE INDEX idx_serialized_assets_product ON serialized_assets(product_id);
CREATE INDEX idx_serialized_assets_warehouse ON serialized_assets(warehouse_id);
CREATE INDEX idx_serialized_assets_status ON serialized_assets(status);
CREATE INDEX idx_serialized_assets_contract ON serialized_assets(current_contract_id);
-- =====================================================================
-- asset_assignments — histórico de vínculo de um serialized_asset a um
-- contrato/cliente ao longo do tempo
-- =====================================================================
CREATE TABLE asset_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
asset_id UUID NOT NULL REFERENCES serialized_assets(id),
contract_id UUID NOT NULL,
assignment_type TEXT NOT NULL CHECK (assignment_type IN ('installed', 'comodato')),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
unassigned_at TIMESTAMPTZ
);
COMMENT ON TABLE asset_assignments IS
'Histórico de vínculo de um ativo serializado a um contrato/cliente — Master Prompt §6.8. contract_id sem FK ainda (mesmo motivo/padrão de serialized_assets.current_contract_id). Nunca UPDATE de um assignment fechado (unassigned_at já preenchido): encerrar um vínculo é preencher unassigned_at UMA vez (camada de repositório só permite esse UPDATE específico, guardado pelo índice único parcial abaixo que impede reabrir/duplicar um vínculo aberto); um novo vínculo é sempre uma linha nova.';
-- Reforça em DB que um ativo nunca tem mais de um vínculo ABERTO
-- simultâneo (unassigned_at IS NULL) — mesma invariante de
-- "não alocado duas vezes" do Master Prompt §15.3, vista pelo lado do
-- histórico de vínculos em vez do status do ativo.
CREATE UNIQUE INDEX idx_asset_assignments_open ON asset_assignments(asset_id)
WHERE unassigned_at IS NULL;
CREATE INDEX idx_asset_assignments_asset ON asset_assignments(asset_id);
CREATE INDEX idx_asset_assignments_contract ON asset_assignments(contract_id);
-- =====================================================================
-- stock_reservations — reserva de quantidade de um produto num
-- warehouse, com ou sem um serial específico já selecionado
-- =====================================================================
CREATE TABLE stock_reservations (
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),
warehouse_id UUID NOT NULL REFERENCES warehouses(id),
product_id UUID NOT NULL REFERENCES products(id),
quantity NUMERIC(14,3) NOT NULL CHECK (quantity > 0),
reference_type TEXT,
reference_id UUID,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'released', 'fulfilled')),
notes TEXT
);
COMMENT ON TABLE stock_reservations IS
'Reserva de quantidade de um produto num warehouse, ANTES (ou no lugar) da seleção de um serial específico — Master Prompt §6.8, primeiro passo do fluxo reservar→selecionar serial→instalar. Duas formas de uso, ambas suportadas por esta mesma tabela (sem coluna asset_id — decisão documentada):
1. Reserva GENÉRICA (produto+quantidade, ainda sem serial escolhido): reference_type/reference_id apontam, por exemplo, para a quote_id ou contract_id que originou a necessidade. Caso descrito no Master Prompt como o primeiro passo do fluxo.
2. Reserva de um ATIVO ESPECÍFICO (POST /assets/:id/reserve): quantity=1, reference_type=''serialized_asset'', reference_id=<serialized_assets.id> — rastreável ao ativo exato sem precisar de uma coluna dedicada.
status=''fulfilled'' quando a reserva efetivamente vira uma saída (ex.: o ativo reservado é instalado); ''released'' quando cancelada sem uso. Sem FK em reference_id (polimórfico).';
CREATE TRIGGER trg_stock_reservations_updated_at BEFORE UPDATE ON stock_reservations
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_stock_reservations_warehouse_product ON stock_reservations(warehouse_id, product_id);
CREATE INDEX idx_stock_reservations_status ON stock_reservations(status);
CREATE INDEX idx_stock_reservations_reference ON stock_reservations(reference_type, reference_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS stock_reservations;
DROP TABLE IF EXISTS asset_assignments;
DROP TABLE IF EXISTS serialized_assets;
DROP TABLE IF EXISTS stock_movements;
DROP FUNCTION IF EXISTS prevent_stock_movement_mutation();
DROP TABLE IF EXISTS warehouses;
`);
};