feat(c3): consulta de pedidos — schema, api, web (OrdersModule + ClientDetailPage)

- Prisma: Order, OrderItem, OrderStatusHistory + migration
- Seed: 17 pedidos em 7 clientes com itens, histórico e desnorm de clientes
- @sar/api-interface: contratos Zod (OrderSummary, OrderDetail, OrderListQuery, etc.)
- API: GET /orders, GET /orders/:id, GET /clients/:id/orders (últimos 10)
- Web: OrdersPage (lista + filtro status/número + pending_approval highlighted)
- Web: ClientDetailPage (ficha completa + últimos 10 pedidos)
- Web: /pedidos e /pedidos/$id adicionados ao router; ClientDetailPage substitui placeholder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:31:18 +00:00
parent 14c8350216
commit c36451dd33
15 changed files with 1494 additions and 71 deletions

View File

@@ -0,0 +1,95 @@
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('budget', 'pending_approval', 'approved', 'invoiced', 'cancelled');
-- CreateTable
CREATE TABLE "Order" (
"id" UUID NOT NULL,
"number" TEXT NOT NULL,
"clientId" UUID NOT NULL,
"repId" TEXT NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'budget',
"discountPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
"subtotal" DECIMAL(15,2) NOT NULL,
"total" DECIMAL(15,2) NOT NULL,
"notes" TEXT,
"approvedById" TEXT,
"approvedAt" TIMESTAMP(3),
"invoicedAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"idempotencyKey" TEXT,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderItem" (
"id" UUID NOT NULL,
"orderId" UUID NOT NULL,
"productCode" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"quantity" DECIMAL(10,3) NOT NULL,
"unitPrice" DECIMAL(15,2) NOT NULL,
"discountPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
"subtotal" DECIMAL(15,2) NOT NULL,
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderStatusHistory" (
"id" UUID NOT NULL,
"orderId" UUID NOT NULL,
"fromStatus" "OrderStatus",
"toStatus" "OrderStatus" NOT NULL,
"changedById" TEXT NOT NULL,
"note" TEXT,
"changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderStatusHistory_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Order_number_key" ON "Order"("number");
-- CreateIndex
CREATE UNIQUE INDEX "Order_idempotencyKey_key" ON "Order"("idempotencyKey");
-- CreateIndex
CREATE INDEX "Order_clientId_idx" ON "Order"("clientId");
-- CreateIndex
CREATE INDEX "Order_repId_idx" ON "Order"("repId");
-- CreateIndex
CREATE INDEX "Order_status_idx" ON "Order"("status");
-- CreateIndex
CREATE INDEX "Order_issuedAt_idx" ON "Order"("issuedAt");
-- CreateIndex
CREATE INDEX "Order_number_idx" ON "Order"("number");
-- CreateIndex
CREATE INDEX "Order_deletedAt_idx" ON "Order"("deletedAt");
-- CreateIndex
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
-- CreateIndex
CREATE INDEX "OrderStatusHistory_orderId_idx" ON "OrderStatusHistory"("orderId");
-- CreateIndex
CREATE INDEX "OrderStatusHistory_changedAt_idx" ON "OrderStatusHistory"("changedAt");
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderStatusHistory" ADD CONSTRAINT "OrderStatusHistory_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -23,49 +23,137 @@ datasource db {
// ─── 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
}
// Status do pedido (FR-3.2). Transições: budget → pending_approval → approved → invoiced.
// Qualquer status pode ir para cancelled.
enum OrderStatus {
budget // orçamento
pending_approval // aprovação pendente
approved // aprovado
invoiced // faturado
cancelled // cancelado
}
// ─── Client (C2) ─────────────────────────────────────────────────────────────
//
// 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).
// creditLimit: gerenciado no SAR (OQ-4 resolvido 2026-05-27).
// lastOrderAt/lastOrderValue/openOrdersCount: desnormalizados de Orders.
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 }
id String @id @default(uuid()) @db.Uuid
name String
tradeName String?
taxId String @unique
email String?
phone String?
address Json?
// 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)
repId String
lastOrderAt DateTime?
lastOrderValue Decimal? @db.Decimal(15, 2)
openOrdersCount Int @default(0)
// Controle de sync com ERP
erpCode String? // código no ERP legado
erpCode String?
syncedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? // soft delete — não remove fisicamente
deletedAt DateTime?
orders Order[]
@@index([repId])
@@index([taxId])
@@index([name])
@@index([deletedAt]) // filtragem de soft delete eficiente
@@index([deletedAt])
}
// ─── Order (C3) ──────────────────────────────────────────────────────────────
//
// Pedido emitido pelo Rep. Itens desnormalizados (produto sem FK — C4 traz catálogo).
// number: gerado pelo SAR (sequencial por workspace, ex: "PED-00042").
// discountPct: desconto global do pedido (além de descontos por item).
// approvedById: userId de quem aprovou (se status = approved ou invoiced).
model Order {
id String @id @default(uuid()) @db.Uuid
number String @unique // "PED-00001"
clientId String @db.Uuid
repId String // userId do Rep que emitiu
status OrderStatus @default(budget)
discountPct Decimal @default(0) @db.Decimal(5, 2) // % desconto global
subtotal Decimal @db.Decimal(15, 2) // soma dos itens sem desconto global
total Decimal @db.Decimal(15, 2) // subtotal × (1 - discountPct/100)
notes String?
approvedById String? // userId de quem aprovou
approvedAt DateTime?
invoicedAt DateTime?
cancelledAt DateTime?
// Idempotency key para lançamentos offline (C4, FR-4.12)
idempotencyKey String? @unique
issuedAt DateTime @default(now()) // data de emissão pelo Rep
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
client Client @relation(fields: [clientId], references: [id])
items OrderItem[]
history OrderStatusHistory[]
@@index([clientId])
@@index([repId])
@@index([status])
@@index([issuedAt])
@@index([number])
@@index([deletedAt])
}
// ─── OrderItem (C3) ──────────────────────────────────────────────────────────
//
// Item do pedido. Produto desnormalizado (nome/código como string) — catálogo virá em C4.
// discountPct: desconto por linha (além do desconto global do Order).
model OrderItem {
id String @id @default(uuid()) @db.Uuid
orderId String @db.Uuid
productCode String // código no ERP / catálogo
productName String // desnormalizado para exibição offline
quantity Decimal @db.Decimal(10, 3)
unitPrice Decimal @db.Decimal(15, 2)
discountPct Decimal @default(0) @db.Decimal(5, 2)
subtotal Decimal @db.Decimal(15, 2) // qty × unitPrice × (1 - discountPct/100)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([orderId])
}
// ─── OrderStatusHistory (C3) ─────────────────────────────────────────────────
//
// Registro imutável de cada transição de status. changedById = userId do ator.
model OrderStatusHistory {
id String @id @default(uuid()) @db.Uuid
orderId String @db.Uuid
fromStatus OrderStatus?
toStatus OrderStatus
changedById String // userId
note String?
changedAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([orderId])
@@index([changedAt])
}

View File

@@ -2,7 +2,7 @@
// Executado via: pnpm exec prisma db seed (apps/api/)
// NUNCA rodar em staging/prod.
import { PrismaClient, FinancialStatus } from '@prisma/client';
import { PrismaClient, FinancialStatus, OrderStatus } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
@@ -15,11 +15,25 @@ const pool = new pg.Pool({
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 APPROVER_ID = 'user-manager-01';
const clients = [
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',
@@ -37,9 +51,6 @@ const clients = [
financialStatus: FinancialStatus.regular,
creditLimit: 15000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(10),
lastOrderValue: 2340.5,
openOrdersCount: 1,
erpCode: 'CLI-001',
},
{
@@ -59,9 +70,6 @@ const clients = [
financialStatus: FinancialStatus.regular,
creditLimit: 50000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(5),
lastOrderValue: 12800.0,
openOrdersCount: 2,
erpCode: 'CLI-002',
},
{
@@ -81,9 +89,6 @@ const clients = [
financialStatus: FinancialStatus.attention,
creditLimit: 5000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(35),
lastOrderValue: 890.0,
openOrdersCount: 0,
erpCode: 'CLI-003',
},
{
@@ -103,9 +108,6 @@ const clients = [
financialStatus: FinancialStatus.regular,
creditLimit: 120000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(2),
lastOrderValue: 45600.0,
openOrdersCount: 3,
erpCode: 'CLI-004',
},
{
@@ -125,9 +127,6 @@ const clients = [
financialStatus: FinancialStatus.blocked,
creditLimit: 2000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(75),
lastOrderValue: 340.0,
openOrdersCount: 0,
erpCode: 'CLI-005',
},
{
@@ -147,9 +146,6 @@ const clients = [
financialStatus: FinancialStatus.regular,
creditLimit: 30000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(18),
lastOrderValue: 7200.0,
openOrdersCount: 1,
erpCode: 'CLI-006',
},
{
@@ -169,9 +165,6 @@ const clients = [
financialStatus: FinancialStatus.regular,
creditLimit: 80000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(8),
lastOrderValue: 32100.0,
openOrdersCount: 2,
erpCode: 'CLI-007',
},
{
@@ -191,12 +184,8 @@ const clients = [
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',
@@ -214,9 +203,6 @@ const clients = [
financialStatus: FinancialStatus.regular,
creditLimit: 25000.0,
repId: DEV_REP2_ID,
lastOrderAt: daysAgo(3),
lastOrderValue: 8900.0,
openOrdersCount: 1,
erpCode: 'CLI-009',
},
{
@@ -236,43 +222,611 @@ const clients = [
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;
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<string, OrderSeed[]> = {
// 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...');
console.log('Seed iniciado...');
for (const data of clients) {
// Upsert clients (sem lastOrderAt/openOrdersCount — calculados depois)
for (const data of clientDefs) {
await prisma.client.upsert({
where: { taxId: data.taxId },
create: {
...data,
creditLimit: data.creditLimit,
lastOrderValue: data.lastOrderValue,
syncedAt: new Date(),
},
create: { ...data, syncedAt: new Date() },
update: {
name: data.name,
financialStatus: data.financialStatus,
lastOrderAt: data.lastOrderAt,
lastOrderValue: data.lastOrderValue,
openOrdersCount: data.openOrdersCount,
creditLimit: data.creditLimit,
syncedAt: new Date(),
},
});
}
console.log(`${clientDefs.length} clientes upserted.`);
console.log(`${clients.length} clientes criados/atualizados.`);
// 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
const itemsData = o.items.map((it) => ({
productCode: it.productCode,
productName: it.productName,
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()

View File

@@ -9,6 +9,7 @@ import { PingModule } from './ping/ping.module';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { ClientsModule } from './clients/clients.module';
import { OrdersModule } from './orders/orders.module';
import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
@@ -21,6 +22,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
HealthModule,
PingModule,
ClientsModule,
OrdersModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -6,9 +6,11 @@ import {
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
type OrderSummary,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { ClientsService } from './clients.service';
import { OrdersService } from '../orders/orders.service';
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
@@ -16,6 +18,7 @@ class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
export class ClientsController {
constructor(
private readonly clients: ClientsService,
private readonly orders: OrdersService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
@@ -30,4 +33,14 @@ export class ClientsController {
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ClientDetail> {
return this.clients.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
}
// Últimos 10 pedidos do cliente — exibidos na ficha (FR-2.4).
@Get(':id/orders')
clientOrders(@Param('id', ParseUUIDPipe) id: string): Promise<OrderSummary[]> {
return this.orders.listByClient(
id,
this.cls.get('userId') ?? '',
this.cls.get('role') ?? 'rep',
);
}
}

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { ClientsController } from './clients.controller';
import { ClientsService } from './clients.service';
import { OrdersModule } from '../orders/orders.module';
@Module({
imports: [OrdersModule],
controllers: [ClientsController],
providers: [ClientsService],
})

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod';
import {
OrderListQuerySchema,
type OrderDetail,
type OrderListQuery,
type OrderListResponse,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { OrdersService } from './orders.service';
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
@Controller({ path: 'orders' })
export class OrdersController {
constructor(
private readonly orders: OrdersService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
@Get()
list(@Query() query: OrderListQueryDto): Promise<OrderListResponse> {
const parsed = OrderListQuerySchema.parse(query) as OrderListQuery;
return this.orders.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderDetail> {
return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}

View File

@@ -0,0 +1,166 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client';
import type {
OrderDetail,
OrderListQuery,
OrderListResponse,
OrderSummary,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
function decimalToString(v: Prisma.Decimal | null | undefined): string {
return v ? v.toString() : '0';
}
@Injectable()
export class OrdersService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async list(query: OrderListQuery, userId: string, role: string): Promise<OrderListResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const { clientId, status, number, from, to, page, limit } = query;
const skip = (page - 1) * limit;
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
const where: Prisma.OrderWhereInput = {
deletedAt: null,
...repFilter,
...(clientId ? { clientId } : {}),
...(status ? { status } : {}),
...(number ? { number: { contains: number, mode: 'insensitive' } } : {}),
...(from || to
? {
issuedAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
};
const [rows, total] = await Promise.all([
prisma.order.findMany({
where,
include: { client: { select: { name: true } } },
skip,
take: limit,
orderBy: { issuedAt: 'desc' },
}),
prisma.order.count({ where }),
]);
const data: OrderSummary[] = rows.map((o) => ({
id: o.id,
number: o.number,
clientId: o.clientId,
clientName: o.client.name,
repId: o.repId,
status: o.status,
discountPct: decimalToString(o.discountPct),
subtotal: decimalToString(o.subtotal),
total: decimalToString(o.total),
issuedAt: o.issuedAt.toISOString(),
approvedAt: o.approvedAt?.toISOString() ?? null,
invoicedAt: o.invoicedAt?.toISOString() ?? null,
cancelledAt: o.cancelledAt?.toISOString() ?? null,
}));
return { data, total, page, limit };
}
async findOne(id: string, userId: string, role: string): Promise<OrderDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
const o = await prisma.order.findFirst({
where: { id, deletedAt: null, ...repFilter },
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
},
});
if (!o) throw new NotFoundException(`Pedido ${id} não encontrado`);
return {
id: o.id,
number: o.number,
clientId: o.clientId,
clientName: o.client.name,
repId: o.repId,
status: o.status,
discountPct: decimalToString(o.discountPct),
subtotal: decimalToString(o.subtotal),
total: decimalToString(o.total),
notes: o.notes,
approvedById: o.approvedById,
idempotencyKey: o.idempotencyKey,
issuedAt: o.issuedAt.toISOString(),
approvedAt: o.approvedAt?.toISOString() ?? null,
invoicedAt: o.invoicedAt?.toISOString() ?? null,
cancelledAt: o.cancelledAt?.toISOString() ?? null,
createdAt: o.createdAt.toISOString(),
updatedAt: o.updatedAt.toISOString(),
items: o.items.map((it) => ({
id: it.id,
productCode: it.productCode,
productName: it.productName,
quantity: decimalToString(it.quantity),
unitPrice: decimalToString(it.unitPrice),
discountPct: decimalToString(it.discountPct),
subtotal: decimalToString(it.subtotal),
})),
history: o.history.map((h) => ({
id: h.id,
fromStatus: h.fromStatus,
toStatus: h.toStatus,
changedById: h.changedById,
note: h.note,
changedAt: h.changedAt.toISOString(),
})),
};
}
// Últimos N pedidos de um cliente — usado na ficha (FR-2.4).
async listByClient(
clientId: string,
userId: string,
role: string,
limit = 10,
): Promise<OrderSummary[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const repFilter: Prisma.OrderWhereInput = role === 'rep' ? { repId: userId } : {};
const rows = await prisma.order.findMany({
where: { clientId, deletedAt: null, ...repFilter },
include: { client: { select: { name: true } } },
orderBy: { issuedAt: 'desc' },
take: limit,
});
return rows.map((o) => ({
id: o.id,
number: o.number,
clientId: o.clientId,
clientName: o.client.name,
repId: o.repId,
status: o.status,
discountPct: decimalToString(o.discountPct),
subtotal: decimalToString(o.subtotal),
total: decimalToString(o.total),
issuedAt: o.issuedAt.toISOString(),
approvedAt: o.approvedAt?.toISOString() ?? null,
invoicedAt: o.invoicedAt?.toISOString() ?? null,
cancelledAt: o.cancelledAt?.toISOString() ?? null,
}));
}
}