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:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Adds the cross-reference foreign keys between Quotes, Client
|
||||
* Registrations and Contracts that were deliberately left as plain
|
||||
* nullable UUIDs when each table was created in parallel (see the header
|
||||
* comments in 1788445280742_quotes.cjs, 1788447739229_client-registrations.cjs
|
||||
* and 1788447802216_contracts.cjs). All three tables now exist on `main`,
|
||||
* so this closes that gap.
|
||||
*
|
||||
* ON DELETE RESTRICT everywhere: none of these aggregates has a DELETE
|
||||
* route today (quotes/client_registrations/contracts are only ever
|
||||
* status-transitioned, never removed), and a business document silently
|
||||
* losing its link to another business document on an unrelated delete
|
||||
* would be a worse failure mode than the delete itself being blocked.
|
||||
*/
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.addConstraint("quotes", "fk_quotes_client_registration", {
|
||||
foreignKeys: {
|
||||
columns: "client_registration_id",
|
||||
references: "client_registrations(id)",
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
});
|
||||
|
||||
pgm.addConstraint("client_registrations", "fk_client_registrations_quote", {
|
||||
foreignKeys: {
|
||||
columns: "quote_id",
|
||||
references: "quotes(id)",
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
});
|
||||
|
||||
pgm.addConstraint("contracts", "fk_contracts_quote", {
|
||||
foreignKeys: {
|
||||
columns: "quote_id",
|
||||
references: "quotes(id)",
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
});
|
||||
|
||||
pgm.addConstraint("contracts", "fk_contracts_client_registration", {
|
||||
foreignKeys: {
|
||||
columns: "client_registration_id",
|
||||
references: "client_registrations(id)",
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.dropConstraint("contracts", "fk_contracts_client_registration");
|
||||
pgm.dropConstraint("contracts", "fk_contracts_quote");
|
||||
pgm.dropConstraint("client_registrations", "fk_client_registrations_quote");
|
||||
pgm.dropConstraint("quotes", "fk_quotes_client_registration");
|
||||
};
|
||||
264
packages/database/migrations/1788451164762_inventory.cjs
Normal file
264
packages/database/migrations/1788451164762_inventory.cjs
Normal 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;
|
||||
`);
|
||||
};
|
||||
164
packages/database/migrations/1788451374900_support.cjs
Normal file
164
packages/database/migrations/1788451374900_support.cjs
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Support/Service Desk bounded context (Master Prompt §6.13). Greenfield —
|
||||
* no legacy OrçaFácil module to port, unlike most other EDEN modules.
|
||||
*
|
||||
* This MVP builds the 4 tables needed for a working ticket lifecycle with
|
||||
* real SLA timers:
|
||||
* - support_tickets (this migration)
|
||||
* - ticket_comments (this migration)
|
||||
* - ticket_status_history (this migration)
|
||||
* - sla_timers (this migration, 1:1 with support_tickets)
|
||||
*
|
||||
* Explicitly DEFERRED, not forgotten:
|
||||
* - Real attachments (S3) — ticket_comments/support_tickets have no
|
||||
* attachment table yet; needs the Documents/S3 integration adapter
|
||||
* (packages/integrations) to land first, same reasoning as
|
||||
* contract_documents in the Contracts migration.
|
||||
* - CSAT (customer satisfaction survey after closing) — needs a survey
|
||||
* delivery mechanism (email/portal) that doesn't exist yet.
|
||||
* - Dedicated work order (OS) entity distinct from the ticket itself —
|
||||
* this MVP treats the ticket as also carrying the OS-equivalent
|
||||
* fields (root_cause/resolution_notes); a first-class `work_orders`
|
||||
* table with its own scheduling/dispatch workflow is future work.
|
||||
* - Queue model with automatic routing rules — there is no `queues`
|
||||
* table yet; tickets are assigned via owner_user_id only (manual).
|
||||
* - Configurable attendance calendar (business hours/holidays) for SLA
|
||||
* due-date math — `sla_timers` due dates are computed with naive
|
||||
* wall-clock offsets (see apps/api/src/support/sla.ts), not a
|
||||
* calendar-aware engine. Category-specific SLA policy is also
|
||||
* deferred — this MVP uses one flat default policy for every ticket
|
||||
* regardless of category/priority (see sla.ts comment).
|
||||
*
|
||||
* `support_tickets.client_registration_id` and `.contract_id` are
|
||||
* deliberately nullable with NO foreign key, same adopted pattern as
|
||||
* `contracts.client_registration_id`/`contracts.quote_id`: those modules
|
||||
* are being built in parallel by other agents. Wiring the FKs is
|
||||
* integration-step work, not this migration's.
|
||||
*/
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- =====================================================================
|
||||
-- support_tickets — the core aggregate
|
||||
-- =====================================================================
|
||||
CREATE SEQUENCE support_tickets_protocol_number_seq START 1;
|
||||
|
||||
CREATE TABLE support_tickets (
|
||||
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),
|
||||
protocol_number TEXT NOT NULL UNIQUE
|
||||
DEFAULT ('TCK-' || to_char(now(), 'YYYY') || '-' || lpad(nextval('support_tickets_protocol_number_seq')::text, 6, '0')),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT,
|
||||
priority TEXT NOT NULL DEFAULT 'medium'
|
||||
CHECK (priority IN ('low', 'medium', 'high', 'critical')),
|
||||
status TEXT NOT NULL DEFAULT 'new'
|
||||
CHECK (status IN (
|
||||
'new', 'triage', 'in_progress', 'waiting_customer',
|
||||
'waiting_third_party', 'resolved', 'closed', 'cancelled'
|
||||
)),
|
||||
reseller_id UUID REFERENCES resellers(id),
|
||||
client_registration_id UUID,
|
||||
contract_id UUID,
|
||||
owner_user_id UUID REFERENCES users(id),
|
||||
opened_by_user_id UUID NOT NULL REFERENCES users(id),
|
||||
root_cause TEXT,
|
||||
resolution_notes TEXT,
|
||||
first_response_at TIMESTAMPTZ,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
closed_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE support_tickets IS
|
||||
'Ticket de atendimento (Service Desk, Master Prompt §6.13) — módulo greenfield, sem equivalente no legado OrçaFácil. protocol_number é gerado inline via support_tickets_protocol_number_seq + to_char, mesmo padrão de quotes.quote_number (ver migration 1788445280742_quotes.cjs), formato TCK-{ano}-{sequência de 6 dígitos}. client_registration_id/contract_id são nullable SEM FK — client_registrations e contracts existem em outras migrations mas a FK cruzada é trabalho de integração, não desta migration (mesmo padrão adotado por contracts.client_registration_id/contracts.quote_id).';
|
||||
COMMENT ON COLUMN support_tickets.root_cause IS
|
||||
'Causa raiz — só tem sentido preenchido no fechamento do ticket (transição para ''closed''), mas não há CHECK de banco forçando isso: a camada de aplicação (support.service.ts) só aceita esse campo no payload da transição para ''closed''.';
|
||||
COMMENT ON COLUMN support_tickets.first_response_at IS
|
||||
'Quando a primeira resposta pública (não-interna) ao ticket ocorreu. Setado uma única vez (nunca sobrescrito) pela camada de aplicação ao adicionar o primeiro ticket_comments.is_internal=false — ver apps/api/src/support/support.service.ts e sla_timers.first_response_met.';
|
||||
COMMENT ON COLUMN support_tickets.resolved_at IS
|
||||
'Quando o status se tornou ''resolved'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE, mesmo padrão de contracts.activated_at).';
|
||||
COMMENT ON COLUMN support_tickets.closed_at IS
|
||||
'Quando o status se tornou ''closed'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE, mesmo padrão de contracts.activated_at).';
|
||||
|
||||
CREATE TRIGGER trg_support_tickets_updated_at BEFORE UPDATE ON support_tickets
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE INDEX idx_support_tickets_status ON support_tickets(status);
|
||||
CREATE INDEX idx_support_tickets_reseller ON support_tickets(reseller_id);
|
||||
CREATE INDEX idx_support_tickets_owner ON support_tickets(owner_user_id);
|
||||
CREATE INDEX idx_support_tickets_client_registration ON support_tickets(client_registration_id);
|
||||
CREATE INDEX idx_support_tickets_contract ON support_tickets(contract_id);
|
||||
|
||||
ALTER SEQUENCE support_tickets_protocol_number_seq OWNED BY support_tickets.protocol_number;
|
||||
|
||||
-- =====================================================================
|
||||
-- ticket_comments — public/internal comment thread
|
||||
-- =====================================================================
|
||||
CREATE TABLE ticket_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||
author_user_id UUID NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL,
|
||||
is_internal BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE ticket_comments IS
|
||||
'Thread de comentários do ticket. is_internal=true nunca pode ser exposto ao portal do assinante ou da revenda — reforçado na camada de query/serviço (apps/api/src/support/support.service.ts), não apenas por convenção de UI, mesma disciplina de isolamento cross-tenant do módulo comercial (Master Prompt, eden-support persona).';
|
||||
CREATE INDEX idx_ticket_comments_ticket ON ticket_comments(ticket_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- ticket_status_history — append-only UX-facing lifecycle log,
|
||||
-- distinct from (and complementary to) the generic audit_log
|
||||
-- =====================================================================
|
||||
CREATE TABLE ticket_status_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||
from_status TEXT,
|
||||
to_status TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
changed_by UUID REFERENCES users(id),
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE ticket_status_history IS
|
||||
'Log de transições de status do ticket — append-only por convenção da CAMADA DE APLICAÇÃO (nenhuma rota de UPDATE/DELETE é exposta), mesmo padrão e mesma justificativa de coexistência com audit_log de contract_status_history (ver migration 1788447802216_contracts.cjs).';
|
||||
CREATE INDEX idx_ticket_status_history_ticket ON ticket_status_history(ticket_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- sla_timers — 1:1 with support_tickets (ticket_id is both PK and FK)
|
||||
-- =====================================================================
|
||||
CREATE TABLE sla_timers (
|
||||
ticket_id UUID PRIMARY KEY REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
first_response_due_at TIMESTAMPTZ NOT NULL,
|
||||
resolution_due_at TIMESTAMPTZ NOT NULL,
|
||||
first_response_met BOOLEAN,
|
||||
resolution_met BOOLEAN,
|
||||
paused_since TIMESTAMPTZ,
|
||||
accumulated_pause_seconds INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
COMMENT ON TABLE sla_timers IS
|
||||
'Timer de SLA, uma linha por ticket (ticket_id é PK e FK ao mesmo tempo — força o 1:1). Regra de pausa: ao entrar em waiting_customer/waiting_third_party, paused_since = now(); ao voltar para in_progress, accumulated_pause_seconds soma o intervalo pausado e paused_since volta a NULL — implementado atomicamente em packages/database/src/repositories/support-tickets.ts::updateTicketStatus (mesmo padrão SELECT...FOR UPDATE + withTransaction de contracts.ts::updateContractStatus). O cálculo de violação de SLA (subtraindo o tempo pausado, incluindo a pausa corrente se ainda pausado) é feito pelas funções puras computeElapsedSeconds/isSlaBreached em apps/api/src/support/sla.ts — nunca reimplementado ad-hoc. first_response_met/resolution_met só são preenchidos no momento em que o evento correspondente (primeira resposta pública / entrada em resolved) de fato ocorre — permanecem NULL até lá, não são recalculados retroativamente.';
|
||||
COMMENT ON COLUMN sla_timers.paused_since IS
|
||||
'Quando não-nulo, o timer está pausado desde este instante — todo o tempo entre paused_since e now() ainda NÃO está refletido em accumulated_pause_seconds e deve ser somado no cálculo de "tempo decorrido" (ver computeElapsedSeconds em sla.ts).';
|
||||
|
||||
CREATE TRIGGER trg_sla_timers_updated_at BEFORE UPDATE ON sla_timers
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS sla_timers;
|
||||
DROP TABLE IF EXISTS ticket_status_history;
|
||||
DROP TABLE IF EXISTS ticket_comments;
|
||||
DROP TABLE IF EXISTS support_tickets;
|
||||
DROP SEQUENCE IF EXISTS support_tickets_protocol_number_seq;
|
||||
`);
|
||||
};
|
||||
245
packages/database/migrations/1788452136875_hr-timeclock.cjs
Normal file
245
packages/database/migrations/1788452136875_hr-timeclock.cjs
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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;
|
||||
`);
|
||||
};
|
||||
231
packages/database/migrations/1788457368744_fiscal.cjs
Normal file
231
packages/database/migrations/1788457368744_fiscal.cjs
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Fiscal bounded context (Master Prompt §6.11, ADR-0011, eden.md módulo 5
|
||||
* "Módulo Fiscal (NCM, CFOP, Municípios)"). The legacy OrçaFácil has a
|
||||
* mature ~19-table catalog layer + automatic (Siscomex/IBGE fetch) and
|
||||
* manual (CSV/XLSX/JSON upload) importers + a sync log (`fiscal_catalog_sync`)
|
||||
* + `product_fiscal_profiles` linking `products` to fiscal classification.
|
||||
*
|
||||
* This MVP builds a coherent, faithfully-shaped SUBSET, end-to-end:
|
||||
* - fiscal_ncm (this migration) — "simple catalog" shape, eden.md §2.2
|
||||
* - fiscal_cfop (this migration) — simple catalog + direction/scope, eden.md §2.3/§4
|
||||
* - fiscal_municipality (this migration) — distinct shape, eden.md §2.3
|
||||
* - fiscal_operation_nature (this migration) — internal enum-like catalog, eden.md §2.3, seeded
|
||||
* - product_fiscal_profiles (this migration) — eden.md §2.5, reduced to the
|
||||
* 4 catalogs actually created here (ncm/cfop/municipality/operation_nature)
|
||||
*
|
||||
* Explicitly DEFERRED, not forgotten:
|
||||
* - The other ~9 "simple catalog" tables (eden.md §2.2): fiscal_product_origin,
|
||||
* fiscal_icms_cst, fiscal_icms_csosn, fiscal_pis_cst, fiscal_cofins_cst,
|
||||
* fiscal_ipi_cst, fiscal_lc116_service, fiscal_nbs, fiscal_ibs_cbs_cst,
|
||||
* fiscal_ibs_cbs_credit_class — same shape as fiscal_ncm/fiscal_cfop, can
|
||||
* be added later without a redesign.
|
||||
* - The "extended catalog" tables (eden.md §2.3): fiscal_cest, fiscal_cbenef,
|
||||
* fiscal_nfcom_cclass, fiscal_nfse_trib_nacional, fiscal_nfse_trib_municipal,
|
||||
* fiscal_ibs_cbs_classification, fiscal_ibs_cbs_standard_rate.
|
||||
* - `fiscal_catalog_sync` (append-only sync log, eden.md §2.4) and the
|
||||
* automatic importers (NCM/Siscomex, Municipality/IBGE — eden.md §3/§5)
|
||||
* + the generic manual file importer with dry-run and the mass-
|
||||
* inactivation safety threshold (eden.md §7) — substantial, isolated
|
||||
* work (network fetch adapters, checksum/threshold logic, file parsing),
|
||||
* deferred to its own leva.
|
||||
* - `fiscal_rules` (eden.md §2.6) — schema-ready-but-unconsumed even in the
|
||||
* legacy itself ("estrutura pronta, sem UI/consumo ainda"); not recreated
|
||||
* here, stays future work until the fiscal resolution engine is built.
|
||||
* - `products.ixc_product_code` / `products.product_code` (eden.md §6) —
|
||||
* belong to the Products migration, not this one.
|
||||
* - product_fiscal_profiles columns for catalogs not yet created here:
|
||||
* nfcom_cclass_id, nfse_trib_nacional_id, nfse_trib_municipal_id,
|
||||
* lc116_service_id, nbs_id, cest_id, product_origin_id, ibs_cbs_cst_id,
|
||||
* ibs_cbs_classification_id, ibs_cbs_credit_class_id (eden.md §2.5) —
|
||||
* added when their respective catalogs land.
|
||||
*
|
||||
* `product_fiscal_profiles.product_id` is deliberately nullable-FK-free:
|
||||
* NO `REFERENCES products(id)`, same cross-module "FK cruzada adiada"
|
||||
* pattern used by `contracts.quote_id`/`contracts.client_registration_id`
|
||||
* (see 1788447802216_contracts.cjs header) and
|
||||
* `support_tickets.client_registration_id`/`.contract_id`
|
||||
* (see 1788451374900_support.cjs header) — this worktree may not always
|
||||
* carry every other module's migration in the same merge order; wiring the
|
||||
* FK is integration-step work at merge time, not this migration's.
|
||||
*
|
||||
* Naming convention: all fiscal tables use `created_at`/`updated_at`
|
||||
* (not `created_date`/`updated_date`) — this is already EDEN's own
|
||||
* repo-wide convention (see every other migration in this directory), so
|
||||
* there is no deviation to reconcile here. eden.md §2.1 calls this "a
|
||||
* deliberate deviation" only relative to the *legacy* OrçaFácil schema,
|
||||
* not relative to EDEN.
|
||||
*/
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- =====================================================================
|
||||
-- fiscal_ncm — Nomenclatura Comum do Mercosul. "Simple catalog" shape,
|
||||
-- eden.md §2.2. Source: Receita Federal / Siscomex (import deferred).
|
||||
-- =====================================================================
|
||||
CREATE TABLE fiscal_ncm (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
valid_from DATE,
|
||||
valid_until DATE,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
source TEXT,
|
||||
source_version TEXT,
|
||||
source_updated_at TIMESTAMPTZ,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE fiscal_ncm IS
|
||||
'Nomenclatura Comum do Mercosul (eden.md §2.2, §3). "Catálogo simples" — mesmo shape reutilizável para as ~9 outras tabelas de catálogo simples ainda não portadas (fiscal_product_origin, ICMS CST/CSOSN, PIS/COFINS/IPI CST, LC116, NBS, IBS-CBS CST/credit_class). Importação automática (Siscomex, checksum, trava de volume mínimo, upsert com inactivateMissing=true) é trabalho DEFERIDO — ver cabeçalho da migration.';
|
||||
|
||||
CREATE UNIQUE INDEX idx_fiscal_ncm_code ON fiscal_ncm(code);
|
||||
CREATE INDEX idx_fiscal_ncm_active ON fiscal_ncm(active);
|
||||
|
||||
CREATE TRIGGER trg_fiscal_ncm_updated_at BEFORE UPDATE ON fiscal_ncm
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- =====================================================================
|
||||
-- fiscal_cfop — Código Fiscal de Operações e Prestações. "Simple
|
||||
-- catalog" shape + direction/scope (eden.md §2.3, §4). Fonte MANUAL
|
||||
-- (sem endpoint estruturado confiável testado no legado).
|
||||
-- =====================================================================
|
||||
CREATE TABLE fiscal_cfop (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
direction TEXT,
|
||||
scope TEXT,
|
||||
valid_from DATE,
|
||||
valid_until DATE,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
source TEXT,
|
||||
source_version TEXT,
|
||||
source_updated_at TIMESTAMPTZ,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE fiscal_cfop IS
|
||||
'Código Fiscal de Operações e Prestações (eden.md §2.3, §4). "Catálogo simples" + direction/scope. Fonte declarada "Portal Nacional NF-e" mas tipo MANUAL no legado (sem endpoint estruturado confiável testado) — cadastro por upload de arquivo ou tela, ambos DEFERIDOS nesta leva (ver cabeçalho da migration).';
|
||||
COMMENT ON COLUMN fiscal_cfop.direction IS
|
||||
'Direção da operação (entrada/saída) — TEXT livre, sem CHECK/enum, exatamente como o legado (eden.md §4). O 1º dígito do código CFOP codifica direção+âmbito na prática brasileira, mas isso NUNCA é derivado automaticamente do código no legado nem aqui — direction/scope são só campos preenchidos manualmente/via importação.';
|
||||
COMMENT ON COLUMN fiscal_cfop.scope IS
|
||||
'Âmbito da operação (estadual/interestadual/exterior) — TEXT livre, sem CHECK/enum, mesma razão de direction (eden.md §4).';
|
||||
|
||||
CREATE UNIQUE INDEX idx_fiscal_cfop_code ON fiscal_cfop(code);
|
||||
CREATE INDEX idx_fiscal_cfop_active ON fiscal_cfop(active);
|
||||
|
||||
CREATE TRIGGER trg_fiscal_cfop_updated_at BEFORE UPDATE ON fiscal_cfop
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- =====================================================================
|
||||
-- fiscal_municipality — shape DIFERENTE do catálogo simples: sem
|
||||
-- valid_from/valid_until/metadata ("não faz sentido um município
|
||||
-- expirar", eden.md §2.3). Fonte: IBGE (import deferido).
|
||||
-- =====================================================================
|
||||
CREATE TABLE fiscal_municipality (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
source TEXT,
|
||||
source_version TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE fiscal_municipality IS
|
||||
'Municípios/IBGE (eden.md §2.3, §5) — base para ISS/NFS-e. Shape deliberadamente distinto do catálogo simples: sem valid_from/valid_until/metadata ("não faz sentido um município expirar"). code = código IBGE, state = UF. Importação automática (IBGE, checksum, trava de volume mínimo de 5000 linhas) é DEFERIDA — ver cabeçalho da migration.';
|
||||
|
||||
CREATE UNIQUE INDEX idx_fiscal_municipality_code ON fiscal_municipality(code);
|
||||
CREATE INDEX idx_fiscal_municipality_state ON fiscal_municipality(state);
|
||||
|
||||
CREATE TRIGGER trg_fiscal_municipality_updated_at BEFORE UPDATE ON fiscal_municipality
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- =====================================================================
|
||||
-- fiscal_operation_nature — cadastro interno, sem fonte externa
|
||||
-- (eden.md §2.3). Seed fixo via ON CONFLICT DO NOTHING.
|
||||
-- =====================================================================
|
||||
CREATE TABLE fiscal_operation_nature (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE fiscal_operation_nature IS
|
||||
'Naturezas de operação (eden.md §2.3) — cadastro interno de negócio, não uma fonte externa sincronizável. Seed fixo abaixo, único endpoint da API para este catálogo é leitura (GET /v1/fiscal/operation-natures) — sem criação via API nesta leva.';
|
||||
|
||||
CREATE TRIGGER trg_fiscal_operation_nature_updated_at BEFORE UPDATE ON fiscal_operation_nature
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
INSERT INTO fiscal_operation_nature (code, name) VALUES
|
||||
('VENDA', 'Venda'),
|
||||
('REMESSA_LOCACAO', 'Remessa em locação'),
|
||||
('RETORNO_LOCACAO', 'Retorno de locação'),
|
||||
('REMESSA_MANUTENCAO', 'Remessa para manutenção'),
|
||||
('RETORNO_MANUTENCAO', 'Retorno de manutenção'),
|
||||
('STFC_RECORRENTE', 'STFC recorrente'),
|
||||
('SVA_RECORRENTE', 'SVA recorrente'),
|
||||
('PABX_RECORRENTE', 'PABX recorrente'),
|
||||
('PABX_IMPLANTACAO', 'PABX implantação'),
|
||||
('SOFTWARE_RECORRENTE', 'Software recorrente'),
|
||||
('SOFTWARE_IMPLANTACAO', 'Software implantação')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- =====================================================================
|
||||
-- product_fiscal_profiles — liga products à classificação fiscal
|
||||
-- (eden.md §2.5). Reduzida aos 4 catálogos criados nesta leva.
|
||||
-- =====================================================================
|
||||
CREATE TABLE product_fiscal_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
product_id UUID NOT NULL,
|
||||
billing_component TEXT NOT NULL CHECK (billing_component IN ('RECURRING', 'IMPLEMENTATION')),
|
||||
document_type TEXT NOT NULL DEFAULT 'NONE' CHECK (document_type IN ('NFCOM', 'NFSE', 'NFE', 'NONE')),
|
||||
ncm_id UUID REFERENCES fiscal_ncm(id),
|
||||
cfop_id UUID REFERENCES fiscal_cfop(id),
|
||||
municipality_id UUID REFERENCES fiscal_municipality(id),
|
||||
operation_nature_id UUID REFERENCES fiscal_operation_nature(id),
|
||||
valid_from DATE,
|
||||
valid_until DATE,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
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)
|
||||
);
|
||||
COMMENT ON TABLE product_fiscal_profiles IS
|
||||
'Configuração fiscal de produto (eden.md §2.5) — deliberadamente separada de products, nunca duplica campos comerciais (price_*, impl_unit_price, metered, tariff_rates, has_ldi continuam só em products). product_id SEM FK (products pode não existir neste worktree isolado — ver cabeçalho da migration, padrão "FK cruzada adiada" de contracts.cjs/support.cjs). Colunas de referência a catálogos ainda não portados (nfcom_cclass_id, nfse_trib_nacional_id, nfse_trib_municipal_id, lc116_service_id, nbs_id, cest_id, product_origin_id, ibs_cbs_cst_id, ibs_cbs_classification_id, ibs_cbs_credit_class_id) ficam para quando os catálogos correspondentes existirem. Sem backfill automático nesta leva (eden.md §2.5 backfill dependia de products.service_type/impl_unit_price já populados em produção real) — profiles são criados sob demanda via API.';
|
||||
COMMENT ON COLUMN product_fiscal_profiles.product_id IS
|
||||
'Sem REFERENCES products(id) nesta leva — products pode não existir neste worktree isolado. FK cruzada é trabalho de integração no merge (mesmo padrão de contracts.quote_id/client_registration_id e support_tickets.client_registration_id/contract_id).';
|
||||
|
||||
-- Invariante central (eden.md §2.5): um produto tem no máximo um
|
||||
-- profile ATIVO por billing_component. Nunca DELETE/UPDATE destrutivo
|
||||
-- de um profile antigo para "trocar" a classificação — cria-se um novo
|
||||
-- profile ativo e inativa-se o anterior (active=false), preservando
|
||||
-- histórico.
|
||||
CREATE UNIQUE INDEX idx_product_fiscal_profiles_active_component
|
||||
ON product_fiscal_profiles(product_id, billing_component) WHERE active = true;
|
||||
|
||||
CREATE INDEX idx_product_fiscal_profiles_product ON product_fiscal_profiles(product_id);
|
||||
|
||||
CREATE TRIGGER trg_product_fiscal_profiles_updated_at BEFORE UPDATE ON product_fiscal_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS product_fiscal_profiles;
|
||||
DROP TABLE IF EXISTS fiscal_operation_nature;
|
||||
DROP TABLE IF EXISTS fiscal_municipality;
|
||||
DROP TABLE IF EXISTS fiscal_cfop;
|
||||
DROP TABLE IF EXISTS fiscal_ncm;
|
||||
`);
|
||||
};
|
||||
185
packages/database/migrations/1788457629203_billing.cjs
Normal file
185
packages/database/migrations/1788457629203_billing.cjs
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Billing bounded context (recurring billing engine) — Master Prompt
|
||||
* §6.10, docs/adr/0008-billing-finance-fiscal-separation.md. ADR-0008 is
|
||||
* explicit: Billing (this module) calculates *what is owed* and produces
|
||||
* an internal invoice; it is strictly separate from Finance/AR-AP
|
||||
* (collecting/paying — títulos, boletos, dunning, conciliação) and from
|
||||
* Fiscal (documento fiscal). Neither of those is built here.
|
||||
*
|
||||
* ADR-0008 lists nine Billing tables (billing_accounts, billing_cycles,
|
||||
* subscriptions/services, charge_components, usage_charges, invoices,
|
||||
* invoice_items, invoice_adjustments, billing_runs). This migration
|
||||
* builds the coherent MVP subset needed for a working, testable
|
||||
* subscription -> billing_run -> invoice flow:
|
||||
*
|
||||
* - billing_subscriptions (this migration)
|
||||
* - billing_runs (this migration)
|
||||
* - invoices (this migration)
|
||||
* - invoice_items (this migration)
|
||||
*
|
||||
* Explicitly DEFERRED, not forgotten:
|
||||
* - billing_accounts — this MVP hangs subscriptions/invoices directly
|
||||
* off `reseller_id`; a billing_account (grouping multiple subscriptions
|
||||
* under one consolidated bill) is deferred until there's a real need
|
||||
* to bill more than one subscription together.
|
||||
* - billing_cycles as its own table — this MVP derives the cycle from
|
||||
* the (billing_day, reference_year, reference_month) triple on the
|
||||
* run/subscription rather than a calendar table of cycle instances.
|
||||
* - charge_components / usage_charges — consumption/telephony billing;
|
||||
* needs the usage-rating module (out of scope), so every subscription
|
||||
* in this MVP bills a single flat `monthly_value` with no metered
|
||||
* component.
|
||||
* - invoice_adjustments / credit_debit_adjustments — the sanctioned way
|
||||
* to correct a *consolidated* invoice (never a silent edit); deferred,
|
||||
* so today a consolidated billing_run's invoices are simply immutable
|
||||
* from the application layer (see billing.ts's assertRunIsMutable).
|
||||
* - billing_run_logs — this MVP uses the shared `audit_log`
|
||||
* (auditRepo.appendAuditEvent) for every billing_run/invoice mutation
|
||||
* instead of a dedicated structured log table.
|
||||
* - proration — every subscription always bills the full monthly_value;
|
||||
* no day-proportional calculation for a subscription that started or
|
||||
* was cancelled mid-cycle. `invoice_items.item_type` already reserves
|
||||
* the 'proration' vocabulary value for when this lands, so it won't
|
||||
* need a migration of its own.
|
||||
*
|
||||
* Deferred cross-context foreign keys (no REFERENCES, plain UUID, same
|
||||
* pattern used by Quotes/Contracts/ClientRegistrations for tables owned by
|
||||
* a module built in a different worktree — see contracts' migration
|
||||
* header for precedent):
|
||||
* - billing_subscriptions.contract_id — `contracts` (Commercial/§6.6) is
|
||||
* not guaranteed to exist in every worktree this batch might land on.
|
||||
* - billing_subscriptions.reseller_id / invoices.reseller_id —
|
||||
* `resellers` is not present in THIS worktree's migration set either
|
||||
* (bootstrap-only checkout). Both FKs are integration work for
|
||||
* whoever merges this alongside the resellers/contracts migrations.
|
||||
*/
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- =====================================================================
|
||||
-- billing_subscriptions — a recurring billing subscription derived
|
||||
-- from an active contract
|
||||
-- =====================================================================
|
||||
CREATE TABLE billing_subscriptions (
|
||||
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),
|
||||
contract_id UUID NOT NULL,
|
||||
reseller_id UUID,
|
||||
monthly_value NUMERIC(14,2) NOT NULL CHECK (monthly_value >= 0),
|
||||
billing_day INTEGER NOT NULL CHECK (billing_day BETWEEN 1 AND 28),
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'suspended', 'cancelled')),
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cancelled_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE billing_subscriptions IS
|
||||
'Assinatura de cobrança recorrente derivada de um contrato ativo (Master Prompt §6.10). contract_id e reseller_id são UUID simples sem REFERENCES — mesmo padrão de FK cruzada adiada de Quotes/Contracts/ClientRegistrations: contracts/resellers não existem garantidamente neste worktree (checkout de bootstrap). A FK real é trabalho de integração central, não desta migration.';
|
||||
COMMENT ON COLUMN billing_subscriptions.monthly_value IS
|
||||
'Snapshot do valor mensal no momento da criação da subscription — NUNCA recalculado automaticamente se o contrato de origem mudar depois (isso seria uma reversão silenciosa de fatura, proibida pelo Master Prompt §24 invariante nº4). Uma mudança de valor exige cancelar e recriar a subscription (ou, quando existir, um endpoint explícito de reajuste) — nesta leva não há reajuste, só criação/cancelamento.';
|
||||
COMMENT ON COLUMN billing_subscriptions.billing_day IS
|
||||
'Dia do mês do ciclo de cobrança, limitado a 1-28 deliberadamente para nunca cair em um mês sem esse dia (evita a ambiguidade de dia 29-31 em fevereiro/meses de 30 dias) — não há regra de "último dia do mês" nesta leva.';
|
||||
COMMENT ON COLUMN billing_subscriptions.cancelled_at IS
|
||||
'Setado uma única vez, quando status transiciona para cancelled (camada de repositório) — nunca sobrescrito depois.';
|
||||
CREATE INDEX idx_billing_subscriptions_reseller ON billing_subscriptions(reseller_id);
|
||||
CREATE INDEX idx_billing_subscriptions_status_day ON billing_subscriptions(status, billing_day);
|
||||
CREATE INDEX idx_billing_subscriptions_contract ON billing_subscriptions(contract_id);
|
||||
|
||||
CREATE TRIGGER trg_billing_subscriptions_updated_at BEFORE UPDATE ON billing_subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- =====================================================================
|
||||
-- billing_runs — one billing-close execution for a
|
||||
-- (reference_year, reference_month, billing_day) period
|
||||
-- =====================================================================
|
||||
CREATE TABLE billing_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
reference_year INTEGER NOT NULL CHECK (reference_year BETWEEN 2000 AND 2100),
|
||||
reference_month INTEGER NOT NULL CHECK (reference_month BETWEEN 1 AND 12),
|
||||
billing_day INTEGER NOT NULL CHECK (billing_day BETWEEN 1 AND 28),
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'consolidated', 'failed')),
|
||||
consolidated_at TIMESTAMPTZ,
|
||||
triggered_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE billing_runs IS
|
||||
'Execução de fechamento de billing para um período + billing_day (Master Prompt §6.10). draft é reexecutável: rodar de novo com os mesmos (reference_year, reference_month, billing_day) sobre um run ainda draft SUBSTITUI (delete+reinsert, mesma transação) as invoices geradas em vez de duplicar — implementado em packages/database/src/repositories/billing.ts, generateOrRegenerateBillingRun(). consolidated é terminal e imutável pela aplicação (ver invoices.status e assertRunIsMutable). failed é reservado para quando esta leva ganhar um passo que possa falhar de forma parcial (ex. rating de consumo) — nada nesta leva efetivamente produz failed.';
|
||||
COMMENT ON COLUMN billing_runs.triggered_by IS
|
||||
'Ator (usuário) que disparou a criação/regeneração deste run — atualizado a cada regeneração em draft para refletir quem rodou por último.';
|
||||
CREATE INDEX idx_billing_runs_period ON billing_runs(reference_year, reference_month, billing_day);
|
||||
|
||||
-- Idempotência: no máximo um run 'draft' por (ano, mês, billing_day) —
|
||||
-- índice único parcial em vez de UNIQUE(reference_year, reference_month,
|
||||
-- billing_day, status) puro, porque este último permitiria dois runs
|
||||
-- 'consolidated' distintos para o mesmo período sem impedir a corrida
|
||||
-- de dois 'draft' concorrentes ao mesmo tempo (o problema real que essa
|
||||
-- invariante precisa resolver). A regra "não criar novo run para um
|
||||
-- período já consolidado" (refaturamento controlado é deferido) é
|
||||
-- reforçada na camada de aplicação, não aqui, porque múltiplos runs
|
||||
-- 'consolidated' históricos para o mesmo período não são logicamente
|
||||
-- impossíveis no schema (só indesejados pelo fluxo atual).
|
||||
CREATE UNIQUE INDEX uq_billing_runs_one_draft_per_period
|
||||
ON billing_runs(reference_year, reference_month, billing_day)
|
||||
WHERE status = 'draft';
|
||||
|
||||
-- =====================================================================
|
||||
-- invoices — internal invoice generated by a billing_run for one
|
||||
-- billing_subscription
|
||||
-- =====================================================================
|
||||
CREATE TABLE invoices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
billing_run_id UUID NOT NULL REFERENCES billing_runs(id) ON DELETE CASCADE,
|
||||
billing_subscription_id UUID NOT NULL REFERENCES billing_subscriptions(id),
|
||||
reseller_id UUID,
|
||||
amount NUMERIC(14,2) NOT NULL CHECK (amount >= 0),
|
||||
reference_year INTEGER NOT NULL,
|
||||
reference_month INTEGER NOT NULL CHECK (reference_month BETWEEN 1 AND 12),
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'consolidated', 'cancelled')),
|
||||
due_date DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (billing_run_id, billing_subscription_id)
|
||||
);
|
||||
COMMENT ON TABLE invoices IS
|
||||
'Fatura interna (Billing) gerada por um billing_run — DISTINTA de um título de AR/AP (Finance) e de um documento fiscal (Fiscal), ADR-0008. status espelha o status do run no momento da geração (draft -> pode ser apagada/regerada enquanto o run ainda é draft; consolidated -> imutável pela aplicação, ver invariante em billing.ts). UNIQUE(billing_run_id, billing_subscription_id) é uma segunda barreira de idempotência a nível de banco: o mesmo run nunca pode gerar duas invoices para a mesma subscription.';
|
||||
COMMENT ON COLUMN invoices.reseller_id IS
|
||||
'Denormalizado a partir de billing_subscriptions.reseller_id no momento da geração — evita join obrigatório para filtrar por revenda em listagens/relatórios; nunca é a fonte da verdade (a subscription é). Sem REFERENCES pelo mesmo motivo de billing_subscriptions.reseller_id (ver cabeçalho da migration).';
|
||||
COMMENT ON COLUMN invoices.due_date IS
|
||||
'Nesta leva, due_date = dia billing_day do mês de referência (reference_year/reference_month) — regra de vencimento mais sofisticada (ex. carência após o fechamento) é responsabilidade do módulo Finance/AR-AP (ADR-0008), fora de escopo aqui.';
|
||||
CREATE INDEX idx_invoices_run ON invoices(billing_run_id);
|
||||
CREATE INDEX idx_invoices_subscription ON invoices(billing_subscription_id);
|
||||
CREATE INDEX idx_invoices_reseller_status ON invoices(reseller_id, status);
|
||||
CREATE INDEX idx_invoices_period ON invoices(reference_year, reference_month);
|
||||
|
||||
-- =====================================================================
|
||||
-- invoice_items — invoice line items (generic vocabulary; this MVP
|
||||
-- only ever generates 'recurring' automatically)
|
||||
-- =====================================================================
|
||||
CREATE TABLE invoice_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
description TEXT NOT NULL,
|
||||
amount NUMERIC(14,2) NOT NULL,
|
||||
item_type TEXT NOT NULL
|
||||
CHECK (item_type IN ('recurring', 'proration', 'implementation', 'adjustment')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE invoice_items IS
|
||||
'Linhas da fatura, modeladas de forma genérica para não exigir migration nova quando proration/implementation/adjustment passarem a ser gerados automaticamente — nesta leva, a camada de aplicação só cria itens item_type=recurring (um por invoice, espelhando billing_subscriptions.monthly_value no momento da geração do run).';
|
||||
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS invoice_items;
|
||||
DROP TABLE IF EXISTS invoices;
|
||||
DROP TABLE IF EXISTS billing_runs;
|
||||
DROP TABLE IF EXISTS billing_subscriptions;
|
||||
`);
|
||||
};
|
||||
Reference in New Issue
Block a user