diff --git a/apps/api/prisma.config.ts b/apps/api/prisma.config.ts index eb245cb..d19c4d4 100644 --- a/apps/api/prisma.config.ts +++ b/apps/api/prisma.config.ts @@ -10,11 +10,6 @@ export default defineConfig({ datasource: { // Prisma 7: url aqui serve apenas para o CLI (migrate/generate/studio). // Runtime usa WorkspacePrismaPool → PrismaClient({ adapter: new PrismaPg(pool) }). - url: - process.env['DATABASE_URL'] ?? - 'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev', - }, - migrations: { - seed: 'tsx prisma/seed.ts', + url: process.env['DATABASE_URL'], }, }); diff --git a/apps/api/prisma/migrations/20260701000000_pedidos_add_end_entrega/migration.sql b/apps/api/prisma/migrations/20260701000000_pedidos_add_end_entrega/migration.sql new file mode 100644 index 0000000..1088244 --- /dev/null +++ b/apps/api/prisma/migrations/20260701000000_pedidos_add_end_entrega/migration.sql @@ -0,0 +1,5 @@ +-- Endereco de entrega alternativo do pedido (feature "novo pedido v2"). +-- O campo existia no schema.prisma sem migration correspondente. +-- Idempotente: provision-client.ts roda sar-erp-schema.sql antes do migrate deploy. +-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui. +ALTER TABLE sar.pedidos ADD COLUMN IF NOT EXISTS end_entrega TEXT; diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts deleted file mode 100644 index 97d6458..0000000 --- a/apps/api/prisma/seed.ts +++ /dev/null @@ -1,1137 +0,0 @@ -// Seed de desenvolvimento — popula sar_workspace_dev com dados fictícios. -// Executado via: pnpm exec prisma db seed (apps/api/) -// NUNCA rodar em staging/prod. - -import { PrismaClient, FinancialStatus, OrderStatus, type Prisma } from '@prisma/client'; -import { PrismaPg } from '@prisma/adapter-pg'; -import pg from 'pg'; - -const pool = new pg.Pool({ - connectionString: - process.env['DATABASE_URL'] ?? - 'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev', - max: 2, -}); -const adapter = new PrismaPg(pool); -const prisma = new PrismaClient({ adapter }); - -const DEV_REP_ID = 'user-001'; -const DEV_REP2_ID = 'user-002'; -const APPROVER_ID = 'user-manager-01'; - -function daysAgo(days: number): Date { - const d = new Date(); - d.setDate(d.getDate() - days); - return d; -} - -function calcSubtotal(qty: number, price: number, discPct: number): number { - return Math.round(qty * price * (1 - discPct / 100) * 100) / 100; -} - -function orderNumber(n: number): string { - return `PED-${String(n).padStart(5, '0')}`; -} - -const clientDefs = [ - { - name: 'Padaria São João Ltda', - tradeName: 'Padaria São João', - taxId: '12345678000195', - email: 'contato@padariasaojoao.com.br', - phone: '(11) 3456-7890', - address: { - street: 'Rua das Flores', - number: '123', - district: 'Centro', - city: 'São Paulo', - state: 'SP', - zip: '01310100', - }, - financialStatus: FinancialStatus.regular, - creditLimit: 15000.0, - repId: DEV_REP_ID, - erpCode: 'CLI-001', - }, - { - name: 'Supermercado Bom Preço Eireli', - tradeName: 'Bom Preço', - taxId: '98765432000187', - email: 'compras@bompreco.com.br', - phone: '(11) 4567-8901', - address: { - street: 'Av. Paulista', - number: '900', - district: 'Bela Vista', - city: 'São Paulo', - state: 'SP', - zip: '01311100', - }, - financialStatus: FinancialStatus.regular, - creditLimit: 50000.0, - repId: DEV_REP_ID, - erpCode: 'CLI-002', - }, - { - name: 'Mercearia do Seu Zé ME', - tradeName: 'Mercearia Zé', - taxId: '11223344000156', - email: null, - phone: '(11) 9876-5432', - address: { - street: 'Rua Quinze de Novembro', - number: '45', - district: 'Vila Nova', - city: 'Guarulhos', - state: 'SP', - zip: '07031070', - }, - financialStatus: FinancialStatus.attention, - creditLimit: 5000.0, - repId: DEV_REP_ID, - erpCode: 'CLI-003', - }, - { - name: 'Distribuidora Norte Alimentos SA', - tradeName: 'Norte Alimentos', - taxId: '55667788000143', - email: 'pedidos@nortealimentos.com.br', - phone: '(92) 3344-5566', - address: { - street: 'Av. Brasil', - number: '2200', - district: 'Industrial', - city: 'Manaus', - state: 'AM', - zip: '69075001', - }, - financialStatus: FinancialStatus.regular, - creditLimit: 120000.0, - repId: DEV_REP_ID, - erpCode: 'CLI-004', - }, - { - name: 'Bar e Lanchonete do Carlos', - tradeName: null, - taxId: '33221100000178', - email: null, - phone: '(21) 99887-6655', - address: { - street: 'Rua da Alfândega', - number: '12', - district: 'Centro', - city: 'Rio de Janeiro', - state: 'RJ', - zip: '20070002', - }, - financialStatus: FinancialStatus.blocked, - creditLimit: 2000.0, - repId: DEV_REP_ID, - erpCode: 'CLI-005', - }, - { - name: 'Restaurante Sabor da Terra Ltda', - tradeName: 'Sabor da Terra', - taxId: '77889900000132', - email: 'admin@sabordaterra.com.br', - phone: '(31) 3322-1100', - address: { - street: 'Rua dos Inconfidentes', - number: '560', - district: 'Savassi', - city: 'Belo Horizonte', - state: 'MG', - zip: '30140128', - }, - financialStatus: FinancialStatus.regular, - creditLimit: 30000.0, - repId: DEV_REP_ID, - erpCode: 'CLI-006', - }, - { - name: 'Atacadão Central Comércio Ltda', - tradeName: 'Atacadão Central', - taxId: '44556677000119', - email: 'compras@atacadaocentral.com.br', - phone: '(51) 3288-9900', - address: { - street: 'Av. Assis Brasil', - number: '3970', - district: "Passo d'Areia", - city: 'Porto Alegre', - state: 'RS', - zip: '91010003', - }, - financialStatus: FinancialStatus.regular, - creditLimit: 80000.0, - repId: DEV_REP_ID, - erpCode: 'CLI-007', - }, - { - name: 'Quitanda Boa Vista ME', - tradeName: 'Quitanda Boa Vista', - taxId: '22334455000167', - email: null, - phone: '(48) 3344-2211', - address: { - street: 'Rua Felipe Schmidt', - number: '88', - district: 'Centro', - city: 'Florianópolis', - state: 'SC', - zip: '88010001', - }, - financialStatus: FinancialStatus.attention, - creditLimit: 3500.0, - repId: DEV_REP_ID, - erpCode: 'CLI-008', - }, - { - name: 'Empório Gourmet Curitiba Ltda', - tradeName: 'Empório Gourmet', - taxId: '66778899000124', - email: 'pedidos@emporiogourmet.com.br', - phone: '(41) 3233-4455', - address: { - street: 'Rua XV de Novembro', - number: '700', - district: 'Centro', - city: 'Curitiba', - state: 'PR', - zip: '80060000', - }, - financialStatus: FinancialStatus.regular, - creditLimit: 25000.0, - repId: DEV_REP2_ID, - erpCode: 'CLI-009', - }, - { - name: 'Mini Mercado Esperança', - tradeName: null, - taxId: '88990011000135', - email: null, - phone: '(85) 9988-7766', - address: { - street: 'Av. Bezerra de Menezes', - number: '1800', - district: 'São Gerardo', - city: 'Fortaleza', - state: 'CE', - zip: '60325001', - }, - financialStatus: FinancialStatus.regular, - creditLimit: 8000.0, - repId: DEV_REP2_ID, - erpCode: 'CLI-010', - }, -]; - -// ─── Catálogo de produtos (25 produtos, 5 categorias) ──────────────────────── - -const products: Prisma.ProductCreateInput[] = [ - // grãos - { - code: 'ARR-001', - name: 'Arroz Tipo 1 5kg cx10', - category: 'grãos', - unitPrice: 75.0, - stock: 500, - erpCode: 'P0001', - }, - { - code: 'FEI-001', - name: 'Feijão Carioca 1kg cx10', - category: 'grãos', - unitPrice: 65.0, - stock: 400, - erpCode: 'P0002', - }, - { - code: 'FAR-001', - name: 'Farinha de Trigo 25kg', - category: 'grãos', - unitPrice: 89.9, - stock: 200, - erpCode: 'P0003', - }, - { - code: 'ACU-001', - name: 'Açúcar Cristal 50kg', - category: 'grãos', - unitPrice: 145.0, - stock: 150, - erpCode: 'P0004', - }, - { - code: 'MIL-001', - name: 'Flocão de Milho 500g cx20', - category: 'grãos', - unitPrice: 48.0, - stock: 300, - erpCode: 'P0005', - }, - // bebidas - { - code: 'OLE-001', - name: 'Óleo de Soja 900ml cx18', - category: 'bebidas', - unitPrice: 98.0, - stock: 600, - erpCode: 'P0006', - }, - { - code: 'REF-001', - name: 'Refrigerante 2L cx6', - category: 'bebidas', - unitPrice: 42.0, - stock: 800, - erpCode: 'P0007', - }, - { - code: 'AGU-001', - name: 'Água Mineral 500ml cx12', - category: 'bebidas', - unitPrice: 18.0, - stock: 1000, - erpCode: 'P0008', - }, - { - code: 'SUC-001', - name: 'Suco de Caixinha 200ml cx27', - category: 'bebidas', - unitPrice: 32.4, - stock: 700, - erpCode: 'P0009', - }, - { - code: 'CER-001', - name: 'Cerveja Lata 350ml cx12', - category: 'bebidas', - unitPrice: 52.8, - stock: 400, - erpCode: 'P0010', - }, - // laticínios - { - code: 'LEI-001', - name: 'Leite UHT Integral 1L cx12', - category: 'laticínios', - unitPrice: 54.0, - stock: 900, - erpCode: 'P0011', - }, - { - code: 'QUE-001', - name: 'Queijo Mussarela kg', - category: 'laticínios', - unitPrice: 38.0, - stock: 200, - erpCode: 'P0012', - }, - { - code: 'IOG-001', - name: 'Iogurte Natural 170g cx12', - category: 'laticínios', - unitPrice: 28.8, - stock: 350, - erpCode: 'P0013', - }, - { - code: 'MAN-001', - name: 'Manteiga com Sal 200g cx12', - category: 'laticínios', - unitPrice: 84.0, - stock: 250, - erpCode: 'P0014', - }, - { - code: 'REQ-001', - name: 'Requeijão Cremoso 200g cx12', - category: 'laticínios', - unitPrice: 72.0, - stock: 180, - erpCode: 'P0015', - }, - // perecíveis - { - code: 'CAR-001', - name: 'Carne Bovina Contrafilé kg', - category: 'perecíveis', - unitPrice: 65.0, - stock: 150, - erpCode: 'P0016', - }, - { - code: 'FRA-001', - name: 'Frango Inteiro Resfriado kg', - category: 'perecíveis', - unitPrice: 18.5, - stock: 400, - erpCode: 'P0017', - }, - { - code: 'PEI-001', - name: 'Peixe Tilápia Filé kg', - category: 'perecíveis', - unitPrice: 32.0, - stock: 100, - erpCode: 'P0018', - }, - { - code: 'EMB-001', - name: 'Embutidos Sortidos kg', - category: 'perecíveis', - unitPrice: 28.0, - stock: 300, - erpCode: 'P0019', - }, - { - code: 'LEG-001', - name: 'Legumes Sortidos kg', - category: 'perecíveis', - unitPrice: 8.5, - stock: 500, - erpCode: 'P0020', - }, - // higiene - { - code: 'SAB-001', - name: 'Sabão em Pó 1kg cx12', - category: 'higiene', - unitPrice: 42.0, - stock: 400, - erpCode: 'P0021', - }, - { - code: 'DET-001', - name: 'Detergente 500ml cx24', - category: 'higiene', - unitPrice: 38.4, - stock: 600, - erpCode: 'P0022', - }, - { - code: 'HIG-001', - name: 'Shampoo 400ml cx12', - category: 'higiene', - unitPrice: 96.0, - stock: 250, - erpCode: 'P0023', - }, - { - code: 'LEV-001', - name: 'Fermento Biológico 500g', - category: 'grãos', - unitPrice: 12.5, - stock: 200, - erpCode: 'P0024', - }, - { - code: 'CON-001', - name: 'Conservas Sortidas cx24', - category: 'grãos', - unitPrice: 96.0, - stock: 180, - erpCode: 'P0025', - }, - { - code: 'MOL-001', - name: 'Molho de Tomate 340g cx24', - category: 'grãos', - unitPrice: 72.0, - stock: 220, - erpCode: 'P0026', - }, - { - code: 'FRU-001', - name: 'Frutas Sortidas cx', - category: 'perecíveis', - unitPrice: 45.0, - stock: 80, - erpCode: 'P0027', - }, - { - code: 'VER-001', - name: 'Verduras Sortidas kg', - category: 'perecíveis', - unitPrice: 6.0, - stock: 300, - erpCode: 'P0028', - }, -]; - -// Mapa code → category para popular productCategory nos OrderItems do seed C3 -const productCategoryMap: Record = Object.fromEntries( - products.map((p) => [p.code, p.category ?? 'geral']), -); - -// Alçadas de desconto de user-001: default 10%, bebidas 8%, perecíveis 5% -const repDiscountLimits = [ - { repId: DEV_REP_ID, category: '__default__', limit: 10 }, - { repId: DEV_REP_ID, category: 'bebidas', limit: 8 }, - { repId: DEV_REP_ID, category: 'perecíveis', limit: 5 }, - { repId: DEV_REP2_ID, category: '__default__', limit: 5 }, -]; - -type OrderSeed = { - num: number; - status: OrderStatus; - issuedDaysAgo: number; - discountPct: number; - notes?: string; - items: { - productCode: string; - productName: string; - qty: number; - unitPrice: number; - discountPct: number; - }[]; -}; - -// 17 pedidos distribuídos por 7 clientes de user-001 -const ordersByTaxId: Record = { - // Padaria São João — 2 pedidos (invoiced + budget) - '12345678000195': [ - { - num: 1, - status: OrderStatus.invoiced, - issuedDaysAgo: 20, - discountPct: 0, - items: [ - { - productCode: 'FAR-001', - productName: 'Farinha de Trigo 25kg', - qty: 10, - unitPrice: 89.9, - discountPct: 0, - }, - { - productCode: 'ACU-001', - productName: 'Açúcar Cristal 50kg', - qty: 5, - unitPrice: 145.0, - discountPct: 0, - }, - ], - }, - { - num: 2, - status: OrderStatus.budget, - issuedDaysAgo: 3, - discountPct: 2, - items: [ - { - productCode: 'FAR-001', - productName: 'Farinha de Trigo 25kg', - qty: 8, - unitPrice: 89.9, - discountPct: 0, - }, - { - productCode: 'LEV-001', - productName: 'Fermento Biológico 500g', - qty: 20, - unitPrice: 12.5, - discountPct: 5, - }, - ], - }, - ], - // Supermercado Bom Preço — 3 pedidos (invoiced + approved + pending_approval) - '98765432000187': [ - { - num: 3, - status: OrderStatus.invoiced, - issuedDaysAgo: 45, - discountPct: 3, - items: [ - { - productCode: 'OLE-001', - productName: 'Óleo de Soja 900ml cx18', - qty: 20, - unitPrice: 98.0, - discountPct: 0, - }, - { - productCode: 'ARR-001', - productName: 'Arroz Tipo 1 5kg cx10', - qty: 15, - unitPrice: 75.0, - discountPct: 2, - }, - { - productCode: 'FEI-001', - productName: 'Feijão Carioca 1kg cx10', - qty: 10, - unitPrice: 65.0, - discountPct: 0, - }, - ], - }, - { - num: 4, - status: OrderStatus.pending_approval, - issuedDaysAgo: 2, - discountPct: 5, - notes: 'Cliente solicitou desconto especial para reposição de estoque', - items: [ - { - productCode: 'OLE-001', - productName: 'Óleo de Soja 900ml cx18', - qty: 30, - unitPrice: 98.0, - discountPct: 3, - }, - { - productCode: 'ARR-001', - productName: 'Arroz Tipo 1 5kg cx10', - qty: 25, - unitPrice: 75.0, - discountPct: 3, - }, - ], - }, - { - num: 5, - status: OrderStatus.approved, - issuedDaysAgo: 10, - discountPct: 0, - items: [ - { - productCode: 'SAB-001', - productName: 'Sabão em Pó 1kg cx12', - qty: 15, - unitPrice: 42.0, - discountPct: 0, - }, - { - productCode: 'DET-001', - productName: 'Detergente 500ml cx24', - qty: 10, - unitPrice: 38.4, - discountPct: 0, - }, - ], - }, - ], - // Mercearia Zé — 1 pedido (invoiced, há 35 dias — activityStatus alert) - '11223344000156': [ - { - num: 6, - status: OrderStatus.invoiced, - issuedDaysAgo: 35, - discountPct: 0, - items: [ - { - productCode: 'REF-001', - productName: 'Refrigerante 2L cx6', - qty: 5, - unitPrice: 42.0, - discountPct: 0, - }, - { - productCode: 'AGU-001', - productName: 'Água Mineral 500ml cx12', - qty: 8, - unitPrice: 18.0, - discountPct: 0, - }, - ], - }, - ], - // Distribuidora Norte — 4 pedidos (2 invoiced + 1 approved + 1 pending_approval) - '55667788000143': [ - { - num: 7, - status: OrderStatus.invoiced, - issuedDaysAgo: 60, - discountPct: 2, - items: [ - { - productCode: 'LEI-001', - productName: 'Leite UHT Integral 1L cx12', - qty: 50, - unitPrice: 54.0, - discountPct: 0, - }, - { - productCode: 'QUE-001', - productName: 'Queijo Mussarela kg', - qty: 30, - unitPrice: 38.0, - discountPct: 5, - }, - ], - }, - { - num: 8, - status: OrderStatus.invoiced, - issuedDaysAgo: 30, - discountPct: 0, - items: [ - { - productCode: 'EMB-001', - productName: 'Embutidos Sortidos kg', - qty: 40, - unitPrice: 28.0, - discountPct: 0, - }, - { - productCode: 'LEI-001', - productName: 'Leite UHT Integral 1L cx12', - qty: 60, - unitPrice: 54.0, - discountPct: 2, - }, - ], - }, - { - num: 9, - status: OrderStatus.pending_approval, - issuedDaysAgo: 1, - discountPct: 8, - notes: 'Grande volume — precisa aprovação de gerente', - items: [ - { - productCode: 'LEI-001', - productName: 'Leite UHT Integral 1L cx12', - qty: 100, - unitPrice: 54.0, - discountPct: 5, - }, - { - productCode: 'QUE-001', - productName: 'Queijo Mussarela kg', - qty: 80, - unitPrice: 38.0, - discountPct: 5, - }, - { - productCode: 'EMB-001', - productName: 'Embutidos Sortidos kg', - qty: 60, - unitPrice: 28.0, - discountPct: 0, - }, - ], - }, - { - num: 10, - status: OrderStatus.approved, - issuedDaysAgo: 5, - discountPct: 3, - items: [ - { - productCode: 'CON-001', - productName: 'Conservas Sortidas cx24', - qty: 20, - unitPrice: 96.0, - discountPct: 0, - }, - { - productCode: 'MOL-001', - productName: 'Molho de Tomate 340g cx24', - qty: 15, - unitPrice: 72.0, - discountPct: 3, - }, - ], - }, - ], - // Restaurante Sabor da Terra — 2 pedidos (invoiced + invoiced) - '77889900000132': [ - { - num: 11, - status: OrderStatus.invoiced, - issuedDaysAgo: 40, - discountPct: 0, - items: [ - { - productCode: 'CAR-001', - productName: 'Carne Bovina Contrafilé kg', - qty: 20, - unitPrice: 65.0, - discountPct: 0, - }, - { - productCode: 'FRA-001', - productName: 'Frango Inteiro Resfriado kg', - qty: 30, - unitPrice: 18.5, - discountPct: 0, - }, - ], - }, - { - num: 12, - status: OrderStatus.invoiced, - issuedDaysAgo: 15, - discountPct: 5, - items: [ - { - productCode: 'CAR-001', - productName: 'Carne Bovina Contrafilé kg', - qty: 25, - unitPrice: 65.0, - discountPct: 0, - }, - { - productCode: 'PEI-001', - productName: 'Peixe Tilápia Filé kg', - qty: 15, - unitPrice: 32.0, - discountPct: 0, - }, - { - productCode: 'LEG-001', - productName: 'Legumes Sortidos kg', - qty: 20, - unitPrice: 8.5, - discountPct: 0, - }, - ], - }, - ], - // Atacadão Central — 3 pedidos (invoiced + approved + pending_approval) - '44556677000119': [ - { - num: 13, - status: OrderStatus.invoiced, - issuedDaysAgo: 50, - discountPct: 4, - items: [ - { - productCode: 'OLE-001', - productName: 'Óleo de Soja 900ml cx18', - qty: 60, - unitPrice: 98.0, - discountPct: 2, - }, - { - productCode: 'ARR-001', - productName: 'Arroz Tipo 1 5kg cx10', - qty: 40, - unitPrice: 75.0, - discountPct: 3, - }, - { - productCode: 'ACU-001', - productName: 'Açúcar Cristal 50kg', - qty: 15, - unitPrice: 145.0, - discountPct: 0, - }, - ], - }, - { - num: 14, - status: OrderStatus.invoiced, - issuedDaysAgo: 20, - discountPct: 3, - items: [ - { - productCode: 'FEI-001', - productName: 'Feijão Carioca 1kg cx10', - qty: 50, - unitPrice: 65.0, - discountPct: 2, - }, - { - productCode: 'LEI-001', - productName: 'Leite UHT Integral 1L cx12', - qty: 40, - unitPrice: 54.0, - discountPct: 0, - }, - ], - }, - { - num: 15, - status: OrderStatus.approved, - issuedDaysAgo: 7, - discountPct: 5, - items: [ - { - productCode: 'HIG-001', - productName: 'Shampoo 400ml cx12', - qty: 30, - unitPrice: 96.0, - discountPct: 0, - }, - { - productCode: 'SAB-001', - productName: 'Sabão em Pó 1kg cx12', - qty: 25, - unitPrice: 42.0, - discountPct: 5, - }, - ], - }, - ], - // Quitanda Boa Vista — 2 pedidos (cancelled + budget) - '22334455000167': [ - { - num: 16, - status: OrderStatus.cancelled, - issuedDaysAgo: 50, - discountPct: 0, - notes: 'Cliente cancelou por falta de espaço em estoque', - items: [ - { - productCode: 'FRU-001', - productName: 'Frutas Sortidas cx', - qty: 10, - unitPrice: 45.0, - discountPct: 0, - }, - ], - }, - { - num: 17, - status: OrderStatus.pending_approval, - issuedDaysAgo: 1, - discountPct: 10, - notes: 'Desconto acima do limite — aguardando aprovação', - items: [ - { - productCode: 'FRU-001', - productName: 'Frutas Sortidas cx', - qty: 8, - unitPrice: 45.0, - discountPct: 10, - }, - { - productCode: 'VER-001', - productName: 'Verduras Sortidas kg', - qty: 15, - unitPrice: 6.0, - discountPct: 0, - }, - ], - }, - ], -}; - -function buildHistoryForStatus(status: OrderStatus, repId: string, issuedDaysAgo: number) { - const entries: { - fromStatus: OrderStatus | null; - toStatus: OrderStatus; - changedById: string; - changedAt: Date; - note?: string; - }[] = []; - - entries.push({ - fromStatus: null, - toStatus: OrderStatus.budget, - changedById: repId, - changedAt: daysAgo(issuedDaysAgo), - }); - - if (status === OrderStatus.budget) return entries; - - if (status === OrderStatus.cancelled) { - entries.push({ - fromStatus: OrderStatus.budget, - toStatus: OrderStatus.cancelled, - changedById: repId, - changedAt: daysAgo(issuedDaysAgo - 1), - }); - return entries; - } - - entries.push({ - fromStatus: OrderStatus.budget, - toStatus: OrderStatus.pending_approval, - changedById: repId, - changedAt: daysAgo(issuedDaysAgo - 0.1), - }); - if (status === OrderStatus.pending_approval) return entries; - - entries.push({ - fromStatus: OrderStatus.pending_approval, - toStatus: OrderStatus.approved, - changedById: APPROVER_ID, - changedAt: daysAgo(issuedDaysAgo - 0.5), - }); - if (status === OrderStatus.approved) return entries; - - entries.push({ - fromStatus: OrderStatus.approved, - toStatus: OrderStatus.invoiced, - changedById: APPROVER_ID, - changedAt: daysAgo(issuedDaysAgo - 1), - }); - return entries; -} - -async function main() { - console.log('Seed iniciado...'); - - // Upsert catálogo de produtos - for (const p of products) { - await prisma.product.upsert({ - where: { code: p.code }, - create: { ...p, syncedAt: new Date() }, - update: { name: p.name, unitPrice: p.unitPrice, stock: p.stock, syncedAt: new Date() }, - }); - } - console.log(`${products.length} produtos upserted.`); - - // Upsert alçadas de desconto - for (const r of repDiscountLimits) { - await prisma.repDiscountLimit.upsert({ - where: { repId_category: { repId: r.repId, category: r.category } }, - create: r, - update: { limit: r.limit }, - }); - } - console.log(`${repDiscountLimits.length} alçadas configuradas.`); - - // Metas mensais (C7) - const now = new Date(); - const repTargets = [ - { - repId: DEV_REP_ID, - year: now.getFullYear(), - month: now.getMonth() + 1, - targetAmount: 60000, - commissionRate: 3, - flexRate: 1, - }, - { - repId: DEV_REP2_ID, - year: now.getFullYear(), - month: now.getMonth() + 1, - targetAmount: 40000, - commissionRate: 3, - flexRate: 1, - }, - ]; - for (const t of repTargets) { - await prisma.repTarget.upsert({ - where: { repId_year_month: { repId: t.repId, year: t.year, month: t.month } }, - create: t, - update: { targetAmount: t.targetAmount }, - }); - } - console.log(`${repTargets.length} metas mensais configuradas.`); - - // Upsert clients (sem lastOrderAt/openOrdersCount — calculados depois) - for (const data of clientDefs) { - await prisma.client.upsert({ - where: { taxId: data.taxId }, - create: { ...data, syncedAt: new Date() }, - update: { - name: data.name, - financialStatus: data.financialStatus, - creditLimit: data.creditLimit, - syncedAt: new Date(), - }, - }); - } - console.log(`${clientDefs.length} clientes upserted.`); - - // Delete existing orders (re-seed idempotente) - await prisma.orderStatusHistory.deleteMany({}); - await prisma.orderItem.deleteMany({}); - await prisma.order.deleteMany({}); - console.log('Pedidos anteriores removidos.'); - - let orderCount = 0; - for (const [taxId, orders] of Object.entries(ordersByTaxId)) { - const client = await prisma.client.findUniqueOrThrow({ where: { taxId } }); - - for (const o of orders) { - const issuedAt = daysAgo(o.issuedDaysAgo); - - // Build items with subtotals + productCategory (desnorm do catálogo) - const itemsData = o.items.map((it) => ({ - productCode: it.productCode, - productName: it.productName, - productCategory: productCategoryMap[it.productCode] ?? 'geral', - quantity: it.qty, - unitPrice: it.unitPrice, - discountPct: it.discountPct, - subtotal: calcSubtotal(it.qty, it.unitPrice, it.discountPct), - })); - - const itemsSubtotal = itemsData.reduce((acc, it) => acc + Number(it.subtotal), 0); - const orderTotal = Math.round(itemsSubtotal * (1 - o.discountPct / 100) * 100) / 100; - - const historyEntries = buildHistoryForStatus(o.status, client.repId, o.issuedDaysAgo); - - const approvedEntry = historyEntries.find((h) => h.toStatus === OrderStatus.approved); - const invoicedEntry = historyEntries.find((h) => h.toStatus === OrderStatus.invoiced); - const cancelledEntry = historyEntries.find((h) => h.toStatus === OrderStatus.cancelled); - - await prisma.order.create({ - data: { - number: orderNumber(o.num), - clientId: client.id, - repId: client.repId, - status: o.status, - discountPct: o.discountPct, - subtotal: itemsSubtotal, - total: orderTotal, - notes: o.notes ?? null, - approvedById: approvedEntry ? APPROVER_ID : null, - approvedAt: approvedEntry?.changedAt ?? null, - invoicedAt: invoicedEntry?.changedAt ?? null, - cancelledAt: cancelledEntry?.changedAt ?? null, - issuedAt, - items: { create: itemsData }, - history: { - create: historyEntries.map((h) => ({ - fromStatus: h.fromStatus ?? null, - toStatus: h.toStatus, - changedById: h.changedById, - changedAt: h.changedAt, - note: h.note ?? null, - })), - }, - }, - }); - orderCount++; - } - } - console.log(`${orderCount} pedidos criados.`); - - // Atualiza desnorm de clientes a partir dos pedidos criados - for (const data of clientDefs) { - const client = await prisma.client.findUniqueOrThrow({ where: { taxId: data.taxId } }); - - const orders = await prisma.order.findMany({ - where: { clientId: client.id, deletedAt: null, status: { not: OrderStatus.cancelled } }, - orderBy: { issuedAt: 'desc' }, - }); - - const openStatuses: OrderStatus[] = [ - OrderStatus.budget, - OrderStatus.pending_approval, - OrderStatus.approved, - ]; - const openCount = orders.filter((o) => openStatuses.includes(o.status)).length; - const lastOrder = orders[0]; - - await prisma.client.update({ - where: { id: client.id }, - data: { - lastOrderAt: lastOrder?.issuedAt ?? null, - lastOrderValue: lastOrder ? lastOrder.total : null, - openOrdersCount: openCount, - }, - }); - } - console.log('Desnorm de clientes atualizada.'); -} - -main() - .catch((e) => { - console.error(e); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - await pool.end(); - }); diff --git a/apps/api/src/app/auth/jwt-auth.guard.ts b/apps/api/src/app/auth/jwt-auth.guard.ts index db81b7e..410f07a 100644 --- a/apps/api/src/app/auth/jwt-auth.guard.ts +++ b/apps/api/src/app/auth/jwt-auth.guard.ts @@ -59,9 +59,8 @@ export class JwtAuthGuard implements CanActivate { this.cls.set('userId', userId); this.cls.set('role', payload.role); - const baseUrl = - process.env['DATABASE_URL'] ?? - 'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev'; + const baseUrl = process.env['DATABASE_URL']; + if (!baseUrl) throw new Error('DATABASE_URL não configurada'); this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, baseUrl)); return true; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 66a4180..5b2c7d8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -20,7 +20,6 @@ services: # subdir por major-version pra suportar pg_upgrade --link sem # boundary issues. Ref: docker-library/postgres#1259. - sar-postgres-data:/var/lib/postgresql - - ./scripts/postgres-init:/docker-entrypoint-initdb.d:ro healthcheck: test: ['CMD-SHELL', 'pg_isready -U sar -d sar_master'] interval: 10s diff --git a/sarweb_views.sql b/sarweb_views.sql deleted file mode 100644 index 12af4bb..0000000 --- a/sarweb_views.sql +++ /dev/null @@ -1,124 +0,0 @@ --- ============================================================================= --- sar_views.sql --- Views PostgreSQL do projeto SAR — schema `sar` no banco ERP (libreplast) --- --- Executar como: psql -U postgres -d libreplast -f sar_views.sql --- --- DECISÃO DE ARQUITETURA: todas as views residem no schema `sar` (mesmo schema --- das tabelas Prisma). O search_path da conexão runtime é `sar`, portanto as --- queries do backend usam os nomes curtos (vw_clientes, vw_pedidos_erp). --- --- ERP base: SIG (schema sig.*) --- Empresa gerencial: id_empresa = 1 (gestao.empresa) --- Empresa fiscal: id_empresa = 9001 (sig.corrent / sig.pedidos) --- Cancelados SIG: situa = 5 (≠ SAR que usa situa = 3) --- Faturados SIG: situa = 4 (coincide com SAR) --- ============================================================================= - --- --------------------------------------------------------------------------- --- 1. CLIENTES --- Fonte: sig.corrent --- Obs: COALESCE(id_empresa, 1) cobre registros antigos sem id_empresa. --- cod_vendedor determina a carteira do representante. --- --------------------------------------------------------------------------- -CREATE OR REPLACE VIEW sar.vw_clientes AS -SELECT - COALESCE(c.id_empresa, 1) AS id_empresa, - c.id_corrent AS id_cliente, - c.ativo, - COALESCE(NULLIF(TRIM(c.nome), ''), TRIM(c.razao)) AS nome, - TRIM(c.razao) AS razao, - c.pesso AS pessoa, - c.consfinal, - c.cgcpf, - c.suf_cgcpf, - c.inscr AS inscricao_estadual, - c.endereco, - COALESCE(c.num_endereco, '') AS num_endereco, - c.bairr AS bairro, - c.id_municipio, - c.cep, - c.ddd, - c.telef AS telefone, - c.e_mail AS email, - c.data AS dt_cadastro, - c.obs, - c.cod_formapag, - ( - SELECT fp.id_formapag - FROM gestao.formapag fp - LEFT JOIN gestao.empresa e ON e.id_empresa = COALESCE(c.id_empresa, 1) - WHERE fp.id_empresa = COALESCE(e.id_matriz, COALESCE(c.id_empresa, 1)) - AND fp.codigo = c.cod_formapag - LIMIT 1 - ) AS id_formapag, - c.indicador_ie, - c.cod_pauta, - c.st_especifica, - COALESCE(c.limcred, 0) AS limite_credito, - c.cod_vendedor, - c.dt_atual -FROM sig.corrent c; - --- --------------------------------------------------------------------------- --- 2. PEDIDOS ERP --- Fonte: sig.pedidos --- Situa SIG → SAR: 5=Cancelado(SIG) vs 3=Cancelado(SAR). O backend --- normaliza em runtime (sigToSar / sarToSig em orders.service.ts). --- --------------------------------------------------------------------------- -CREATE OR REPLACE VIEW sar.vw_pedidos_erp AS -SELECT - p.id_empresa, - p.id_pedido, - p.num_ped_sar, - p.numero, - p.tipo, - p.situa, - CASE p.situa - WHEN 1 THEN 'Pendente' - WHEN 2 THEN 'Liberado' - WHEN 4 THEN 'Faturado' - WHEN 5 THEN 'Cancelado' - ELSE 'Enviado' - END AS status_descr, - p.data AS dt_pedido, - p.data_emissao AS dt_emissao, - p.clien AS id_cliente, - p.cod_vendedor, - p.cod_formapag, - fp.id_formapag, - fp.descr AS forma_pagamento, - pau.id_pauta, - COALESCE(p.obs, '') AS obs, - p.totpr AS total_produtos, - COALESCE(p.ipi, 0) AS total_ipi, - 0 AS total_icmsst, - COALESCE(p.total, 0) AS total, - COALESCE(p.descp, 0) AS desconto_perc, - COALESCE(p.descv, 0) AS desconto_valor, - COALESCE(p.tx_acrescimo, 0) AS acrescimo, - COALESCE(p.com_fat, 0) AS comissao, - COALESCE(p.ped_flex::integer, 0) AS ped_flex, - p.cod_vend2 AS cod_supervisor, - p.tx_com_vend2 AS taxa_com_super -FROM sig.pedidos p -LEFT JOIN gestao.formapag fp - ON fp.codigo = p.cod_formapag - AND fp.id_empresa = CASE WHEN p.id_empresa > 9000 - THEN p.id_empresa - 9000 - ELSE p.id_empresa END -LEFT JOIN gestao.pauta pau - ON pau.codigo = p.cod_pauta - AND pau.id_empresa = CASE WHEN p.id_empresa > 9000 - THEN p.id_empresa - 9000 - ELSE p.id_empresa END; - --- --------------------------------------------------------------------------- --- 3. STATUS DE PEDIDO ERP --- Fonte: sig.sitpedido --- --------------------------------------------------------------------------- -CREATE OR REPLACE VIEW sar.vw_sitpedido AS -SELECT - s.id_sitpedido, - TRIM(s.descricao) AS descricao -FROM sig.sitpedido s; diff --git a/scripts/postgres-init/01-create-dev-workspace.sql b/scripts/postgres-init/01-create-dev-workspace.sql deleted file mode 100644 index 94dccec..0000000 --- a/scripts/postgres-init/01-create-dev-workspace.sql +++ /dev/null @@ -1,16 +0,0 @@ --- SAR dev bootstrap --- Cria o BD do workspace de exemplo para desenvolvimento local. --- Multi-tenancy real (BD-por-workspace) vem com master-login na próxima sessão. - -CREATE DATABASE sar_workspace_dev OWNER sar; -GRANT ALL PRIVILEGES ON DATABASE sar_workspace_dev TO sar; - --- Habilita pgcrypto (PII encryption — STACK.md §22) -\c sar_workspace_dev -CREATE EXTENSION IF NOT EXISTS pgcrypto; -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Cria role app_reader (que será usada para conexão runtime — não admin) --- Padrão ADR 0006: app cliente conecta como app_reader, senha vem do master-login -\c sar_master -CREATE EXTENSION IF NOT EXISTS pgcrypto; diff --git a/scripts/provision-client.ts b/scripts/provision-client.ts new file mode 100644 index 0000000..38632cd --- /dev/null +++ b/scripts/provision-client.ts @@ -0,0 +1,189 @@ +#!/usr/bin/env tsx +/** + * SAR — Provisionamento de cliente + * + * Aplica o schema SAR (views + tabelas + triggers) no banco ERP do cliente. + * Não requer psql — usa a lib pg que já é dependência do projeto. + * + * Uso: + * pnpm client:provision --host --db [opções] + * + * Opções: + * --host Host do PostgreSQL (obrigatório) + * --db Nome do banco ERP (obrigatório) + * --port Porta (default: 5432) + * --user Usuário (default: postgres) + * --password Senha (default: lê PG_PASSWORD do env) + * --role Role do app SAR — aplica GRANTs se informado (ex: sar_app) + * + * O que faz: + * 1. Executa scripts/sar-erp-schema.sql (schema sar + views + tabelas SAR) + * 2. Executa scripts/sar-triggers.sql (triggers de sync ERP + staging tables) + * 3. Aplica GRANTs se --role foi informado + * 4. Executa `prisma migrate deploy` (aplica migrations Prisma pendentes) + */ + +import { readFileSync, readdirSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { execSync } from 'child_process'; +import pg from 'pg'; + +// ─── CLI args ───────────────────────────────────────────────────────────────── + +function arg(flag: string): string | undefined { + const idx = process.argv.indexOf(flag); + return idx !== -1 ? process.argv[idx + 1] : undefined; +} + +const host = arg('--host'); +const db = arg('--db'); +const port = parseInt(arg('--port') ?? '5432', 10); +const user = arg('--user') ?? 'postgres'; +const password = arg('--password') ?? process.env['PG_PASSWORD'] ?? ''; +const role = arg('--role'); + +if (!host || !db) { + console.error( + 'Uso: pnpm client:provision --host --db [--port 5432] [--user postgres] [--password ...] [--role ]', + ); + console.error( + 'Exemplo: pnpm client:provision --host 192.168.0.43 --db libreplast --role sar_app', + ); + process.exit(1); +} + +// ─── Paths ──────────────────────────────────────────────────────────────────── + +const SCRIPTS_DIR = dirname(fileURLToPath(import.meta.url)); +const MONOREPO_ROOT = resolve(SCRIPTS_DIR, '..'); +const API_DIR = resolve(MONOREPO_ROOT, 'apps/api'); + +// Banco ERP usa LATIN1 — strip chars fora do range 0x00-0xFF (decorações nos comentários SQL) +function toLatinSafe(sql: string): string { + // O range LATIN1 começa em \x00 por definição — control chars intencionais. + // eslint-disable-next-line no-control-regex + return sql.replace(/[^\x00-\xFF]/g, (c) => { + if (c === '—' || c === '–') return '--'; // em dash / en dash + return '-'; // box-drawing chars e outros + }); +} + +const SQL_SCHEMA = toLatinSafe(readFileSync(resolve(SCRIPTS_DIR, 'sar-erp-schema.sql'), 'utf8')); +const SQL_TRIGGERS = toLatinSafe(readFileSync(resolve(SCRIPTS_DIR, 'sar-triggers.sql'), 'utf8')); + +const DATABASE_URL = `postgresql://${user}:${encodeURIComponent(password)}@${host}:${port}/${db}`; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function runSql(client: pg.Client, label: string, sql: string): Promise { + console.log(` Executando ${label}...`); + await client.query(sql); + console.log(` ✓ ${label} aplicado`); +} + +async function applyGrants(client: pg.Client, appRole: string): Promise { + console.log(` Aplicando GRANTs para role "${appRole}"...`); + await client.query(` + GRANT USAGE ON SCHEMA sar TO ${appRole}; + GRANT SELECT ON ALL TABLES IN SCHEMA sar TO ${appRole}; + GRANT INSERT, UPDATE, DELETE ON + sar.pedidos, + sar.pedido_itens, + sar.historico_pedido, + sar.alcada_desconto, + sar.meta_representante, + sar.push_subscription, + sar.promocoes, + sar.clientes_novos, + sar.contatos_novos + TO ${appRole}; + GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA sar TO ${appRole}; + `); + console.log(` ✓ GRANTs aplicados`); +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log('\nSAR — Provisionamento de Cliente'); + console.log('──────────────────────────────────'); + console.log(`Host: ${host}:${port}`); + console.log(`Banco: ${db}`); + console.log(`User: ${user}`); + if (role) console.log(`Role: ${role} (GRANTs serão aplicados)`); + console.log(''); + + const client = new pg.Client({ host, port, database: db, user, password }); + await client.connect(); + + let needsBaseline = false; + + try { + console.log('[1/3] Schema SAR...'); + await runSql(client, 'sar-erp-schema.sql', SQL_SCHEMA); + + console.log('[2/3] Triggers e tabelas staging...'); + await runSql(client, 'sar-triggers.sql', SQL_TRIGGERS); + + if (role) { + console.log('[+] GRANTs...'); + await applyGrants(client, role); + } + + // O schema SQL acima já criou as tabelas, então o schema `sar` nunca está + // vazio quando o Prisma roda. Sem histórico de migrations o `migrate deploy` + // aborta com P3005 — é preciso marcar a migration baseline como aplicada. + const { rows } = await client.query( + `SELECT to_regclass('sar._prisma_migrations') IS NOT NULL AS existe`, + ); + needsBaseline = !rows[0]?.existe; + } finally { + await client.end(); + } + + // _prisma_migrations precisa morar no schema sar, não no public do ERP. + const MIGRATE_URL = `${DATABASE_URL}?schema=sar`; + const prismaEnv = { ...process.env, DATABASE_URL: MIGRATE_URL }; + + console.log('[3/3] Migrations Prisma...'); + + if (needsBaseline) { + const baseline = readdirSync(resolve(API_DIR, 'prisma/migrations'), { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort()[0]; + if (!baseline) throw new Error('nenhuma migration encontrada para baseline'); + console.log(` Banco pré-existente: marcando "${baseline}" como aplicada (baseline)...`); + execSync(`pnpm exec prisma migrate resolve --applied ${baseline}`, { + cwd: API_DIR, + env: prismaEnv, + stdio: 'inherit', + }); + } + + execSync('pnpm exec prisma migrate deploy', { + cwd: API_DIR, + env: prismaEnv, + stdio: 'inherit', + }); + console.log(' ✓ Migrations aplicadas'); + + console.log(''); + console.log('════════════════════════════════════'); + console.log('✓ Cliente provisionado com sucesso'); + console.log('════════════════════════════════════'); + console.log(''); + console.log('Próximos passos:'); + console.log(` 1. Configure DATABASE_URL=${DATABASE_URL}`); + console.log(` no ambiente de produção (Vault ou .env do cliente)`); + if (!role) { + console.log(' 2. Execute os GRANTs manualmente ou rode novamente com --role '); + } + console.log(''); +} + +main().catch((e: unknown) => { + console.error('\nErro durante provisionamento:', e); + process.exit(1); +}); diff --git a/scripts/provision-workspace.ts b/scripts/provision-workspace.ts deleted file mode 100644 index 8528536..0000000 --- a/scripts/provision-workspace.ts +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env tsx -/** - * SAR — Provisionamento de workspace (C9) - * - * Uso: - * pnpm workspace:provision --id [--name ] [--with-seed] - * - * O que faz: - * 1. Cria o banco sar_workspace_ (ignora se já existe) - * 2. Habilita pgcrypto + uuid-ossp - * 3. Roda `prisma migrate deploy` no novo banco - * 4. Se --with-seed: popula dados demo (mesmos do seed de dev) - * - * Variáveis de ambiente (todas opcionais — padrões para dev local): - * PG_HOST (default: localhost) - * PG_PORT (default: 5432) - * PG_USER (default: sar) - * PG_PASSWORD (default: sar_dev_password) - * PG_ADMIN_DB (default: postgres) - * - * Convenção BD-por-workspace (ADR 0006): - * O JwtAuthGuard constrói a URL como sar_workspace_{workspaceId} automaticamente. - * Nenhuma entrada em master DB é necessária para o MVP. - */ - -import { execSync } from 'child_process'; -import { resolve } from 'path'; -import pg from 'pg'; - -// ─── CLI args ───────────────────────────────────────────────────────────────── - -function arg(flag: string): string | undefined { - const idx = process.argv.indexOf(flag); - return idx !== -1 ? process.argv[idx + 1] : undefined; -} - -const workspaceId = arg('--id'); -const workspaceName = arg('--name') ?? workspaceId; -const withSeed = process.argv.includes('--with-seed'); - -if (!workspaceId) { - console.error('Uso: pnpm workspace:provision --id [--name ] [--with-seed]'); - console.error('Exemplo: pnpm workspace:provision --id acme-001 --name "Acme Distribuidora"'); - process.exit(1); -} - -if (!/^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/.test(workspaceId)) { - console.error('Erro: --id deve conter apenas letras minúsculas, números e hífens (3-64 chars).'); - process.exit(1); -} - -// ─── Config ─────────────────────────────────────────────────────────────────── - -const PG_HOST = process.env['PG_HOST'] ?? 'localhost'; -const PG_PORT = parseInt(process.env['PG_PORT'] ?? '5432', 10); -const PG_USER = process.env['PG_USER'] ?? 'sar'; -const PG_PASSWORD = process.env['PG_PASSWORD'] ?? 'sar_dev_password'; -const PG_ADMIN_DB = process.env['PG_ADMIN_DB'] ?? 'postgres'; - -const DB_NAME = `sar_workspace_${workspaceId}`; -const DB_URL = `postgresql://${PG_USER}:${PG_PASSWORD}@${PG_HOST}:${PG_PORT}/${DB_NAME}`; - -const MONOREPO_ROOT = resolve(import.meta.dirname, '..'); -const API_DIR = resolve(MONOREPO_ROOT, 'apps/api'); - -// ─── Steps ──────────────────────────────────────────────────────────────────── - -async function createDatabase(): Promise { - const pool = new pg.Pool({ - host: PG_HOST, - port: PG_PORT, - user: PG_USER, - password: PG_PASSWORD, - database: PG_ADMIN_DB, - }); - - try { - await pool.query(`CREATE DATABASE "${DB_NAME}" OWNER ${PG_USER}`); - console.log(` ✓ Banco criado: ${DB_NAME}`); - } catch (e: unknown) { - if ((e as { code?: string }).code === '42P04') { - console.log(` ~ Banco já existe: ${DB_NAME} — pulando criação`); - } else { - throw e; - } - } finally { - await pool.end(); - } - - // Extensões no novo banco - const wsPool = new pg.Pool({ - host: PG_HOST, - port: PG_PORT, - user: PG_USER, - password: PG_PASSWORD, - database: DB_NAME, - }); - try { - await wsPool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto`); - await wsPool.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); - console.log(` ✓ Extensões habilitadas (pgcrypto, uuid-ossp)`); - } finally { - await wsPool.end(); - } -} - -function runMigrations(): void { - execSync(`pnpm exec prisma migrate deploy`, { - cwd: API_DIR, - env: { ...process.env, DATABASE_URL: DB_URL }, - stdio: 'inherit', - }); - console.log(` ✓ Migrations aplicadas`); -} - -function runSeed(): void { - execSync(`pnpm exec prisma db seed`, { - cwd: API_DIR, - env: { ...process.env, DATABASE_URL: DB_URL }, - stdio: 'inherit', - }); - console.log(` ✓ Seed de demo executado`); -} - -// ─── Main ───────────────────────────────────────────────────────────────────── - -async function main(): Promise { - console.log(`\nSAR — Provisionamento de Workspace`); - console.log(`────────────────────────────────────`); - console.log(`ID: ${workspaceId}`); - console.log(`Nome: ${workspaceName}`); - console.log(`Banco: ${DB_NAME}`); - console.log(`Seed: ${withSeed ? 'sim (--with-seed)' : 'não'}`); - console.log(''); - - console.log(`[1/3] Criando banco de dados...`); - await createDatabase(); - - console.log(`[2/3] Aplicando migrations...`); - runMigrations(); - - if (withSeed) { - console.log(`[3/3] Populando dados demo...`); - runSeed(); - } else { - console.log(`[3/3] Seed pulado (use --with-seed para dados demo)`); - } - - console.log(''); - console.log(`════════════════════════════════════`); - console.log(`✓ Workspace provisionado com sucesso`); - console.log(`════════════════════════════════════`); - console.log(''); - console.log(`Próximos passos:`); - console.log(` 1. Emita um JWT com workspace_id = "${workspaceId}"`); - console.log(` usando MASTER_LOGIN_JWT_SECRET do ambiente alvo`); - console.log(` 2. O JwtAuthGuard conecta automaticamente ao banco`); - console.log(` via convenção sar_workspace_{id} (ADR 0006)`); - console.log(` 3. Em produção, defina DATABASE_URL no ambiente`); - console.log(` se o banco não seguir a convenção de nome padrão`); - console.log(''); -} - -main().catch((e: unknown) => { - console.error('\nErro durante provisionamento:', e); - process.exit(1); -}); diff --git a/scripts/sar-erp-schema.sql b/scripts/sar-erp-schema.sql index 284fe38..c7ce973 100644 --- a/scripts/sar-erp-schema.sql +++ b/scripts/sar-erp-schema.sql @@ -82,9 +82,20 @@ SELECT e.nome, e.razao_social, e.cnpj, - e.estado AS uf, + e.estado AS uf, e.id_matriz, e.id_portador_padrao, + -- Endereço e contato + e.inscr_estadual, + e.endereco, + NULLIF(e.numero, 0) AS num_endereco, + NULLIF(TRIM(e.complemento), '.') AS complemento, + e.bairro, + e.id_municipio, + e.cep::text AS cep, + e.telefone::text AS telefone, + e.email, + -- Configurações SAR (gestao.sarcfg) s.origem_descmax, s.tp_estoque, s.bloq_preco_pedido, @@ -381,6 +392,15 @@ SELECT NULL::integer AS tp_pauta FROM gestao.pauxpro pp; +-- ----------------------------------------------------------------------------- +-- 12b. Situações de pedido do ERP (sig.sitpedido — somente leitura) +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE VIEW sar.vw_sitpedido AS +SELECT + s.id_sitpedido, + TRIM(s.descricao) AS descricao +FROM sig.sitpedido s; + -- ----------------------------------------------------------------------------- -- 13. Pedidos históricos do ERP (sig.pedidos — somente leitura) -- ----------------------------------------------------------------------------- @@ -418,7 +438,10 @@ SELECT COALESCE(p.com_fat, 0) AS comissao, COALESCE(p.ped_flex, 0) AS ped_flex, p.cod_vend2 AS cod_supervisor, - p.tx_com_vend2 AS taxa_com_super + p.tx_com_vend2 AS taxa_com_super, + -- Campos usados internamente pelas views de consulta composta + p.dtprevent AS dt_prevent, + p.numer_ped_vinc FROM sig.pedidos p LEFT JOIN gestao.formapag fp ON fp.codigo = p.cod_formapag AND fp.id_empresa = CASE @@ -542,6 +565,22 @@ LEFT JOIN sig.corrent cr ON cr.id_corrent = CASE WHERE TRIM(c.id_entidade) ~ '^\d+$' OR TRIM(c.id_entidade) ~ '^COR#\d+$'; +-- ----------------------------------------------------------------------------- +-- 18. Notas Fiscais emitidas (gestao.nf — somente leitura) +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE VIEW sar.vw_nf AS +SELECT + nf.id_nf, + nf.id_empresa, + nf.numero, + TRIM(nf.serie) AS serie, + nf.dt_emissao, + nf.vl_nf, + nf.num_entrega, + TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe, + nf.status +FROM gestao.nf nf; + -- ============================================================================= -- PARTE 2 — TABELAS DE ESCRITA DO SAR -- Dados gerados pelo SAR — não existem no ERP. @@ -571,6 +610,7 @@ CREATE TABLE IF NOT EXISTS sar.pedidos ( comissao NUMERIC(15,2) NOT NULL DEFAULT 0, ped_flex NUMERIC(15,2) NOT NULL DEFAULT 0, obs TEXT, + end_entrega TEXT, -- endereco de entrega alternativo aprovado_por INTEGER, -- cod_vendedor do supervisor aprovado_em TIMESTAMP, motivo_recusa TEXT, @@ -658,6 +698,74 @@ CREATE TABLE IF NOT EXISTS sar.push_subscription ( updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); +-- ----------------------------------------------------------------------------- +-- Promoções comerciais (C8 — criadas pelo Gerente) +-- descPct em % (ex.: 5.00 = 5%). Pode ser por produto ou grupo de produto. +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS sar.promocoes ( + id SERIAL PRIMARY KEY, + id_empresa INTEGER NOT NULL, + descricao VARCHAR(200) NOT NULL, + cod_produto VARCHAR(30), + grp_prod VARCHAR(30), + desc_pct NUMERIC(5,2) NOT NULL, + data_inicio DATE NOT NULL, + data_fim DATE NOT NULL, + ativa BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by VARCHAR(100) NOT NULL +); + +-- ----------------------------------------------------------------------------- +-- Oportunidades (Funil de Vendas) +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS sar.oportunidades ( + id SERIAL PRIMARY KEY, + id_empresa INTEGER NOT NULL, + cod_vendedor INTEGER NOT NULL, + id_cliente INTEGER, + nome_prospect VARCHAR(200), + empresa_prospect VARCHAR(200), + titulo VARCHAR(200) NOT NULL, + etapa VARCHAR(20) NOT NULL DEFAULT 'lead', + valor_estimado NUMERIC(15,2), + observacoes TEXT, + id_pedido UUID REFERENCES sar.pedidos(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ----------------------------------------------------------------------------- +-- Chamados (SAC) — pedido pode ser SAR (id_pedido/num_ped_sar) ou ERP (num_ped_erp) +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS sar.chamados ( + id SERIAL PRIMARY KEY, + id_empresa INTEGER NOT NULL, + cod_vendedor INTEGER NOT NULL, + id_pedido UUID REFERENCES sar.pedidos(id) ON DELETE SET NULL, + num_ped_sar INTEGER, + num_ped_erp INTEGER, + id_cliente INTEGER NOT NULL, + nome_cliente VARCHAR(200), + tipo VARCHAR(50) NOT NULL, + assunto VARCHAR(200) NOT NULL, + descricao TEXT NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'aberto', + resolucao VARCHAR(50), + nota_resolucao TEXT, + itens_afetados JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS sar.chamado_mensagens ( + id SERIAL PRIMARY KEY, + id_chamado INTEGER NOT NULL REFERENCES sar.chamados(id) ON DELETE CASCADE, + autor VARCHAR(10) NOT NULL, + texto TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + -- ============================================================================= -- ÍNDICES -- ============================================================================= @@ -673,17 +781,23 @@ CREATE INDEX IF NOT EXISTS idx_sar_push_empresa ON sar.push_subscription(id_emp CREATE INDEX IF NOT EXISTS idx_sar_push_vend ON sar.push_subscription(cod_vendedor); CREATE INDEX IF NOT EXISTS idx_sar_meta_vend ON sar.meta_representante(cod_vendedor, id_empresa); CREATE INDEX IF NOT EXISTS idx_sar_alcada_vend ON sar.alcada_desconto(cod_vendedor, id_empresa); +CREATE INDEX IF NOT EXISTS idx_sar_promo_empresa ON sar.promocoes(id_empresa); +CREATE INDEX IF NOT EXISTS idx_sar_promo_vigente ON sar.promocoes(id_empresa, data_fim) WHERE ativa; +CREATE INDEX IF NOT EXISTS idx_sar_oport_vend ON sar.oportunidades(id_empresa, cod_vendedor); +CREATE INDEX IF NOT EXISTS idx_sar_oport_etapa ON sar.oportunidades(etapa); +CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor); +CREATE INDEX IF NOT EXISTS idx_chamados_empresa_status ON sar.chamados(id_empresa, status); +CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagens(id_chamado); -- ============================================================================= --- GRANTS (descomentar e ajustar o role conforme o ambiente) +-- GRANTS +-- Executado pelo provision-client.ts quando --role é informado. +-- Referência manual: +-- GRANT USAGE ON SCHEMA sar TO ; +-- GRANT SELECT ON ALL TABLES IN SCHEMA sar TO ; +-- GRANT INSERT, UPDATE, DELETE ON +-- sar.pedidos, sar.pedido_itens, sar.historico_pedido, +-- sar.alcada_desconto, sar.meta_representante, sar.push_subscription, +-- sar.promocoes, sar.clientes_novos, sar.contatos_novos +-- TO ; -- ============================================================================= --- GRANT USAGE ON SCHEMA sar TO sar_app; --- GRANT SELECT ON ALL TABLES IN SCHEMA sar TO sar_app; --- GRANT INSERT, UPDATE, DELETE ON --- sar.pedidos, --- sar.pedido_itens, --- sar.historico_pedido, --- sar.alcada_desconto, --- sar.meta_representante, --- sar.push_subscription --- TO sar_app;