<?php
// src/Controller/PointVenteController.php
namespace App\Controller;
use App\Entity\Categorie;
use App\Entity\Client;
use App\Entity\CommandeCaisse;
use App\Entity\LigneCommandeCaisse;
use App\Entity\Produit;
use App\Entity\Salarie;
use App\Entity\TableRestaurant;
use App\Form\CategorieType;
use App\Form\ClientType;
use App\Form\ProduitCaisseType;
use App\Repository\CategorieRepository;
use App\Repository\ClientRepository;
use App\Repository\ClotureCaisseRepository;
use App\Repository\CommandeCaisseRepository;
use App\Repository\ProduitRepository;
use App\Service\CaisseService;
use App\Service\EntrepriseResolver;
use App\Service\FileUploader;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* Écran de caisse. Toute la logique métier (panier, calculs, attente,
* encaissement) est dans CaisseService ; ce contrôleur ne fait que
* l'interface. Les routes "point_vente_*" sont ouvertes aux salariés
* (SalarieAccessSubscriber), les droits fins sont vérifiés ici.
*
* @Route("/caisse")
*/
class PointVenteController extends AbstractController
{
private $entityManager;
private $entrepriseResolver;
private $caisse;
public function __construct(EntityManagerInterface $entityManager, EntrepriseResolver $entrepriseResolver, CaisseService $caisse)
{
$this->entityManager = $entityManager;
$this->entrepriseResolver = $entrepriseResolver;
$this->caisse = $caisse;
}
/**
* @Route("/", name="point_vente_index")
*/
public function index(CategorieRepository $categorieRepository, ProduitRepository $produitRepository, Request $request, ClientRepository $clientRepository, ClotureCaisseRepository $clotureCaisseRepository, CommandeCaisseRepository $commandeRepository): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('app_login');
}
$principal = $this->getUser();
$user = $this->entrepriseResolver->getOwnerUser();
$entreprise = $this->entrepriseResolver->getCurrentEntreprise();
$client = new Client();
$form = $this->createForm(ClientType::class, $client);
$commande = $this->caisse->commandeCourante();
if ($commande) {
$this->caisse->synchroniserPrix($commande);
}
$calcul = $commande ? $this->caisse->calculer($commande) : $this->caisse->calculer(new CommandeCaisse());
$searchTerm = $request->query->get('search');
$categorieId = $request->query->get('categorie');
return $this->render('caisse/point_vente/index.html.twig', [
'categories' => $categorieRepository->findWithProduitsByUser($user),
'produits' => $produitRepository->search($searchTerm, $categorieId, $user),
'topProduits' => $produitRepository->findBy(['topVente' => true, 'user' => $user], null, 5),
'commande' => $commande,
'panier' => $calcul['lignes'],
'totalPanier' => $calcul['totalHT'],
'totalTva' => $calcul['totalTva'],
'totalTTC' => $calcul['totalTTC'],
'detailsTva' => $calcul['detailsTva'],
'timbre' => $calcul['timbre'],
'searchTerm' => $searchTerm,
'selectedCategory' => $categorieId,
'clients' => $user ? $clientRepository->findByUser($user) : [],
'form' => $form->createView(),
'isSalarie' => $principal instanceof Salarie,
'sessionCaisse' => $entreprise ? $clotureCaisseRepository->findOuverte($entreprise) : null,
'canCloture' => $this->peutGerer(),
'canVoirRapports' => !($principal instanceof Salarie) || $principal->isAccesRapports(),
'canReparations' => $principal instanceof Salarie && $principal->isAccesReparations(),
'canEncaisser' => $this->peutEncaisser(),
'principalNom' => $principal instanceof Salarie
? $principal->getNomComplet()
: trim($principal->getFirstname() . ' ' . $principal->getLastname()),
'entreprise' => $entreprise,
'nbEnAttente' => $user ? $commandeRepository->countEnAttente($user) : 0,
'typesCommande' => CommandeCaisse::TYPES_RESTAURANT,
'tablesTransfert' => $commande && $commande->getTable() && $entreprise && $entreprise->isCaisseGestionTables()
? $this->entityManager->getRepository(TableRestaurant::class)->findBy(['user' => $user, 'active' => true], ['salle' => 'ASC', 'position' => 'ASC'])
: [],
]);
}
// ------------------------------------------------------------------
// Panier
// ------------------------------------------------------------------
/**
* @Route("/add-to-cart/{id}", name="point_vente_add_to_cart", methods={"POST"})
*/
public function addToCart(Produit $produit, Request $request): Response
{
$commande = $this->caisse->commandeCourante(true);
$ligne = $this->caisse->ajouterProduit($commande, $produit, 1.0, $request->request->get('note'));
$remise = $request->request->get('remise');
if ($remise !== null && $remise !== '') {
$ligne->setRemiseManuelle((float) $remise);
$this->entityManager->flush();
}
return $this->redirectToRoute('point_vente_index', $this->filtresCatalogue($request));
}
/**
* @Route("/scan-barcode", name="point_vente_scan_barcode", methods={"POST"})
*/
public function scanBarcode(Request $request, ProduitRepository $produitRepository): Response
{
if (!$this->isCsrfTokenValid('scan_barcode', $request->request->get('_token'))) {
return $this->redirectToRoute('point_vente_index');
}
$codeBarre = trim((string) $request->request->get('code_barre'));
$user = $this->entrepriseResolver->getOwnerUser();
if ($codeBarre === '' || !$user) {
return $this->redirectToRoute('point_vente_index');
}
$produit = $produitRepository->findOneByCodeBarreForUser($codeBarre, $user);
if (!$produit) {
// Pas de correspondance exacte : on retombe sur la recherche classique,
// qui filtre déjà aussi par code-barres (résultat partiel possible).
$this->addFlash('warning', 'Aucun produit avec ce code-barres : ' . $codeBarre);
return $this->redirectToRoute('point_vente_index', ['search' => $codeBarre]);
}
$this->caisse->ajouterProduit($this->caisse->commandeCourante(true), $produit);
$this->addFlash('success', 'Produit ajouté au panier : ' . $produit->getDescription());
return $this->redirectToRoute('point_vente_index');
}
/**
* @Route("/ligne/{id}/quantite", name="point_vente_update_cart", methods={"POST"})
*/
public function updateCart(LigneCommandeCaisse $ligne, Request $request): Response
{
if ($refus = $this->refusSiNonModifiable($ligne)) {
return $refus;
}
$quantite = (float) str_replace(',', '.', (string) $request->request->get('quantite', 1));
if ($quantite < $ligne->getQuantiteEnvoyeeCuisine() && !$this->peutGerer()) {
$this->addFlash('error', 'Cet article a déjà été envoyé en cuisine : seul un responsable peut le retirer.');
return $this->redirectToRoute('point_vente_index');
}
$this->caisse->modifierQuantite($ligne, $quantite);
return $this->redirectToRoute('point_vente_index');
}
/**
* @Route("/ligne/{id}/remise", name="point_vente_apply_discount", methods={"POST"})
*/
public function applyDiscount(LigneCommandeCaisse $ligne, Request $request): Response
{
if ($refus = $this->refusSiNonModifiable($ligne)) {
return $refus;
}
$remise = $request->request->get('remise');
$ligne->setRemiseManuelle($remise === null || $remise === '' ? null : (float) str_replace(',', '.', (string) $remise));
$this->entityManager->flush();
if ($request->isXmlHttpRequest()) {
return $this->json(['success' => true]);
}
return $this->redirectToRoute('point_vente_index');
}
/**
* @Route("/ligne/{id}/note", name="point_vente_ligne_note", methods={"POST"})
*/
public function noteLigne(LigneCommandeCaisse $ligne, Request $request): Response
{
if ($refus = $this->refusSiNonModifiable($ligne)) {
return $refus;
}
$ligne->setNote($request->request->get('note'));
$this->entityManager->flush();
return $this->redirectToRoute('point_vente_index');
}
/**
* @Route("/ligne/{id}/supprimer", name="point_vente_remove_from_cart", methods={"POST"})
*/
public function removeFromCart(LigneCommandeCaisse $ligne): Response
{
if ($refus = $this->refusSiNonModifiable($ligne)) {
return $refus;
}
if ($ligne->isEnvoyeeCuisine() && !$this->peutGerer()) {
$this->addFlash('error', 'Cet article a déjà été envoyé en cuisine : seul un responsable peut le retirer.');
return $this->redirectToRoute('point_vente_index');
}
$this->caisse->supprimerLigne($ligne);
return $this->redirectToRoute('point_vente_index');
}
/**
* @Route("/vider-panier", name="point_vente_vider_panier", methods={"POST"})
*/
public function viderPanier(Request $request): Response
{
$commande = $this->caisse->commandeCourante();
if ($commande) {
foreach ($commande->getLignes() as $ligne) {
if ($ligne->isEnvoyeeCuisine() && !$this->peutGerer()) {
$this->addFlash('error', 'Des articles ont déjà été envoyés en cuisine : seul un responsable peut vider cette commande.');
return $this->redirectToRoute('point_vente_index');
}
}
if ($commande->getTable()) {
// Une table vidée est libérée.
$this->caisse->annuler($commande);
} else {
$this->caisse->vider($commande);
}
}
if ($request->isXmlHttpRequest()) {
return $this->json(['success' => true, 'message' => 'Panier vidé avec succès']);
}
$this->addFlash('success', 'Le panier a été vidé');
return $this->redirectToRoute('point_vente_index');
}
/**
* Type de commande (sur place / à emporter / livraison), couverts, nom, note.
*
* @Route("/commande/infos", name="point_vente_commande_infos", methods={"POST"})
*/
public function infosCommande(Request $request): Response
{
$commande = $this->caisse->commandeCourante(true);
$type = $request->request->get('type_commande');
if ($type !== null && isset(CommandeCaisse::TYPES_RESTAURANT[$type]) && !$commande->getTable()) {
$commande->setTypeCommande($type);
}
if ($request->request->has('nb_couverts')) {
$commande->setNbCouverts((int) $request->request->get('nb_couverts'));
}
if ($request->request->has('libelle')) {
$commande->setLibelle($request->request->get('libelle'));
}
if ($request->request->has('note')) {
$commande->setNote(trim((string) $request->request->get('note')) ?: null);
}
$this->entityManager->flush();
return $this->redirectToRoute('point_vente_index');
}
// ------------------------------------------------------------------
// Commandes en attente
// ------------------------------------------------------------------
/**
* @Route("/commande/attente", name="point_vente_mettre_en_attente", methods={"POST"})
*/
public function mettreEnAttente(Request $request): Response
{
$commande = $this->caisse->commandeCourante();
if (!$commande || $commande->isVide()) {
$this->addFlash('warning', 'Le panier est vide : rien à mettre en attente.');
return $this->redirectToRoute('point_vente_index');
}
$this->caisse->mettreEnAttente($commande, $request->request->get('libelle'));
$this->addFlash('success', $commande->getTable()
? sprintf('Commande de la %s enregistrée.', $commande->getTable()->getLibelle())
: sprintf('« %s » mise en attente.', $commande->getNomAffiche()));
return $this->redirectToRoute($commande->getTable() ? 'point_vente_tables' : 'point_vente_index');
}
/**
* @Route("/commandes-en-attente", name="point_vente_en_attente", methods={"GET"})
*/
public function listeEnAttente(CommandeCaisseRepository $commandeRepository): Response
{
$owner = $this->entrepriseResolver->getOwnerUser();
$commandes = $owner ? $commandeRepository->findEnAttente($owner) : [];
$totaux = [];
foreach ($commandes as $c) {
$totaux[$c->getId()] = $this->caisse->calculer($c)['totalAPayer'];
}
return $this->render('caisse/point_vente/en_attente.html.twig', [
'commandes' => $commandes,
'totaux' => $totaux,
'commandeCourante' => $this->caisse->commandeCourante(),
'canGerer' => $this->peutGerer(),
]);
}
/**
* @Route("/commande/{id}/reprendre", name="point_vente_reprendre", methods={"POST"})
*/
public function reprendre(CommandeCaisse $commande, Request $request): Response
{
if (!$this->isCsrfTokenValid('commande' . $commande->getId(), $request->request->get('_token')) || !$commande->isModifiable()) {
$this->addFlash('error', 'Cette commande ne peut plus être reprise.');
return $this->redirectToRoute('point_vente_en_attente');
}
$courante = $this->caisse->commandeCourante();
if ($courante && $courante->getId() !== $commande->getId() && !$courante->isVide()) {
// Le panier du poste n'est jamais perdu : il part en attente.
$this->caisse->mettreEnAttente($courante);
$this->addFlash('info', sprintf('Le panier en cours a été mis en attente (« %s »).', $courante->getNomAffiche()));
}
$this->caisse->definirCourante($commande);
return $this->redirectToRoute('point_vente_index');
}
/**
* @Route("/commande/{id}/annuler", name="point_vente_annuler_commande", methods={"POST"})
*/
public function annulerCommande(CommandeCaisse $commande, Request $request): Response
{
if (!$this->isCsrfTokenValid('commande' . $commande->getId(), $request->request->get('_token')) || !$commande->isModifiable()) {
$this->addFlash('error', 'Cette commande ne peut pas être annulée.');
return $this->redirectToRoute('point_vente_en_attente');
}
if ($commande->hasArticlesEnvoyesCuisine() && !$this->peutGerer()) {
$this->addFlash('error', 'Commande déjà envoyée en cuisine : seul un responsable peut l\'annuler.');
return $this->redirectToRoute('point_vente_en_attente');
}
$this->caisse->annuler($commande);
$this->addFlash('success', sprintf('« %s » annulée.', $commande->getNomAffiche()));
return $this->redirectToRoute($commande->getTable() ? 'point_vente_tables' : 'point_vente_en_attente');
}
// ------------------------------------------------------------------
// Encaissement
// ------------------------------------------------------------------
/**
* Encaissement par formulaire (espèces, carte, chèque, non payé) : retour à la caisse.
*
* @Route("/valider-commande", name="point_vente_valider_commande", methods={"POST"})
*/
public function validerCommande(Request $request): Response
{
try {
$encaissement = $this->encaisserDepuisRequete($request);
} catch (\RuntimeException $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('point_vente_index');
}
$this->flashStock($encaissement['stock']);
$this->addFlash('success', 'Vente enregistrée (Réf : ' . $encaissement['devis']->getReference() . ')');
return $this->redirectToRoute('point_vente_index');
}
/**
* Encaissement avec impression du ticket (appel AJAX) : renvoie les données du ticket.
*
* @Route("/valider-commande-ticket", name="point_vente_valider_commande_ticket", methods={"POST"})
*/
public function validerCommandeAvecTicket(Request $request): JsonResponse
{
try {
$encaissement = $this->encaisserDepuisRequete($request);
} catch (\RuntimeException $e) {
return $this->json(['success' => false, 'message' => $e->getMessage()], 400);
}
$this->flashStock($encaissement['stock']);
return $this->json([
'success' => true,
'message' => 'Vente enregistrée (Réf : ' . $encaissement['devis']->getReference() . ')',
'ticketData' => $this->caisse->donneesTicket($encaissement, $this->entrepriseResolver->getCurrentEntreprise()),
]);
}
private function encaisserDepuisRequete(Request $request): array
{
if (!$this->peutEncaisser()) {
throw new \RuntimeException('Vous n\'avez pas le droit d\'encaisser : la commande doit être encaissée par un responsable.');
}
if (!$this->isCsrfTokenValid('validate_order', $request->request->get('_token'))) {
throw new \RuntimeException('Session expirée, rechargez la caisse.');
}
$entreprise = $this->entrepriseResolver->getCurrentEntreprise();
$sessionCaisse = $entreprise ? $this->entityManager->getRepository(\App\Entity\ClotureCaisse::class)->findOuverte($entreprise) : null;
if (!$sessionCaisse) {
throw new \RuntimeException('La caisse est fermée : ouvrez la caisse avant d\'encaisser.');
}
$commande = $this->caisse->commandeCourante();
if (!$commande || $commande->isVide()) {
throw new \RuntimeException('Le panier est vide.');
}
$client = null;
$clientId = $request->request->get('selectedClientId')
?: $request->request->get('selectedespeceClientId')
?: $request->request->get('selectedClientIdTicket');
if ($clientId) {
// Filtre multi-entreprise : un client d'une autre entreprise est introuvable.
$client = $this->entityManager->getRepository(Client::class)->find($clientId);
if (!$client) {
throw new \RuntimeException('Client sélectionné invalide.');
}
}
return $this->caisse->encaisser(
$commande,
(string) ($request->request->get('payment_method') ?: 'espece'),
(float) str_replace(',', '.', (string) $request->request->get('montant_recu', 0)),
(float) str_replace(',', '.', (string) $request->request->get('global_discount', 0)),
$client
);
}
private function flashStock(array $stock): void
{
if (!empty($stock['negatifs'])) {
$this->addFlash('warning', 'Stock négatif pour : ' . implode(', ', $stock['negatifs']) . '. Pensez à régulariser le stock.');
}
}
// ------------------------------------------------------------------
// Droits
// ------------------------------------------------------------------
/** Patron, ou salarié ayant le droit de clôture (responsable). */
private function peutGerer(): bool
{
$principal = $this->getUser();
return !($principal instanceof Salarie) || $principal->isAccesCloture();
}
private function peutEncaisser(): bool
{
$principal = $this->getUser();
return !($principal instanceof Salarie) || $principal->isAccesEncaissement();
}
private function refusSiNonModifiable(LigneCommandeCaisse $ligne): ?Response
{
if (!$ligne->getCommande()->isModifiable()) {
$this->addFlash('error', 'Cette commande est déjà clôturée.');
return $this->redirectToRoute('point_vente_index');
}
return null;
}
private function filtresCatalogue(Request $request): array
{
return array_filter([
'categorie' => $request->request->get('categorie'),
'search' => $request->request->get('search'),
]);
}
// ------------------------------------------------------------------
// Créations rapides depuis la caisse
// ------------------------------------------------------------------
/**
* @Route("/client/new/modal", name="client_new_modal", methods={"GET","POST"})
*/
public function newModal(Request $request): Response
{
$client = new Client();
$form = $this->createForm(ClientType::class, $client);
$form->handleRequest($request);
if ($form->isSubmitted()) {
if ($form->isValid()) {
$client->setUser($this->entrepriseResolver->getOwnerUser());
$this->entityManager->persist($client);
$this->entityManager->flush();
$this->addFlash('success', 'Client ajouté avec succès');
return $this->redirectToRoute('point_vente_index');
}
$this->addFlash('error', 'Veuillez corriger les erreurs dans le formulaire');
}
return $this->redirectToRoute('point_vente_index');
}
/**
* @Route("/new-from-caisse", name="app_produit_new_from_caisse", methods={"GET", "POST"})
*/
public function newFromCaisse(Request $request, FileUploader $fileUploader): Response
{
$produit = new Produit();
$form = $this->createForm(ProduitCaisseType::class, $produit);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$imageFile = $form->get('imageUrl')->getData();
if ($imageFile) {
try {
$produit->setImageUrl($fileUploader->upload($imageFile));
} catch (\Exception $e) {
$this->addFlash('error', 'Erreur lors de l\'upload de l\'image: ' . $e->getMessage());
return $this->redirectToRoute('point_vente_index');
}
}
$produit->setUser($this->entrepriseResolver->getOwnerUser());
$produit->setDateCommande(new \DateTime());
try {
$this->entityManager->persist($produit);
$this->entityManager->flush();
$this->addFlash('success', 'Produit ajouté avec succès');
return $this->redirectToRoute('point_vente_index');
} catch (\Exception $e) {
$this->addFlash('error', 'Erreur lors de l\'enregistrement: ' . $e->getMessage());
}
}
return $this->render('caisse/produit/_caisse_form.html.twig', [
'form' => $form->createView(),
]);
}
/**
* @Route("/categorie/new-from-caisse", name="app_categorie_new_from_caisse", methods={"GET", "POST"})
*/
public function newCategorieFromCaisse(Request $request): Response
{
$categorie = new Categorie();
$form = $this->createForm(CategorieType::class, $categorie);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Associer l'utilisateur propriétaire (patron), même si créé par un salarié
$categorie->setUser($this->entrepriseResolver->getOwnerUser());
try {
$this->entityManager->persist($categorie);
$this->entityManager->flush();
$this->addFlash('success', 'Catégorie ajoutée avec succès');
return $this->redirectToRoute('point_vente_index');
} catch (\Exception $e) {
$this->addFlash('error', 'Erreur lors de l\'enregistrement: ' . $e->getMessage());
}
}
return $this->render('caisse/categorie/_caisse_form.html.twig', [
'form' => $form->createView(),
]);
}
}