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

@@ -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()