feat(api,web): c2 consulta de clientes — list + search + auth flow

prisma: modelo Client + migração 20260527225728_add_client + seed dev (10 clientes)
api: GET /clients (list, busca, filtro atividade/financeiro, paginação) + GET /clients/:id
     rep vê carteira própria; supervisor/admin vê tudo; activityStatus calculado de lastOrderAt
@sar/api-interface: ClientSummarySchema, ClientDetailSchema, ClientListResponseSchema
web: ClientsPage (tabela AntD, busca, filtro), DevLogin (token dev), authStore, Bearer no apiFetch
oq-4 resolvida: creditLimit gerenciado no SAR

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:08:57 +00:00
parent 2a8be3fd82
commit 14c8350216
26 changed files with 1394 additions and 84 deletions

View File

@@ -0,0 +1,41 @@
-- CreateEnum
CREATE TYPE "FinancialStatus" AS ENUM ('regular', 'attention', 'blocked');
-- CreateTable
CREATE TABLE "Client" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"tradeName" TEXT,
"taxId" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"address" JSONB,
"financialStatus" "FinancialStatus" NOT NULL DEFAULT 'regular',
"creditLimit" DECIMAL(15,2),
"repId" TEXT NOT NULL,
"lastOrderAt" TIMESTAMP(3),
"lastOrderValue" DECIMAL(15,2),
"openOrdersCount" INTEGER NOT NULL DEFAULT 0,
"erpCode" TEXT,
"syncedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Client_taxId_key" ON "Client"("taxId");
-- CreateIndex
CREATE INDEX "Client_repId_idx" ON "Client"("repId");
-- CreateIndex
CREATE INDEX "Client_taxId_idx" ON "Client"("taxId");
-- CreateIndex
CREATE INDEX "Client_name_idx" ON "Client"("name");
-- CreateIndex
CREATE INDEX "Client_deletedAt_idx" ON "Client"("deletedAt");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -9,18 +9,63 @@
// CODING-RULES PGD-DB-001: MIGRATION_DATABASE_URL aponta direto ao PG (sem PgBouncer)
generator client {
provider = "prisma-client-js"
output = "../../../node_modules/.prisma/client"
moduleFormat = "cjs"
provider = "prisma-client-js"
output = "../../../node_modules/.prisma/client"
moduleFormat = "cjs"
}
// Prisma 7: url foi removida do schema — conexão fica em prisma.config.ts (migrate)
// Prisma 7: url removida do schema — conexão em prisma.config.ts (migrate)
// e no WorkspacePrismaPool via PrismaPg adapter (runtime).
datasource db {
provider = "postgresql"
}
// ─── Modelos de domínio serão adicionados por feature ──────────────────────
// ─── Enums ───────────────────────────────────────────────────────────────────
// Situação financeira resumida do cliente — cacheável offline (FR-2.4, FR-2.5).
// Valor numérico de crédito e inadimplência requerem conexão.
enum FinancialStatus {
regular
attention
blocked
}
// ─── Client (C2) ─────────────────────────────────────────────────────────────
//
// Próximos: Client (C2), Order + OrderItem (C3/C4) — vindos das stories.
// Cada model novo exige: migration versionada + seed de dev atualizado.
// Cadastro sincronizado do ERP legado (FR-2.6). Rep não cria/edita no MVP.
// creditLimit: gerenciado no SAR — admin/supervisor define (OQ-4 resolvido 2026-05-27).
// lastOrderAt/lastOrderValue: desnormalizados, atualizados ao sincronizar Orders (C3/C4).
// activityStatus: calculado em runtime a partir de lastOrderAt (não persiste — evita drift).
model Client {
id String @id @default(uuid()) @db.Uuid
name String // razão social / nome completo
tradeName String? // nome fantasia
taxId String @unique // CNPJ (14 dígitos) ou CPF (11 dígitos), sem máscara
email String?
phone String?
address Json? // { street, number, complement?, district, city, state, zip }
// Situação financeira — resumo cacheável; detalhes numéricos requerem conexão
financialStatus FinancialStatus @default(regular)
creditLimit Decimal? @db.Decimal(15, 2)
// Desnormalizados de Orders (atualizados em C3/C4)
repId String // userId do Rep responsável (JWT sub)
lastOrderAt DateTime?
lastOrderValue Decimal? @db.Decimal(15, 2)
openOrdersCount Int @default(0)
// Controle de sync com ERP
erpCode String? // código no ERP legado
syncedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? // soft delete — não remove fisicamente
@@index([repId])
@@index([taxId])
@@index([name])
@@index([deletedAt]) // filtragem de soft delete eficiente
}

286
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,286 @@
// 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 } 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 });
// Rep dev padrão — mesmo userId emitido pelo POST /auth/dev/token no smoke test
const DEV_REP_ID = 'user-001';
const DEV_REP2_ID = 'user-002';
const clients = [
{
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,
lastOrderAt: daysAgo(10),
lastOrderValue: 2340.5,
openOrdersCount: 1,
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,
lastOrderAt: daysAgo(5),
lastOrderValue: 12800.0,
openOrdersCount: 2,
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,
lastOrderAt: daysAgo(35),
lastOrderValue: 890.0,
openOrdersCount: 0,
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,
lastOrderAt: daysAgo(2),
lastOrderValue: 45600.0,
openOrdersCount: 3,
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,
lastOrderAt: daysAgo(75),
lastOrderValue: 340.0,
openOrdersCount: 0,
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,
lastOrderAt: daysAgo(18),
lastOrderValue: 7200.0,
openOrdersCount: 1,
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,
lastOrderAt: daysAgo(8),
lastOrderValue: 32100.0,
openOrdersCount: 2,
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,
lastOrderAt: daysAgo(45),
lastOrderValue: 560.0,
openOrdersCount: 0,
erpCode: 'CLI-008',
},
// Clientes do segundo rep (para testar filtro de carteira)
{
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,
lastOrderAt: daysAgo(3),
lastOrderValue: 8900.0,
openOrdersCount: 1,
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,
lastOrderAt: daysAgo(20),
lastOrderValue: 1200.0,
openOrdersCount: 0,
erpCode: 'CLI-010',
},
];
function daysAgo(days: number): Date {
const d = new Date();
d.setDate(d.getDate() - days);
return d;
}
async function main() {
console.log('🌱 Seed iniciado...');
for (const data of clients) {
await prisma.client.upsert({
where: { taxId: data.taxId },
create: {
...data,
creditLimit: data.creditLimit,
lastOrderValue: data.lastOrderValue,
syncedAt: new Date(),
},
update: {
name: data.name,
financialStatus: data.financialStatus,
lastOrderAt: data.lastOrderAt,
lastOrderValue: data.lastOrderValue,
openOrdersCount: data.openOrdersCount,
syncedAt: new Date(),
},
});
}
console.log(`${clients.length} clientes criados/atualizados.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});