/** * 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= num install_out, ou reference_type=''serialized_asset''/reference_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= — 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; `); };