Files
siop2/apps/mobile/src/file/actions.ts
pr-daaif c8b3c1769a 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>
2026-07-17 09:50:02 +01:00

126 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { QueryClient } from '@tanstack/react-query';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TRANSITIONS,
type ChecklistState,
type ReportUpsert,
type WorkOrderDetail,
type WorkOrderStatus,
} from '@siop/shared';
import { enfiler, lireFile } from './store';
import { rejouer } from './synchro';
/** Chaque geste terrain = une saisie en file + un patch OPTIMISTE du cache
* (le technicien voit son travail tout de suite, même en mode avion — le
* cache persisté garde ce patch après redémarrage). En ligne, la file se
* vide dans la foulée : le comportement R4.2 est conservé. */
function patchDetail(
queryClient: QueryClient,
otId: string,
patch: (ot: WorkOrderDetail) => WorkOrderDetail,
): void {
queryClient.setQueryData<WorkOrderDetail>(['work-orders', otId], (courant) =>
courant ? patch(courant) : courant,
);
}
export function enfilerTransition(
queryClient: QueryClient,
ot: WorkOrderDetail,
to: WorkOrderStatus,
): void {
enfiler({
type: 'TRANSITION',
otId: ot.id,
otReference: ot.reference,
libelle:
to === 'DONE' ? 'Clôture de lintervention' : `Passage à « ${WORK_ORDER_STATUS_LABELS[to]} »`,
baseUpdatedAt: ot.updatedAt,
payload: { to },
});
patchDetail(queryClient, ot.id, (c) => ({
...c,
status: to,
allowedTransitions: WORK_ORDER_TRANSITIONS[to],
}));
void rejouer(queryClient);
}
export function enfilerCoche(
queryClient: QueryClient,
ot: WorkOrderDetail,
item: { id: string; label: string },
state: ChecklistState,
): void {
enfiler({
type: 'COCHE',
otId: ot.id,
otReference: ot.reference,
libelle: `Coche « ${item.label} » → ${state === 'DONE' ? 'fait' : state === 'NA' ? 'N/A' : 'à faire'}`,
baseUpdatedAt: ot.updatedAt,
payload: { itemId: item.id, state },
});
patchDetail(queryClient, ot.id, (c) => ({
...c,
checklist: c.checklist.map((i) => (i.id === item.id ? { ...i, state } : i)),
}));
void rejouer(queryClient);
}
export function enfilerBilan(
queryClient: QueryClient,
ot: WorkOrderDetail,
corps: Omit<ReportUpsert, 'baseUpdatedAt'>,
labels: Partial<Record<keyof ReportUpsert, { id: string; label: string } | null>>,
): void {
enfiler({
type: 'BILAN',
otId: ot.id,
otReference: ot.reference,
libelle: 'Bilan dintervention codé',
baseUpdatedAt: ot.updatedAt,
payload: corps,
});
patchDetail(queryClient, ot.id, (c) => ({
...c,
report: {
note: c.report?.note ?? null,
doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null),
cabinPosition:
labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null),
anomaly: labels.anomalyId !== undefined ? (labels.anomalyId ?? null) : (c.report?.anomaly ?? null),
externalCause:
labels.externalCauseId !== undefined ? (labels.externalCauseId ?? null) : (c.report?.externalCause ?? null),
actionTaken:
labels.actionTakenId !== undefined ? (labels.actionTakenId ?? null) : (c.report?.actionTaken ?? null),
componentConcerned:
labels.componentConcernedId !== undefined
? (labels.componentConcernedId ?? null)
: (c.report?.componentConcerned ?? null),
},
}));
void rejouer(queryClient);
}
export function enfilerPhoto(
queryClient: QueryClient,
ot: WorkOrderDetail,
fichier: { uri: string; nom: string; mime: string },
): void {
enfiler({
type: 'PHOTO',
otId: ot.id,
otReference: ot.reference,
libelle: `Photo — ${fichier.nom}`,
baseUpdatedAt: ot.updatedAt,
payload: fichier,
});
void rejouer(queryClient);
}
/** Ce que la file porte encore pour cet OT — chips « en file » des écrans. */
export function saisiesPour(otId: string) {
return lireFile().filter((s) => s.otId === otId);
}