diff --git a/apps/api/prisma/migrations/20260527233639_c4_catalog/migration.sql b/apps/api/prisma/migrations/20260527233639_c4_catalog/migration.sql new file mode 100644 index 0000000..6276552 --- /dev/null +++ b/apps/api/prisma/migrations/20260527233639_c4_catalog/migration.sql @@ -0,0 +1,52 @@ +-- AlterTable +ALTER TABLE "OrderItem" ADD COLUMN "productCategory" TEXT NOT NULL DEFAULT 'geral'; + +-- CreateTable +CREATE TABLE "Product" ( + "id" UUID NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "category" TEXT NOT NULL DEFAULT 'geral', + "unitPrice" DECIMAL(15,2) NOT NULL, + "stock" DECIMAL(10,3), + "active" BOOLEAN NOT NULL DEFAULT true, + "erpCode" TEXT, + "syncedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RepDiscountLimit" ( + "repId" TEXT NOT NULL, + "category" TEXT NOT NULL, + "limit" DECIMAL(5,2) NOT NULL DEFAULT 5, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RepDiscountLimit_pkey" PRIMARY KEY ("repId","category") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Product_code_key" ON "Product"("code"); + +-- CreateIndex +CREATE INDEX "Product_code_idx" ON "Product"("code"); + +-- CreateIndex +CREATE INDEX "Product_name_idx" ON "Product"("name"); + +-- CreateIndex +CREATE INDEX "Product_category_idx" ON "Product"("category"); + +-- CreateIndex +CREATE INDEX "Product_active_idx" ON "Product"("active"); + +-- CreateIndex +CREATE INDEX "Product_deletedAt_idx" ON "Product"("deletedAt"); + +-- CreateIndex +CREATE INDEX "RepDiscountLimit_repId_idx" ON "RepDiscountLimit"("repId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a63b647..d78206e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -119,26 +119,75 @@ model Order { @@index([deletedAt]) } -// ─── OrderItem (C3) ────────────────────────────────────────────────────────── +// ─── OrderItem (C3/C4) ─────────────────────────────────────────────────────── // -// Item do pedido. Produto desnormalizado (nome/código como string) — catálogo virá em C4. +// Item do pedido. Produto desnormalizado (nome/código/categoria) — snapshot no momento do pedido. +// productCategory: usado para validação de alçada por linha no POST /orders. // 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) + 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 + productCategory String @default("geral") // desnormalizado para alçada por linha + 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]) } +// ─── Product (C4) ──────────────────────────────────────────────────────────── +// +// Catálogo sincronizado da view do ERP (FR-4.4). Rep usa para montar pedido. +// category: agrupa produtos por linha para validação de alçada por linha (OQ-2). +// unitPrice/stock: snapshot da última sync (TTL 4h — FR-4.4 [ASSUMPTION]). +// Produto inativo (active=false) não aparece no catálogo mas histórico de pedidos mantém referência. + +model Product { + id String @id @default(uuid()) @db.Uuid + code String @unique + name String + description String? + category String @default("geral") + unitPrice Decimal @db.Decimal(15, 2) + stock Decimal? @db.Decimal(10, 3) + active Boolean @default(true) + erpCode String? + syncedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@index([code]) + @@index([name]) + @@index([category]) + @@index([active]) + @@index([deletedAt]) +} + +// ─── RepDiscountLimit (C4) ─────────────────────────────────────────────────── +// +// Alçada de desconto por rep e por linha de produto (OQ-2 resolvida 2026-05-27). +// category = "__default__" → limite global do rep (fallback quando linha não tem override). +// Lookup: (repId, category) → se não encontrado → (repId, "__default__") → senão 5%. + +model RepDiscountLimit { + repId String + category String // "__default__" para limite global; category string para override por linha + limit Decimal @default(5) @db.Decimal(5, 2) + + updatedAt DateTime @updatedAt + + @@id([repId, category]) + @@index([repId]) +} + // ─── OrderStatusHistory (C3) ───────────────────────────────────────────────── // // Registro imutável de cada transição de status. changedById = userId do ator. diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index a1bb59f..3cd89f1 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -2,7 +2,7 @@ // Executado via: pnpm exec prisma db seed (apps/api/) // NUNCA rodar em staging/prod. -import { PrismaClient, FinancialStatus, OrderStatus } from '@prisma/client'; +import { PrismaClient, FinancialStatus, OrderStatus, type Prisma } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; import pg from 'pg'; @@ -226,6 +226,253 @@ const clientDefs = [ }, ]; +// ─── Catálogo de produtos (25 produtos, 5 categorias) ──────────────────────── + +const products: Prisma.ProductCreateInput[] = [ + // grãos + { + code: 'ARR-001', + name: 'Arroz Tipo 1 5kg cx10', + category: 'grãos', + unitPrice: 75.0, + stock: 500, + erpCode: 'P0001', + }, + { + code: 'FEI-001', + name: 'Feijão Carioca 1kg cx10', + category: 'grãos', + unitPrice: 65.0, + stock: 400, + erpCode: 'P0002', + }, + { + code: 'FAR-001', + name: 'Farinha de Trigo 25kg', + category: 'grãos', + unitPrice: 89.9, + stock: 200, + erpCode: 'P0003', + }, + { + code: 'ACU-001', + name: 'Açúcar Cristal 50kg', + category: 'grãos', + unitPrice: 145.0, + stock: 150, + erpCode: 'P0004', + }, + { + code: 'MIL-001', + name: 'Flocão de Milho 500g cx20', + category: 'grãos', + unitPrice: 48.0, + stock: 300, + erpCode: 'P0005', + }, + // bebidas + { + code: 'OLE-001', + name: 'Óleo de Soja 900ml cx18', + category: 'bebidas', + unitPrice: 98.0, + stock: 600, + erpCode: 'P0006', + }, + { + code: 'REF-001', + name: 'Refrigerante 2L cx6', + category: 'bebidas', + unitPrice: 42.0, + stock: 800, + erpCode: 'P0007', + }, + { + code: 'AGU-001', + name: 'Água Mineral 500ml cx12', + category: 'bebidas', + unitPrice: 18.0, + stock: 1000, + erpCode: 'P0008', + }, + { + code: 'SUC-001', + name: 'Suco de Caixinha 200ml cx27', + category: 'bebidas', + unitPrice: 32.4, + stock: 700, + erpCode: 'P0009', + }, + { + code: 'CER-001', + name: 'Cerveja Lata 350ml cx12', + category: 'bebidas', + unitPrice: 52.8, + stock: 400, + erpCode: 'P0010', + }, + // laticínios + { + code: 'LEI-001', + name: 'Leite UHT Integral 1L cx12', + category: 'laticínios', + unitPrice: 54.0, + stock: 900, + erpCode: 'P0011', + }, + { + code: 'QUE-001', + name: 'Queijo Mussarela kg', + category: 'laticínios', + unitPrice: 38.0, + stock: 200, + erpCode: 'P0012', + }, + { + code: 'IOG-001', + name: 'Iogurte Natural 170g cx12', + category: 'laticínios', + unitPrice: 28.8, + stock: 350, + erpCode: 'P0013', + }, + { + code: 'MAN-001', + name: 'Manteiga com Sal 200g cx12', + category: 'laticínios', + unitPrice: 84.0, + stock: 250, + erpCode: 'P0014', + }, + { + code: 'REQ-001', + name: 'Requeijão Cremoso 200g cx12', + category: 'laticínios', + unitPrice: 72.0, + stock: 180, + erpCode: 'P0015', + }, + // perecíveis + { + code: 'CAR-001', + name: 'Carne Bovina Contrafilé kg', + category: 'perecíveis', + unitPrice: 65.0, + stock: 150, + erpCode: 'P0016', + }, + { + code: 'FRA-001', + name: 'Frango Inteiro Resfriado kg', + category: 'perecíveis', + unitPrice: 18.5, + stock: 400, + erpCode: 'P0017', + }, + { + code: 'PEI-001', + name: 'Peixe Tilápia Filé kg', + category: 'perecíveis', + unitPrice: 32.0, + stock: 100, + erpCode: 'P0018', + }, + { + code: 'EMB-001', + name: 'Embutidos Sortidos kg', + category: 'perecíveis', + unitPrice: 28.0, + stock: 300, + erpCode: 'P0019', + }, + { + code: 'LEG-001', + name: 'Legumes Sortidos kg', + category: 'perecíveis', + unitPrice: 8.5, + stock: 500, + erpCode: 'P0020', + }, + // higiene + { + code: 'SAB-001', + name: 'Sabão em Pó 1kg cx12', + category: 'higiene', + unitPrice: 42.0, + stock: 400, + erpCode: 'P0021', + }, + { + code: 'DET-001', + name: 'Detergente 500ml cx24', + category: 'higiene', + unitPrice: 38.4, + stock: 600, + erpCode: 'P0022', + }, + { + code: 'HIG-001', + name: 'Shampoo 400ml cx12', + category: 'higiene', + unitPrice: 96.0, + stock: 250, + erpCode: 'P0023', + }, + { + code: 'LEV-001', + name: 'Fermento Biológico 500g', + category: 'grãos', + unitPrice: 12.5, + stock: 200, + erpCode: 'P0024', + }, + { + code: 'CON-001', + name: 'Conservas Sortidas cx24', + category: 'grãos', + unitPrice: 96.0, + stock: 180, + erpCode: 'P0025', + }, + { + code: 'MOL-001', + name: 'Molho de Tomate 340g cx24', + category: 'grãos', + unitPrice: 72.0, + stock: 220, + erpCode: 'P0026', + }, + { + code: 'FRU-001', + name: 'Frutas Sortidas cx', + category: 'perecíveis', + unitPrice: 45.0, + stock: 80, + erpCode: 'P0027', + }, + { + code: 'VER-001', + name: 'Verduras Sortidas kg', + category: 'perecíveis', + unitPrice: 6.0, + stock: 300, + erpCode: 'P0028', + }, +]; + +// Mapa code → category para popular productCategory nos OrderItems do seed C3 +const productCategoryMap: Record = Object.fromEntries( + products.map((p) => [p.code, p.category ?? 'geral']), +); + +// Alçadas de desconto de user-001: default 10%, bebidas 8%, perecíveis 5% +const repDiscountLimits = [ + { repId: DEV_REP_ID, category: '__default__', limit: 10 }, + { repId: DEV_REP_ID, category: 'bebidas', limit: 8 }, + { repId: DEV_REP_ID, category: 'perecíveis', limit: 5 }, + { repId: DEV_REP2_ID, category: '__default__', limit: 5 }, +]; + type OrderSeed = { num: number; status: OrderStatus; @@ -721,6 +968,26 @@ function buildHistoryForStatus(status: OrderStatus, repId: string, issuedDaysAgo async function main() { console.log('Seed iniciado...'); + // Upsert catálogo de produtos + for (const p of products) { + await prisma.product.upsert({ + where: { code: p.code }, + create: { ...p, syncedAt: new Date() }, + update: { name: p.name, unitPrice: p.unitPrice, stock: p.stock, syncedAt: new Date() }, + }); + } + console.log(`${products.length} produtos upserted.`); + + // Upsert alçadas de desconto + for (const r of repDiscountLimits) { + await prisma.repDiscountLimit.upsert({ + where: { repId_category: { repId: r.repId, category: r.category } }, + create: r, + update: { limit: r.limit }, + }); + } + console.log(`${repDiscountLimits.length} alçadas configuradas.`); + // Upsert clients (sem lastOrderAt/openOrdersCount — calculados depois) for (const data of clientDefs) { await prisma.client.upsert({ @@ -749,10 +1016,11 @@ async function main() { for (const o of orders) { const issuedAt = daysAgo(o.issuedDaysAgo); - // Build items with subtotals + // Build items with subtotals + productCategory (desnorm do catálogo) const itemsData = o.items.map((it) => ({ productCode: it.productCode, productName: it.productName, + productCategory: productCategoryMap[it.productCode] ?? 'geral', quantity: it.qty, unitPrice: it.unitPrice, discountPct: it.discountPct, diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 133573e..04b7d1b 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -10,6 +10,7 @@ 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 { CatalogModule } from './catalog/catalog.module'; import { ProblemDetailsFilter } from './filters/problem-details.filter'; @Module({ @@ -23,6 +24,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter'; PingModule, ClientsModule, OrdersModule, + CatalogModule, ], providers: [ { provide: APP_PIPE, useClass: ZodValidationPipe }, diff --git a/apps/api/src/app/catalog/catalog.controller.ts b/apps/api/src/app/catalog/catalog.controller.ts new file mode 100644 index 0000000..2fd0271 --- /dev/null +++ b/apps/api/src/app/catalog/catalog.controller.ts @@ -0,0 +1,46 @@ +import { + Body, + Controller, + Get, + NotFoundException, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { createZodDto } from 'nestjs-zod'; +import { + ProductListQuerySchema, + ProductSyncRequestSchema, + type ProductDetail, + type ProductListQuery, + type ProductListResponse, + type ProductSyncResponse, +} from '@sar/api-interface'; +import { CatalogService } from './catalog.service'; + +class ProductListQueryDto extends createZodDto(ProductListQuerySchema) {} +class ProductSyncRequestDto extends createZodDto(ProductSyncRequestSchema) {} + +@Controller({ path: 'catalog' }) +export class CatalogController { + constructor(private readonly catalog: CatalogService) {} + + @Get() + list(@Query() query: ProductListQueryDto): Promise { + const parsed = ProductListQuerySchema.parse(query) as ProductListQuery; + return this.catalog.list(parsed); + } + + @Get(':id') + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + const product = await this.catalog.findOne(id); + if (!product) throw new NotFoundException(`Produto ${id} não encontrado`); + return product; + } + + @Post('sync') + sync(@Body() body: ProductSyncRequestDto): Promise { + return this.catalog.sync(body); + } +} diff --git a/apps/api/src/app/catalog/catalog.module.ts b/apps/api/src/app/catalog/catalog.module.ts new file mode 100644 index 0000000..161cdd6 --- /dev/null +++ b/apps/api/src/app/catalog/catalog.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CatalogController } from './catalog.controller'; +import { CatalogService } from './catalog.service'; + +@Module({ + controllers: [CatalogController], + providers: [CatalogService], + exports: [CatalogService], +}) +export class CatalogModule {} diff --git a/apps/api/src/app/catalog/catalog.service.ts b/apps/api/src/app/catalog/catalog.service.ts new file mode 100644 index 0000000..8c80bb8 --- /dev/null +++ b/apps/api/src/app/catalog/catalog.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import { Prisma } from '@prisma/client'; +import type { + ProductDetail, + ProductListQuery, + ProductListResponse, + ProductSummary, + ProductSyncRequest, + ProductSyncResponse, +} from '@sar/api-interface'; +import type { WorkspaceClsStore } from '../workspace/workspace.types'; + +function decimalToString(v: Prisma.Decimal | null | undefined): string | null { + return v ? v.toString() : null; +} + +@Injectable() +export class CatalogService { + constructor(private readonly cls: ClsService) {} + + async list(query: ProductListQuery): Promise { + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + + const { q, category, page, limit } = query; + const skip = (page - 1) * limit; + + const where: Prisma.ProductWhereInput = { + deletedAt: null, + active: true, + ...(category ? { category } : {}), + ...(q + ? { + OR: [ + { name: { contains: q, mode: 'insensitive' } }, + { code: { contains: q, mode: 'insensitive' } }, + ], + } + : {}), + }; + + const [rows, total] = await Promise.all([ + prisma.product.findMany({ + where, + select: { + id: true, + code: true, + name: true, + category: true, + unitPrice: true, + stock: true, + active: true, + }, + skip, + take: limit, + orderBy: [{ category: 'asc' }, { name: 'asc' }], + }), + prisma.product.count({ where }), + ]); + + const data: ProductSummary[] = rows.map((p) => ({ + id: p.id, + code: p.code, + name: p.name, + category: p.category, + unitPrice: decimalToString(p.unitPrice) ?? '0', + stock: decimalToString(p.stock), + active: p.active, + })); + + return { data, total, page, limit }; + } + + async findOne(id: string): Promise { + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + + const p = await prisma.product.findFirst({ where: { id, deletedAt: null, active: true } }); + if (!p) return null; + + return { + id: p.id, + code: p.code, + name: p.name, + description: p.description, + category: p.category, + unitPrice: decimalToString(p.unitPrice) ?? '0', + stock: decimalToString(p.stock), + active: p.active, + erpCode: p.erpCode, + syncedAt: p.syncedAt?.toISOString() ?? null, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + }; + } + + async sync(req: ProductSyncRequest): Promise { + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + + const syncedAt = new Date(); + let upserted = 0; + + for (const item of req.items) { + await prisma.product.upsert({ + where: { code: item.code }, + create: { + code: item.code, + name: item.name, + description: item.description ?? null, + category: item.category ?? 'geral', + unitPrice: item.unitPrice, + stock: item.stock ?? null, + active: item.active ?? true, + erpCode: item.erpCode ?? null, + syncedAt, + }, + update: { + name: item.name, + description: item.description ?? null, + category: item.category ?? 'geral', + unitPrice: item.unitPrice, + stock: item.stock ?? null, + active: item.active ?? true, + erpCode: item.erpCode ?? null, + syncedAt, + }, + }); + upserted++; + } + + return { upserted, syncedAt: syncedAt.toISOString() }; + } +} diff --git a/apps/api/src/app/orders/orders.controller.ts b/apps/api/src/app/orders/orders.controller.ts index f82d490..6000991 100644 --- a/apps/api/src/app/orders/orders.controller.ts +++ b/apps/api/src/app/orders/orders.controller.ts @@ -1,8 +1,10 @@ -import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; import { createZodDto } from 'nestjs-zod'; import { + CreateOrderSchema, OrderListQuerySchema, + type CreateOrder, type OrderDetail, type OrderListQuery, type OrderListResponse, @@ -11,6 +13,7 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types'; import { OrdersService } from './orders.service'; class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {} +class CreateOrderDto extends createZodDto(CreateOrderSchema) {} @Controller({ path: 'orders' }) export class OrdersController { @@ -25,6 +28,13 @@ export class OrdersController { return this.orders.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep'); } + @Post() + @HttpCode(201) + create(@Body() body: CreateOrderDto): Promise { + const parsed = CreateOrderSchema.parse(body) as CreateOrder; + return this.orders.create(parsed, this.cls.get('userId') ?? ''); + } + @Get(':id') findOne(@Param('id', ParseUUIDPipe) id: string): Promise { return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep'); diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index c4a3333..c269c81 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -1,7 +1,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; -import { Prisma } from '@prisma/client'; +import { OrderStatus, Prisma } from '@prisma/client'; import type { + CreateOrder, OrderDetail, OrderListQuery, OrderListResponse, @@ -128,6 +129,217 @@ export class OrdersService { }; } + // Cria novo pedido. Valida alçada por linha de produto (OQ-2). + // Idempotency-Key: retorna pedido existente se já processado (FR-4.3). + async create(dto: CreateOrder, userId: string): Promise { + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + + // Idempotency-Key: retorna pedido existente sem re-processar + if (dto.idempotencyKey) { + const existing = await prisma.order.findUnique({ + where: { idempotencyKey: dto.idempotencyKey }, + include: { + client: { select: { name: true } }, + items: true, + history: { orderBy: { changedAt: 'asc' } }, + }, + }); + if (existing) return this.mapDetail(existing); + } + + // Verifica que o cliente existe e pertence ao rep + const client = await prisma.client.findFirst({ + where: { id: dto.clientId, repId: userId, deletedAt: null }, + }); + if (!client) throw new NotFoundException(`Cliente ${dto.clientId} não encontrado`); + + // Resolve alçadas por categoria: (repId, category) → fallback (repId, '__default__') → 5% + const limitRows = await prisma.repDiscountLimit.findMany({ where: { repId: userId } }); + const limitMap = new Map(limitRows.map((r) => [r.category, Number(r.limit)])); + const getLimit = (category: string) => + limitMap.get(category) ?? limitMap.get('__default__') ?? 5; + + // Valida alçada item a item + let needsApproval = false; + for (const item of dto.items) { + const lim = getLimit(item.productCategory ?? 'geral'); + if (item.discountPct > lim) { + needsApproval = true; + break; + } + } + // Alçada global: compara desconto global do pedido com o default do rep + const globalLimit = getLimit('__default__'); + if (dto.discountPct > globalLimit) needsApproval = true; + + const status = needsApproval ? OrderStatus.pending_approval : OrderStatus.budget; + + // Gera número sequencial: PED-NNNNN + const lastOrder = await prisma.order.findFirst({ + orderBy: { createdAt: 'desc' }, + select: { number: true }, + }); + const seq = lastOrder ? parseInt(lastOrder.number.replace('PED-', ''), 10) + 1 : 1; + const number = `PED-${String(seq).padStart(5, '0')}`; + + // Calcula subtotais + const itemsData = dto.items.map((it) => { + const subtotal = + Math.round(it.quantity * it.unitPrice * (1 - it.discountPct / 100) * 100) / 100; + return { + productCode: it.productCode, + productName: it.productName, + productCategory: it.productCategory ?? 'geral', + quantity: it.quantity, + unitPrice: it.unitPrice, + discountPct: it.discountPct, + subtotal, + }; + }); + const itemsSubtotal = itemsData.reduce((acc, it) => acc + it.subtotal, 0); + const total = Math.round(itemsSubtotal * (1 - dto.discountPct / 100) * 100) / 100; + + const now = new Date(); + const order = await prisma.order.create({ + data: { + number, + clientId: dto.clientId, + repId: userId, + status, + discountPct: dto.discountPct, + subtotal: itemsSubtotal, + total, + notes: dto.notes ?? null, + idempotencyKey: dto.idempotencyKey ?? null, + issuedAt: now, + items: { create: itemsData }, + history: { + create: [ + { fromStatus: null, toStatus: OrderStatus.budget, changedById: userId, changedAt: now }, + ], + }, + }, + include: { + client: { select: { name: true } }, + items: true, + history: { orderBy: { changedAt: 'asc' } }, + }, + }); + + if (status === OrderStatus.pending_approval) { + await prisma.orderStatusHistory.create({ + data: { + orderId: order.id, + fromStatus: OrderStatus.budget, + toStatus: OrderStatus.pending_approval, + changedById: userId, + changedAt: now, + }, + }); + } + + // Atualiza desnorm do cliente + const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved]; + const openCount = await prisma.order.count({ + where: { clientId: dto.clientId, deletedAt: null, status: { in: openStatuses } }, + }); + await prisma.client.update({ + where: { id: dto.clientId }, + data: { lastOrderAt: now, lastOrderValue: total, openOrdersCount: openCount }, + }); + + if (status === OrderStatus.pending_approval) { + // Buscar order com history atualizado + const updated = await prisma.order.findUniqueOrThrow({ + where: { id: order.id }, + include: { + client: { select: { name: true } }, + items: true, + history: { orderBy: { changedAt: 'asc' } }, + }, + }); + return this.mapDetail(updated); + } + return this.mapDetail(order); + } + + private mapDetail(o: { + id: string; + number: string; + clientId: string; + client: { name: string }; + repId: string; + status: OrderStatus; + discountPct: Prisma.Decimal; + subtotal: Prisma.Decimal; + total: Prisma.Decimal; + notes: string | null; + approvedById: string | null; + idempotencyKey: string | null; + issuedAt: Date; + approvedAt: Date | null; + invoicedAt: Date | null; + cancelledAt: Date | null; + createdAt: Date; + updatedAt: Date; + items: { + id: string; + productCode: string; + productName: string; + quantity: Prisma.Decimal; + unitPrice: Prisma.Decimal; + discountPct: Prisma.Decimal; + subtotal: Prisma.Decimal; + }[]; + history: { + id: string; + fromStatus: OrderStatus | null; + toStatus: OrderStatus; + changedById: string; + note: string | null; + changedAt: Date; + }[]; + }): OrderDetail { + 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, diff --git a/apps/web/src/cockpits/rafael/ClientDetailPage.tsx b/apps/web/src/cockpits/rafael/ClientDetailPage.tsx index 479674b..15c96e0 100644 --- a/apps/web/src/cockpits/rafael/ClientDetailPage.tsx +++ b/apps/web/src/cockpits/rafael/ClientDetailPage.tsx @@ -1,6 +1,6 @@ -import { Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd'; +import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd'; import type { TableColumnsType } from 'antd'; -import { Link, useParams } from '@tanstack/react-router'; +import { Link, useNavigate, useParams } from '@tanstack/react-router'; import type { OrderSummary, OrderStatus } from '@sar/api-interface'; import { useClientDetail } from '../../lib/queries/clients'; import { useClientOrders } from '../../lib/queries/orders'; @@ -77,6 +77,7 @@ const orderColumns: TableColumnsType = [ export function ClientDetailPage() { const { id } = useParams({ from: '/clientes/$id' }); + const navigate = useNavigate(); const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(id); const { data: orders, isLoading: ordersLoading } = useClientOrders(id); @@ -88,7 +89,7 @@ export function ClientDetailPage() { return (
- + ← Clientes {client.tradeName ?? client.name} @@ -99,6 +100,13 @@ export function ClientDetailPage() { <Tag color={ACTIVITY_COLOR[client.activityStatus]}> {ACTIVITY_LABEL[client.activityStatus]} </Tag> + <Button + type="primary" + onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })} + disabled={client.financialStatus === 'blocked'} + > + Novo Pedido + </Button> </Space> <Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}> diff --git a/apps/web/src/cockpits/rafael/NewOrderPage.tsx b/apps/web/src/cockpits/rafael/NewOrderPage.tsx new file mode 100644 index 0000000..2a84a1b --- /dev/null +++ b/apps/web/src/cockpits/rafael/NewOrderPage.tsx @@ -0,0 +1,436 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + Button, + Descriptions, + Divider, + Form, + InputNumber, + Space, + Spin, + Steps, + Table, + Tag, + Typography, + Input, +} from 'antd'; +import type { TableColumnsType } from 'antd'; +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import { Link, useNavigate, useSearch } from '@tanstack/react-router'; +import type { CreateOrder, CreateOrderItem, ProductSummary } from '@sar/api-interface'; +import { useClientDetail } from '../../lib/queries/clients'; +import { useCatalog } from '../../lib/queries/catalog'; +import { apiFetch } from '../../lib/api-client'; + +const { Title, Text } = Typography; +const { Search } = Input; + +type CartItem = CreateOrderItem & { key: string }; + +function calcItemTotal(qty: number, price: number, disc: number): number { + return Math.round(qty * price * (1 - disc / 100) * 100) / 100; +} + +function fmt(n: number): string { + return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); +} + +// ─── Step 1 — Selecionar Produtos ──────────────────────────────────────────── + +function ProductStep({ + cart, + onAdd, + onRemove, + onQtyChange, + onDiscChange, +}: { + cart: CartItem[]; + onAdd: (p: ProductSummary) => void; + onRemove: (key: string) => void; + onQtyChange: (key: string, qty: number) => void; + onDiscChange: (key: string, disc: number) => void; +}) { + const [q, setQ] = useState(''); + const { data, isLoading } = useCatalog({ q: q || undefined, limit: 20 }); + + const cartKeys = new Set(cart.map((c) => c.productCode)); + + const catalogColumns: TableColumnsType<ProductSummary> = [ + { title: 'Código', dataIndex: 'code', width: 100 }, + { title: 'Produto', dataIndex: 'name', ellipsis: true }, + { + title: 'Categoria', + dataIndex: 'category', + width: 110, + render: (v: string) => <Tag>{v}</Tag>, + }, + { + title: 'Preço', + dataIndex: 'unitPrice', + width: 110, + align: 'right', + render: (v: string) => fmt(Number(v)), + }, + { + title: '', + width: 80, + render: (_: unknown, row: ProductSummary) => ( + <Button + size="small" + icon={<PlusOutlined />} + disabled={cartKeys.has(row.code)} + onClick={() => onAdd(row)} + > + Add + </Button> + ), + }, + ]; + + const cartColumns: TableColumnsType<CartItem> = [ + { title: 'Produto', dataIndex: 'productName', ellipsis: true }, + { + title: 'Qtd', + dataIndex: 'quantity', + width: 100, + render: (v: number, row: CartItem) => ( + <InputNumber + min={0.001} + step={1} + value={v} + size="small" + style={{ width: 80 }} + onChange={(n) => onQtyChange(row.key, n ?? 1)} + /> + ), + }, + { + title: 'Desc %', + dataIndex: 'discountPct', + width: 100, + render: (v: number, row: CartItem) => ( + <InputNumber + min={0} + max={100} + step={0.5} + value={v} + size="small" + style={{ width: 80 }} + onChange={(n) => onDiscChange(row.key, n ?? 0)} + /> + ), + }, + { + title: 'Subtotal', + width: 120, + align: 'right', + render: (_: unknown, row: CartItem) => + fmt(calcItemTotal(row.quantity, row.unitPrice, row.discountPct)), + }, + { + title: '', + width: 40, + render: (_: unknown, row: CartItem) => ( + <Button danger size="small" icon={<DeleteOutlined />} onClick={() => onRemove(row.key)} /> + ), + }, + ]; + + return ( + <Space direction="vertical" style={{ width: '100%' }} size="middle"> + <Search + placeholder="Buscar produto por nome ou código..." + allowClear + style={{ maxWidth: 400 }} + onSearch={setQ} + onChange={(e) => { + if (!e.target.value) setQ(''); + }} + /> + <Table<ProductSummary> + rowKey="id" + columns={catalogColumns} + dataSource={data?.data ?? []} + loading={isLoading} + size="small" + pagination={false} + scroll={{ y: 220 }} + /> + <Divider orientation="left">Itens do Pedido ({cart.length})</Divider> + <Table<CartItem> + rowKey="key" + columns={cartColumns} + dataSource={cart} + size="small" + pagination={false} + locale={{ emptyText: 'Nenhum produto adicionado ainda.' }} + /> + </Space> + ); +} + +// ─── Step 2 — Desconto Global + Observações ─────────────────────────────────── + +function ReviewStep({ + cart, + globalDisc, + notes, + creditLimit, + onDiscChange, + onNotesChange, +}: { + cart: CartItem[]; + globalDisc: number; + notes: string; + creditLimit: string | null; + onDiscChange: (v: number) => void; + onNotesChange: (v: string) => void; +}) { + const itemsSubtotal = cart.reduce( + (acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct), + 0, + ); + const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100; + const limit = creditLimit ? Number(creditLimit) : null; + const creditOk = limit === null || total <= limit; + + return ( + <Space direction="vertical" style={{ width: '100%' }} size="middle"> + <Descriptions bordered size="small" column={2}> + <Descriptions.Item label="Subtotal dos itens" span={2}> + <Text strong>{fmt(itemsSubtotal)}</Text> + </Descriptions.Item> + <Descriptions.Item label="Desconto global do pedido"> + <InputNumber + min={0} + max={100} + step={0.5} + value={globalDisc} + addonAfter="%" + style={{ width: 120 }} + onChange={(n) => onDiscChange(n ?? 0)} + /> + </Descriptions.Item> + <Descriptions.Item label="Total do pedido"> + <Text strong style={{ fontSize: 16 }}> + {fmt(total)} + </Text> + </Descriptions.Item> + <Descriptions.Item label="Limite de crédito">{limit ? fmt(limit) : '—'}</Descriptions.Item> + <Descriptions.Item label="Situação crédito"> + <Tag color={creditOk ? 'success' : 'error'}>{creditOk ? 'OK' : 'Acima do limite'}</Tag> + </Descriptions.Item> + </Descriptions> + + <Form.Item label="Observações (opcional)"> + <Input.TextArea + rows={3} + maxLength={500} + showCount + value={notes} + onChange={(e) => onNotesChange(e.target.value)} + placeholder="Instruções de entrega, referência do comprador, etc." + /> + </Form.Item> + </Space> + ); +} + +// ─── Step 3 — Confirmação ───────────────────────────────────────────────────── + +function ConfirmStep({ + cart, + globalDisc, + notes, + clientName, +}: { + cart: CartItem[]; + globalDisc: number; + notes: string; + clientName: string; +}) { + const itemsSubtotal = cart.reduce( + (acc, it) => acc + calcItemTotal(it.quantity, it.unitPrice, it.discountPct), + 0, + ); + const total = Math.round(itemsSubtotal * (1 - globalDisc / 100) * 100) / 100; + + return ( + <Space direction="vertical" style={{ width: '100%' }} size="middle"> + <Descriptions bordered size="small" column={1}> + <Descriptions.Item label="Cliente">{clientName}</Descriptions.Item> + <Descriptions.Item label="Produtos">{cart.length} item(ns)</Descriptions.Item> + <Descriptions.Item label="Subtotal dos itens">{fmt(itemsSubtotal)}</Descriptions.Item> + <Descriptions.Item label="Desconto global">{globalDisc}%</Descriptions.Item> + <Descriptions.Item label="Total"> + <Text strong style={{ fontSize: 18 }}> + {fmt(total)} + </Text> + </Descriptions.Item> + {notes && <Descriptions.Item label="Observações">{notes}</Descriptions.Item>} + </Descriptions> + <Alert + type="info" + message="O pedido será criado com status Orçamento ou Aguardando Aprovação, conforme a sua alçada de desconto." + showIcon + /> + </Space> + ); +} + +// ─── NewOrderPage ───────────────────────────────────────────────────────────── + +type SearchParams = { clientId?: string }; + +export function NewOrderPage() { + const { clientId } = useSearch({ strict: false }) as SearchParams; + const navigate = useNavigate(); + const qc = useQueryClient(); + + const { data: client, isLoading: clientLoading } = useClientDetail(clientId); + + const [step, setStep] = useState(0); + const [cart, setCart] = useState<CartItem[]>([]); + const [globalDisc, setGlobalDisc] = useState(0); + const [notes, setNotes] = useState(''); + const [error, setError] = useState<string | null>(null); + + const mutation = useMutation({ + mutationFn: async () => { + if (!clientId) throw new Error('clientId ausente'); + const body: CreateOrder = { + clientId, + discountPct: globalDisc, + notes: notes || undefined, + idempotencyKey: crypto.randomUUID(), + items: cart.map((it) => ({ + productCode: it.productCode, + productName: it.productName, + productCategory: it.productCategory, + quantity: it.quantity, + unitPrice: it.unitPrice, + discountPct: it.discountPct, + })), + }; + const res = await apiFetch('/orders', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as { detail?: string }).detail ?? `Erro ${res.status}`); + } + return res.json(); + }, + onSuccess: (order: { id: string }) => { + void qc.invalidateQueries({ queryKey: ['orders'] }); + void qc.invalidateQueries({ queryKey: ['clients', clientId] }); + void navigate({ to: '/pedidos/$id', params: { id: order.id } }); + }, + onError: (e: Error) => setError(e.message), + }); + + const addToCart = (p: ProductSummary) => { + setCart((prev) => [ + ...prev, + { + key: p.code, + productCode: p.code, + productName: p.name, + productCategory: p.category, + quantity: 1, + unitPrice: Number(p.unitPrice), + discountPct: 0, + }, + ]); + }; + + const removeFromCart = (key: string) => setCart((prev) => prev.filter((it) => it.key !== key)); + const setQty = (key: string, qty: number) => + setCart((prev) => prev.map((it) => (it.key === key ? { ...it, quantity: qty } : it))); + const setDisc = (key: string, disc: number) => + setCart((prev) => prev.map((it) => (it.key === key ? { ...it, discountPct: disc } : it))); + + if (!clientId) + return <Alert type="error" message="Parâmetro clientId ausente." style={{ margin: 24 }} />; + + if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />; + + if (!client) + return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />; + + const steps = [{ title: 'Produtos' }, { title: 'Desconto / Obs.' }, { title: 'Confirmar' }]; + + const canNext = (step === 0 && cart.length > 0) || step === 1; + + return ( + <div style={{ padding: 24, maxWidth: 900 }}> + <Space align="center" style={{ marginBottom: 16 }}> + <Link to="/clientes/$id" params={{ id: clientId }}> + ← {client.tradeName ?? client.name} + </Link> + <Title level={3} style={{ margin: 0 }}> + Novo Pedido + + + + + + {step === 0 && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + + {error && ( + setError(null)} + /> + )} + + + + {step > 0 && } + {step < 2 && ( + + )} + {step === 2 && ( + + )} + +
+ ); +} diff --git a/apps/web/src/lib/queries/catalog.ts b/apps/web/src/lib/queries/catalog.ts new file mode 100644 index 0000000..41650df --- /dev/null +++ b/apps/web/src/lib/queries/catalog.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; +import { + ProductListResponseSchema, + type ProductListQuery, + type ProductListResponse, +} from '@sar/api-interface'; +import { apiFetch } from '../api-client'; + +export function useCatalog(params: Partial = {}) { + const search = new URLSearchParams(); + if (params.q) search.set('q', params.q); + if (params.category) search.set('category', params.category); + if (params.page) search.set('page', String(params.page)); + if (params.limit) search.set('limit', String(params.limit)); + + const qs = search.toString(); + return useQuery({ + queryKey: ['catalog', params], + queryFn: async () => { + const res = await apiFetch(`/catalog${qs ? `?${qs}` : ''}`); + if (!res.ok) throw new Error(`catalog error ${res.status}`); + return ProductListResponseSchema.parse(await res.json()); + }, + staleTime: 4 * 60 * 60 * 1000, // TTL 4h — FR-4.4 + }); +} diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 4656e3c..8d5432d 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -4,6 +4,7 @@ import { RafaelPainel } from '../cockpits/rafael/RafaelPainel'; import { ClientsPage } from '../cockpits/rafael/ClientsPage'; import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage'; import { OrdersPage } from '../cockpits/rafael/OrdersPage'; +import { NewOrderPage } from '../cockpits/rafael/NewOrderPage'; const rootRoute = createRootRoute({ component: () => ( @@ -43,6 +44,12 @@ const pedidosRoute = createRoute({ component: OrdersPage, }); +const novoOrderRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/pedidos/novo', + component: NewOrderPage, +}); + const pedidoDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/pedidos/$id', @@ -62,6 +69,7 @@ const routeTree = rootRoute.addChildren([ clientesRoute, clienteDetailRoute, pedidosRoute, + novoOrderRoute, pedidoDetailRoute, ]); diff --git a/libs/shared/api-interface/src/index.ts b/libs/shared/api-interface/src/index.ts index 65c0008..1950069 100644 --- a/libs/shared/api-interface/src/index.ts +++ b/libs/shared/api-interface/src/index.ts @@ -2,3 +2,4 @@ export * from './lib/ping.contract'; export * from './lib/auth.contract'; export * from './lib/client.contract'; export * from './lib/order.contract'; +export * from './lib/product.contract'; diff --git a/libs/shared/api-interface/src/lib/order.contract.ts b/libs/shared/api-interface/src/lib/order.contract.ts index 4b300da..8bffb2c 100644 --- a/libs/shared/api-interface/src/lib/order.contract.ts +++ b/libs/shared/api-interface/src/lib/order.contract.ts @@ -91,3 +91,24 @@ export const OrderListResponseSchema = z.object({ limit: z.number().int().positive(), }); export type OrderListResponse = z.infer; + +// ─── Create Order (POST /orders) ────────────────────────────────────────────── + +export const CreateOrderItemSchema = z.object({ + productCode: z.string().min(1), + productName: z.string().min(1), + productCategory: z.string().default('geral'), + quantity: z.number().positive(), + unitPrice: z.number().positive(), + discountPct: z.number().min(0).max(100).default(0), +}); +export type CreateOrderItem = z.infer; + +export const CreateOrderSchema = z.object({ + clientId: z.string().uuid(), + discountPct: z.number().min(0).max(100).default(0), // desconto global do pedido + notes: z.string().optional(), + idempotencyKey: z.string().optional(), + items: z.array(CreateOrderItemSchema).min(1), +}); +export type CreateOrder = z.infer; diff --git a/libs/shared/api-interface/src/lib/product.contract.ts b/libs/shared/api-interface/src/lib/product.contract.ts new file mode 100644 index 0000000..7078c8f --- /dev/null +++ b/libs/shared/api-interface/src/lib/product.contract.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; + +// Contratos canônicos de C4 — Catálogo de Produtos. +// Consumidos pela API (output tipado) e pela Web (TanStack Query + parse). + +// ─── Product Summary (lista) ────────────────────────────────────────────────── + +export const ProductSummarySchema = z.object({ + id: z.string().uuid(), + code: z.string(), + name: z.string(), + category: z.string(), + unitPrice: z.string(), // Decimal serializado + stock: z.string().nullable(), + active: z.boolean(), +}); +export type ProductSummary = z.infer; + +// ─── Product Detail ─────────────────────────────────────────────────────────── + +export const ProductDetailSchema = ProductSummarySchema.extend({ + description: z.string().nullable(), + erpCode: z.string().nullable(), + syncedAt: z.iso.datetime().nullable(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}); +export type ProductDetail = z.infer; + +// ─── List query + response ──────────────────────────────────────────────────── + +export const ProductListQuerySchema = z.object({ + q: z.string().optional(), // busca nome/código + category: z.string().optional(), // filtra por categoria + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().min(1).max(200).default(50), +}); +export type ProductListQuery = z.infer; + +export const ProductListResponseSchema = z.object({ + data: z.array(ProductSummarySchema), + total: z.number().int().nonnegative(), + page: z.number().int().positive(), + limit: z.number().int().positive(), +}); +export type ProductListResponse = z.infer; + +// ─── Sync (importação da view do ERP) ──────────────────────────────────────── + +export const ProductSyncItemSchema = z.object({ + code: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + category: z.string().default('geral'), + unitPrice: z.number().positive(), + stock: z.number().nonnegative().optional(), + active: z.boolean().default(true), + erpCode: z.string().optional(), +}); +export type ProductSyncItem = z.infer; + +export const ProductSyncRequestSchema = z.object({ + items: z.array(ProductSyncItemSchema).min(1).max(5000), +}); +export type ProductSyncRequest = z.infer; + +export const ProductSyncResponseSchema = z.object({ + upserted: z.number().int().nonnegative(), + syncedAt: z.iso.datetime(), +}); +export type ProductSyncResponse = z.infer;