src/app/api/mobile/restaurant/commandes/route.ts

route·app·11.6 KB · 319 lignes· Voir l'itinéraire
Annotation non disponible

Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.

1 export

POST

Code source· typescript· tronqué à 200 lignes sur 319

import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma/client";
import { getSessionFromRequest } from "@/lib/auth/session";
import { getConfigEffective } from "@/lib/acces-vitrine";
import { sendPushToTenantAdmin } from "@/lib/push";
import {
  estOuvertMaintenant,
  getConfigRestaurantEffective,
  type HorairesRestaurant,
} from "@/lib/restaurant";
import { invalidateCache } from "@/lib/cache";
import { captureError } from "@/lib/sentry";

const MODES_VALIDES = ["SUR_PLACE", "A_RECUPERER", "LIVRAISON"] as const;
type Mode = (typeof MODES_VALIDES)[number];

const MODES_PAIEMENT_VALIDES = [
  "ORANGE_MONEY", "MOOV_MONEY", "WAVE", "AIRTEL_MONEY", "CASH", "VIREMENT",
] as const;

const MODE_TOGGLE: Record<Mode, "accepteSurPlace" | "accepteARecuperer" | "accepteLivraison"> = {
  SUR_PLACE: "accepteSurPlace",
  A_RECUPERER: "accepteARecuperer",
  LIVRAISON: "accepteLivraison",
};

type LigneInput = {
  platId: string;
  quantite: number;
  options?: Record<string, string>;
  note?: string;
};

export async function POST(req: NextRequest) {
  const session = await getSessionFromRequest(req);
  const clientId = session?.role === "CLIENT" ? session.userId : null;

  const body = await req.json().catch(() => null);
  if (!body || typeof body !== "object") {
    return NextResponse.json({ error: "Body invalide" }, { status: 400 });
  }
  const b = body as Record<string, unknown>;

  if (typeof b.tenantId !== "string" || !b.tenantId) {
    return NextResponse.json({ error: "tenantId requis" }, { status: 400 });
  }
  if (typeof b.mode !== "string" || !(MODES_VALIDES as readonly string[]).includes(b.mode)) {
    return NextResponse.json({ error: "mode invalide" }, { status: 400 });
  }
  const mode = b.mode as Mode;

  if (!Array.isArray(b.lignes) || b.lignes.length === 0) {
    return NextResponse.json({ error: "lignes requis (au moins 1)" }, { status: 400 });
  }
  const lignesInput: LigneInput[] = [];
  for (const raw of b.lignes) {
    if (!raw || typeof raw !== "object") {
      return NextResponse.json({ error: "ligne invalide" }, { status: 400 });
    }
    const l = raw as Record<string, unknown>;
    if (typeof l.platId !== "string" || !l.platId) {
      return NextResponse.json({ error: "ligne.platId requis" }, { status: 400 });
    }
    if (typeof l.quantite !== "number" || !Number.isFinite(l.quantite) || l.quantite < 1) {
      return NextResponse.json({ error: "ligne.quantite invalide" }, { status: 400 });
    }
    const options = (l.options && typeof l.options === "object" && !Array.isArray(l.options))
      ? (l.options as Record<string, string>)
      : undefined;
    lignesInput.push({
      platId: l.platId,
      quantite: Math.floor(l.quantite),
      options,
      note: typeof l.note === "string" ? l.note.slice(0, 200) : undefined,
    });
  }

  // 1. Tenant actif + module restaurant + configAcces (commandeSansCompte)
  const tenant = await prisma.tenant.findFirst({
    where: { id: b.tenantId, actif: true, vitrineActive: true },
    select: {
      id: true,
      modules: { where: { actif: true, nom: "restaurant" }, select: { nom: true } },
      configAcces: true,
    },
  });
  if (!tenant || tenant.modules.length === 0) {
    return NextResponse.json({ error: "Restaurant indisponible" }, { status: 404 });
  }
  const accesEff = getConfigEffective(tenant.configAcces);
  if (!clientId && !accesEff.commandeSansCompte) {
    return NextResponse.json({ error: "Connexion requise pour commander" }, { status: 401 });
  }

  // 2. Mode accepté
  const configDb = await prisma.configRestaurant.findUnique({
    where: { tenantId: tenant.id },
  });
  const config = getConfigRestaurantEffective(configDb);
  if (!config[MODE_TOGGLE[mode]]) {
    return NextResponse.json({ error: `Mode ${mode} non accepté` }, { status: 400 });
  }

  // 3. Restaurant ouvert (sinon DEC-219 : message + 400)
  if (!estOuvertMaintenant(config.horaires as HorairesRestaurant | null)) {
    return NextResponse.json(
      { error: "Restaurant fermé", messageOuverture: config.messageFerme },
      { status: 400 },
    );
  }

  // 4. Charger plats + options en une fois (1 query)
  const platIds = Array.from(new Set(lignesInput.map((l) => l.platId)));
  const plats = await prisma.plat.findMany({
    where: { id: { in: platIds }, tenantId: tenant.id },
    include: { options: true },
  });
  const platMap = new Map(plats.map((p) => [p.id, p]));
  for (const l of lignesInput) {
    const plat = platMap.get(l.platId);
    if (!plat) return NextResponse.json({ error: `Plat ${l.platId} introuvable` }, { status: 400 });
    if (!plat.disponible) return NextResponse.json({ error: `Plat "${plat.nom}" indisponible` }, { status: 400 });
    if (plat.modesDisponibles.length > 0 && !plat.modesDisponibles.includes(mode)) {
      return NextResponse.json(
        { error: `Plat "${plat.nom}" non disponible en mode ${mode}` },
        { status: 400 },
      );
    }
    // Options obligatoires : chaque OptionPlat avec obligatoire=true doit être renseignée
    for (const opt of plat.options) {
      if (opt.obligatoire) {
        const choisi = l.options?.[opt.id];
        if (!choisi || !opt.choix.includes(choisi)) {
          return NextResponse.json(
            { error: `Option "${opt.nom}" requise pour "${plat.nom}"` },
            { status: 400 },
          );
        }
      }
      // Choix non vide : doit être dans la liste des choix possibles
      const choisi = l.options?.[opt.id];
      if (choisi !== undefined && !opt.choix.includes(choisi)) {
        return NextResponse.json(
          { error: `Choix invalide pour "${opt.nom}"` },
          { status: 400 },
        );
      }
    }
  }

  // 5. Validation mode-specific + calcul total
  let total = 0;
  type LigneSnapshot = {
    platId: string;
    nom: string;
    prix: number;
    quantite: number;
    options: { optionId: string; nom: string; choix: string; prixSupp: number }[];
    note: string | null;
  };
  const snapshots: LigneSnapshot[] = [];
  for (const l of lignesInput) {
    const plat = platMap.get(l.platId)!;
    const optionsSnap: LigneSnapshot["options"] = [];
    let prixSuppLigne = 0;
    for (const opt of plat.options) {
      const choisi = l.options?.[opt.id];
      if (choisi !== undefined) {
        optionsSnap.push({ optionId: opt.id, nom: opt.nom, choix: choisi, prixSupp: opt.prixSupp });
        prixSuppLigne += opt.prixSupp;
      }
    }
    const prixUnitaire = plat.prix + prixSuppLigne;
    total += prixUnitaire * l.quantite;
    snapshots.push({
      platId: plat.id,
      nom: plat.nom,
      prix: prixUnitaire,
      quantite: l.quantite,
      options: optionsSnap,
      note: l.note ?? null,
    });
  }

  // Validation montant min livraison
  if (mode === "LIVRAISON" && config.montantMinLivraison > 0 && total < config.montantMinLivraison) {
    return NextResponse.json(
      { error: `Montant minimum de livraison : ${config.montantMinLivraison} FCFA` },
      { status: 400 },
    );
  }
  // Ajout frais de livraison
  const fraisLivraison = mode === "LIVRAISON" ? config.fraisLivraison : 0;
  total += fraisLivraison;

  // 6. Validation table (SUR_PLACE) : tableId optionnel mais doit appartenir au tenant si fourni
  let tableIdResolved: string | null = null;
  let numeroTableResolved: string | null = null;
  if (mode === "SUR_PLACE") {