Files
eden/packages/database/migrations/1788451164762_inventory.cjs
Matheus (Handix) eff515b70d 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>
2026-09-03 17:25:31 -03:00

265 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
`);
};