feat(r4.3): file d'écriture, verrou optimiste D2, Synchro & conflits

Serveur : updatedAt exposé au détail OT ; baseUpdatedAt optionnel sur
transition/coche/bilan → 409 « Conflit de version » contextualisé (qui,
quand) ; toute écriture secondaire (coche, bilan, commentaire, conso,
MO) fait avancer la version — sans version fournie, le web est
inchangé. ADR-003 : sécurité & protocole de routage mobile (qui vit où
sur l'appareil, purge complète à la déconnexion — correctif réel : la
file et le cache persisté survivaient au logout).

Mobile : file persistée AsyncStorage rejouée dans l'ordre — succès →
propagation de la version fraîche aux saisies restantes du même OT
(nos écritures ne se conflictent pas entre elles, un écart étranger
reste détecté) ; coupure → tout attend ; refus → CONFLIT, la file
s'arrête, l'humain tranche (voir l'OT / rejouer sur version à jour /
abandonner). Transitions, coches, bilan et photos (D5, compressées
~1600 px) passent par la file avec patch optimiste du cache ; écran
Synchro (badge tabbar ambre/rouge) ; préchargement parc + référentiels
(le bilan hors-ligne a ses vocabulaires).

Recette « mode avion » 13/13 en Expo web piloté : gestes hors-ligne →
3 en file → modification concurrente de Salma → conflit tranché →
serveur Terminé avec bilan. 17 tests jest-expo, 74 tests API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-17 09:50:02 +01:00
parent a22ea60f83
commit c8b3c1769a
26 changed files with 1218 additions and 84 deletions

View File

@@ -199,6 +199,7 @@ export class WorkOrdersService {
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const detail = await this.get(id, user);
await this.assertVersion(id, dto.baseUpdatedAt);
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
throw new ConflictException(
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]}${WORK_ORDER_STATUS_LABELS[dto.to]}`,
@@ -245,6 +246,7 @@ export class WorkOrdersService {
await this.prisma.workOrderEvent.create({
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
});
await this.toucher(id);
return this.get(id, user);
}
@@ -284,6 +286,7 @@ export class WorkOrdersService {
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
await this.get(id, user);
await this.assertVersion(id, dto.baseUpdatedAt);
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
const valeur = dto[colonne as keyof ReportUpsert];
@@ -310,6 +313,7 @@ export class WorkOrdersService {
update: donnees,
create: { workOrderId: id, ...donnees },
});
await this.toucher(id);
return this.get(id, user);
}
@@ -320,6 +324,7 @@ export class WorkOrdersService {
user: AuthenticatedUser,
): Promise<ChecklistItemDto> {
await this.get(id, user);
await this.assertVersion(id, dto.baseUpdatedAt);
const { count } = await this.prisma.checklistItem.updateMany({
where: { id: itemId, workOrderId: id },
data: {
@@ -329,6 +334,7 @@ export class WorkOrdersService {
},
});
if (count === 0) throw new NotFoundException('Tâche inconnue');
await this.toucher(id);
const item = await this.prisma.checklistItem.findUniqueOrThrow({
where: { id: itemId },
include: { doneBy: true },
@@ -368,6 +374,7 @@ export class WorkOrdersService {
byId: user.userId,
},
});
await this.toucher(id);
return this.get(id, user);
}
@@ -398,6 +405,7 @@ export class WorkOrdersService {
note: dto.note,
},
});
await this.toucher(id);
return this.get(id, user);
}
@@ -522,6 +530,43 @@ export class WorkOrdersService {
},
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
closureBlockers: blockers,
updatedAt: row.updatedAt.toISOString(),
};
}
/** Verrou optimiste (D2 mobile) : si l'appelant fournit la version lue et
* que l'OT a changé depuis, 409 avec le contexte (qui, quand) — rien
* n'est écrasé en silence. Sans version fournie (web), comportement
* inchangé. */
private async assertVersion(id: string, baseUpdatedAt?: string): Promise<void> {
if (!baseUpdatedAt) return;
const wo = await this.prisma.workOrder.findUniqueOrThrow({
where: { id },
select: {
updatedAt: true,
events: {
orderBy: { createdAt: 'desc' },
take: 1,
include: { by: true },
},
},
});
if (wo.updatedAt.getTime() > new Date(baseUpdatedAt).getTime()) {
const dernier = wo.events[0];
const qui = dernier?.by?.displayName ?? 'quelquun';
const quand = new Intl.DateTimeFormat('fr-FR', {
hour: '2-digit',
minute: '2-digit',
}).format(wo.updatedAt);
throw new ConflictException(
`Conflit de version : cet OT a été modifié par ${qui} à ${quand} — votre saisie n'a pas été appliquée.`,
);
}
}
/** Toute écriture « secondaire » (coche, bilan, commentaire, coûts) fait
* avancer la version de l'OT — sinon le verrou D2 ne verrait rien. */
private async toucher(id: string): Promise<void> {
await this.prisma.workOrder.update({ where: { id }, data: { updatedAt: new Date() } });
}
}