mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Trouvé en recette : GET /assets/options n'avait aucun filtre — tout Demandeur voyait le parc complet dans le sélecteur d'équipement de "Nouvelle demande", sur le web ET le mobile (même endpoint partagé). Risque réel : signaler accidentellement une panne sur un ascenseur qu'on ne gère pas. Corrigé à la racine, sur les deux plateformes à la fois : - Relation many-to-many User↔Location (assignedSites/assignedUsers, migration r6_demandeur_sites, même style que Team.members) — vide = aucune restriction, comportement historique inchangé pour tous les rôles sauf un Demandeur affecté à un site. - AssetsService.allowedLocationIds(user) : sites + zones filles autorisés, ou null si aucune restriction — réutilisée par options() ET par RequestsService.create (défense en profondeur : un assetId soumis directement hors périmètre est rejeté, 400). - UsersService : assertTopLevelSites (un Demandeur est affecté à un site, jamais une zone) ; invite()/update() branchent locationIds (remplace l'affectation, comme teamIds). - Web (personnes.tsx) : ModaleInvitation affiche les sites à cocher pour un rôle Demandeur ; colonne "Sites" éditable via une modale dédiée. - Mobile (formulaire-demande.tsx) : bouton "Scanner l'étiquette" en raccourci — résout uniquement contre les options déjà chargées (déjà filtrées), jamais de repli sur le parc complet qui annulerait la restriction. Aucun changement à useAssetOptions() : le filtrage serveur profite automatiquement au formulaire mobile. - Seed : Karim Doukkali (démo) rattaché à Tour Atlas. Bug trouvé en vérification avant tout commit : create() comparait allowed.includes(dto.assetId), mais allowed est une liste d'ids de sites/zones, pas d'ids d'appareils — aurait rejeté à tort tout signalement d'un Demandeur affecté, y compris dans son propre périmètre. Corrigé (comparaison sur asset.locationId) ; méthode renommée allowedAssetIds → allowedLocationIds pour que le nom dise ce qu'elle retourne. exploitation.e2e-spec.ts mis à jour (A1/C1 → A2/B1, dans le site de Karim — sinon rejetés par la nouvelle règle, comportement voulu). 79/80 tests API verts, le seul échec (documents-analytics, monthCost) est le flake calendaire déjà identifié cette session, sans rapport. Typecheck/tests/lint verts sur les 4 paquets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
800 lines
28 KiB
TypeScript
800 lines
28 KiB
TypeScript
/**
|
||
* Seed R0 — idempotent (upserts) : 7 rôles, matrice COMPLÈTE (une ligne par
|
||
* rôle × catégorie, invariant R0) et 7 comptes de démonstration (ADR-002).
|
||
* La matrice ci-dessous est le point de DÉPART pédagogique : elle vit en base
|
||
* et sera administrable (R1) — la modifier ici ne change pas une base déjà
|
||
* seedée (les lignes existantes ne sont pas écrasées, voir plus bas).
|
||
* Usage : pnpm seed (ou prisma db seed)
|
||
*/
|
||
import 'dotenv/config';
|
||
import { PrismaClient } from '@prisma/client';
|
||
import {
|
||
OBJECT_CATEGORIES,
|
||
ROLE_NAMES,
|
||
type ObjectCategory,
|
||
type RoleName,
|
||
} from '@siop/shared';
|
||
import * as argon2 from 'argon2';
|
||
|
||
type Grant = Partial<{
|
||
view: boolean;
|
||
viewOther: boolean;
|
||
create: boolean;
|
||
edit: boolean;
|
||
delete: boolean;
|
||
}>;
|
||
|
||
const FULL: Grant = { view: true, viewOther: true, create: true, edit: true, delete: true };
|
||
const READ: Grant = { view: true, viewOther: true };
|
||
|
||
/** Matrice de départ (rôle → catégorie → droits ; absent = tout à false). */
|
||
const MATRIX: Record<RoleName, Partial<Record<ObjectCategory, Grant>>> = {
|
||
Administrateur: Object.fromEntries(
|
||
OBJECT_CATEGORIES.map((c) => [c, FULL]),
|
||
) as Record<ObjectCategory, Grant>,
|
||
Gestionnaire: {
|
||
WORK_ORDERS: FULL,
|
||
REQUESTS: FULL,
|
||
ASSETS: FULL,
|
||
LOCATIONS: FULL,
|
||
METERS: FULL,
|
||
PARTS: FULL,
|
||
PURCHASE_ORDERS: FULL,
|
||
PEOPLE_TEAMS: { view: true, viewOther: true, create: true, edit: true },
|
||
ANALYTICS: READ,
|
||
SETTINGS: { view: true },
|
||
},
|
||
Dispatcher: {
|
||
WORK_ORDERS: { view: true, viewOther: true, create: true, edit: true },
|
||
REQUESTS: { view: true, viewOther: true, create: true, edit: true },
|
||
ASSETS: READ,
|
||
LOCATIONS: READ,
|
||
METERS: READ,
|
||
PARTS: { view: true },
|
||
PEOPLE_TEAMS: READ,
|
||
ANALYTICS: READ,
|
||
},
|
||
Technicien: {
|
||
WORK_ORDERS: { view: true, edit: true }, // ses OT uniquement (viewOther=false)
|
||
REQUESTS: { view: true },
|
||
ASSETS: READ,
|
||
LOCATIONS: READ,
|
||
METERS: { view: true, viewOther: true, create: true }, // relevés
|
||
PARTS: { view: true },
|
||
},
|
||
'Technicien limité': {
|
||
WORK_ORDERS: { view: true, edit: true }, // ses OT, sans consultation du parc
|
||
ASSETS: { view: true },
|
||
},
|
||
Demandeur: {
|
||
REQUESTS: { view: true, create: true }, // ses demandes uniquement
|
||
},
|
||
'Vue seule': {
|
||
WORK_ORDERS: READ,
|
||
REQUESTS: READ,
|
||
ASSETS: READ,
|
||
LOCATIONS: READ,
|
||
METERS: READ,
|
||
PARTS: READ,
|
||
PURCHASE_ORDERS: READ,
|
||
PEOPLE_TEAMS: READ,
|
||
ANALYTICS: READ,
|
||
},
|
||
};
|
||
|
||
/** 7 comptes démo — un par rôle. Les noms sont CEUX de la maquette validée
|
||
* (maquette-web.html) : la revue pixel compare écrans et maquettes. */
|
||
const DEMO_USERS: { email: string; displayName: string; role: RoleName }[] = [
|
||
{ email: 'admin@demo.siop.ma', displayName: 'Yasmine Alaoui', role: 'Administrateur' },
|
||
{ email: 'dispatcher@demo.siop.ma', displayName: 'Salma Idrissi', role: 'Dispatcher' },
|
||
{ email: 'technicien@demo.siop.ma', displayName: 'Ahmed Benali', role: 'Technicien' },
|
||
{ email: 'technicien-limite@demo.siop.ma', displayName: 'Youssef Tazi', role: 'Technicien limité' },
|
||
{ email: 'gestionnaire@demo.siop.ma', displayName: 'Nadia Berrada', role: 'Gestionnaire' },
|
||
{ email: 'demandeur@demo.siop.ma', displayName: 'Karim Doukkali', role: 'Demandeur' },
|
||
{ email: 'vue-seule@demo.siop.ma', displayName: 'Rachid Bennis', role: 'Vue seule' },
|
||
];
|
||
|
||
export async function seed(prisma: PrismaClient): Promise<void> {
|
||
const roleIds = new Map<RoleName, string>();
|
||
for (const name of ROLE_NAMES) {
|
||
const role = await prisma.role.upsert({
|
||
where: { name },
|
||
update: {},
|
||
create: { name },
|
||
});
|
||
roleIds.set(name, role.id);
|
||
}
|
||
|
||
// Matrice complète : une ligne par rôle × catégorie. Les lignes existantes
|
||
// ne sont PAS écrasées (la base est la source de vérité, pas ce fichier).
|
||
for (const name of ROLE_NAMES) {
|
||
const roleId = roleIds.get(name)!;
|
||
for (const category of OBJECT_CATEGORIES) {
|
||
const g = MATRIX[name][category] ?? {};
|
||
await prisma.permission.upsert({
|
||
where: { roleId_objectCategory: { roleId, objectCategory: category } },
|
||
update: {},
|
||
create: {
|
||
roleId,
|
||
objectCategory: category,
|
||
canView: g.view ?? false,
|
||
canViewOther: g.viewOther ?? false,
|
||
canCreate: g.create ?? false,
|
||
canEdit: g.edit ?? false,
|
||
canDelete: g.delete ?? false,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
// Mot de passe commun des comptes démo (la connexion classique reste testable).
|
||
const passwordHash = await argon2.hash(
|
||
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
||
);
|
||
await seedUsers(prisma, roleIds, passwordHash);
|
||
await seedReferentiel(prisma);
|
||
await seedExploitation(prisma);
|
||
await seedGestion(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).
|
||
await prisma.user.upsert({
|
||
where: { email: u.email },
|
||
update: { displayName: u.displayName, roleId: roleIds.get(u.role)! },
|
||
create: {
|
||
email: u.email,
|
||
displayName: u.displayName,
|
||
passwordHash,
|
||
roleId: roleIds.get(u.role)!,
|
||
isDemo: true,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
// ————— 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 })) },
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
// ————— R2 — Exploitation (référentiels du bilan, gabarits, données maquette) —————
|
||
|
||
const REFERENCE_VALUES: Record<string, string[]> = {
|
||
DOOR_STATE: [
|
||
'Fonctionnement normal',
|
||
'Porte bloquée ouverte',
|
||
'Porte bloquée fermée',
|
||
'Fermeture incomplète',
|
||
'Réouverture intempestive',
|
||
],
|
||
CABIN_POSITION: ['À niveau', 'Entre deux niveaux', 'Cuvette', 'Dernier niveau'],
|
||
ANOMALY: [
|
||
'Frottement mécanique',
|
||
'Défaut électrique',
|
||
'Usure normale',
|
||
'Choc / vandalisme',
|
||
'Aucune anomalie constatée',
|
||
],
|
||
EXTERNAL_CAUSE: ['Coupure électrique', 'Dégât des eaux', 'Mauvais usage', 'Aucune'],
|
||
ACTION_TAKEN: [
|
||
'Réglage',
|
||
'Remplacement de pièce',
|
||
'Nettoyage / graissage',
|
||
'Remise en service simple',
|
||
'Visite d’entretien',
|
||
'Attente de pièce',
|
||
],
|
||
COMPONENT_CONCERNED: [
|
||
'Portes',
|
||
'Guides',
|
||
'Treuil / machinerie',
|
||
'Armoire de commande',
|
||
'Boutons / signalisation',
|
||
'Parachute',
|
||
'Cabine',
|
||
],
|
||
};
|
||
|
||
/** Gabarits du préventif (maquette R2) — période calendaire en mois. */
|
||
const TASK_TEMPLATES: {
|
||
label: string;
|
||
periodMonths: number;
|
||
componentType?: string;
|
||
isRegulatory?: boolean;
|
||
}[] = [
|
||
{ label: 'Contrôle fermeture / verrouillage des portes', periodMonths: 1, componentType: 'Portes cabine / palières' },
|
||
{ label: 'Nettoyage cuvette et toit de cabine', periodMonths: 1 },
|
||
{ label: 'Contrôle boutons cabine & paliers', periodMonths: 1, componentType: 'Boutons & signalisation' },
|
||
{ label: 'Vérification éclairage de secours', periodMonths: 1 },
|
||
{ label: 'Contrôle niveau d’huile réducteur', periodMonths: 3, componentType: 'Treuil / machinerie' },
|
||
{ label: 'Vérification jeu des coulisseaux', periodMonths: 6 },
|
||
{ label: 'Contrôle câbles de traction (usure, tension)', periodMonths: 6, componentType: 'Treuil / machinerie' },
|
||
{ label: 'Essai du parachute', periodMonths: 12, componentType: 'Parachute', isRegulatory: true },
|
||
];
|
||
|
||
async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
||
const refIds = new Map<string, string>(); // « FIELD/label » → id
|
||
for (const [field, labels] of Object.entries(REFERENCE_VALUES)) {
|
||
for (const label of labels) {
|
||
const value = await prisma.referenceValue.upsert({
|
||
where: { field_label: { field: field as never, label } },
|
||
update: {},
|
||
create: { field: field as never, label },
|
||
});
|
||
refIds.set(`${field}/${label}`, value.id);
|
||
}
|
||
}
|
||
|
||
const componentTypes = new Map(
|
||
(await prisma.category.findMany({ where: { kind: 'COMPONENT_TYPE' } })).map((c) => [
|
||
c.name,
|
||
c.id,
|
||
]),
|
||
);
|
||
for (const t of TASK_TEMPLATES) {
|
||
await prisma.taskTemplate.upsert({
|
||
where: { label: t.label },
|
||
update: {},
|
||
create: {
|
||
label: t.label,
|
||
periodMonths: t.periodMonths,
|
||
isRegulatory: t.isRegulatory ?? false,
|
||
componentTypeId: t.componentType ? componentTypes.get(t.componentType) : undefined,
|
||
},
|
||
});
|
||
}
|
||
|
||
// Données de démonstration (rejouent la maquette : OT en cours, urgence,
|
||
// grille du mois, demandes à approuver). Idempotent par référence.
|
||
const parReference = async (ref: string) =>
|
||
(await prisma.asset.findUniqueOrThrow({ where: { reference: ref } })).id;
|
||
const parEmail = async (email: string) =>
|
||
(await prisma.user.findUniqueOrThrow({ where: { email } })).id;
|
||
|
||
const a1 = await parReference('A1');
|
||
const b2 = await parReference('B2');
|
||
const c1 = await parReference('C1');
|
||
const ahmed = await parEmail('technicien@demo.siop.ma');
|
||
const salma = await parEmail('dispatcher@demo.siop.ma');
|
||
const karim = await parEmail('demandeur@demo.siop.ma');
|
||
const annee = new Date().getFullYear();
|
||
|
||
// R6.6 — Karim (Demandeur) est rattaché à Tour Atlas (cohérent avec le
|
||
// guardianName déjà seedé pour ce site) : son signalement se limite à ce
|
||
// parc, ses demandes historiques sur d'autres sites restent visibles.
|
||
const tourAtlas = await prisma.location.findFirst({
|
||
where: { name: 'Tour Atlas', parentId: null },
|
||
});
|
||
if (tourAtlas) {
|
||
await prisma.user.update({
|
||
where: { id: karim },
|
||
data: { assignedSites: { connect: [{ id: tourAtlas.id }] } },
|
||
});
|
||
}
|
||
|
||
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
||
(t) => t.label,
|
||
);
|
||
|
||
const OTS: {
|
||
ref: string; title: string; type: 'CORRECTIVE' | 'PREVENTIVE' | 'WORKS';
|
||
status: 'OPEN' | 'IN_PROGRESS' | 'ON_HOLD' | 'DONE' | 'CANCELLED';
|
||
priority: 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'PERSON_TRAPPED';
|
||
assetId: string; assignees?: string[]; dueJours?: number;
|
||
checklist?: string[]; bilan?: Record<string, string>;
|
||
}[] = [
|
||
{
|
||
ref: `OT-${annee}-0342`, title: 'Personne bloquée en cabine', type: 'CORRECTIVE',
|
||
status: 'OPEN', priority: 'PERSON_TRAPPED', assetId: b2,
|
||
},
|
||
{
|
||
ref: `OT-${annee}-0341`, title: 'Bruit anormal en gaine', type: 'CORRECTIVE',
|
||
status: 'IN_PROGRESS', priority: 'HIGH', assetId: a1, assignees: [ahmed], dueJours: 2,
|
||
},
|
||
{
|
||
ref: `OT-${annee}-0338`, title: 'Grille du mois — juillet', type: 'PREVENTIVE',
|
||
status: 'IN_PROGRESS', priority: 'LOW', assetId: a1, assignees: [ahmed],
|
||
dueJours: 15, checklist: grilleLabels,
|
||
},
|
||
{
|
||
ref: `OT-${annee}-0332`, title: 'Réglage nivellement cabine', type: 'CORRECTIVE',
|
||
status: 'DONE', priority: 'LOW', assetId: a1,
|
||
bilan: {
|
||
doorStateId: refIds.get('DOOR_STATE/Fonctionnement normal')!,
|
||
actionTakenId: refIds.get('ACTION_TAKEN/Réglage')!,
|
||
componentConcernedId: refIds.get('COMPONENT_CONCERNED/Guides')!,
|
||
},
|
||
},
|
||
];
|
||
for (const ot of OTS) {
|
||
const existant = await prisma.workOrder.findUnique({ where: { reference: ot.ref } });
|
||
if (existant) continue;
|
||
await prisma.workOrder.create({
|
||
data: {
|
||
reference: ot.ref,
|
||
title: ot.title,
|
||
type: ot.type,
|
||
status: ot.status,
|
||
priority: ot.priority,
|
||
assetId: ot.assetId,
|
||
createdById: salma,
|
||
dueDate: ot.dueJours
|
||
? new Date(Date.now() + ot.dueJours * 24 * 3600 * 1000)
|
||
: undefined,
|
||
startedAt: ot.status === 'IN_PROGRESS' || ot.status === 'DONE' ? new Date() : undefined,
|
||
completedAt: ot.status === 'DONE' ? new Date() : undefined,
|
||
assignees: ot.assignees ? { connect: ot.assignees.map((id) => ({ id })) } : undefined,
|
||
events: { create: { kind: 'CREATED', message: 'OT créé (seed)', byId: salma } },
|
||
checklist: ot.checklist
|
||
? { create: ot.checklist.map((label) => ({ label })) }
|
||
: undefined,
|
||
report: ot.bilan ? { create: ot.bilan } : undefined,
|
||
},
|
||
});
|
||
}
|
||
|
||
const DEMANDES: {
|
||
ref: string; description: string; assetId: string; isPersonTrapped?: boolean;
|
||
status: 'RECEIVED' | 'APPROVED' | 'REJECTED';
|
||
rejectionReason?: string; otRef?: string;
|
||
}[] = [
|
||
{
|
||
ref: `DEM-${annee}-0111`, assetId: a1, status: 'RECEIVED',
|
||
description: 'La porte ne se ferme plus au 3ᵉ étage, il faut la retenir à la main.',
|
||
},
|
||
{ ref: `DEM-${annee}-0110`, assetId: c1, status: 'RECEIVED', description: 'Voyant étage éteint.' },
|
||
{
|
||
ref: `DEM-${annee}-0107`, assetId: a1, status: 'APPROVED',
|
||
description: 'Bruit anormal en gaine.', otRef: `OT-${annee}-0341`,
|
||
},
|
||
{
|
||
ref: `DEM-${annee}-0104`, assetId: b2, status: 'REJECTED',
|
||
description: 'Odeur de brûlé.', rejectionReason: 'Fausse alerte confirmée sur place par le gardien.',
|
||
},
|
||
];
|
||
for (const dem of DEMANDES) {
|
||
const existant = await prisma.request.findUnique({ where: { reference: dem.ref } });
|
||
if (existant) continue;
|
||
const workOrderId = dem.otRef
|
||
? (await prisma.workOrder.findUnique({ where: { reference: dem.otRef } }))?.id
|
||
: undefined;
|
||
await prisma.request.create({
|
||
data: {
|
||
reference: dem.ref,
|
||
description: dem.description,
|
||
isPersonTrapped: dem.isPersonTrapped ?? false,
|
||
status: dem.status,
|
||
rejectionReason: dem.rejectionReason,
|
||
assetId: dem.assetId,
|
||
requestedById: karim,
|
||
workOrderId,
|
||
},
|
||
});
|
||
}
|
||
|
||
// Compteurs de la maquette (A1)
|
||
for (const [kind, valeurs] of [
|
||
['RUNNING_HOURS', [12246, 12322, 12411]],
|
||
['STARTS', [1815400, 1831970]],
|
||
] as const) {
|
||
const meter = await prisma.meter.upsert({
|
||
where: { assetId_kind: { assetId: a1, kind } },
|
||
update: {},
|
||
create: { assetId: a1, kind },
|
||
include: { _count: { select: { readings: true } } },
|
||
});
|
||
if (meter._count.readings === 0) {
|
||
await prisma.meterReading.createMany({
|
||
data: valeurs.map((value) => ({ meterId: meter.id, value, readById: ahmed })),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// ————— R3 — Gestion (tiers, pièces, mouvements, BC, taux — données maquette) —————
|
||
|
||
const PARTNERS: {
|
||
name: string; kind: 'SUPPLIER' | 'CLIENT'; contactName?: string;
|
||
phone?: string; email?: string; city?: string;
|
||
}[] = [
|
||
{ name: 'Ascentech Maroc', kind: 'SUPPLIER', contactName: 'M. Berrini', phone: '05 22 34 56 78', city: 'Casablanca' },
|
||
{ name: 'SchindlerParts', kind: 'SUPPLIER', email: 'commandes@schindlerparts.ma' },
|
||
{ name: 'Lubmaroc', kind: 'SUPPLIER', phone: '05 22 11 22 33' },
|
||
{ name: 'Atlas Property Management', kind: 'CLIENT', contactName: 'Mme Zerhouni', phone: '06 61 98 76 54' },
|
||
{ name: 'Syndic Al Manar', kind: 'CLIENT', contactName: 'M. Alami (gardien référent)' },
|
||
];
|
||
|
||
const PARTS: {
|
||
reference: string; designation: string; threshold: number;
|
||
lastUnitPrice?: number; supplier?: string; compatible?: string;
|
||
}[] = [
|
||
{ reference: 'P-0019', designation: 'Cellule barrière porte (paire)', threshold: 4, lastUnitPrice: 640, supplier: 'SchindlerParts' },
|
||
{ reference: 'P-0031', designation: 'Bouton palier lumineux Ø22', threshold: 10, lastUnitPrice: 45, supplier: 'Ascentech Maroc' },
|
||
{ reference: 'P-0042', designation: 'Graisse guide (cartouche 400 g)', threshold: 8, lastUnitPrice: 85, supplier: 'Lubmaroc' },
|
||
{ reference: 'P-0087', designation: 'Coulisseau de guide 16 mm', threshold: 6, lastUnitPrice: 120, supplier: 'Ascentech Maroc' },
|
||
{ reference: 'P-0113', designation: 'Contact de porte NC-31', threshold: 5, lastUnitPrice: 85, supplier: 'Ascentech Maroc', compatible: 'Otis Gen2 · Schindler 3300' },
|
||
];
|
||
|
||
async function seedGestion(prisma: PrismaClient): Promise<void> {
|
||
const partnerIds = new Map<string, string>();
|
||
for (const p of PARTNERS) {
|
||
const partner = await prisma.partner.upsert({
|
||
where: { name: p.name },
|
||
update: {},
|
||
create: p,
|
||
});
|
||
partnerIds.set(p.name, partner.id);
|
||
}
|
||
|
||
// Rattachements de la maquette Tiers : les syndics gèrent leur site.
|
||
for (const [site, partner] of [
|
||
['Tour Atlas', 'Atlas Property Management'],
|
||
['Résidence Al Manar', 'Syndic Al Manar'],
|
||
] as const) {
|
||
await prisma.location.updateMany({
|
||
where: { name: site, parentId: null, partnerId: null },
|
||
data: { partnerId: partnerIds.get(partner) },
|
||
});
|
||
}
|
||
|
||
const partIds = new Map<string, string>();
|
||
for (const p of PARTS) {
|
||
const part = await prisma.part.upsert({
|
||
where: { reference: p.reference },
|
||
update: {},
|
||
create: {
|
||
reference: p.reference,
|
||
designation: p.designation,
|
||
threshold: p.threshold,
|
||
lastUnitPrice: p.lastUnitPrice,
|
||
compatible: p.compatible,
|
||
supplierId: p.supplier ? partnerIds.get(p.supplier) : undefined,
|
||
},
|
||
});
|
||
partIds.set(p.reference, part.id);
|
||
}
|
||
|
||
// Taux horaires courants (les saisies figent leur propre taux)
|
||
for (const [email, taux] of [
|
||
['technicien@demo.siop.ma', 120],
|
||
['technicien-limite@demo.siop.ma', 90],
|
||
] as const) {
|
||
await prisma.user.update({ where: { email }, data: { hourlyRate: taux } });
|
||
}
|
||
|
||
// Idempotence : si des mouvements existent déjà, l'histoire est en place
|
||
if ((await prisma.stockMovement.count()) > 0) return;
|
||
|
||
const annee = new Date().getFullYear();
|
||
const ahmed = await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } });
|
||
const nadia = await prisma.user.findUniqueOrThrow({ where: { email: 'gestionnaire@demo.siop.ma' } });
|
||
|
||
// BC reçus (historique) et BC en cours — comme la maquette
|
||
const bc21 = await prisma.purchaseOrder.create({
|
||
data: {
|
||
reference: `BC-${annee}-0021`,
|
||
status: 'RECEIVED',
|
||
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||
createdById: nadia.id,
|
||
sentAt: new Date(Date.now() - 40 * 86400e3),
|
||
receivedAt: new Date(Date.now() - 34 * 86400e3),
|
||
lines: {
|
||
create: [
|
||
{ partId: partIds.get('P-0113')!, quantity: 5, unitPrice: 85 },
|
||
{ partId: partIds.get('P-0087')!, quantity: 6, unitPrice: 120 },
|
||
{ partId: partIds.get('P-0031')!, quantity: 20, unitPrice: 45 },
|
||
],
|
||
},
|
||
},
|
||
include: { lines: true },
|
||
});
|
||
await prisma.purchaseOrder.create({
|
||
data: {
|
||
reference: `BC-${annee}-0024`,
|
||
status: 'SENT',
|
||
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||
createdById: nadia.id,
|
||
sentAt: new Date(),
|
||
lines: {
|
||
create: [
|
||
{ partId: partIds.get('P-0113')!, quantity: 10, unitPrice: 85 },
|
||
{ partId: partIds.get('P-0087')!, quantity: 2, unitPrice: 127.5 },
|
||
],
|
||
},
|
||
},
|
||
});
|
||
|
||
// Mouvements : réceptions du BC-0021 + entrées initiales + consommations
|
||
for (const line of bc21.lines) {
|
||
await prisma.stockMovement.create({
|
||
data: {
|
||
partId: line.partId,
|
||
kind: 'RECEIPT',
|
||
quantity: line.quantity,
|
||
unitPrice: line.unitPrice,
|
||
purchaseOrderId: bc21.id,
|
||
byId: nadia.id,
|
||
createdAt: new Date(Date.now() - 34 * 86400e3),
|
||
},
|
||
});
|
||
}
|
||
for (const [ref, qte] of [
|
||
['P-0042', 19],
|
||
['P-0019', 7],
|
||
['P-0031', 6],
|
||
] as const) {
|
||
await prisma.stockMovement.create({
|
||
data: {
|
||
partId: partIds.get(ref)!,
|
||
kind: 'ENTRY',
|
||
quantity: qte,
|
||
reason: 'Reprise de l’inventaire initial',
|
||
byId: nadia.id,
|
||
createdAt: new Date(Date.now() - 60 * 86400e3),
|
||
},
|
||
});
|
||
}
|
||
// P-0113 : consommations + ajustement → stock 2 (sous le seuil, maquette)
|
||
const ot341 = await prisma.workOrder.findUnique({
|
||
where: { reference: `OT-${annee}-0341` },
|
||
});
|
||
await prisma.stockMovement.createMany({
|
||
data: [
|
||
{ partId: partIds.get('P-0113')!, kind: 'ADJUSTMENT', quantity: -1, reason: 'Inventaire — pièce endommagée', byId: nadia.id },
|
||
{ partId: partIds.get('P-0113')!, kind: 'CONSUMPTION', quantity: -2, unitPrice: 85, byId: ahmed.id },
|
||
],
|
||
});
|
||
// P-0087 : consommation sur OT-0341 (carte maquette : 2 × 120 = 240 MAD)
|
||
if (ot341) {
|
||
await prisma.stockMovement.create({
|
||
data: {
|
||
partId: partIds.get('P-0087')!,
|
||
kind: 'CONSUMPTION',
|
||
quantity: -2,
|
||
unitPrice: 120,
|
||
workOrderId: ot341.id,
|
||
byId: ahmed.id,
|
||
},
|
||
});
|
||
await prisma.stockMovement.create({
|
||
data: {
|
||
partId: partIds.get('P-0042')!,
|
||
kind: 'CONSUMPTION',
|
||
quantity: -1,
|
||
unitPrice: 85,
|
||
workOrderId: ot341.id,
|
||
byId: ahmed.id,
|
||
},
|
||
});
|
||
// main-d'œuvre : 1 h 30 × 120 MAD/h = 180 (total maquette : 505 MAD)
|
||
await prisma.laborTime.create({
|
||
data: { workOrderId: ot341.id, userId: ahmed.id, minutes: 90, hourlyRate: 120 },
|
||
});
|
||
}
|
||
}
|
||
|
||
/* c8 ignore start — wrapper CLI */
|
||
if (require.main === module) {
|
||
const prisma = new PrismaClient();
|
||
seed(prisma)
|
||
.then(async () => {
|
||
const [roles, permissions, users] = await Promise.all([
|
||
prisma.role.count(),
|
||
prisma.permission.count(),
|
||
prisma.user.count({ where: { isDemo: true } }),
|
||
]);
|
||
console.log(
|
||
`Seed OK — ${roles} rôles, ${permissions} lignes de matrice, ${users} comptes démo.`,
|
||
);
|
||
})
|
||
.catch((e) => {
|
||
console.error(e);
|
||
process.exitCode = 1;
|
||
})
|
||
.finally(() => prisma.$disconnect());
|
||
}
|
||
/* c8 ignore stop */
|