mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r1.1): socle backend du référentiel — modèle, contrat, API, seed, tests
- migration r1_referentiel : Category (EQUIPMENT/COMPONENT_TYPE), Location (site → zone, lat/lng + colonne PostGIS générée geography(Point,4326) + index GIST), Asset (statut d'équipement), AssetComponent (organe sans emplacement PAR CONSTRUCTION), Team, invitation sur User ; migration autosuffisante (CREATE EXTENSION IF NOT EXISTS postgis) - contrat : 21 nouvelles opérations (26 total), générateur OpenAPI étendu aux paramètres de chemin ; spec + client web régénérés dans ce commit - API : modules categories/locations/assets/teams + gestion des personnes (liste, rôles, invitation lien 7 j à usage unique, activation publique qui connecte directement, mise à jour rôle/équipes) — tout sous @RequirePermission ; invariants en service (profondeur 2, kinds, catégorie jamais supprimée) - seed : parc de la maquette validée (5 sites + 8 zones, 8 appareils, organes A1/B2, 9 catégories, 2 équipes) — idempotent - 36 tests verts (couverture 96 % stmts / 85 % branches) : recette site→zone→appareil→organes, matrice vivante, invitation→activation ; smoke test sur build de prod - CI : postgres → postgis/postgis:18-3.6 (la migration R1 l'exige) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -131,6 +131,15 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
const passwordHash = await argon2.hash(
|
||||
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
||||
);
|
||||
await seedUsers(prisma, roleIds, passwordHash);
|
||||
await seedReferentiel(prisma);
|
||||
}
|
||||
|
||||
async function seedUsers(
|
||||
prisma: PrismaClient,
|
||||
roleIds: Map<RoleName, string>,
|
||||
passwordHash: string,
|
||||
): Promise<void> {
|
||||
for (const u of DEMO_USERS) {
|
||||
// Les comptes démo appartiennent au seed : nom et rôle sont réalignés
|
||||
// à chaque exécution (jamais le mot de passe d'un compte existant).
|
||||
@@ -148,6 +157,207 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ————— R1 — Référentiel (données des maquettes validées) —————
|
||||
|
||||
const EQUIPMENT_CATEGORIES = [
|
||||
'Ascenseur électrique',
|
||||
'Ascenseur hydraulique',
|
||||
'Monte-charge',
|
||||
'EPMR (plateforme PMR)',
|
||||
];
|
||||
const COMPONENT_TYPES = [
|
||||
'Portes cabine / palières',
|
||||
'Treuil / machinerie',
|
||||
'Parachute',
|
||||
'Armoire de commande',
|
||||
'Boutons & signalisation',
|
||||
];
|
||||
|
||||
/** site → zones ; positions réelles Casablanca/Mohammedia. */
|
||||
const SITES: {
|
||||
name: string;
|
||||
address: string;
|
||||
city: string;
|
||||
guardianName?: string;
|
||||
guardianPhone?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zones: string[];
|
||||
}[] = [
|
||||
{
|
||||
name: 'Tour Atlas', address: 'Bd de la Corniche, Aïn Diab', city: 'Casablanca',
|
||||
guardianName: 'Karim Doukkali', guardianPhone: '06 61 23 45 67',
|
||||
latitude: 33.6062, longitude: -7.6706,
|
||||
zones: ['Hall principal', 'Tour bureaux (étages 1-24)', 'Parking sous-sol', 'Résidence (aile est)'],
|
||||
},
|
||||
{
|
||||
name: 'Résidence Al Manar', address: 'Bd Hassan II', city: 'Mohammedia',
|
||||
guardianName: 'Hassan Alami',
|
||||
latitude: 33.6866, longitude: -7.383,
|
||||
zones: ['Hall principal'],
|
||||
},
|
||||
{
|
||||
name: 'Anfa Place', address: 'Bd de l’Océan Atlantique', city: 'Casablanca',
|
||||
latitude: 33.5883, longitude: -7.6822,
|
||||
zones: ['Galerie commerciale'],
|
||||
},
|
||||
{
|
||||
name: 'Clinique Yasmine', address: 'Rue Ibn Rochd', city: 'Casablanca',
|
||||
guardianName: 'Rachid Mansouri',
|
||||
latitude: 33.5731, longitude: -7.6316,
|
||||
zones: ['Bloc A'],
|
||||
},
|
||||
{
|
||||
name: 'Marina Center', address: 'Av. des FAR', city: 'Mohammedia',
|
||||
latitude: 33.7, longitude: -7.39,
|
||||
zones: ['Hall B'],
|
||||
},
|
||||
];
|
||||
|
||||
const ASSETS: {
|
||||
reference: string; brand: string; model: string; serialNumber?: string;
|
||||
commissionedAt?: string; loadKg?: number; floors?: number;
|
||||
category: string; site: string; zone: string;
|
||||
status?: 'IN_SERVICE' | 'OUT_OF_SERVICE' | 'UNDER_MAINTENANCE';
|
||||
components?: { type: string; designation?: string }[];
|
||||
}[] = [
|
||||
{
|
||||
reference: 'A1', brand: 'Otis', model: 'Gen2 Premier', serialNumber: 'OT-2020-4521',
|
||||
commissionedAt: '2020-03-15', loadKg: 630, floors: 8,
|
||||
category: 'Ascenseur électrique', site: 'Résidence Al Manar', zone: 'Hall principal',
|
||||
components: [
|
||||
{ type: 'Portes cabine / palières', designation: 'Fermator 40/10' },
|
||||
{ type: 'Treuil / machinerie', designation: 'Gen2 gearless' },
|
||||
{ type: 'Parachute' },
|
||||
{ type: 'Armoire de commande', designation: 'MCS 220' },
|
||||
],
|
||||
},
|
||||
{
|
||||
reference: 'A2', brand: 'Otis', model: 'Gen2', loadKg: 630, floors: 12,
|
||||
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Hall principal',
|
||||
},
|
||||
{
|
||||
reference: 'B1', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||
},
|
||||
{
|
||||
reference: 'B2', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||
status: 'OUT_OF_SERVICE',
|
||||
components: [
|
||||
{ type: 'Portes cabine / palières', designation: 'Sematic' },
|
||||
{ type: 'Armoire de commande' },
|
||||
],
|
||||
},
|
||||
{
|
||||
reference: 'M1', brand: 'Kone', model: 'TranSys', loadKg: 2000, floors: 3,
|
||||
category: 'Monte-charge', site: 'Tour Atlas', zone: 'Parking sous-sol',
|
||||
status: 'UNDER_MAINTENANCE',
|
||||
},
|
||||
{
|
||||
reference: 'C1', brand: 'Kone', model: 'MonoSpace 500', loadKg: 800, floors: 5,
|
||||
category: 'Ascenseur électrique', site: 'Anfa Place', zone: 'Galerie commerciale',
|
||||
},
|
||||
{
|
||||
reference: 'D1', brand: 'ThyssenKrupp', model: 'Evolution', loadKg: 1600, floors: 6,
|
||||
category: 'Ascenseur électrique', site: 'Clinique Yasmine', zone: 'Bloc A',
|
||||
},
|
||||
{
|
||||
reference: 'E2', brand: 'Otis', model: 'HydroFit', loadKg: 630, floors: 4,
|
||||
category: 'Ascenseur hydraulique', site: 'Marina Center', zone: 'Hall B',
|
||||
},
|
||||
];
|
||||
|
||||
const TEAMS: { name: string; description: string; memberEmails: string[] }[] = [
|
||||
{
|
||||
name: 'Casablanca Centre',
|
||||
description: 'Tour Atlas · Anfa Place · Clinique Yasmine',
|
||||
memberEmails: ['technicien@demo.siop.ma', 'technicien-limite@demo.siop.ma'],
|
||||
},
|
||||
{
|
||||
name: 'Mohammedia',
|
||||
description: 'Résidence Al Manar · Marina Center',
|
||||
memberEmails: [],
|
||||
},
|
||||
];
|
||||
|
||||
async function seedReferentiel(prisma: PrismaClient): Promise<void> {
|
||||
const categoryIds = new Map<string, string>();
|
||||
for (const [kind, names] of [
|
||||
['EQUIPMENT', EQUIPMENT_CATEGORIES],
|
||||
['COMPONENT_TYPE', COMPONENT_TYPES],
|
||||
] as const) {
|
||||
for (const name of names) {
|
||||
const category = await prisma.category.upsert({
|
||||
where: { kind_name: { kind, name } },
|
||||
update: {},
|
||||
create: { kind, name },
|
||||
});
|
||||
categoryIds.set(name, category.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Pas d'unicité en base sur (parent, nom) : idempotence par findFirst.
|
||||
const zoneIds = new Map<string, string>(); // « site / zone » → id
|
||||
for (const site of SITES) {
|
||||
const { zones, ...data } = site;
|
||||
let root = await prisma.location.findFirst({
|
||||
where: { name: site.name, parentId: null },
|
||||
});
|
||||
root ??= await prisma.location.create({ data });
|
||||
for (const zoneName of zones) {
|
||||
let zone = await prisma.location.findFirst({
|
||||
where: { name: zoneName, parentId: root.id },
|
||||
});
|
||||
zone ??= await prisma.location.create({
|
||||
data: { name: zoneName, parentId: root.id, city: site.city },
|
||||
});
|
||||
zoneIds.set(`${site.name} / ${zoneName}`, zone.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of ASSETS) {
|
||||
const created = await prisma.asset.upsert({
|
||||
where: { reference: asset.reference },
|
||||
update: {},
|
||||
create: {
|
||||
reference: asset.reference,
|
||||
brand: asset.brand,
|
||||
model: asset.model,
|
||||
serialNumber: asset.serialNumber,
|
||||
commissionedAt: asset.commissionedAt ? new Date(asset.commissionedAt) : undefined,
|
||||
loadKg: asset.loadKg,
|
||||
floors: asset.floors,
|
||||
status: asset.status ?? 'IN_SERVICE',
|
||||
categoryId: categoryIds.get(asset.category)!,
|
||||
locationId: zoneIds.get(`${asset.site} / ${asset.zone}`)!,
|
||||
},
|
||||
include: { _count: { select: { components: true } } },
|
||||
});
|
||||
if (asset.components?.length && created._count.components === 0) {
|
||||
await prisma.assetComponent.createMany({
|
||||
data: asset.components.map((c) => ({
|
||||
assetId: created.id,
|
||||
typeId: categoryIds.get(c.type)!,
|
||||
designation: c.designation,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const team of TEAMS) {
|
||||
await prisma.team.upsert({
|
||||
where: { name: team.name },
|
||||
update: {},
|
||||
create: {
|
||||
name: team.name,
|
||||
description: team.description,
|
||||
members: { connect: team.memberEmails.map((email) => ({ email })) },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start — wrapper CLI */
|
||||
if (require.main === module) {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
Reference in New Issue
Block a user