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() } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,3 +139,109 @@ describe('Recherche globale & corrections de recette R3 (e2e)', () => {
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verrou optimiste D2 (e2e) — la version protège les saisies mobiles', () => {
|
||||
let app: INestApplication;
|
||||
let salma: string;
|
||||
let ahmed: string;
|
||||
let otId: string;
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
const suffix = `verrou-${Date.now().toString(36)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot()],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
const { body } = await http().get('/auth/demo-accounts');
|
||||
const login = async (roleName: string) => {
|
||||
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||
.accessToken as string;
|
||||
};
|
||||
salma = await login('Dispatcher');
|
||||
ahmed = await login('Technicien');
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
const technicien = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
|
||||
const cree = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({
|
||||
title: `Verrou E2E ${suffix}`,
|
||||
type: 'CORRECTIVE',
|
||||
assetId: a1.id,
|
||||
assigneeIds: [technicien.id],
|
||||
})
|
||||
.expect(201);
|
||||
otId = cree.body.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('version à jour : la transition passe ; le détail expose updatedAt', async () => {
|
||||
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||
expect(detail.body.updatedAt).toBeTruthy();
|
||||
await http()
|
||||
.post(`/work-orders/${otId}/transition`)
|
||||
.set(auth(ahmed))
|
||||
.send({ to: 'IN_PROGRESS', baseUpdatedAt: detail.body.updatedAt })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('OT modifié entre-temps (commentaire) : 409 « Conflit de version » contextualisé', async () => {
|
||||
const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||
const versionLue = detail.body.updatedAt as string;
|
||||
// Salma commente pendant qu'Ahmed est « hors-ligne » — la version avance
|
||||
await http()
|
||||
.post(`/work-orders/${otId}/comments`)
|
||||
.set(auth(salma))
|
||||
.send({ message: 'Réassigné après appel du syndic' })
|
||||
.expect(201);
|
||||
const refus = await http()
|
||||
.post(`/work-orders/${otId}/transition`)
|
||||
.set(auth(ahmed))
|
||||
.send({ to: 'ON_HOLD', baseUpdatedAt: versionLue })
|
||||
.expect(409);
|
||||
expect(refus.body.message).toContain('Conflit de version');
|
||||
expect(refus.body.message).toContain('Salma');
|
||||
// L'OT n'a PAS bougé — rien d'écrasé en silence
|
||||
const apres = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200);
|
||||
expect(apres.body.status).toBe('IN_PROGRESS');
|
||||
});
|
||||
|
||||
it('le bilan et la coche font avancer la version (sinon le verrou est aveugle)', async () => {
|
||||
const avant = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
|
||||
const { body: refs } = await http().get('/reference-values').set(auth(ahmed));
|
||||
const porte = refs.referenceValues.find(
|
||||
(v: { field: string; isActive: boolean }) => v.field === 'DOOR_STATE' && v.isActive,
|
||||
);
|
||||
await http()
|
||||
.put(`/work-orders/${otId}/report`)
|
||||
.set(auth(ahmed))
|
||||
.send({ doorStateId: porte.id })
|
||||
.expect(200);
|
||||
const apres = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt;
|
||||
expect(new Date(apres).getTime()).toBeGreaterThan(new Date(avant).getTime());
|
||||
});
|
||||
|
||||
it('sans baseUpdatedAt (web) : comportement inchangé, même après modification', async () => {
|
||||
await http()
|
||||
.post(`/work-orders/${otId}/comments`)
|
||||
.set(auth(salma))
|
||||
.send({ message: 'Nouvelle note' })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post(`/work-orders/${otId}/transition`)
|
||||
.set(auth(ahmed))
|
||||
.send({ to: 'ON_HOLD' })
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user