mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
@@ -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 ?? 'quelqu’un';
|
||||
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() } });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user