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/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}
+ />
+
+ )}
-
-
-
{(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/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/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,
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
+}