<?php
namespace App\Service;
use App\Entity\Client;
use App\Entity\CommandeCaisse;
use App\Entity\Devis;
use App\Entity\DevisLine;
use App\Entity\Entreprise;
use App\Entity\LigneCommandeCaisse;
use App\Entity\Produit;
use App\Entity\Salarie;
use App\Entity\TableRestaurant;
use App\Entity\User;
use App\Repository\CommandeCaisseRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Cœur de la caisse : commande en cours du poste, calcul des totaux (source
* unique pour l'écran, le ticket et l'enregistrement), mise en attente,
* tables de restaurant, bon de cuisine et encaissement.
*
* La commande en cours est en base (CommandeCaisse) ; la session ne retient
* que son id. Un poste peut donc reprendre une commande commencée sur un autre
* (tables, commandes en attente), et rien n'est perdu à la déconnexion.
*/
class CaisseService
{
public const SESSION_COMMANDE = 'caisse_commande_id';
/** Commande d'origine à rouvrir après l'encaissement d'une addition partagée. */
public const SESSION_RETOUR = 'caisse_commande_retour_id';
public const MODES_PAIEMENT = [
'espece' => 'Espèces',
'carte' => 'Carte bancaire',
'cheque' => 'Chèque',
'nonPaye' => 'Non payé',
];
private $em;
private $entrepriseResolver;
private $requestStack;
private $numerotation;
private $stockService;
private $commandeRepository;
public function __construct(
EntityManagerInterface $em,
EntrepriseResolver $entrepriseResolver,
RequestStack $requestStack,
NumerotationService $numerotation,
StockService $stockService,
CommandeCaisseRepository $commandeRepository
) {
$this->em = $em;
$this->entrepriseResolver = $entrepriseResolver;
$this->requestStack = $requestStack;
$this->numerotation = $numerotation;
$this->stockService = $stockService;
$this->commandeRepository = $commandeRepository;
}
// ------------------------------------------------------------------
// Commande en cours du poste
// ------------------------------------------------------------------
public function commandeCourante(bool $creer = false): ?CommandeCaisse
{
$session = $this->requestStack->getSession();
$id = $session->get(self::SESSION_COMMANDE);
$commande = $id ? $this->commandeRepository->find($id) : null;
$owner = $this->entrepriseResolver->getOwnerUser();
if ($commande && (!$commande->isModifiable() || !$owner || $commande->getUser()->getId() !== $owner->getId())) {
$commande = null;
}
if (!$commande) {
$session->remove(self::SESSION_COMMANDE);
if ($creer) {
$commande = $this->nouvelleCommande();
}
}
return $commande;
}
public function nouvelleCommande(?TableRestaurant $table = null): CommandeCaisse
{
$entreprise = $this->entrepriseResolver->getCurrentEntreprise();
$commande = new CommandeCaisse();
$commande->setUser($this->entrepriseResolver->getOwnerUser());
$commande->setEntreprise($entreprise);
$commande->setCreePar($this->entrepriseResolver->getCurrentPrincipal());
$commande->setTable($table);
if ($entreprise && $entreprise->isCaisseModeRestaurant()) {
$commande->setTypeCommande(CommandeCaisse::TYPE_SUR_PLACE);
}
$this->em->persist($commande);
$this->em->flush();
$this->definirCourante($commande);
return $commande;
}
/**
* Fait de cette commande celle du poste (reprise d'une commande en attente
* ou d'une table).
*/
public function definirCourante(CommandeCaisse $commande): void
{
if ($commande->getStatut() === CommandeCaisse::STATUT_EN_ATTENTE) {
$commande->setStatut(CommandeCaisse::STATUT_OUVERTE);
$this->em->flush();
}
$this->requestStack->getSession()->set(self::SESSION_COMMANDE, $commande->getId());
}
public function libererPoste(): void
{
$this->requestStack->getSession()->remove(self::SESSION_COMMANDE);
}
// ------------------------------------------------------------------
// Articles
// ------------------------------------------------------------------
public function ajouterProduit(CommandeCaisse $commande, Produit $produit, float $quantite = 1.0, ?string $note = null): LigneCommandeCaisse
{
$note = $note !== null && trim($note) !== '' ? trim($note) : null;
foreach ($commande->getLignes() as $ligne) {
if ($ligne->getProduit() && $ligne->getProduit()->getId() === $produit->getId() && $ligne->getNote() === $note) {
$ligne->setQuantite($ligne->getQuantite() + $quantite);
$this->em->flush();
return $ligne;
}
}
$ligne = new LigneCommandeCaisse();
$ligne->setProduit($produit);
$ligne->setQuantite($quantite);
$ligne->setNote($note);
$this->appliquerPrixProduit($ligne, $produit, $commande->getEntreprise());
$commande->addLigne($ligne);
$this->em->persist($ligne);
$this->em->flush();
return $ligne;
}
public function modifierQuantite(LigneCommandeCaisse $ligne, float $quantite): void
{
if ($quantite <= 0) {
$this->supprimerLigne($ligne);
return;
}
$ligne->setQuantite($quantite);
if ($ligne->getQuantiteEnvoyeeCuisine() > $quantite) {
$ligne->setQuantiteEnvoyeeCuisine($quantite);
}
$this->em->flush();
}
public function supprimerLigne(LigneCommandeCaisse $ligne): void
{
$ligne->getCommande()->removeLigne($ligne);
$this->em->remove($ligne);
$this->em->flush();
}
public function vider(CommandeCaisse $commande): void
{
foreach ($commande->getLignes()->toArray() as $ligne) {
$commande->removeLigne($ligne);
$this->em->remove($ligne);
}
$this->em->flush();
}
/**
* Recopie le prix, la TVA et la promo actuels des produits : l'écran et
* l'encaissement utilisent toujours le prix à jour.
*/
public function synchroniserPrix(CommandeCaisse $commande): void
{
$modifie = false;
foreach ($commande->getLignes() as $ligne) {
if ($ligne->getProduit()) {
$avant = [$ligne->getPrixUnitaireHt(), $ligne->getTauxTva(), $ligne->getRemiseProduit(), $ligne->getLibelle()];
$this->appliquerPrixProduit($ligne, $ligne->getProduit(), $commande->getEntreprise());
$modifie = $modifie || $avant !== [$ligne->getPrixUnitaireHt(), $ligne->getTauxTva(), $ligne->getRemiseProduit(), $ligne->getLibelle()];
}
}
if ($modifie) {
$this->em->flush();
}
}
private function appliquerPrixProduit(LigneCommandeCaisse $ligne, Produit $produit, ?Entreprise $entreprise): void
{
$tvaDefaut = $entreprise && $entreprise->getDefaultTvaRate() !== null ? (float) $entreprise->getDefaultTvaRate() : 19.0;
$ligne->setLibelle($produit->getDescription() ?: ($produit->getReference() ?: 'Article'));
$ligne->setPrixUnitaireHt((float) ($produit->getPrixVente() ?: $produit->getPuht() ?: 0));
$ligne->setTauxTva((float) ($produit->getTvaRate() ?: $tvaDefaut));
$ligne->setRemiseProduit((float) ($produit->getDiscountRate() ?: 0));
}
// ------------------------------------------------------------------
// Calcul
// ------------------------------------------------------------------
/**
* Totaux de la commande, avec les mêmes arrondis (3 décimales) que les
* bons de livraison : ce qui est affiché est exactement ce qui est encaissé.
*/
public function calculer(CommandeCaisse $commande, float $remiseGlobale = 0.0): array
{
$lignes = [];
$totalHT = 0.0;
$totalTva = 0.0;
$totalRemise = 0.0;
$detailsTva = [];
foreach ($commande->getLignes() as $ligne) {
$pu = round($ligne->getPrixUnitaireHt(), 3);
$taux = $ligne->getTauxRemise();
$puRemise = $taux > 0 ? round($pu * (1 - $taux / 100), 3) : $pu;
$q = $ligne->getQuantite();
$ht = round($puRemise * $q, 3);
$tauxTva = $ligne->getTauxTva();
$tva = round($ht * $tauxTva / 100, 3);
$totalRemise += round(($pu - $puRemise) * $q, 3);
$totalHT += $ht;
$totalTva += $tva;
$cle = (string) $tauxTva;
if (!isset($detailsTva[$cle])) {
$detailsTva[$cle] = ['taux' => $tauxTva, 'base' => 0.0, 'montant' => 0.0];
}
$detailsTva[$cle]['base'] += $ht;
$detailsTva[$cle]['montant'] += $tva;
$lignes[] = [
'ligne' => $ligne,
'prixUnitaire' => $pu,
'tauxRemise' => $taux,
'prixApresRemise' => $puRemise,
'totalHT' => $ht,
'tauxTva' => $tauxTva,
'montantTva' => $tva,
'totalTTC' => round($ht + $tva, 3),
];
}
$totalHT = round($totalHT, 3);
$totalTva = round($totalTva, 3);
$totalTTC = round($totalHT + $totalTva, 3);
$remiseGlobale = round(max(0.0, min($remiseGlobale, $totalTTC)), 3);
$entreprise = $commande->getEntreprise();
$timbre = ($entreprise && $entreprise->isDroitDeTimbre() && $lignes) ? 1.0 : 0.0;
return [
'lignes' => $lignes,
'totalHT' => $totalHT,
'totalTva' => $totalTva,
'detailsTva' => $detailsTva,
'totalRemise' => round($totalRemise, 3),
'totalTTC' => $totalTTC,
'remiseGlobale' => $remiseGlobale,
'timbre' => $timbre,
'totalAPayer' => round($totalTTC - $remiseGlobale + $timbre, 3),
];
}
// ------------------------------------------------------------------
// Attente / annulation
// ------------------------------------------------------------------
public function mettreEnAttente(CommandeCaisse $commande, ?string $libelle = null): void
{
if ($libelle !== null) {
$commande->setLibelle($libelle);
}
if (!$commande->getTable()) {
$commande->setStatut(CommandeCaisse::STATUT_EN_ATTENTE);
}
$this->em->flush();
$this->libererPoste();
}
public function annuler(CommandeCaisse $commande): void
{
$commande->setStatut(CommandeCaisse::STATUT_ANNULEE);
$commande->setTable(null);
$this->em->flush();
$this->libererSiCourante($commande);
}
private function libererSiCourante(CommandeCaisse $commande): void
{
$session = $this->requestStack->getSession();
if ((int) $session->get(self::SESSION_COMMANDE) === $commande->getId()) {
$session->remove(self::SESSION_COMMANDE);
}
}
// ------------------------------------------------------------------
// Restaurant
// ------------------------------------------------------------------
/**
* Ouvre la table (nouvelle commande) ou reprend sa commande en cours.
*/
public function ouvrirTable(TableRestaurant $table, ?int $couverts = null): CommandeCaisse
{
$commande = $this->commandeRepository->findOuverteParTable($table);
if (!$commande) {
$commande = $this->nouvelleCommande($table);
$commande->setNbCouverts($couverts);
$this->em->flush();
} else {
if ($couverts) {
$commande->setNbCouverts($couverts);
}
$this->definirCourante($commande);
}
return $commande;
}
/**
* Déplace la commande vers une autre table ; si cette table est déjà
* occupée, les deux commandes sont fusionnées. Renvoie la commande résultante.
*/
public function transfererVersTable(CommandeCaisse $commande, TableRestaurant $cible): CommandeCaisse
{
$existante = $this->commandeRepository->findOuverteParTable($cible);
if (!$existante || $existante->getId() === $commande->getId()) {
$commande->setTable($cible);
$commande->setTypeCommande(CommandeCaisse::TYPE_SUR_PLACE);
if ($commande->getStatut() === CommandeCaisse::STATUT_EN_ATTENTE) {
$commande->setStatut(CommandeCaisse::STATUT_OUVERTE);
}
$this->em->flush();
$this->definirCourante($commande);
return $commande;
}
foreach ($commande->getLignes()->toArray() as $ligne) {
$commande->removeLigne($ligne);
$existante->addLigne($ligne);
}
if ($commande->getNbCouverts()) {
$existante->setNbCouverts((int) $existante->getNbCouverts() + $commande->getNbCouverts());
}
if ($commande->getNote()) {
$existante->setNote(trim($existante->getNote() . "\n" . $commande->getNote()));
}
$this->libererSiCourante($commande);
$this->em->remove($commande);
$this->em->flush();
$this->definirCourante($existante);
return $existante;
}
/**
* Articles pas encore transmis à la cuisine ; les marque comme envoyés.
*
* @return array<array{libelle:string, quantite:float, note:?string}>
*/
public function envoyerEnCuisine(CommandeCaisse $commande): array
{
$bon = [];
foreach ($commande->getLignes() as $ligne) {
$aEnvoyer = $ligne->getQuantiteAEnvoyer();
if ($aEnvoyer <= 0) {
continue;
}
$bon[] = ['libelle' => $ligne->getLibelle(), 'quantite' => $aEnvoyer, 'note' => $ligne->getNote()];
$ligne->setQuantiteEnvoyeeCuisine($ligne->getQuantite());
}
$this->em->flush();
return $bon;
}
/**
* Addition partagée : sort les quantités choisies dans une nouvelle
* commande, à encaisser tout de suite. La commande d'origine garde le reste.
*
* @param array<int, float> $quantitesParLigne id de ligne => quantité à payer maintenant
*/
public function separer(CommandeCaisse $commande, array $quantitesParLigne): ?CommandeCaisse
{
$partie = new CommandeCaisse();
$partie->setUser($commande->getUser());
$partie->setEntreprise($commande->getEntreprise());
$partie->setCreePar($this->entrepriseResolver->getCurrentPrincipal());
$partie->setTypeCommande($commande->getTypeCommande());
$partie->setClient($commande->getClient());
$partie->setLibelle(sprintf('%s (partie)', $commande->getNomAffiche()));
foreach ($commande->getLignes()->toArray() as $ligne) {
$q = min((float) ($quantitesParLigne[$ligne->getId()] ?? 0), $ligne->getQuantite());
if ($q <= 0) {
continue;
}
$copie = new LigneCommandeCaisse();
$copie->setProduit($ligne->getProduit());
$copie->setLibelle($ligne->getLibelle());
$copie->setPrixUnitaireHt($ligne->getPrixUnitaireHt());
$copie->setTauxTva($ligne->getTauxTva());
$copie->setRemiseProduit($ligne->getRemiseProduit());
$copie->setRemiseManuelle($ligne->getRemiseManuelle());
$copie->setNote($ligne->getNote());
$copie->setQuantite($q);
$copie->setQuantiteEnvoyeeCuisine(min($q, $ligne->getQuantiteEnvoyeeCuisine()));
$partie->addLigne($copie);
$reste = $ligne->getQuantite() - $q;
if ($reste <= 0) {
$commande->removeLigne($ligne);
$this->em->remove($ligne);
} else {
$ligne->setQuantite($reste);
$ligne->setQuantiteEnvoyeeCuisine(max(0.0, $ligne->getQuantiteEnvoyeeCuisine() - $copie->getQuantiteEnvoyeeCuisine()));
}
}
if ($partie->isVide()) {
return null;
}
$this->em->persist($partie);
$this->em->flush();
$this->requestStack->getSession()->set(self::SESSION_RETOUR, $commande->getId());
$this->definirCourante($partie);
return $partie;
}
// ------------------------------------------------------------------
// Encaissement
// ------------------------------------------------------------------
/**
* Encaisse la commande : crée le bon de livraison (numéro unique), sort le
* stock et clôt la commande. Tout ou rien (transaction).
*
* @return array{devis: Devis, calcul: array, stock: array, client: Client}
*/
public function encaisser(CommandeCaisse $commande, string $modePaiement, float $montantRecu = 0.0, float $remiseGlobale = 0.0, ?Client $client = null): array
{
if (!$commande->isModifiable()) {
throw new \RuntimeException('Cette commande est déjà clôturée.');
}
if ($commande->isVide()) {
throw new \RuntimeException('Le panier est vide.');
}
if (!isset(self::MODES_PAIEMENT[$modePaiement])) {
throw new \RuntimeException('Mode de paiement inconnu.');
}
$owner = $commande->getUser();
$principal = $this->entrepriseResolver->getCurrentPrincipal();
$entreprise = $commande->getEntreprise() ?: $this->entrepriseResolver->getCurrentEntreprise();
$this->synchroniserPrix($commande);
$calcul = $this->calculer($commande, $remiseGlobale);
$client = $client ?: $commande->getClient() ?: $this->clientComptant($owner);
$maintenant = new \DateTime();
$resultat = $this->em->wrapInTransaction(function () use ($commande, $modePaiement, $montantRecu, $calcul, $client, $owner, $principal, $entreprise, $maintenant) {
$devis = new Devis();
$devis->setType('livraison');
$devis->setUser($owner);
$devis->setClient($client);
$devis->setEntreprise($entreprise);
if ($principal instanceof Salarie) {
$devis->setVendeurSalarie($principal);
} elseif ($principal instanceof User) {
$devis->setVendeurUser($principal);
}
$devis->setReference($this->numerotation->suivant('devis', 'reference', 'L-' . $maintenant->format('Y') . '-', $owner->getId()));
$devis->setDescription($this->descriptionVente($commande, $maintenant));
$devis->setDateCreation($maintenant);
$devis->setModePaiement($modePaiement);
$devis->setStatut($modePaiement === 'nonPaye' ? 'non payé' : 'payé');
foreach ($calcul['lignes'] as $l) {
/** @var LigneCommandeCaisse $ligne */
$ligne = $l['ligne'];
$devisLine = new DevisLine();
$devisLine->setProduit($ligne->getProduit());
$devisLine->setDescription($ligne->getNote() ? $ligne->getLibelle() . ' (' . $ligne->getNote() . ')' : $ligne->getLibelle());
$devisLine->setReference($ligne->getProduit() ? $ligne->getProduit()->getReference() : null);
$devisLine->setNb($ligne->getQuantite());
$devisLine->setPuht($l['prixUnitaire']);
$devisLine->setTvaRate($l['tauxTva']);
if ($l['tauxRemise'] > 0) {
$devisLine->setDiscountRate($l['tauxRemise']);
}
$devisLine->setPrixht($l['totalHT']);
$devisLine->setTtcAmount($l['totalTTC']);
$devis->addDevisLine($devisLine);
$this->em->persist($devisLine);
}
$devis->setTotalht($calcul['totalHT']);
$devis->setTotaltva($calcul['totalTva']);
$devis->setTotalremise($calcul['totalRemise']);
$devis->setGlobalDiscount($calcul['remiseGlobale'] > 0 ? $calcul['remiseGlobale'] : null);
if ($calcul['timbre'] > 0) {
$devis->setTimbre($calcul['timbre']);
}
$devis->setTotalttc($calcul['totalAPayer']);
if ($modePaiement === 'espece') {
$recu = round(max($montantRecu, 0), 3);
$devis->setMontantRecu($recu);
$devis->setMonnaieRendre(round(max(0, $recu - $calcul['totalAPayer']), 3));
}
$this->em->persist($devis);
$stock = $this->stockService->sortirVente($devis);
$commande->setDevis($devis);
$commande->setClient($client);
$commande->setStatut(CommandeCaisse::STATUT_PAYEE);
$commande->setTable(null);
$this->em->flush();
return ['devis' => $devis, 'stock' => $stock];
});
$this->libererSiCourante($commande);
$this->reprendreCommandeOrigine();
return ['devis' => $resultat['devis'], 'calcul' => $calcul, 'stock' => $resultat['stock'], 'client' => $client];
}
/**
* Après le paiement d'une partie d'addition, le poste revient sur la
* commande d'origine (s'il reste quelque chose à payer).
*/
private function reprendreCommandeOrigine(): void
{
$session = $this->requestStack->getSession();
$retourId = $session->get(self::SESSION_RETOUR);
if (!$retourId) {
return;
}
$session->remove(self::SESSION_RETOUR);
$origine = $this->commandeRepository->find($retourId);
if ($origine && $origine->isModifiable()) {
if ($origine->isVide() && !$origine->getTable()) {
$origine->setStatut(CommandeCaisse::STATUT_ANNULEE);
$this->em->flush();
return;
}
$this->definirCourante($origine);
}
}
private function descriptionVente(CommandeCaisse $commande, \DateTimeInterface $date): string
{
$description = 'Vente caisse du ' . $date->format('d/m/Y');
if ($commande->getTable()) {
$description .= ' - ' . $commande->getTable()->getLibelle();
} elseif ($commande->getTypeLibelle()) {
$description .= ' - ' . $commande->getTypeLibelle();
}
return $description;
}
public function clientComptant(User $owner): Client
{
$client = $this->em->getRepository(Client::class)->findOneBy(['user' => $owner, 'nom' => 'Client Comptant']);
if (!$client) {
$client = new Client();
$client->setNom('Client Comptant');
$client->setUser($owner);
$this->em->persist($client);
$this->em->flush();
}
return $client;
}
/**
* Données du ticket client imprimé après l'encaissement (format attendu
* par printTicket() dans la caisse).
*/
public function donneesTicket(array $encaissement, ?Entreprise $entreprise): array
{
/** @var Devis $devis */
$devis = $encaissement['devis'];
$calcul = $encaissement['calcul'];
$client = $encaissement['client'];
$items = [];
foreach ($calcul['lignes'] as $l) {
$ligne = $l['ligne'];
$items[] = [
'description' => $ligne->getLibelle() . ($ligne->getNote() ? ' (' . $ligne->getNote() . ')' : ''),
'quantite' => $ligne->getQuantite(),
'prixUnitaire' => $l['prixApresRemise'],
'total' => $l['totalHT'],
'tauxRemise' => $l['tauxRemise'],
'tauxTva' => $l['tauxTva'],
];
}
return [
'reference' => $devis->getReference(),
'date' => $devis->getDateCreation()->format('d/m/Y H:i'),
'items' => $items,
'totalHT' => $calcul['totalHT'],
'tva' => $calcul['totalTva'],
'totalTTC' => $calcul['totalAPayer'],
'montantRecu' => (float) $devis->getMontantRecu(),
'monnaieRendre' => (float) $devis->getMonnaieRendre(),
'modePaiement' => self::MODES_PAIEMENT[$devis->getModePaiement()] ?? $devis->getModePaiement(),
'nomEntreprise' => $entreprise ? $entreprise->getNomentreprise() : 'VOTRE ENTREPRISE',
'nomClient' => trim($client->getNom() . ' ' . $client->getPrenom()),
'detailsTva' => $calcul['detailsTva'],
'timbre' => $calcul['timbre'],
'totalRemise' => $calcul['totalRemise'],
'globalDiscount' => $calcul['remiseGlobale'] > 0 ? $calcul['remiseGlobale'] : null,
];
}
}