refactor(infra)!: schema sar no banco ERP substitui banco-por-workspace
Revoga o ADR 0006. O isolamento multi-tenant deixa de ser um banco por workspace e passa a ser o schema `sar` dentro do proprio banco ERP do cliente, com `id_empresa` discriminando o tenant em todas as tabelas. Provisionamento: - `provision-client.ts` substitui `provision-workspace.ts`. Nao cria mais banco: aplica schema + views + triggers num banco ERP ja existente. Trata o encoding LATIN1 dos bancos ERP via `toLatinSafe()`. - `sar-erp-schema.sql` vira a fonte unica: absorve as views que estavam em `sarweb_views.sql` e passa a conter tambem `sar.chamados` / `sar.chamado_mensagens`, que so existiam via migration. Correcoes necessarias para o provisionamento rodar ponta a ponta: - `migrate deploy` abortava com P3005, porque o schema SQL roda antes do Prisma e o schema `sar` nunca esta vazio. O script agora detecta banco pre-existente e faz o baseline sozinho. - `_prisma_migrations` ia parar no schema `public` do ERP do cliente; a URL de migracao agora leva `?schema=sar`. - `sar.pedidos` nao tinha `end_entrega`, coluna que o schema.prisma ja declarava desde a feature de endereco de entrega, e que derrubava GET /orders com 500. Migration adicionada. - `vw_sitpedido` se perdeu junto com `sarweb_views.sql` e e usada pela consulta de pedidos ERP. Restaurada. Remove o seed de dados demo e os fallbacks de DATABASE_URL hardcoded: sem a variavel configurada a API agora falha explicitamente, em vez de apontar silenciosamente para um banco de desenvolvimento inexistente. Verificado ponta a ponta contra um banco ERP real: schema aplicado, 13 tabelas + 20 views, migrations registradas, e segunda execucao idempotente. BREAKING CHANGE: DATABASE_URL passa a ser obrigatoria. O comando de provisionamento muda para `pnpm client:provision --host <host> --db <banco>` (rename efetivado no commit de dependencias). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,11 +10,6 @@ export default defineConfig({
|
|||||||
datasource: {
|
datasource: {
|
||||||
// Prisma 7: url aqui serve apenas para o CLI (migrate/generate/studio).
|
// Prisma 7: url aqui serve apenas para o CLI (migrate/generate/studio).
|
||||||
// Runtime usa WorkspacePrismaPool → PrismaClient({ adapter: new PrismaPg(pool) }).
|
// Runtime usa WorkspacePrismaPool → PrismaClient({ adapter: new PrismaPg(pool) }).
|
||||||
url:
|
url: process.env['DATABASE_URL'],
|
||||||
process.env['DATABASE_URL'] ??
|
|
||||||
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev',
|
|
||||||
},
|
|
||||||
migrations: {
|
|
||||||
seed: 'tsx prisma/seed.ts',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -59,9 +59,8 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
this.cls.set('userId', userId);
|
this.cls.set('userId', userId);
|
||||||
this.cls.set('role', payload.role);
|
this.cls.set('role', payload.role);
|
||||||
|
|
||||||
const baseUrl =
|
const baseUrl = process.env['DATABASE_URL'];
|
||||||
process.env['DATABASE_URL'] ??
|
if (!baseUrl) throw new Error('DATABASE_URL não configurada');
|
||||||
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev';
|
|
||||||
this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, baseUrl));
|
this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, baseUrl));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ services:
|
|||||||
# subdir por major-version pra suportar pg_upgrade --link sem
|
# subdir por major-version pra suportar pg_upgrade --link sem
|
||||||
# boundary issues. Ref: docker-library/postgres#1259.
|
# boundary issues. Ref: docker-library/postgres#1259.
|
||||||
- sar-postgres-data:/var/lib/postgresql
|
- sar-postgres-data:/var/lib/postgresql
|
||||||
- ./scripts/postgres-init:/docker-entrypoint-initdb.d:ro
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U sar -d sar_master']
|
test: ['CMD-SHELL', 'pg_isready -U sar -d sar_master']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
124
sarweb_views.sql
124
sarweb_views.sql
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
189
scripts/provision-client.ts
Normal file
189
scripts/provision-client.ts
Normal file
@@ -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 <host> --db <banco> [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 <host> --db <banco> [--port 5432] [--user postgres] [--password ...] [--role <pg-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<void> {
|
||||||
|
console.log(` Executando ${label}...`);
|
||||||
|
await client.query(sql);
|
||||||
|
console.log(` ✓ ${label} aplicado`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyGrants(client: pg.Client, appRole: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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 <pg-role>');
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e: unknown) => {
|
||||||
|
console.error('\nErro durante provisionamento:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
#!/usr/bin/env tsx
|
|
||||||
/**
|
|
||||||
* SAR — Provisionamento de workspace (C9)
|
|
||||||
*
|
|
||||||
* Uso:
|
|
||||||
* pnpm workspace:provision --id <workspace-id> [--name <nome>] [--with-seed]
|
|
||||||
*
|
|
||||||
* O que faz:
|
|
||||||
* 1. Cria o banco sar_workspace_<id> (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 <workspace-id> [--name <nome>] [--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<void> {
|
|
||||||
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<void> {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
@@ -82,9 +82,20 @@ SELECT
|
|||||||
e.nome,
|
e.nome,
|
||||||
e.razao_social,
|
e.razao_social,
|
||||||
e.cnpj,
|
e.cnpj,
|
||||||
e.estado AS uf,
|
e.estado AS uf,
|
||||||
e.id_matriz,
|
e.id_matriz,
|
||||||
e.id_portador_padrao,
|
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.origem_descmax,
|
||||||
s.tp_estoque,
|
s.tp_estoque,
|
||||||
s.bloq_preco_pedido,
|
s.bloq_preco_pedido,
|
||||||
@@ -381,6 +392,15 @@ SELECT
|
|||||||
NULL::integer AS tp_pauta
|
NULL::integer AS tp_pauta
|
||||||
FROM gestao.pauxpro pp;
|
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)
|
-- 13. Pedidos históricos do ERP (sig.pedidos — somente leitura)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
@@ -418,7 +438,10 @@ SELECT
|
|||||||
COALESCE(p.com_fat, 0) AS comissao,
|
COALESCE(p.com_fat, 0) AS comissao,
|
||||||
COALESCE(p.ped_flex, 0) AS ped_flex,
|
COALESCE(p.ped_flex, 0) AS ped_flex,
|
||||||
p.cod_vend2 AS cod_supervisor,
|
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
|
FROM sig.pedidos p
|
||||||
LEFT JOIN gestao.formapag fp ON fp.codigo = p.cod_formapag
|
LEFT JOIN gestao.formapag fp ON fp.codigo = p.cod_formapag
|
||||||
AND fp.id_empresa = CASE
|
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+$'
|
WHERE TRIM(c.id_entidade) ~ '^\d+$'
|
||||||
OR TRIM(c.id_entidade) ~ '^COR#\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
|
-- PARTE 2 — TABELAS DE ESCRITA DO SAR
|
||||||
-- Dados gerados pelo SAR — não existem no ERP.
|
-- 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,
|
comissao NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||||
ped_flex NUMERIC(15,2) NOT NULL DEFAULT 0,
|
ped_flex NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||||
obs TEXT,
|
obs TEXT,
|
||||||
|
end_entrega TEXT, -- endereco de entrega alternativo
|
||||||
aprovado_por INTEGER, -- cod_vendedor do supervisor
|
aprovado_por INTEGER, -- cod_vendedor do supervisor
|
||||||
aprovado_em TIMESTAMP,
|
aprovado_em TIMESTAMP,
|
||||||
motivo_recusa TEXT,
|
motivo_recusa TEXT,
|
||||||
@@ -658,6 +698,74 @@ CREATE TABLE IF NOT EXISTS sar.push_subscription (
|
|||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
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
|
-- Í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_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_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_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 <nome> é informado.
|
||||||
|
-- Referência manual:
|
||||||
|
-- GRANT USAGE ON SCHEMA sar TO <role>;
|
||||||
|
-- GRANT SELECT ON ALL TABLES IN SCHEMA sar TO <role>;
|
||||||
|
-- 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 <role>;
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 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;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user