Documentation développeur Caisses Caisse v2 Technique Services
November 26, 2025 at 2:48 AMServices 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 courantebasket: Panier actifenv: 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
-
Gestion du panier :
- Ajout/suppression de lignes (produits, séances)
- Calcul des prix et taxes via
PosMathService - Formatage pour l’affichage (
DisplayBasket)
-
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
-
Initialisation :
- Mode preview (pour tests) ou mode réel
- Initialisation de
PosMathServiceavec les dépendances nécessaires
Signaux Angular
isReady: Indique si le service est prêt à fonctionnerpersisting: Indique si une opération de persistance est en coursaddingShow: 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
initAsRealmais avecisPreview = 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
-
Calcul des prix :
- Prix HT des lignes (produits, séances)
- Application des taxes
- Calcul des totaux HT/TTC
-
Réductions :
- Application des réductions avec cartes
- Application des réductions avec prépayés
- Réductions employés
-
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 dansPosMathServicemais n’est pas utilisée dans le flux principal de paiement en espèce - L’arrondi est appliqué dans
PayCashModalComponentlors 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
- Vérification du numéro de beeper (si requis)
- Recalcul du panier pour s’assurer que tous les calculs sont à jour
- Affichage du modal de finalisation
- Persistance finale via
PosBasketApiService.persist()avecfinalize: true - Génération des tickets via
PosMathService.getPrintables() - Conversion en images pour l’impression (via
html2canvas) - Impression via le modal de finalisation
- 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);
}
}