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:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
32
apps/api/src/app/orders/orders.controller.ts
Normal file
32
apps/api/src/app/orders/orders.controller.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
10
apps/api/src/app/orders/orders.module.ts
Normal file
10
apps/api/src/app/orders/orders.module.ts
Normal 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 {}
|
||||
166
apps/api/src/app/orders/orders.service.ts
Normal file
166
apps/api/src/app/orders/orders.service.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user