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:
125
apps/mobile/src/file/actions.ts
Normal file
125
apps/mobile/src/file/actions.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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 l’intervention' : `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 d’intervention 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);
|
||||
}
|
||||
Reference in New Issue
Block a user