Services de la caisse v2

CaisseService

Rôle

Service principal qui gère l’état global de la caisse : statut, données POS, session et panier.

Localisation

projects/caisse-ui/src/app/service/caisse.service.ts

Héritage

Le service étend CaisseProperties qui définit les propriétés de base

Propriétés principales

  • status : Signal Angular contenant le statut actuel (CaisseStatus)
  • posData : Données du point de vente (territoire, entreprise, cinéma, POS, produits, séances)
  • session : Session de caisse courante
  • basket : Panier actif
  • env : Environnement complet (utilisateur, territoire, entreprise, cinéma)
  • logoUrl : URL du logo à afficher

Méthodes principales

start()

Démarrage complet de la caisse :

  • Récupération de l’environnement via EnvironmentService
  • Vérification de la connexion hardware
  • Souscription aux changements d’environnement
  • Appel de bootHostname() si le hardware est connecté

quickStart()

Démarrage rapide (utilisé après fermeture de session) :

  • Met le statut à BOOTING
  • Appelle bootHostname()

bootHostname()

Initialisation basée sur le hostname :

  • Récupération du hostname via HwProxyService
  • Chargement des données POS via PointOfSaleApiService.getPosData()
  • Chargement de la session si elle existe
  • Souscription aux notifications Kafka pour les mises à jour en temps réel
  • Détermination du statut selon l’état de la session

openSession(cashDrawerAmount: number)

Ouverture d’une nouvelle session :

  • Vérification du statut (READY_OPEN)
  • Appel de PointOfSaleApiService.open()
  • Mise à jour du panier et de la session
  • Passage au statut WORKING

continueSession()

Reprise d’une session existante :

  • Vérification du statut (READY_CONTINUE)
  • Utilisation du montant d’ouverture de la session existante
  • Appel de PointOfSaleApiService.open()
  • Mise à jour du panier
  • Passage au statut WORKING

Gestion des notifications Kafka

Le service s’abonne aux notifications Kafka pour :

  • Territoire (territory)
  • Entreprise (company)
  • Cinéma (cinema)
  • Point de vente (pos)

Les mises à jour sont appliquées automatiquement via fast-json-patch pour détecter les changements :

  private knotUpdateTerritory(notif: ItemTypeIdNotif<Territory>) {
    if (this.posData?.territory?.id !== notif.id) {
      return;
    }

    const oldItem = JSON.parse(JSON.stringify(this.posData.territory)) as Territory;
    const patch = compare(oldItem, notif.item);
    this.logger.debug(`caisseService: territory changed: ${patch}`);

    this.posData.territory = notif.item;
    this.posDataChange.emit(this.posData);
  }

BasketComposerService

Rôle

Service qui gère la composition, le calcul et la persistance du panier.

Localisation

projects/core-lib/src/lib/caisse/basketcomposer.service.ts

Responsabilités

  1. Gestion du panier :

    • Ajout/suppression de lignes (produits, séances)
    • Calcul des prix et taxes via PosMathService
    • Formatage pour l’affichage (DisplayBasket)
  2. Persistance :

    • File d’attente pour les opérations de persistance
    • Une seule opération à la fois
    • Remplacement automatique si nouvelle opération avant la fin de la précédente
  3. Initialisation :

    • Mode preview (pour tests) ou mode réel
    • Initialisation de PosMathService avec les dépendances nécessaires

Signaux Angular

  • isReady : Indique si le service est prêt à fonctionner
  • persisting : Indique si une opération de persistance est en cours
  • addingShow : Indique si une séance est en cours d’ajout

Méthodes principales

initAsReal(posData, session, user)

Initialisation en mode réel :

  • Configure le service avec les données réelles
  • Initialise PosMathService
  • Utilise le panier fourni ou en crée un nouveau
  • Marque le service comme prêt

initAsPreview(posData, session, user)

Initialisation en mode preview :

  • Même logique que initAsReal mais avec isPreview = true
  • La persistance est désactivée en mode preview

computeBasket(basket?)

Calcul du panier :

  • Appelle PosMathService.compute() pour calculer les prix et taxes
  • Applique les modifications via JSON Patch
  • Appelle persist() pour sauvegarder
  • Reconstruit l’affichage via rebuild()

persist(basket, finalize)

Persistance du panier :

  • Ignore en mode preview
  • Vérifie que le panier est valide (ID, statut OPEN)
  • Ajoute à la file d’attente (remplace l’entrée précédente)
  • Traite la file de manière séquentielle
  • Gère les erreurs et met à jour les signaux
  public persist(basket: PosBasket, finalize: boolean) {
    if (this.isPreview) {
      return;
    }
    
    // Don't persist if basket doesn't have a valid ID or status
    if (!basket || !basket.id || basket.status !== BasketStatus.OPEN) {
      this.logger.debug(`Skipping persist: basket not opened (id=${basket?.id}, status=${basket?.status})`);
      return;
    }
    
    if (this.persistHasFinalize) {
      throw new Error(`queue already contains a finalizing item`);
    }

    const clone = JSON.parse(JSON.stringify(basket)) as PosBasket;
    const entry: PersistQueueEntry = {
      basket: clone,
      finalize: finalize,
    };
    this.persistQueue.splice(0, this.persistQueue.length, entry);
    this.persistHasFinalize = entry.finalize;

    if (!this.persistProm) {
      this.persisting.set(true);
      this.persistHasFinalize = entry.finalize;
      this.persistProm = new Promise((resolve, reject) => {
        (async (): Promise<void> => {
          while (this.persistQueue.length > 0) {
            const itemEntry: PersistQueueEntry = this.persistQueue.splice(0, 1)[0];
            const params: PosBasketPersistParams = {
              pointOfSaleId: this.posData.pos.id,
              posSessionId: this.session.id,
              posBasketId: itemEntry.basket.id,
              item: itemEntry.basket,
              finalize: itemEntry.finalize,
            };
            const op = await this.basketService.persist(params);
            
            if (op.validationError && op.validationError.length > 0) {
              this.logger.error('Validation errors during persist:', op.validationError);
            }
            
            if (itemEntry.finalize) {
              // Si le backend a créé un nouveau panier (ID différent), on remplace le panier actuel
              if (op.basket.id !== basket.id) {
                this.logger.info(`Backend created new basket during finalization: ${basket.id} -> ${op.basket.id}`);
                this.basket = op.basket;
              } else {
                // Même ID, mais le panier a pu être modifié par le backend
                this.basket = op.basket;
              }
            }
          }
        })().then(b => {
          this.persistProm = undefined;
          this.persistHasFinalize = false;
          this.persisting.set(false);
          resolve(b);
        }).catch((e: HttpErrorResponse) => {
          this.logger.error("persist loop error");
          this.logger.error(e);
          this.persistProm = undefined;
          this.persisting.set(false);
          reject(e);
        });
      });
    } else {
      this.logger.debug(`persist loop enqueued: ${this.persistQueue.length}`);
    }
  }

rebuild()

Reconstruction de l’affichage :

  • Crée un DisplayBasket à partir du panier
  • Regroupe les lignes identiques (même produit/séance)
  • Formate les données pour l’affichage

PosMathService

Rôle

Service de calcul mathématique pour les prix, taxes et totaux du panier.

Localisation

projects/core-lib/src/lib/posmath.service.ts

Responsabilités

  1. Calcul des prix :

    • Prix HT des lignes (produits, séances)
    • Application des taxes
    • Calcul des totaux HT/TTC
  2. Réductions :

    • Application des réductions avec cartes
    • Application des réductions avec prépayés
    • Réductions employés
  3. Gestion des arrondis :

    • Arrondis pour les paiements en espèce (XPF uniquement)

Méthode principale

compute(basket)

Calcul complet du panier :

  • Appelle computeBasketReal() pour les calculs
  • Retourne un patch JSON pour les modifications
  • Utilisé par BasketComposerService

Gestion des arrondis pour les paiements en espèce

Conditions d’activation

L’arrondi automatique des paiements en espèce est activé lorsque toutes les conditions suivantes sont réunies :

  • La devise locale du territoire est XPF (Franc Pacifique)
  • La précision décimale de la devise (currencyDecimalPrecision) est 0 (montants entiers uniquement)

Algorithme d’arrondi

L’arrondi est calculé en fonction du dernier chiffre du montant à payer. La logique applique un décalage (offset) selon la table suivante :

Dernier chiffre Offset appliqué
0 0
1 -1
2 -2
3 +2
4 +1
5 0
6 -1
7 -2
8 +2
9 +1

Exemples :

  • Montant à payer : 1231 XPF → Arrondi à 1230 XPF (dernier chiffre 1, offset -1)
  • Montant à payer : 1233 XPF → Arrondi à 1235 XPF (dernier chiffre 3, offset +2)

Implémentation

L’arrondi est calculé dans PayCashModalComponent lors de l’initialisation, mais les calculs du panier dans PosMathService utilisent le montant arrondi :

    for (const entry of paidEntries) {
      // For cash payments with rounding, entry.amount is the rounded amount (toPayReal)
      // cashRounding stores the difference but is NOT used in calculations
      // The backend uses entry.amount (rounded) for all calculations, not amount + cashRounding
      // totalPaid should be the sum of all amounts actually paid by the customer
      // For cash payments, entry.totalPaid is the amount given by the customer
      // For other payment methods, entry.totalPaid should equal entry.amount
      const amountActuallyPaid = entry.totalPaid !== undefined && entry.totalPaid !== 0 
        ? entry.totalPaid 
        : entry.amount;
      basket.payment.totalPaid += amountActuallyPaid;
      // changeReturned is calculated using entry.amount (rounded), matching backend behavior
      const change = entry.totalPaid !== undefined && entry.totalPaid !== 0
        ? entry.totalPaid - entry.amount
        : 0;
      basket.payment.changeReturned += change;
      this.logger.debug(`Payment method use: ${entry.paymentMethod}, amount: ${entry.amount}, cashRounding: ${entry.cashRounding}, totalPaid: ${amountActuallyPaid}, change: ${change}`);
    }

Comportement important : Le champ cashRounding n’est pas utilisé dans les calculs. Seul le montant arrondi (entry.amount) est pris en compte pour tous les calculs du panier, conformément au comportement du backend.

Le reste à payer (totalRemaining) est calculé en soustrayant la somme des entry.amount (montants arrondis) du total TTC réel :

    // totalRemaining calculation: backend uses totalWithTaxReal - sum(entry.amount)
    // This matches backend behavior even though it seems counterintuitive
    // The backend doesn't use totalPaid for this calculation, only entry.amount
    let totalAmountFromEntries = 0;
    for (const entry of paidEntries) {
      totalAmountFromEntries += entry.amount;
    }
    totalAmountFromEntries = OdooMath.round(totalAmountFromEntries, currencyRounding);
    
    let totalRemaining = OdooMath.round(basket.payment.totalWithTaxReal - totalAmountFromEntries, currencyRounding);
    basket.payment.totalRemaining = this.clearAmountNegate(totalRemaining);

Notes :

  • La méthode applyCashRounding() existe également dans PosMathService mais n’est pas utilisée dans le flux principal de paiement en espèce
  • L’arrondi est appliqué dans PayCashModalComponent lors de l’affichage du modal de paiement
  • Le backend utilise également uniquement entry.amount (montant arrondi) pour tous ses calculs

FinalizeBasketService

Rôle

Service qui gère la finalisation du panier : validation, persistance finale, génération et impression des tickets.

Localisation

projects/core-lib/src/lib/services/finalize-basket.service.ts

Flux de finalisation

  public finalize(): Observable<OperationResponseWithBasket> {
    return new Observable<OperationResponseWithBasket>(observer => {
      (async () => {
        const basket = this.basketComposer.displayBasket._src;

        if (basket.askBeeperNumber && (!basket.beeperNumber || basket.beeperNumber === '')) {
          this.logger.info('Beeper number required but not set, showing beeper modal...');
          try {
            const result = await BeeperNumberModalComponent.show(this.modalService, basket.beeperNumber);
            if (!result.isOk) {
              this.logger.info('Beeper number input cancelled');
              observer.complete();
              return;
            }
            basket.beeperNumber = result.beeperNumber!;
            this.logger.info(`Beeper number set to: ${basket.beeperNumber}`);
            await this.basketComposer.computeBasket(basket);
          } catch (error) {
            this.logger.error('Error in beeper number modal:', error);
            observer.complete();
            return;
          }
        }

        basket.finalizeStatus = BasketFinalizeStatus.DONE;

        let modalComponent: FinalizingModalComponent | null = null;
        try {
          this.logger.info('Starting basket finalization...');
          
          // Recompute basket to ensure all calculations are up to date before finalization
          await this.basketComposer.computeBasket(basket);
          const updatedBasket = this.basketComposer.displayBasket._src;
          
          modalComponent = await FinalizingModalComponent.show(this.modalService);
          
          const params: PosBasketPersistParams = {
            pointOfSaleId: updatedBasket.posId,
            posSessionId: updatedBasket.sessionId,
            posBasketId: updatedBasket.id,
            item: updatedBasket,
            finalize: true
          };

          this.logger.info('Calling persist API with params:', params);
          this.logger.debug('Basket payment details:', updatedBasket.payment.paymentDetails);
          this.logger.debug('Basket totalWithTaxReal:', updatedBasket.payment.totalWithTaxReal);
          this.logger.debug('Basket totalPaid:', updatedBasket.payment.totalPaid);
          this.logger.debug('Basket totalRemaining:', updatedBasket.payment.totalRemaining);
          const response = await this.basketService.persist(params);
          this.logger.info('Persist API response received:', response);
          
          if (response?.validationError) {
            this.logger.error('Finalization validation error:', response.validationError);
            const errorMsg = response.validationError.map(v => v.message).join(', ');
            modalComponent.setError(errorMsg);
            observer.complete();
            return;
          }

          const posMath = this.basketComposer.getPosMath();
          const session = this.basketComposer.getSession();
          const user = this.basketComposer.getUser();
          
          const printables = await posMath.getPrintables(session, user, updatedBasket);
          this.logger.info(`Got ${printables.length} printables to print`);
          
          if (printables.length > 0) {
            modalComponent.setSaving(false);
            modalComponent.setPrinting(true);
            
            try {
              const printImages = await this.convertPrintablesToImages(printables);
              await modalComponent.print(printImages);
            } catch (printError) {
              this.logger.error('Error printing tickets, continuing with finalization:', printError);
              alert('Imprimante indisponible. La vente a été enregistrée mais les tickets n\'ont pas pu être imprimés.');
            }
          }
          
          await this.basketComposer.replaceBasket(response.basket);
          
          modalComponent.close();
          
          this.saleDoneSubject.next();
          observer.next(response);
          observer.complete();
        } catch (error: any) {
          this.logger.error('Error during basket finalization:', error);
          if (error?.error) {
            this.logger.error('Backend error details:', error.error);
          }
          if (error?.status === 400) {
            this.logger.error('Bad Request (400) - Invalid data sent to backend');
          }
          
          // Show error in the modal if it's still open
          if (modalComponent) {
            const errorMsg = error?.error?.validationError 
              ? error.error.validationError.map((v: any) => v.message).join(', ')
              : error?.message || 'Erreur lors de la finalisation du panier';
            modalComponent.setError(errorMsg);
          }
          
          observer.error(error);
        }
      })();
    });
  }

Étapes de finalisation

  1. Vérification du numéro de beeper (si requis)
  2. Recalcul du panier pour s’assurer que tous les calculs sont à jour
  3. Affichage du modal de finalisation
  4. Persistance finale via PosBasketApiService.persist() avec finalize: true
  5. Génération des tickets via PosMathService.getPrintables()
  6. Conversion en images pour l’impression (via html2canvas)
  7. Impression via le modal de finalisation
  8. Remplacement du panier avec la réponse du backend

Conversion des tickets en images

Les tickets HTML sont convertis en images PNG via html2canvas avant l’impression :

  private async convertPrintablesToImages(printables: Printable[]): Promise<PrintImageRequest[]> {
    const container = document.createElement('div');
    container.style.position = 'absolute';
    container.style.left = '-9999px';
    document.body.appendChild(container);

    try {
      const promises = printables.map((printable) => {
        return new Promise<PrintImageRequest>((resolve, reject) => {
          const iframe = document.createElement('iframe');
          iframe.addEventListener('load', () => {
            setTimeout(() => {
              try {
                const elem = iframe.contentWindow!.document.body;
                html2canvas(elem, { foreignObjectRendering: true }).then((canvas) => {
                  const scale = 0.75;
                  const scaledCanvas = document.createElement('canvas');
                  scaledCanvas.width = canvas.width * scale;
                  scaledCanvas.height = canvas.height * scale;
                  const ctx = scaledCanvas.getContext('2d')!;
                  ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, scaledCanvas.width, scaledCanvas.height);

                  const pngDataURL = scaledCanvas.toDataURL('image/png');
                  const pngBase64 = pngDataURL.split(',')[1];

                  resolve({
                    image: pngBase64,
                    align: 'center',
                    cut: 'full'
                  });
                }, reject);
              } catch (error) {
                reject(error);
              }
            }, 100);
          });
          iframe.addEventListener('error', reject);
          iframe.style.width = '100%';
          iframe.style.height = '100%';
          iframe.srcdoc = `<!DOCTYPE html><html><body>${printable}</body></html>`;
          container.appendChild(iframe);
        });
      });

      return await Promise.all(promises);
    } finally {
      document.body.removeChild(container);
    }
  }