From 37de5828e6c3782ba71cccf6a6d5847b281e13f8 Mon Sep 17 00:00:00 2001 From: gustavo-BoxDistribuidor Date: Fri, 18 Sep 2026 11:55:36 -0300 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20ajuste=20de=20exibi=C3=A7=C3=A3o=20d?= =?UTF-8?q?e=20dados=20e=20ocuta=C3=A7=C3=A3o=20sens=C3=ADvel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(authenticated)/admin/users/page.tsx | 116 +++++++++++++------ src/server/api/routers/user.ts | 68 ++++++++++- 2 files changed, 146 insertions(+), 38 deletions(-) diff --git a/src/app/(authenticated)/admin/users/page.tsx b/src/app/(authenticated)/admin/users/page.tsx index 02022fe2..438060ec 100644 --- a/src/app/(authenticated)/admin/users/page.tsx +++ b/src/app/(authenticated)/admin/users/page.tsx @@ -543,23 +543,9 @@ function UserListRow(props: UserManagementCardProps) {

{user.email}

- {user.accountType === "CORPORATE" ? ( - user.cnpj ? ( - - CNPJ: {formatCnpj(user.cnpj)} - - ) : ( - - Sem CNPJ - - ) - ) : user.cpf ? ( - - CPF: {formatCpf(user.cpf)} - - ) : ( - - Sem CPF + {user.accountType === "CORPORATE" && ( + + Corporativo )} {setorLabel && ( @@ -684,7 +670,8 @@ function UserManagementCard({ user, allForms, filiais = [], empresas = [], setor const [isEditingDadosPrivados, setIsEditingDadosPrivados] = useState(false) const [dadosPrivadosData, setDadosPrivadosData] = useState({ lojinha_full_name: (user as { lojinha_full_name?: string | null }).lojinha_full_name ?? "", - lojinha_cpf: (user as { lojinha_cpf?: string | null }).lojinha_cpf ?? "", + cpf: user.cpf ? formatCpf(user.cpf) : "", + cnpj: user.cnpj ? formatCnpj(user.cnpj) : "", lojinha_address: (user as { lojinha_address?: string | null }).lojinha_address ?? "", lojinha_neighborhood: (user as { lojinha_neighborhood?: string | null }).lojinha_neighborhood ?? "", lojinha_cep: (user as { lojinha_cep?: string | null }).lojinha_cep ?? "", @@ -697,7 +684,8 @@ function UserManagementCard({ user, allForms, filiais = [], empresas = [], setor useEffect(() => { setDadosPrivadosData({ lojinha_full_name: (user as { lojinha_full_name?: string | null }).lojinha_full_name ?? "", - lojinha_cpf: (user as { lojinha_cpf?: string | null }).lojinha_cpf ?? "", + cpf: user.cpf ? formatCpf(user.cpf) : "", + cnpj: user.cnpj ? formatCnpj(user.cnpj) : "", lojinha_address: (user as { lojinha_address?: string | null }).lojinha_address ?? "", lojinha_neighborhood: (user as { lojinha_neighborhood?: string | null }).lojinha_neighborhood ?? "", lojinha_cep: (user as { lojinha_cep?: string | null }).lojinha_cep ?? "", @@ -880,10 +868,33 @@ function UserManagementCard({ user, allForms, filiais = [], empresas = [], setor } const handleSaveDadosPrivados = () => { + if (user.accountType === "CORPORATE" && dadosPrivadosData.cnpj) { + const clean = getDigits(dadosPrivadosData.cnpj) + if (!isValidCnpj(clean)) { + toast({ + title: "CNPJ inválido", + description: "Por favor, informe um CNPJ válido com 14 dígitos.", + variant: "destructive", + }) + return + } + } else if (user.accountType !== "CORPORATE" && dadosPrivadosData.cpf) { + const clean = getDigits(dadosPrivadosData.cpf) + if (!isValidCpf(clean)) { + toast({ + title: "CPF inválido", + description: "Por favor, informe um CPF válido com 11 dígitos.", + variant: "destructive", + }) + return + } + } + updateDadosPrivados.mutate({ userId: user.id, + cpf: user.accountType !== "CORPORATE" ? (dadosPrivadosData.cpf ? getDigits(dadosPrivadosData.cpf) : null) : null, + cnpj: user.accountType === "CORPORATE" ? (dadosPrivadosData.cnpj ? getDigits(dadosPrivadosData.cnpj) : null) : null, lojinha_full_name: dadosPrivadosData.lojinha_full_name.trim() || null, - lojinha_cpf: dadosPrivadosData.lojinha_cpf.replace(/\D/g, "") || null, lojinha_address: dadosPrivadosData.lojinha_address.trim() || null, lojinha_neighborhood: dadosPrivadosData.lojinha_neighborhood.trim() || null, lojinha_cep: dadosPrivadosData.lojinha_cep.replace(/\D/g, "") || null, @@ -2215,15 +2226,29 @@ function UserManagementCard({ user, allForms, filiais = [], empresas = [], setor placeholder="Nome completo" />
-
- - setDadosPrivadosData({ ...dadosPrivadosData, lojinha_cpf: e.target.value.replace(/\D/g, "").slice(0, 11) })} - placeholder="11 dígitos" - /> -
+ {user.accountType === "CORPORATE" ? ( +
+ + setDadosPrivadosData({ ...dadosPrivadosData, cnpj: formatCnpj(e.target.value) })} + placeholder="00.000.000/0000-00" + maxLength={18} + /> +
+ ) : ( +
+ + setDadosPrivadosData({ ...dadosPrivadosData, cpf: formatCpf(e.target.value) })} + placeholder="000.000.000-00" + maxLength={14} + /> +
+ )}
Nome completo

{(user as { lojinha_full_name?: string | null }).lojinha_full_name ?? "—"}

-
- -

{(user as { lojinha_cpf?: string | null }).lojinha_cpf ?? "—"}

-
+ {user.accountType === "CORPORATE" ? ( +
+ +

+ {user.cnpj ? formatCnpj(user.cnpj) : "—"} +

+
+ ) : ( +
+ +

+ {user.cpf ? formatCpf(user.cpf) : "—"} +

+
+ )}

{(user as { lojinha_rg?: string | null }).lojinha_rg ?? "—"}

@@ -2384,8 +2420,11 @@ const AUDIT_FIELD_LABELS: Record = { email_empresarial: "E-mail empresarial", filialId: "Filial", enterprise: "Empresa", + accountType: "Tipo de Perfil", + cpf: "CPF", + cnpj: "CNPJ", lojinha_full_name: "Nome completo (privado)", - lojinha_cpf: "CPF", + lojinha_cpf: "CPF (legado)", lojinha_address: "Endereço", lojinha_neighborhood: "Bairro", lojinha_cep: "CEP", @@ -2418,6 +2457,15 @@ function formatAuditValue( if (field === "setor") { return setores.find((s) => s.value === value)?.name ?? value } + if (field === "cpf") { + return formatCpf(value) + } + if (field === "cnpj") { + return formatCnpj(value) + } + if (field === "accountType") { + return value === "CORPORATE" ? "Corporativo" : "Colaborador" + } return value } return JSON.stringify(value) diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 185ab46d..e402c4be 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -547,12 +547,23 @@ export const userRouter = createTRPCRouter({ const total = filteredUsers.length let paginatedUsers = filteredUsers.slice(input.offset, input.offset + input.limit) - // Ocultar dados privados (lojinha_*) se o usuário não tiver can_view_dados_privados + // Ocultar dados privados (lojinha_*, cpf, cnpj) se o usuário não tiver can_view_dados_privados if (!canViewDadosPrivados) { - const lojinhaKeys = ["lojinha_full_name", "lojinha_cpf", "lojinha_address", "lojinha_neighborhood", "lojinha_cep", "lojinha_rg", "lojinha_email", "lojinha_phone"] as const + const privateKeys = [ + "lojinha_full_name", + "lojinha_cpf", + "lojinha_address", + "lojinha_neighborhood", + "lojinha_cep", + "lojinha_rg", + "lojinha_email", + "lojinha_phone", + "cpf", + "cnpj", + ] as const paginatedUsers = paginatedUsers.map(u => { const rest = { ...u } as Record - lojinhaKeys.forEach(k => delete rest[k]) + privateKeys.forEach(k => delete rest[k]) return rest }) as typeof paginatedUsers } @@ -926,6 +937,8 @@ export const userRouter = createTRPCRouter({ updateDadosPrivados: protectedProcedure .input(z.object({ userId: z.string(), + cpf: z.string().optional().nullable(), + cnpj: z.string().optional().nullable(), lojinha_full_name: z.string().optional().nullable(), lojinha_cpf: z.string().optional().nullable(), lojinha_address: z.string().optional().nullable(), @@ -950,8 +963,53 @@ export const userRouter = createTRPCRouter({ } const { userId, ...data } = input; const updateData: Record = {}; + + if (data.cpf !== undefined) { + if (data.cpf) { + const cleanCpf = getDigits(data.cpf); + if (!isValidCpf(cleanCpf)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "CPF inválido.", + }); + } + const existingWithCpf = await ctx.db.user.findFirst({ + where: { cpf: cleanCpf, id: { not: userId } }, + }); + if (existingWithCpf) { + throw new TRPCError({ + code: "CONFLICT", + message: "Este CPF já está cadastrado em outra conta.", + }); + } + updateData.cpf = cleanCpf; + updateData.cnpj = null; + updateData.lojinha_cpf = cleanCpf; + } else { + updateData.cpf = null; + updateData.lojinha_cpf = null; + } + } + + if (data.cnpj !== undefined) { + if (data.cnpj) { + const cleanCnpj = getDigits(data.cnpj); + if (!isValidCnpj(cleanCnpj)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "CNPJ inválido.", + }); + } + updateData.cnpj = cleanCnpj; + updateData.cpf = null; + updateData.lojinha_cpf = null; + } else { + updateData.cnpj = null; + } + } + if (data.lojinha_full_name !== undefined) updateData.lojinha_full_name = data.lojinha_full_name?.trim() ?? null; - if (data.lojinha_cpf !== undefined) updateData.lojinha_cpf = data.lojinha_cpf ? data.lojinha_cpf.replace(/\D/g, "") : null; + if (data.lojinha_cpf !== undefined && data.cpf === undefined) updateData.lojinha_cpf = data.lojinha_cpf ? data.lojinha_cpf.replace(/\D/g, "") : null; if (data.lojinha_address !== undefined) updateData.lojinha_address = data.lojinha_address?.trim() ?? null; if (data.lojinha_neighborhood !== undefined) updateData.lojinha_neighborhood = data.lojinha_neighborhood?.trim() ?? null; if (data.lojinha_cep !== undefined) updateData.lojinha_cep = data.lojinha_cep ? data.lojinha_cep.replace(/\D/g, "") : null; @@ -963,6 +1021,8 @@ export const userRouter = createTRPCRouter({ const before = await ctx.db.user.findUnique({ where: { id: userId }, select: { + cpf: true, + cnpj: true, lojinha_full_name: true, lojinha_cpf: true, lojinha_address: true, From 3ae23280a283fc62dd9b651bc5ee6ee0fae2ccad Mon Sep 17 00:00:00 2001 From: gustavo-BoxDistribuidor Date: Mon, 21 Sep 2026 09:26:01 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20ajuste=20de=20pre=C3=A7os=20de=20ali?= =?UTF-8?q?menta=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 100 +++++++++++++++++ prisma/schema.prisma | 21 +++- scripts/sql/backfill-meal-pricing.sql | 104 ++++++++++++++++++ scripts/verify-meal-pricing.mjs | 48 ++++++++ .../admin/food/_components/orders-tab.tsx | 4 +- src/server/api/routers/food-order.ts | 103 ++++++++++++++++- src/server/api/routers/order-log.ts | 2 +- src/server/services/food-pricing.ts | 57 ++++++++++ 8 files changed, 431 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260921000000_add_meal_pricing_policy_and_order_unit_price/migration.sql create mode 100644 scripts/sql/backfill-meal-pricing.sql create mode 100644 scripts/verify-meal-pricing.mjs create mode 100644 src/server/services/food-pricing.ts diff --git a/prisma/migrations/20260921000000_add_meal_pricing_policy_and_order_unit_price/migration.sql b/prisma/migrations/20260921000000_add_meal_pricing_policy_and_order_unit_price/migration.sql new file mode 100644 index 00000000..ac7cee58 --- /dev/null +++ b/prisma/migrations/20260921000000_add_meal_pricing_policy_and_order_unit_price/migration.sql @@ -0,0 +1,100 @@ +-- 1. Criação da tabela de vigência de preços de refeição +CREATE TABLE IF NOT EXISTS "meal_pricing_policies" ( + "id" TEXT NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3), + "price" DOUBLE PRECISION NOT NULL, + "description" TEXT, + "restaurantId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "meal_pricing_policies_pkey" PRIMARY KEY ("id") +); + +-- 2. Índice para consultas rápidas por faixa de data +CREATE INDEX IF NOT EXISTS "meal_pricing_policies_startDate_endDate_idx" +ON "meal_pricing_policies"("startDate", "endDate"); + +-- 3. Foreign key opcional para restaurante +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'meal_pricing_policies_restaurantId_fkey' + ) THEN + ALTER TABLE "meal_pricing_policies" + ADD CONSTRAINT "meal_pricing_policies_restaurantId_fkey" + FOREIGN KEY ("restaurantId") REFERENCES "restaurants"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- 4. Adicionar coluna unitPrice na tabela food_orders (se ainda não existir) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'food_orders' AND column_name = 'unitPrice' + ) THEN + ALTER TABLE "food_orders" ADD COLUMN "unitPrice" DOUBLE PRECISION; + END IF; +END $$; + +-- 5. Inserir políticas de preço padrão (idempotente) +-- Vigência de 20/08 a 31/08: R$ 4,00 +INSERT INTO "meal_pricing_policies" ("id", "startDate", "endDate", "price", "description", "updatedAt") +VALUES ( + 'policy_2026_08_vigencia_4', + '2026-08-20 00:00:00.000', + '2026-08-31 23:59:59.999', + 4.00, + 'Vigência de 20/08 a 31/08: R$ 4,00 por refeição', + CURRENT_TIMESTAMP +) +ON CONFLICT ("id") DO UPDATE +SET "startDate" = EXCLUDED."startDate", + "endDate" = EXCLUDED."endDate", + "price" = EXCLUDED."price", + "description" = EXCLUDED."description", + "updatedAt" = CURRENT_TIMESTAMP; + +-- Vigência a partir de 01/09: R$ 7,00 (endDate NULL = vigente) +INSERT INTO "meal_pricing_policies" ("id", "startDate", "endDate", "price", "description", "updatedAt") +VALUES ( + 'policy_2026_09_vigencia_7', + '2026-09-01 00:00:00.000', + NULL, + 7.00, + 'Vigência a partir de 01/09: R$ 7,00 por refeição', + CURRENT_TIMESTAMP +) +ON CONFLICT ("id") DO UPDATE +SET "startDate" = EXCLUDED."startDate", + "endDate" = EXCLUDED."endDate", + "price" = EXCLUDED."price", + "description" = EXCLUDED."description", + "updatedAt" = CURRENT_TIMESTAMP; + +-- 6. Backfill nos pedidos já existentes (FoodOrder.unitPrice): +-- Pedidos com orderDate anterior a 01/09/2026 -> R$ 4,00 (ou o valor gravado no cardápio caso seja diferente de null) +UPDATE "food_orders" +SET "unitPrice" = 4.00 +WHERE "orderDate" < '2026-09-01 00:00:00.000' + AND ("unitPrice" IS NULL OR "unitPrice" != 4.00); + +-- Pedidos com orderDate a partir de 01/09/2026 -> R$ 7,00 +UPDATE "food_orders" +SET "unitPrice" = 7.00 +WHERE "orderDate" >= '2026-09-01 00:00:00.000' + AND ("unitPrice" IS NULL OR "unitPrice" != 7.00); + +-- Caso exista algum pedido com data anterior ao corte que ficou NULL, preenche com o preço do menuItem +UPDATE "food_orders" fo +SET "unitPrice" = COALESCE(mi.price, 4.00) +FROM "menu_items" mi +WHERE fo."menuItemId" = mi.id + AND fo."unitPrice" IS NULL; + +-- 7. Atualizar o preço dos itens de menu ativos para R$ 7,00 (novo valor do cardápio padrão) +UPDATE "menu_items" +SET "price" = 7.00, "updatedAt" = CURRENT_TIMESTAMP +WHERE "price" = 4.00; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fb58befb..21bc1476 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -535,8 +535,9 @@ model Restaurant { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt filial Filial? @relation(fields: [filialId], references: [id], onDelete: SetNull) - orders FoodOrder[] - menuItems MenuItem[] + orders FoodOrder[] + menuItems MenuItem[] + pricingPolicies MealPricingPolicy[] @@index([filialId]) @@map("restaurants") @@ -598,6 +599,7 @@ model FoodOrder { orderTime DateTime status OrderStatus @default(PENDING) observations String? + unitPrice Float? // Snapshot do valor unitário da refeição na data do pedido createdAt DateTime @default(now()) updatedAt DateTime @updatedAt menuItem MenuItem @relation(fields: [menuItemId], references: [id], onDelete: Cascade) @@ -609,6 +611,21 @@ model FoodOrder { @@map("food_orders") } +model MealPricingPolicy { + id String @id @default(cuid()) + startDate DateTime // Início da vigência (inclusivo) + endDate DateTime? // Fim da vigência (null = em aberto) + price Float // Valor por refeição + description String? // Ex: "Vigência Agosto 2026" + restaurantId String? // Opcional para regras específicas de restaurante + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + restaurant Restaurant? @relation(fields: [restaurantId], references: [id], onDelete: Cascade) + + @@index([startDate, endDate]) + @@map("meal_pricing_policies") +} + model OrderOptionSelection { id String @id @default(cuid()) orderId String diff --git a/scripts/sql/backfill-meal-pricing.sql b/scripts/sql/backfill-meal-pricing.sql new file mode 100644 index 00000000..e28fd650 --- /dev/null +++ b/scripts/sql/backfill-meal-pricing.sql @@ -0,0 +1,104 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS "meal_pricing_policies" ( + "id" TEXT NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3), + "price" DOUBLE PRECISION NOT NULL, + "description" TEXT, + "restaurantId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "meal_pricing_policies_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "meal_pricing_policies_startDate_endDate_idx" +ON "meal_pricing_policies"("startDate", "endDate"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'meal_pricing_policies_restaurantId_fkey' + ) THEN + ALTER TABLE "meal_pricing_policies" + ADD CONSTRAINT "meal_pricing_policies_restaurantId_fkey" + FOREIGN KEY ("restaurantId") REFERENCES "restaurants"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'food_orders' AND column_name = 'unitPrice' + ) THEN + ALTER TABLE "food_orders" ADD COLUMN "unitPrice" DOUBLE PRECISION; + END IF; +END $$; + +INSERT INTO "meal_pricing_policies" ("id", "startDate", "endDate", "price", "description", "updatedAt") +VALUES ( + 'policy_2026_08_vigencia_4', + '2026-08-20 00:00:00.000', + '2026-08-31 23:59:59.999', + 4.00, + 'Vigência de 20/08 a 31/08: R$ 4,00 por refeição', + CURRENT_TIMESTAMP +) +ON CONFLICT ("id") DO UPDATE +SET "startDate" = EXCLUDED."startDate", + "endDate" = EXCLUDED."endDate", + "price" = EXCLUDED."price", + "description" = EXCLUDED."description", + "updatedAt" = CURRENT_TIMESTAMP; + +INSERT INTO "meal_pricing_policies" ("id", "startDate", "endDate", "price", "description", "updatedAt") +VALUES ( + 'policy_2026_09_vigencia_7', + '2026-09-01 00:00:00.000', + NULL, + 7.00, + 'Vigência a partir de 01/09: R$ 7,00 por refeição', + CURRENT_TIMESTAMP +) +ON CONFLICT ("id") DO UPDATE +SET "startDate" = EXCLUDED."startDate", + "endDate" = EXCLUDED."endDate", + "price" = EXCLUDED."price", + "description" = EXCLUDED."description", + "updatedAt" = CURRENT_TIMESTAMP; + +UPDATE "food_orders" +SET "unitPrice" = 4.00 +WHERE "orderDate" < '2026-09-01 00:00:00.000' + AND ("unitPrice" IS NULL OR "unitPrice" != 4.00); + +UPDATE "food_orders" +SET "unitPrice" = 7.00 +WHERE "orderDate" >= '2026-09-01 00:00:00.000' + AND ("unitPrice" IS NULL OR "unitPrice" != 7.00); + +UPDATE "food_orders" fo +SET "unitPrice" = COALESCE(mi.price, 4.00) +FROM "menu_items" mi +WHERE fo."menuItemId" = mi.id + AND fo."unitPrice" IS NULL; + +UPDATE "menu_items" +SET "price" = 7.00, "updatedAt" = CURRENT_TIMESTAMP +WHERE "price" = 4.00; + +COMMIT; + +SELECT + CASE + WHEN "orderDate" < '2026-09-01 00:00:00.000' THEN 'Até 31/08 (R$ 4,00)' + ELSE 'A partir de 01/09 (R$ 7,00)' + END AS periodo, + "unitPrice", + COUNT(*) AS total_pedidos, + SUM("unitPrice") AS valor_total +FROM "food_orders" +GROUP BY 1, 2 +ORDER BY 1, 2; \ No newline at end of file diff --git a/scripts/verify-meal-pricing.mjs b/scripts/verify-meal-pricing.mjs new file mode 100644 index 00000000..221cc76f --- /dev/null +++ b/scripts/verify-meal-pricing.mjs @@ -0,0 +1,48 @@ +import { PrismaClient } from "@prisma/client" + +const db = new PrismaClient() + +async function main() { + console.log("=== VERIFICAÇÃO DE POLÍTICAS DE PREÇO ===") + const policies = await db.mealPricingPolicy.findMany({ + orderBy: { startDate: "asc" }, + }) + console.log(policies) + + console.log("\n=== VERIFICAÇÃO DE PEDIDOS POR PERÍODO E VALOR ===") + const breakdown = await db.$queryRaw` + SELECT + CASE + WHEN "orderDate" < '2026-09-01 00:00:00.000' THEN 'Até 31/08 (R$ 4,00)' + ELSE 'A partir de 01/09 (R$ 7,00)' + END AS periodo, + "unitPrice", + COUNT(*)::int AS total_pedidos, + ROUND(SUM("unitPrice")::numeric, 2) AS valor_total + FROM food_orders + GROUP BY 1, 2 + ORDER BY 1, 2; + ` + console.log(breakdown) + + const nullOrdersCount = await db.foodOrder.count({ + where: { unitPrice: null }, + }) + console.log(`\nPedidos com unitPrice nulo: ${nullOrdersCount}`) + + console.log("\n=== PREÇO MÉDIO DOS ITENS DE MENU ===") + const menuItemsSummary = await db.menuItem.aggregate({ + _avg: { price: true }, + _min: { price: true }, + _max: { price: true }, + _count: true, + }) + console.log(menuItemsSummary) +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err) + process.exit(1) + }) diff --git a/src/app/(authenticated)/admin/food/_components/orders-tab.tsx b/src/app/(authenticated)/admin/food/_components/orders-tab.tsx index 96795f9c..c61ca9dc 100644 --- a/src/app/(authenticated)/admin/food/_components/orders-tab.tsx +++ b/src/app/(authenticated)/admin/food/_components/orders-tab.tsx @@ -227,7 +227,7 @@ export default function OrdersTab({ const nome = `${user?.firstName ?? ""} ${user?.lastName ?? ""}`.trim() const empresa = user?.enterprise ?? undefined const setor = user?.setor ?? null - const valor = order.menuItem?.price ?? 0 + const valor = order.unitPrice ?? order.menuItem?.price ?? 0 let resumo = resumoPorUsuario[email] if (!resumo) { resumo = { @@ -925,7 +925,7 @@ export default function OrdersTab({ )}
-

R$ {order.menuItem.price.toFixed(2)}

+

R$ {(order.unitPrice ?? order.menuItem.price).toFixed(2)}

{getStatusText(order.status)} diff --git a/src/server/api/routers/food-order.ts b/src/server/api/routers/food-order.ts index dfa33840..527e9edc 100644 --- a/src/server/api/routers/food-order.ts +++ b/src/server/api/routers/food-order.ts @@ -6,6 +6,7 @@ import { Enterprise, type Prisma } from "@prisma/client" import type { RolesConfig } from "@/types/role-config" import { sendFoodOrdersEmail } from "@/server/services/food-order-email" import { checkSelfServiceFoodOrderDate } from "@/lib/food-order-self-service-date" +import { resolveOrderUnitPrice } from "@/server/services/food-pricing" /** * Resolve o intervalo [startDate, endDate] usado pelas consultas do relatório DRE. @@ -123,6 +124,13 @@ export const foodOrderRouter = createTRPCRouter({ }) } + // Resolver preço unitário da refeição para a data do pedido + const unitPrice = await resolveOrderUnitPrice(ctx.db, { + orderDate: orderDateNormalized, + restaurantId: input.restaurantId, + fallbackPrice: menuItem.price, + }) + // Criação do pedido const createdOrder = await ctx.db.foodOrder.create({ data: { @@ -132,6 +140,7 @@ export const foodOrderRouter = createTRPCRouter({ orderDate: orderDateNormalized, orderTime, observations: input.observations, + unitPrice, }, include: { user: { @@ -244,6 +253,13 @@ export const foodOrderRouter = createTRPCRouter({ const orderTime = new Date() + // Resolver preço unitário da refeição para a data do pedido + const unitPrice = await resolveOrderUnitPrice(ctx.db, { + orderDate: orderDateNormalized, + restaurantId: input.restaurantId, + fallbackPrice: menuItem.price, + }) + const createdOrder = await ctx.db.foodOrder.create({ data: { userId: input.userId, @@ -253,6 +269,7 @@ export const foodOrderRouter = createTRPCRouter({ orderTime, observations: input.observations, status: input.status ?? "PENDING", + unitPrice, }, include: { user: { @@ -908,7 +925,7 @@ export const foodOrderRouter = createTRPCRouter({ const metric = metricsMap.get(key)! metric.totalOrders += 1 - metric.totalRevenue += order.menuItem.price + metric.totalRevenue += order.unitPrice ?? order.menuItem.price }) return Array.from(metricsMap.values()).sort((a, b) => b.totalOrders - a.totalOrders) @@ -1162,7 +1179,7 @@ export const foodOrderRouter = createTRPCRouter({ const dreDataMap = new Map() orders.forEach((order) => { - const price = order.menuItem.price + const price = order.unitPrice ?? order.menuItem.price const effectiveRestaurant = order.restaurant ?? order.menuItem.restaurant // Empresa (cadastro novo) derivada da filial do colaborador. const empresa = order.user.filial?.empresa ?? null @@ -1384,8 +1401,88 @@ export const foodOrderRouter = createTRPCRouter({ orderDate: order.orderDate, restaurantName: (order.restaurant ?? order.menuItem.restaurant)?.name ?? null, menuItemName: order.menuItem.name, - price: order.menuItem.price, + price: order.unitPrice ?? order.menuItem.price, status: order.status, })) }), + + // Listar políticas de vigência de preço de refeição + listPricingPolicies: protectedProcedure.query(async ({ ctx }) => { + return ctx.db.mealPricingPolicy.findMany({ + include: { restaurant: { select: { id: true, name: true } } }, + orderBy: { startDate: "desc" }, + }) + }), + + // Criar ou atualizar política de vigência de preço + savePricingPolicy: protectedProcedure + .input( + z.object({ + id: z.string().optional(), + startDate: z.date(), + endDate: z.date().optional().nullable(), + price: z.number().positive(), + description: z.string().optional().nullable(), + restaurantId: z.string().optional().nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + const caller = await ctx.db.user.findUnique({ + where: { id: ctx.auth.userId }, + select: { role_config: true }, + }) + const roleConfig = caller?.role_config as RolesConfig | null + const canManage = roleConfig?.sudo === true || roleConfig?.can_view_dre_report === true + if (!canManage) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Você não tem permissão para configurar políticas de preço", + }) + } + + if (input.id) { + return ctx.db.mealPricingPolicy.update({ + where: { id: input.id }, + data: { + startDate: input.startDate, + endDate: input.endDate, + price: input.price, + description: input.description, + restaurantId: input.restaurantId, + }, + }) + } + + return ctx.db.mealPricingPolicy.create({ + data: { + startDate: input.startDate, + endDate: input.endDate, + price: input.price, + description: input.description, + restaurantId: input.restaurantId, + }, + }) + }), + + // Excluir política de preço + deletePricingPolicy: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const caller = await ctx.db.user.findUnique({ + where: { id: ctx.auth.userId }, + select: { role_config: true }, + }) + const roleConfig = caller?.role_config as RolesConfig | null + const canManage = roleConfig?.sudo === true || roleConfig?.can_view_dre_report === true + if (!canManage) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Você não tem permissão para excluir políticas de preço", + }) + } + + return ctx.db.mealPricingPolicy.delete({ + where: { id: input.id }, + }) + }), }) \ No newline at end of file diff --git a/src/server/api/routers/order-log.ts b/src/server/api/routers/order-log.ts index a278be89..8d4650cd 100644 --- a/src/server/api/routers/order-log.ts +++ b/src/server/api/routers/order-log.ts @@ -62,7 +62,7 @@ export const orderLogRouter = createTRPCRouter({ totalValue: number } userData.orders.push(order) - userData.totalValue += order.menuItem.price + userData.totalValue += (order.unitPrice ?? order.menuItem.price) }) // Criar ou atualizar logs diff --git a/src/server/services/food-pricing.ts b/src/server/services/food-pricing.ts new file mode 100644 index 00000000..13edc981 --- /dev/null +++ b/src/server/services/food-pricing.ts @@ -0,0 +1,57 @@ +import { type PrismaClient } from "@prisma/client" + +interface ResolveOrderUnitPriceParams { + orderDate: Date + restaurantId?: string | null + fallbackPrice?: number +} + +/** + * Resolve o preço unitário da refeição para a data do pedido. + */ +export async function resolveOrderUnitPrice( + db: PrismaClient, + params: ResolveOrderUnitPriceParams, +): Promise { + const { orderDate, restaurantId, fallbackPrice } = params + + try { + const policies = await db.mealPricingPolicy.findMany({ + where: { + startDate: { lte: orderDate }, + OR: [ + { endDate: null }, + { endDate: { gte: orderDate } }, + ], + }, + orderBy: [ + { startDate: "desc" }, + ], + }) + + if (policies.length > 0) { + if (restaurantId) { + const restaurantPolicy = policies.find((p) => p.restaurantId === restaurantId) + if (restaurantPolicy) { + return restaurantPolicy.price + } + } + + const globalPolicy = policies.find((p) => !p.restaurantId) + if (globalPolicy) { + return globalPolicy.price + } + + return policies[0]!.price + } + } catch (error) { + console.error("Erro ao resolver preço da refeição pela política de vigência:", error) + } + + if (typeof fallbackPrice === "number" && !isNaN(fallbackPrice)) { + return fallbackPrice + } + + const cutoffSeptember2026 = new Date("2026-09-01T00:00:00.000Z") + return orderDate < cutoffSeptember2026 ? 4.00 : 7.00 +}