feat: auto populate machine structures and seed sample data

This commit is contained in:
Matthieu
2025-10-13 09:01:33 +02:00
parent b7682ac312
commit dc4a12440b
21 changed files with 2218 additions and 7267 deletions

View File

@@ -101,48 +101,35 @@ Site → Machine → Composant → Sous-composant → ...
- `PATCH /api/machines/:id` - Modifier une machine
- `DELETE /api/machines/:id` - Supprimer une machine
#### Payloads `componentSelections` / `pieceSelections`
#### Payloads `componentLinks` / `pieceLinks`
Lors de la création d'une machine à partir d'un type, il est possible de fournir des sélections de composants et de pièces qui viendront remplir les exigences définies dans le type de machine.
Lors de la création ou de la reconfiguration d'une machine, il faut transmettre les liens qui associent la machine aux composants et pièces existants en respectant les exigences du type de machine.
```json
{
"name": "Presse HP-2000",
"siteId": "<identifiant du site>",
"typeMachineId": "<identifiant du type de machine>",
"componentSelections": [
"componentLinks": [
{
"requirementId": "<id d'une TypeMachineComponentRequirement>",
"typeComposantId": "<optionnel : forcer un type spécifique>",
"definition": {
"composantId": "<id du composant existant>",
"parentLinkId": "<optionnel : id d'un MachineComponentLink parent>",
"overrides": {
"name": "Bloc moteur série X",
"reference": "COMP-001",
"prix": "12000.00",
"customFields": [
{
"name": "Puissance nominale",
"type": "text",
"required": true,
"value": "7 kW"
}
]
"prix": "12000.00"
}
}
],
"pieceSelections": [
"pieceLinks": [
{
"requirementId": "<id d'une TypeMachinePieceRequirement>",
"typePieceId": "<optionnel : forcer un type spécifique>",
"definition": {
"pieceId": "<id de la pièce existante>",
"parentLinkId": "<optionnel : id du MachineComponentLink parent>",
"overrides": {
"name": "Kit maintenance niveau 1",
"reference": "KIT-001",
"customFields": [
{
"name": "Référence fournisseur",
"type": "text",
"value": "STD-002"
}
]
"reference": "KIT-001"
}
}
]
@@ -152,10 +139,10 @@ Lors de la création d'une machine à partir d'un type, il est possible de fourn
Principales règles de validation :
- `requirementId` doit correspondre à une exigence déclarée dans le type de machine (composant ou pièce).
- Le nombre de sélections pour une exigence doit respecter `minCount` et `maxCount` (si défini). Les exigences marquées `required` imposent au moins une sélection.
- Si `allowNewModels` vaut `false`, la sélection doit réutiliser un composant ou une pièce existante et respecter strictement le type imposé par le requirement. Les squelettes définis sur les types sont instanciés automatiquement lors de la création.
- Les modèles sélectionnés doivent appartenir au type attendu (`typeComposantId` ou `typePieceId`) sous peine d'échec de la création.
- Les champs personnalisés du `definition.customFields` permettent de surcharger la valeur par défaut définie au niveau du type; la valeur est automatiquement injectée dans les `customFieldValues` de la machine, du composant ou de la pièce créée.
- Le nombre de liens fournis pour une exigence doit respecter `minCount` et `maxCount` (si défini). Les exigences marquées `required` imposent au moins un lien.
- Les composants et pièces liés doivent être compatibles avec le type attendu (`typeComposantId` ou `typePieceId`).
- `parentLinkId` permet de rattacher un composant ou une pièce à un lien parent déjà créé pour la même machine.
- Les `overrides` sont optionnels et permettent de surcharger le nom, la référence ou le prix affichés sur la machine sans modifier l'élément d'origine.
### Composants
- `GET /api/composants` - Liste tous les composants

43
package-lock.json generated
View File

@@ -243,7 +243,6 @@
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -2287,7 +2286,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -2456,7 +2454,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.5.tgz",
"integrity": "sha512-DQpWdr3ShO0BHWkHl3I4W/jR6R3pDtxyBlmrpTuZF+PXxQyBXNvsUne0Wyo6QHPEDi+pAz9XchBFoKbqOhcdTg==",
"license": "MIT",
"peer": true,
"dependencies": {
"file-type": "21.0.0",
"iterare": "1.2.1",
@@ -2504,7 +2501,6 @@
"integrity": "sha512-Qr25MEY9t8VsMETy7eXQ0cNXqu0lzuFrrTr+f+1G57ABCtV5Pogm7n9bF71OU2bnkDD32Bi4hQLeFR90cku3Tw==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@nuxt/opencollective": "0.4.1",
"fast-safe-stringify": "2.1.1",
@@ -2565,7 +2561,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.5.tgz",
"integrity": "sha512-OsoiUBY9Shs5IG3uvDIt9/IDfY5OlvWBESuB/K4Eun8xILw1EK5d5qMfC3d2sIJ+kA3l+kBR1d/RuzH7VprLIg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cors": "2.8.5",
"express": "5.1.0",
@@ -2949,7 +2944,6 @@
"integrity": "sha512-Q5FsI3Cw0fGMXhmsg7c08i4EmXCrcl+WnAxb6LYOLHw4JFFC3yzmx9LaXZ7QMbA+JZXbigU2TirI7RAfO0Qlnw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@xhmikosr/bin-wrapper": "^13.0.5",
@@ -3022,7 +3016,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.23"
@@ -3386,7 +3379,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@@ -3534,7 +3526,6 @@
"integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3676,7 +3667,6 @@
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.38.0",
"@typescript-eslint/types": "8.38.0",
@@ -4355,7 +4345,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4369,6 +4358,7 @@
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.13.0"
},
@@ -4405,7 +4395,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -4929,7 +4918,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -5184,7 +5172,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -5232,15 +5219,13 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz",
"integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/validator": "^13.11.8",
"libphonenumber-js": "^1.11.1",
@@ -6020,7 +6005,6 @@
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6082,7 +6066,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7707,7 +7690,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -9380,7 +9362,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@@ -9648,7 +9629,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9706,7 +9686,6 @@
"integrity": "sha512-pmV7NEqQej9WjizN6RSNIwf7Y+jeh9mY1JEX2WjGxJi4YZWexClhde1yz/FuvAM+cTwzchcMytu2m4I6wPkIzg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.12.0",
"@prisma/engines": "6.12.0"
@@ -9911,8 +9890,7 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/repeat-string": {
"version": "1.6.1",
@@ -10107,7 +10085,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -10961,7 +10938,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -11312,7 +11288,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -11473,7 +11448,6 @@
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.25.tgz",
"integrity": "sha512-fTKDFzWXKwAaBdEMU4k661seZewbNYET4r1J/z3Jwf+eAvlzMVpTLKAVcAzg75WwQk7GDmtsmkZ5MfkmXCiFWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@sqltools/formatter": "^1.2.5",
"ansis": "^3.17.0",
@@ -11686,7 +11660,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12033,6 +12006,7 @@
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
@@ -12051,6 +12025,7 @@
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
@@ -12064,6 +12039,7 @@
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@@ -12078,6 +12054,7 @@
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=4.0"
}
@@ -12087,7 +12064,8 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/webpack/node_modules/mime-db": {
"version": "1.52.0",
@@ -12095,6 +12073,7 @@
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -12105,6 +12084,7 @@
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -12118,6 +12098,7 @@
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",

View File

@@ -18,7 +18,8 @@
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"seed:demo": "ts-node --transpile-only scripts/seed-industrial-data.ts"
"seed:demo": "ts-node --transpile-only scripts/seed-industrial-data.ts",
"seed:sample": "ts-node --transpile-only scripts/seed-sample-data.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.1",

View File

@@ -0,0 +1,3 @@
-- Add JSON column to store instantiated structure selections on components
ALTER TABLE "composants"
ADD COLUMN IF NOT EXISTS "structure" JSONB;

View File

@@ -84,6 +84,7 @@ model Composant {
prix Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
structure Json?
typeComposantId String?
typeComposant ModelType? @relation("ModelTypeComponentAssignments", fields: [typeComposantId], references: [id])

View File

@@ -1,42 +0,0 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main () {
try {
console.log('Starting custom fields cleanup...')
const deletedValues = await prisma.customFieldValue.deleteMany({
where: {
customField: {
OR: [
{ typeComposantId: { not: null } },
{ typePieceId: { not: null } },
{ typeMachineId: { not: null } }
]
}
}
})
console.log(`Deleted ${deletedValues.count} custom field values linked to type-level definitions.`)
const deletedFields = await prisma.customField.deleteMany({
where: {
OR: [
{ typeComposantId: { not: null } },
{ typePieceId: { not: null } },
{ typeMachineId: { not: null } }
]
}
})
console.log(`Deleted ${deletedFields.count} custom field definitions linked to model types.`)
console.log('Cleanup complete.')
} catch (error) {
console.error('Cleanup failed:', error)
process.exitCode = 1
} finally {
await prisma.$disconnect()
}
}
main()

View File

@@ -1,593 +0,0 @@
import { Prisma, PrismaClient, ModelCategory } from '@prisma/client';
const prisma = new PrismaClient();
type CustomFieldInput = {
name: string;
type: 'text' | 'number' | 'select';
required?: boolean;
options?: readonly string[];
};
type ModelTypeSeed = {
code: string;
name: string;
description: string;
customFields: readonly CustomFieldInput[];
};
type ComponentRequirementSeed = {
typeCode: string;
label: string;
minCount: number;
maxCount?: number | null;
required?: boolean;
allowNewModels?: boolean;
};
type PieceRequirementSeed = {
typeCode: string;
label: string;
minCount: number;
maxCount?: number | null;
required?: boolean;
allowNewModels?: boolean;
};
const componentTypes: readonly ModelTypeSeed[] = [
{
code: 'drive-module',
name: 'Module d entrainement',
description: 'Sous-ensemble moteur et reducteur pour entrainements principaux.',
customFields: [
{ name: 'Puissance nominale (kW)', type: 'number', required: true },
{ name: 'Indice de protection', type: 'select', options: ['IP55', 'IP65', 'IP66'] },
],
},
{
code: 'sensor-array',
name: 'Chaine de capteurs',
description: 'Groupe de capteurs industriels (temperature, vibration, debit).',
customFields: [
{ name: 'Type principal', type: 'select', options: ['Temperature', 'Vibration', 'Debit'] },
{ name: 'Plage de mesure', type: 'text' },
],
},
{
code: 'control-cabinet',
name: 'Armoire de controle',
description: 'Armoire electrique avec automate, protection et distribution.',
customFields: [
{ name: 'Tension alimentation (V)', type: 'number' },
{ name: 'Nombre de departs', type: 'number' },
],
},
{
code: 'hydraulic-pack',
name: 'Groupe hydraulique',
description: 'Bloc hydraulique complet (pompes, accumulateurs, filtration).',
customFields: [
{ name: 'Pression nominale (bar)', type: 'number', required: true },
{ name: 'Debit nominal (L/min)', type: 'number' },
],
},
{
code: 'structure-frame',
name: 'Chassis structurel',
description: 'Structure porteuse ou chassis mecano-soude.',
customFields: [
{ name: 'Matiere', type: 'select', options: ['Acier', 'Inox', 'Aluminium'] },
{ name: 'Charge admissible (kg)', type: 'number' },
],
},
];
const pieceTypes: readonly ModelTypeSeed[] = [
{
code: 'belt-kit',
name: 'Kit courroie',
description: 'Courroie et accessoires pour entrainements.',
customFields: [
{ name: 'Type', type: 'select', options: ['Poly-V', 'Trapezoidale', 'Synchronisee'] },
{ name: 'Longueur (mm)', type: 'number' },
],
},
{
code: 'bearing-set',
name: 'Jeu de roulements',
description: 'Paire de roulements avec bagues et graisse.',
customFields: [
{ name: 'Diametre interieur (mm)', type: 'number', required: true },
{ name: 'Classe', type: 'select', options: ['P0', 'P6', 'P5'] },
],
},
{
code: 'filter-cartridge',
name: 'Cartouche filtrante',
description: 'Element filtrant pour fluides ou air.',
customFields: [
{ name: 'Grade de filtration (um)', type: 'number' },
{ name: 'Type de media', type: 'select', options: ['Cellulose', 'Synthetique', 'Inox'] },
],
},
{
code: 'sensor-probe',
name: 'Sonde de mesure',
description: 'Sonde ou capteur unitaire avec cable.',
customFields: [
{ name: 'Signal de sortie', type: 'select', options: ['4-20 mA', '0-10 V', 'PT100'] },
{ name: 'Indice IP', type: 'select', options: ['IP67', 'IP68'] },
],
},
{
code: 'maintenance-kit',
name: 'Kit maintenance',
description: 'Ensemble de pieces pour maintenance planifiee.',
customFields: [
{ name: 'Niveau de maintenance', type: 'select', options: ['Preventif', 'Correctif', 'Lourde'] },
{ name: 'Duree estimee (h)', type: 'number' },
],
},
];
const constructors = [
{ name: 'ElectroMec Industrie', email: 'contact@electromec.fr', phone: '+33 4 72 00 11 22' },
{ name: 'Hydraulic Systems Europe', email: 'sales@hydraulics-eu.com', phone: '+33 5 56 12 34 56' },
{ name: 'Automation Lyon', email: 'support@automation-lyon.fr', phone: '+33 4 37 50 60 70' },
{ name: 'ThermoTech Solutions', email: 'info@thermotech.eu', phone: '+33 1 44 55 66 77' },
{ name: 'BearingWorks', email: 'service@bearingworks.com', phone: '+33 3 88 90 12 45' },
] as const;
const machineCustomFields: readonly CustomFieldInput[] = [
{ name: 'Reference installation', type: 'text' },
{ name: 'Puissance installee (kW)', type: 'number' },
{ name: 'Zone critique', type: 'select', options: ['Zone A', 'Zone B', 'Zone C'] },
];
const componentRequirementSeeds: readonly ComponentRequirementSeed[] = [
{
typeCode: 'structure-frame',
label: 'Chassis principal',
minCount: 1,
maxCount: 1,
required: true,
allowNewModels: false,
},
{
typeCode: 'drive-module',
label: 'Module d entrainement principal',
minCount: 1,
maxCount: 1,
required: true,
allowNewModels: false,
},
{
typeCode: 'control-cabinet',
label: 'Armoire de controle',
minCount: 1,
maxCount: 1,
required: true,
allowNewModels: false,
},
{
typeCode: 'sensor-array',
label: 'Capteurs de surveillance',
minCount: 1,
maxCount: 3,
required: true,
allowNewModels: true,
},
{
typeCode: 'hydraulic-pack',
label: 'Groupe hydraulique auxiliaire',
minCount: 0,
maxCount: 1,
required: false,
allowNewModels: true,
},
];
const pieceRequirementSeeds: readonly PieceRequirementSeed[] = [
{
typeCode: 'belt-kit',
label: 'Kit courroie de rechange',
minCount: 1,
maxCount: 2,
required: true,
allowNewModels: true,
},
{
typeCode: 'bearing-set',
label: 'Roulements de secours',
minCount: 1,
maxCount: 2,
required: true,
allowNewModels: true,
},
{
typeCode: 'filter-cartridge',
label: 'Cartouches de filtration',
minCount: 0,
maxCount: 4,
required: false,
allowNewModels: true,
},
{
typeCode: 'maintenance-kit',
label: 'Kit maintenance planifiee',
minCount: 0,
maxCount: 1,
required: false,
allowNewModels: true,
},
{
typeCode: 'sensor-probe',
label: 'Sondes de rechange',
minCount: 1,
maxCount: 4,
required: true,
allowNewModels: true,
},
];
function mapCustomFields(fields: readonly CustomFieldInput[]) {
if (!fields.length) {
return undefined;
}
return {
create: fields.map((field) => ({
name: field.name,
type: field.type,
required: field.required ?? false,
options: field.options ? [...field.options] : [],
})),
} as const;
}
async function upsertComponentType(type: ModelTypeSeed) {
const customFields = mapCustomFields(type.customFields);
await prisma.modelType.upsert({
where: { code: type.code },
update: {
name: type.name,
description: type.description,
notes: type.description,
customFields: {
deleteMany: {},
...(customFields ?? {}),
},
},
create: {
code: type.code,
name: type.name,
description: type.description,
notes: type.description,
category: ModelCategory.COMPONENT,
...(customFields ? { customFields } : {}),
},
});
}
async function upsertPieceType(type: ModelTypeSeed) {
const customFields = mapCustomFields(type.customFields);
await prisma.modelType.upsert({
where: { code: type.code },
update: {
name: type.name,
description: type.description,
notes: type.description,
pieceCustomFields: {
deleteMany: {},
...(customFields ?? {}),
},
},
create: {
code: type.code,
name: type.name,
description: type.description,
notes: type.description,
category: ModelCategory.PIECE,
...(customFields ? { pieceCustomFields: customFields } : {}),
},
});
}
async function applyPieceSkeletons(pieceMap: Map<string, { id: string }>) {
type PieceSkeleton = {
customFields?: Array<{ name: string; value?: unknown; type?: string; required?: boolean; options?: unknown }>;
[key: string]: unknown;
};
const definitions: Record<string, PieceSkeleton> = {
'belt-kit': {
customFields: [
{ name: 'Type', value: 'Poly-V' },
{ name: 'Longueur (mm)', value: 1800 },
],
remplacementHeures: 1500,
stockageRecommande: 'Local sec et tempere',
},
'bearing-set': {
customFields: [
{ name: 'Diametre interieur (mm)', value: 45 },
{ name: 'Classe', value: 'P6' },
],
graisseRecommandee: 'Lithium NLGI2',
},
'filter-cartridge': {
customFields: [
{ name: 'Grade de filtration (um)', value: 10 },
{ name: 'Type de media', value: 'Synthetique' },
],
remplacementMensuel: true,
},
'sensor-probe': {
customFields: [
{ name: 'Signal de sortie', value: '4-20 mA' },
{ name: 'Indice IP', value: 'IP67' },
],
calibrationIntervalJours: 180,
},
'maintenance-kit': {
customFields: [
{ name: 'Niveau de maintenance', value: 'Preventif' },
{ name: 'Duree estimee (h)', value: 4 },
],
contenuStandard: ['Filtres', 'Joints', 'Visserie'],
},
};
for (const [code, structure] of Object.entries(definitions)) {
const record = pieceMap.get(code);
if (!record) {
continue;
}
await prisma.modelType.update({
where: { id: record.id },
data: {
pieceSkeleton: structure as Prisma.InputJsonValue,
},
});
}
}
async function applyComponentSkeletons(
componentMap: Map<string, { id: string }>,
pieceMap: Map<string, { id: string }>,
) {
const pieceRef = (code: string, role?: string) => {
const piece = pieceMap.get(code);
if (!piece) {
throw new Error(`Piece type ${code} requis pour le squelette`);
}
return {
typePieceId: piece.id,
...(role ? { role } : {}),
};
};
const componentRef = (code: string, alias?: string) => {
const component = componentMap.get(code);
if (!component) {
throw new Error(`Component type ${code} requis pour le squelette`);
}
return {
typeComposantId: component.id,
...(alias ? { alias } : {}),
};
};
type ComponentSkeleton = {
pieces: Array<{ typePieceId: string; role?: string }>;
customFields: Array<{ key: string; value: unknown }>;
subcomponents: Array<{ typeComposantId?: string; alias?: string; familyCode?: string; modelId?: string }>;
};
const definitions: Record<string, ComponentSkeleton> = {
'drive-module': {
pieces: [
pieceRef('belt-kit', 'Courroie principale'),
pieceRef('bearing-set', 'Roulements de sortie'),
],
customFields: [
{ key: 'Lubrification', value: 'Graissage centralise' },
{ key: 'ControleVibration', value: 'Capteurs integres' },
],
subcomponents: [componentRef('sensor-array', 'Capteurs integres')],
},
'sensor-array': {
pieces: [pieceRef('sensor-probe', 'Sonde principale')],
customFields: [
{ key: 'Calibration', value: 'A effectuer tous les 6 mois' },
{ key: 'NombreCapteursMax', value: 6 },
],
subcomponents: [],
},
'control-cabinet': {
pieces: [
pieceRef('maintenance-kit', 'Kit rechange armoire'),
pieceRef('sensor-probe', 'Sonde ambiance'),
],
customFields: [
{ key: 'ClassementLocal', value: 'Non ATEX' },
{ key: 'RefAutomate', value: 'PLC-STD-200' },
],
subcomponents: [],
},
'hydraulic-pack': {
pieces: [
pieceRef('filter-cartridge', 'Filtre hydraulique'),
pieceRef('maintenance-kit', 'Kit joints hydrauliques'),
],
customFields: [
{ key: 'ReservoirLitres', value: 120 },
{ key: 'TypeHuile', value: 'HLP46' },
],
subcomponents: [componentRef('sensor-array', 'Capteurs pression et debit')],
},
'structure-frame': {
pieces: [],
customFields: [
{ key: 'Revêtement', value: 'Peinture epoxy' },
{ key: 'PointsLevage', value: 4 },
],
subcomponents: [componentRef('sensor-array', 'Capteurs deformation')],
},
};
for (const [code, structure] of Object.entries(definitions)) {
const record = componentMap.get(code);
if (!record) {
continue;
}
await prisma.modelType.update({
where: { id: record.id },
data: {
componentSkeleton: structure as Prisma.InputJsonValue,
},
});
}
}
function buildComponentRequirements(
componentMap: Map<string, { id: string }>,
seeds: readonly ComponentRequirementSeed[],
) {
return seeds.map((seed) => {
const type = componentMap.get(seed.typeCode);
if (!type) {
throw new Error(`Type composant ${seed.typeCode} introuvable pour le requirement`);
}
return {
label: seed.label,
minCount: seed.minCount,
maxCount: seed.maxCount ?? null,
required: seed.required ?? true,
allowNewModels: seed.allowNewModels ?? true,
typeComposant: { connect: { id: type.id } },
};
});
}
function buildPieceRequirements(
pieceMap: Map<string, { id: string }>,
seeds: readonly PieceRequirementSeed[],
) {
return seeds.map((seed) => {
const type = pieceMap.get(seed.typeCode);
if (!type) {
throw new Error(`Type piece ${seed.typeCode} introuvable pour le requirement`);
}
return {
label: seed.label,
minCount: seed.minCount,
maxCount: seed.maxCount ?? null,
required: seed.required ?? true,
allowNewModels: seed.allowNewModels ?? true,
typePiece: { connect: { id: type.id } },
};
});
}
async function seedMachineTemplate(
componentMap: Map<string, { id: string }>,
pieceMap: Map<string, { id: string }>,
) {
const name = 'Cellule Modulaire Standard';
const description = 'Module generique compose d un chassis, d un entrainement, de capteurs et d une armoire de controle.';
const componentRequirements = buildComponentRequirements(componentMap, componentRequirementSeeds);
const pieceRequirements = buildPieceRequirements(pieceMap, pieceRequirementSeeds);
await prisma.typeMachine.upsert({
where: { name },
update: {
description,
category: 'Module',
maintenanceFrequency: 'Mensuelle',
customFields: {
deleteMany: {},
...(mapCustomFields(machineCustomFields) ?? {}),
},
componentRequirements: {
deleteMany: {},
create: componentRequirements,
},
pieceRequirements: {
deleteMany: {},
create: pieceRequirements,
},
},
create: {
name,
description,
category: 'Module',
maintenanceFrequency: 'Mensuelle',
...(mapCustomFields(machineCustomFields) ? { customFields: mapCustomFields(machineCustomFields)! } : {}),
componentRequirements: {
create: componentRequirements,
},
pieceRequirements: {
create: pieceRequirements,
},
},
});
}
async function main() {
console.log('Seeding component categories...');
for (const component of componentTypes) {
await upsertComponentType(component);
}
console.log('Seeding piece categories...');
for (const piece of pieceTypes) {
await upsertPieceType(piece);
}
const componentRecords = await prisma.modelType.findMany({
where: { code: { in: componentTypes.map((type) => type.code) } },
select: { id: true, code: true },
});
const pieceRecords = await prisma.modelType.findMany({
where: { code: { in: pieceTypes.map((type) => type.code) } },
select: { id: true, code: true },
});
const componentMap = new Map(componentRecords.map((record) => [record.code, { id: record.id }]));
const pieceMap = new Map(pieceRecords.map((record) => [record.code, { id: record.id }]));
console.log('Applying piece skeletons...');
await applyPieceSkeletons(pieceMap);
console.log('Applying component skeletons...');
await applyComponentSkeletons(componentMap, pieceMap);
console.log('Seeding constructors...');
for (const constructeur of constructors) {
await prisma.constructeur.upsert({
where: { name: constructeur.name },
update: {
email: constructeur.email,
phone: constructeur.phone,
},
create: constructeur,
});
}
console.log('Configuring machine template...');
await seedMachineTemplate(componentMap, pieceMap);
}
main()
.then(() => {
console.log('Seed completed.');
})
.catch((error) => {
console.error('Seed failed:', error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});

File diff suppressed because it is too large Load Diff

550
scripts/seed-sample-data.ts Normal file
View File

@@ -0,0 +1,550 @@
import { PrismaClient, Prisma } from '@prisma/client';
const prisma = new PrismaClient();
type CreatedFields = Record<string, string>;
async function deleteExistingData() {
await prisma.machineComponentLink.deleteMany();
await prisma.machinePieceLink.deleteMany();
await prisma.machine.deleteMany();
await prisma.customFieldValue.deleteMany();
await prisma.composant.deleteMany();
await prisma.piece.deleteMany();
await prisma.modelType.deleteMany({
where: {
code: {
in: [
'hydraulic-pump',
'hydraulic-reservoir',
'cooling-fan',
'cooling-module',
'structural-frame',
'hydraulic-power-unit',
],
},
},
});
}
async function createPieceType(
name: string,
code: string,
description: string,
fields: Array<{
name: string;
type: 'text' | 'number' | 'select' | 'boolean' | 'date';
required?: boolean;
options?: string[];
}>,
skeleton?: Record<string, unknown>,
) {
const type = await prisma.modelType.create({
data: {
name,
code,
category: 'PIECE',
description,
pieceSkeleton: skeleton
? (skeleton as Prisma.InputJsonValue)
: Prisma.JsonNull,
pieceCustomFields: {
create: fields.map((field) => ({
name: field.name,
type: field.type,
required: field.required ?? false,
options: field.options ?? [],
})),
},
},
});
const customFields = await prisma.customField.findMany({
where: { typePieceId: type.id },
});
const fieldMap: CreatedFields = {};
customFields.forEach((field) => {
fieldMap[field.name] = field.id;
});
return { type, fieldMap };
}
async function createComponentType(
name: string,
code: string,
description: string,
fields: Array<{
name: string;
type: 'text' | 'number' | 'select' | 'boolean' | 'date';
required?: boolean;
options?: string[];
}>,
skeleton?: Record<string, unknown>,
) {
const type = await prisma.modelType.create({
data: {
name,
code,
category: 'COMPONENT',
description,
componentSkeleton: skeleton
? (skeleton as Prisma.InputJsonValue)
: Prisma.JsonNull,
customFields: {
create: fields.map((field) => ({
name: field.name,
type: field.type,
required: field.required ?? false,
options: field.options ?? [],
})),
},
},
});
const customFields = await prisma.customField.findMany({
where: { typeComposantId: type.id },
});
const fieldMap: CreatedFields = {};
customFields.forEach((field) => {
fieldMap[field.name] = field.id;
});
return { type, fieldMap };
}
async function createPiece(options: {
name: string;
reference: string;
price: number;
constructeurId?: string | null;
typeId: string;
fieldValues: Record<string, string>;
}) {
const customFields = await prisma.customField.findMany({
where: { typePieceId: options.typeId },
});
const customFieldValues = Object.entries(options.fieldValues).map(
([fieldName, value]) => {
const target = customFields.find((field) => field.name === fieldName);
if (!target) {
throw new Error(
`Custom field "${fieldName}" introuvable pour le type de pièce ${options.typeId}`,
);
}
return {
value,
customFieldId: target.id,
};
},
);
return prisma.piece.create({
data: {
name: options.name,
reference: options.reference,
prix: new Prisma.Decimal(options.price),
typePieceId: options.typeId,
constructeurId: options.constructeurId ?? null,
customFieldValues: {
create: customFieldValues,
},
},
});
}
async function createComponent(options: {
name: string;
reference: string;
price: number;
constructeurId?: string | null;
typeId: string;
fieldValues: Record<string, string>;
structure?: Prisma.InputJsonValue;
}) {
const customFields = await prisma.customField.findMany({
where: { typeComposantId: options.typeId },
});
const customFieldValues = Object.entries(options.fieldValues).map(
([fieldName, value]) => {
const target = customFields.find((field) => field.name === fieldName);
if (!target) {
throw new Error(
`Custom field "${fieldName}" introuvable pour le type de composant ${options.typeId}`,
);
}
return {
value,
customFieldId: target.id,
};
},
);
return prisma.composant.create({
data: {
name: options.name,
reference: options.reference,
prix: new Prisma.Decimal(options.price),
typeComposantId: options.typeId,
constructeurId: options.constructeurId ?? null,
structure:
options.structure === undefined
? Prisma.JsonNull
: options.structure ?? Prisma.JsonNull,
customFieldValues: {
create: customFieldValues,
},
},
});
}
async function main() {
console.log('Nettoyage des données existantes…');
await deleteExistingData();
console.log('Création des types de pièces…');
const pumpPieceFields: {
name: string;
type: 'text' | 'number' | 'select' | 'boolean' | 'date';
required?: boolean;
options?: string[];
}[] = [
{ name: 'Pression nominale (bar)', type: 'number', required: true },
{ name: 'Débit nominal (L/min)', type: 'number', required: true },
{ name: 'Puissance (kW)', type: 'number', required: true },
{ name: 'Indice de protection', type: 'text' },
{ name: 'Vitesse max (rpm)', type: 'number' },
] as const;
const pumpPieceType = await createPieceType(
'Pompe hydraulique',
'hydraulic-pump',
'Pompes à pistons pour unités hydrauliques',
pumpPieceFields,
{
customFields: pumpPieceFields.map((field) => ({
name: field.name,
key: field.name,
type: field.type,
required: field.required ?? false,
})),
},
);
const reservoirPieceFields: {
name: string;
type: 'text' | 'number' | 'select' | 'boolean' | 'date';
required?: boolean;
options?: string[];
}[] = [
{ name: 'Pression nominale (bar)', type: 'number', required: true },
{ name: 'Débit nominal (L/min)', type: 'number', required: true },
{ name: 'Capacité (L)', type: 'number', required: true },
{ name: 'Type dhuile', type: 'text', required: true },
{ name: 'Filtration', type: 'text' },
] as const;
const reservoirPieceType = await createPieceType(
'Réservoir hydraulique',
'hydraulic-reservoir',
'Réservoirs haute capacité pour fluide hydraulique',
reservoirPieceFields,
{
customFields: reservoirPieceFields.map((field) => ({
name: field.name,
key: field.name,
type: field.type,
required: field.required ?? false,
})),
},
);
const fanPieceFields: {
name: string;
type: 'text' | 'number' | 'select' | 'boolean' | 'date';
required?: boolean;
options?: string[];
}[] = [
{ name: 'Diamètre (mm)', type: 'number', required: true },
{ name: 'Débit air (m³/h)', type: 'number', required: true },
{ name: 'Consommation (A)', type: 'number' },
{ name: 'Tension (V)', type: 'number', required: true },
{ name: 'Niveau sonore (dB)', type: 'number' },
] as const;
const fanPieceType = await createPieceType(
'Ventilateur de refroidissement',
'cooling-fan',
'Ventilateurs axiaux pour modules de refroidissement',
fanPieceFields,
{
customFields: fanPieceFields.map((field) => ({
name: field.name,
key: field.name,
type: field.type,
required: field.required ?? false,
})),
},
);
console.log('Création des pièces…');
const pumpPiece = await createPiece({
name: 'Pompe à pistons PX-300',
reference: 'PX-300',
price: 1850,
typeId: pumpPieceType.type.id,
fieldValues: {
'Pression nominale (bar)': '180',
'Débit nominal (L/min)': '260',
'Puissance (kW)': '45',
'Indice de protection': 'IP55',
'Vitesse max (rpm)': '3200',
},
});
const reservoirPiece = await createPiece({
name: 'Réservoir 120L Inox',
reference: 'RES-120-INX',
price: 720,
typeId: reservoirPieceType.type.id,
fieldValues: {
'Pression nominale (bar)': '12',
'Débit nominal (L/min)': '280',
'Capacité (L)': '120',
'Type dhuile': 'HLP46',
Filtration: '10µ absolu',
},
});
const fanPiece = await createPiece({
name: 'Ventilateur axial VE-450',
reference: 'VE-450',
price: 390,
typeId: fanPieceType.type.id,
fieldValues: {
'Diamètre (mm)': '450',
'Débit air (m³/h)': '5200',
'Consommation (A)': '3.2',
'Tension (V)': '400',
'Niveau sonore (dB)': '68',
},
});
console.log('Création des types de composants…');
const coolingComponentFields: {
name: string;
type: 'text' | 'number' | 'select' | 'boolean' | 'date';
required?: boolean;
options?: string[];
}[] = [
{ name: 'Type de fluide', type: 'text', required: true },
{ name: 'Puissance thermique (kW)', type: 'number', required: true },
{ name: 'Température max (°C)', type: 'number' },
{ name: 'Débit eau (L/min)', type: 'number' },
];
const coolingComponentType = await createComponentType(
'Module de refroidissement',
'cooling-module',
'Modules compacts pour dissipation thermique du circuit hydraulique',
coolingComponentFields,
{
customFields: coolingComponentFields.map((field) => ({
name: field.name,
key: field.name,
type: field.type,
required: field.required ?? false,
})),
pieces: [
{
typePieceId: fanPieceType.type.id,
role: 'Ventilation principale',
},
],
subcomponents: [],
},
);
const frameComponentFields: {
name: string;
type: 'text' | 'number' | 'select' | 'boolean' | 'date';
required?: boolean;
options?: string[];
}[] = [
{ name: 'Matière', type: 'text', required: true },
{ name: 'Charge admissible (kg)', type: 'number', required: true },
{ name: 'Revêtement', type: 'text' },
{ name: 'Points de levage', type: 'number' },
];
const frameComponentType = await createComponentType(
'Châssis structurel',
'structural-frame',
'Châssis mécano-soudé pour unités hydrauliques',
frameComponentFields,
{
customFields: frameComponentFields.map((field) => ({
name: field.name,
key: field.name,
type: field.type,
required: field.required ?? false,
})),
pieces: [],
subcomponents: [],
},
);
const powerUnitComponentFields: {
name: string;
type: 'text' | 'number' | 'select' | 'boolean' | 'date';
required?: boolean;
options?: string[];
}[] = [
{ name: 'Pression service (bar)', type: 'number', required: true },
{ name: 'Débit maxi (L/min)', type: 'number', required: true },
{ name: 'Niveau sonore (dB)', type: 'number' },
{ name: 'Indice de protection', type: 'text' },
];
const powerUnitComponentType = await createComponentType(
'Centrale hydraulique',
'hydraulic-power-unit',
'Unités hydrauliques complètes pour machines industrielles',
powerUnitComponentFields,
{
customFields: powerUnitComponentFields.map((field) => ({
name: field.name,
key: field.name,
type: field.type,
required: field.required ?? false,
})),
pieces: [
{
typePieceId: pumpPieceType.type.id,
role: 'Pompe principale',
},
{
typePieceId: reservoirPieceType.type.id,
role: 'Réservoir dhuile',
},
],
subcomponents: [
{
typeComposantId: coolingComponentType.type.id,
alias: 'Module de refroidissement',
},
{
typeComposantId: frameComponentType.type.id,
alias: 'Châssis structurel',
},
],
},
);
console.log('Création des composants (sous-ensembles)…');
const coolingModule = await createComponent({
name: 'Module de refroidissement AquaCool 50',
reference: 'AC-50',
price: 2450,
typeId: coolingComponentType.type.id,
fieldValues: {
'Type de fluide': 'Huile minérale',
'Puissance thermique (kW)': '35',
'Température max (°C)': '85',
'Débit eau (L/min)': '65',
},
structure: {
path: 'root',
pieces: [
{
path: 'root:piece-0',
definition: {
typePieceId: fanPieceType.type.id,
},
selectedPieceId: fanPiece.id,
},
],
subcomponents: [],
} as Prisma.InputJsonValue,
});
const structuralFrame = await createComponent({
name: 'Châssis structurel XC-800',
reference: 'FRAME-XC800',
price: 1280,
typeId: frameComponentType.type.id,
fieldValues: {
Matière: 'Acier S355',
'Charge admissible (kg)': '1800',
Revêtement: 'Peinture epoxy',
'Points de levage': '4',
},
});
console.log('Création du composant principal…');
await createComponent({
name: 'Centrale hydraulique HX-500',
reference: 'HX-500',
price: 12900,
typeId: powerUnitComponentType.type.id,
fieldValues: {
'Pression service (bar)': '210',
'Débit maxi (L/min)': '320',
'Niveau sonore (dB)': '72',
'Indice de protection': 'IP54',
},
structure: {
path: 'root',
pieces: [
{
path: 'root:piece-0',
definition: {
typePieceId: pumpPieceType.type.id,
},
selectedPieceId: pumpPiece.id,
},
{
path: 'root:piece-1',
definition: {
typePieceId: reservoirPieceType.type.id,
},
selectedPieceId: reservoirPiece.id,
},
],
subcomponents: [
{
path: 'root:sub-0',
definition: {
alias: 'Module de refroidissement',
typeComposantId: coolingComponentType.type.id,
},
selectedComponentId: coolingModule.id,
},
{
path: 'root:sub-1',
definition: {
alias: 'Châssis structurel',
typeComposantId: frameComponentType.type.id,
},
selectedComponentId: structuralFrame.id,
},
],
} as Prisma.InputJsonValue,
});
console.log('Population terminée ✅');
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -9,46 +9,22 @@ const CUSTOM_FIELD_SELECT = {
} as const;
export const COMPONENT_WITH_RELATIONS_INCLUDE = {
machine: true,
parentComposant: true,
typeComposant: {
include: {
customFields: true,
},
},
typeMachineComponentRequirement: {
include: {
typeComposant: {
include: {
customFields: true,
},
},
},
},
constructeur: true,
customFieldValues: {
include: {
customField: { select: CUSTOM_FIELD_SELECT },
},
},
pieces: {
machineLinks: {
include: {
customFieldValues: {
include: {
customField: { select: CUSTOM_FIELD_SELECT },
},
},
constructeur: true,
typeMachinePieceRequirement: {
include: {
typePiece: {
include: {
customFields: true,
},
},
},
},
documents: true,
machine: true,
typeMachineComponentRequirement: true,
childLinks: true,
},
},
documents: true,

View File

@@ -27,16 +27,6 @@ export class ComposantsController {
return this.composantsService.findAll();
}
@Get('hierarchy/:machineId')
findHierarchy(@Param('machineId') machineId: string) {
return this.composantsService.findHierarchy(machineId);
}
@Get('machine/:machineId')
findByMachine(@Param('machineId') machineId: string) {
return this.composantsService.findByMachine(machineId);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.composantsService.findOne(id);

View File

@@ -1,32 +1,20 @@
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { ComposantsService } from './composants.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateComposantDto } from '../shared/dto/composant.dto';
import { CreateComposantDto, UpdateComposantDto } from '../shared/dto/composant.dto';
describe('ComposantsService', () => {
let service: ComposantsService;
let prisma: any;
let prisma: { composant: any };
beforeEach(async () => {
prisma = {
composant: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
findMany: jest.fn(),
},
machine: {
findUnique: jest.fn(),
},
customField: {
findMany: jest.fn(),
},
customFieldValue: {
findMany: jest.fn(),
create: jest.fn(),
},
piece: {
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
@@ -40,248 +28,27 @@ describe('ComposantsService', () => {
service = module.get<ComposantsService>(ComposantsService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create a component when requirement matches the machine skeleton', async () => {
it('creates a component', async () => {
const dto: CreateComposantDto = {
name: 'Comp A',
machineId: 'machine-1',
typeComposantId: 'type-comp-1',
typeMachineComponentRequirementId: 'req-1',
typeComposantId: 'type-1',
};
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
componentRequirements: [
{
id: 'req-1',
typeComposantId: 'type-comp-1',
typeComposant: {
id: 'type-comp-1',
name: 'Comp type',
code: 'comp-type',
componentSkeleton: null,
},
},
],
pieceRequirements: [],
},
});
prisma.composant.create.mockResolvedValue({ id: 'comp-1', name: dto.name });
const created = {
id: 'component-1',
name: 'Comp A',
machineId: 'machine-1',
typeComposantId: 'type-comp-1',
};
prisma.composant.create.mockResolvedValue(created);
prisma.composant.findUnique.mockResolvedValue({
...created,
machine: null,
parentComposant: null,
typeComposant: {
id: 'type-comp-1',
name: 'Comp type',
code: 'comp-type',
componentSkeleton: null,
customFields: [],
},
typeMachineComponentRequirement: null,
constructeur: null,
customFieldValues: [],
pieces: [],
documents: [],
});
prisma.composant.findMany.mockResolvedValue([]);
await expect(service.create(dto)).resolves.toMatchObject({
id: 'component-1',
});
const result = await service.create(dto);
expect(prisma.composant.create).toHaveBeenCalled();
expect(prisma.composant.create.mock.calls[0][0].data.typeComposantId).toBe(
'type-comp-1',
);
expect(result).toMatchObject({ id: 'comp-1' });
});
it('should refuse creation when requirement does not belong to machine skeleton', async () => {
const dto: CreateComposantDto = {
name: 'Comp A',
machineId: 'machine-1',
typeComposantId: 'type-comp-1',
typeMachineComponentRequirementId: 'req-2',
};
it('updates a component', async () => {
const dto: UpdateComposantDto = { name: 'Updated' };
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
componentRequirements: [
{ id: 'req-1', typeComposantId: 'type-comp-1' },
],
pieceRequirements: [],
},
});
prisma.composant.update.mockResolvedValue({ id: 'comp-1', name: 'Updated' });
await expect(service.create(dto)).rejects.toBeInstanceOf(
BadRequestException,
);
await service.update('comp-1', dto);
expect(prisma.composant.create).not.toHaveBeenCalled();
});
it('should create nested components, pieces, and custom field values from the type skeleton', async () => {
const dto: CreateComposantDto = {
name: 'Comp B',
machineId: 'machine-1',
typeMachineComponentRequirementId: 'req-root',
} as any;
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
componentRequirements: [
{
id: 'req-root',
typeComposantId: 'type-root',
typeComposant: {
id: 'type-root',
name: 'Root type',
code: 'root',
componentSkeleton: {
customFields: [{ key: 'color', value: 'red' }],
pieces: [
{
typePieceId: 'type-piece',
role: 'Primary piece',
},
],
subcomponents: [
{
typeComposantId: 'type-child',
alias: 'Child component',
},
],
},
},
maxCount: null,
},
{
id: 'req-child',
typeComposantId: 'type-child',
typeComposant: {
id: 'type-child',
name: 'Child type',
code: 'child',
componentSkeleton: null,
},
maxCount: null,
},
],
pieceRequirements: [
{
id: 'req-piece',
typePieceId: 'type-piece',
typePiece: {
id: 'type-piece',
name: 'Piece type',
code: 'piece',
},
maxCount: null,
},
],
},
});
prisma.customField.findMany.mockResolvedValue([
{ id: 'cf-color', name: 'color' },
]);
prisma.customFieldValue.findMany.mockResolvedValue([]);
const rootComponent = {
id: 'component-1',
name: 'Comp B',
machineId: 'machine-1',
typeComposantId: 'type-root',
typeComposant: {
id: 'type-root',
name: 'Root type',
code: 'root',
componentSkeleton: {
customFields: [{ key: 'color', value: 'red' }],
pieces: [],
subcomponents: [],
},
customFields: [],
},
machine: null,
parentComposant: null,
typeMachineComponentRequirement: null,
constructeur: null,
customFieldValues: [],
pieces: [],
documents: [],
};
prisma.composant.create
.mockResolvedValueOnce(rootComponent)
.mockResolvedValueOnce({
id: 'component-child',
name: 'Child component',
machineId: 'machine-1',
parentComposantId: 'component-1',
typeComposantId: 'type-child',
});
prisma.composant.findUnique.mockResolvedValue(rootComponent);
prisma.composant.findMany.mockResolvedValue([
{ ...rootComponent, parentComposantId: null },
{
id: 'component-child',
name: 'Child component',
machineId: 'machine-1',
parentComposantId: 'component-1',
typeComposantId: 'type-child',
machine: null,
parentComposant: rootComponent,
typeComposant: null,
typeMachineComponentRequirement: null,
constructeur: null,
customFieldValues: [],
pieces: [],
documents: [],
},
]);
await service.create(dto);
expect(prisma.customField.findMany).toHaveBeenCalledWith({
where: { typeComposantId: 'type-root' },
select: { id: true, name: true },
});
expect(prisma.customFieldValue.create).toHaveBeenCalledWith({
data: {
customFieldId: 'cf-color',
composantId: 'component-1',
value: 'red',
},
});
expect(prisma.piece.create).toHaveBeenCalledWith({
data: {
name: 'Primary piece',
machineId: 'machine-1',
composantId: 'component-1',
typePieceId: 'type-piece',
typeMachinePieceRequirementId: 'req-piece',
},
});
expect(prisma.composant.create).toHaveBeenCalledTimes(2);
expect(prisma.composant.create.mock.calls[1][0].data).toMatchObject({
parentComposantId: 'component-1',
typeComposantId: 'type-child',
typeMachineComponentRequirementId: 'req-child',
});
expect(prisma.composant.update).toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -9,638 +9,99 @@ import {
COMPONENT_WITH_RELATIONS_INCLUDE,
ComposantWithRelations,
} from '../common/constants/component-includes';
import {
buildComponentHierarchy,
buildComponentSubtree,
} from '../common/utils/component-tree.util';
import { ComponentModelStructureSchema } from '../shared/schemas/inventory';
import type { ComponentModelStructure } from '../shared/types/inventory';
type ComponentRequirementWithType =
Prisma.TypeMachineComponentRequirementGetPayload<{
include: { typeComposant: true };
}>;
type PieceRequirementWithType = Prisma.TypeMachinePieceRequirementGetPayload<{
include: { typePiece: true };
}>;
type ModelTypeWithSkeleton = ComponentRequirementWithType['typeComposant'];
type PieceTypeWithSkeleton = PieceRequirementWithType['typePiece'];
@Injectable()
export class ComposantsService {
constructor(private prisma: PrismaService) {}
private async fetchComponentsByMachine(
machineId: string,
): Promise<ComposantWithRelations[]> {
return this.prisma.composant.findMany({
where: { machineId },
include: COMPONENT_WITH_RELATIONS_INCLUDE,
}) as Promise<ComposantWithRelations[]>;
}
private buildCreateInput(
createComposantDto: CreateComposantDto,
): Prisma.ComposantCreateInput {
const data: Prisma.ComposantCreateInput = {
name: createComposantDto.name,
reference: createComposantDto.reference ?? null,
prix:
createComposantDto.prix !== undefined ? createComposantDto.prix : null,
};
private async getComponentWithHierarchy(
id: string,
): Promise<ComposantWithRelations | null> {
const baseComponent = (await this.prisma.composant.findUnique({
where: { id },
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations | null;
if (!baseComponent) {
return null;
if (createComposantDto.constructeurId) {
data.constructeur = {
connect: { id: createComposantDto.constructeurId },
};
}
if (!baseComponent.machineId) {
baseComponent.sousComposants = [];
return baseComponent;
if (createComposantDto.typeComposantId) {
data.typeComposant = {
connect: { id: createComposantDto.typeComposantId },
};
}
const components = await this.fetchComponentsByMachine(
baseComponent.machineId,
);
const subtree = buildComponentSubtree(components, id);
return subtree ?? baseComponent;
if (createComposantDto.structure !== undefined) {
data.structure = createComposantDto.structure as Prisma.InputJsonValue;
}
return data;
}
async create(createComposantDto: CreateComposantDto) {
const requirementId =
createComposantDto.typeMachineComponentRequirementId ?? null;
if (requirementId && !createComposantDto.machineId) {
throw new BadRequestException(
'Un requirement ne peut pas être utilisé sans machine ciblée.',
);
}
let machineId = createComposantDto.machineId ?? null;
if (createComposantDto.parentComposantId) {
const parentMachineId = await this.resolveMachineIdFromComposant(
createComposantDto.parentComposantId,
);
if (machineId && parentMachineId && machineId !== parentMachineId) {
throw new BadRequestException(
'Le composant parent ne correspond pas à la machine ciblée.',
);
}
machineId = parentMachineId ?? machineId;
}
let requirement: ComponentRequirementWithType | null = null;
let componentRequirements: ComponentRequirementWithType[] = [];
let pieceRequirements: PieceRequirementWithType[] = [];
if (machineId) {
const machine = await this.prisma.machine.findUnique({
where: { id: machineId },
include: {
typeMachine: {
include: {
componentRequirements: {
include: {
typeComposant: true,
},
},
pieceRequirements: {
include: {
typePiece: true,
},
},
},
},
},
});
if (!machine || !machine.typeMachine) {
throw new BadRequestException(
'La machine ciblée doit être associée à un type de machine pour valider les requirements.',
);
}
componentRequirements =
(machine.typeMachine
.componentRequirements as ComponentRequirementWithType[]) ?? [];
pieceRequirements =
(machine.typeMachine.pieceRequirements as PieceRequirementWithType[]) ??
[];
if (requirementId) {
requirement =
componentRequirements.find(
(componentRequirement) => componentRequirement.id === requirementId,
) ?? null;
if (!requirement) {
throw new BadRequestException(
'Le requirement de composant fourni ne correspond pas au squelette de la machine.',
);
}
if (
createComposantDto.typeComposantId &&
createComposantDto.typeComposantId !== requirement.typeComposantId
) {
throw new BadRequestException(
'Le type de composant fourni ne correspond pas au requirement pour cette machine.',
);
}
}
}
const typeComposantId =
createComposantDto.typeComposantId ??
requirement?.typeComposantId ??
null;
const data: Prisma.ComposantUncheckedCreateInput = {
name: createComposantDto.name,
reference: createComposantDto.reference ?? null,
constructeurId: createComposantDto.constructeurId ?? null,
prix:
createComposantDto.prix !== undefined ? createComposantDto.prix : null,
machineId,
parentComposantId: createComposantDto.parentComposantId ?? null,
typeComposantId,
typeMachineComponentRequirementId:
requirement?.id ?? requirementId ?? null,
};
const created = (await this.prisma.composant.create({
data,
const created = await this.prisma.composant.create({
data: this.buildCreateInput(createComposantDto),
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations;
});
if (machineId && requirement?.id) {
const componentRequirementUsage = new Map<string, number>();
componentRequirementUsage.set(requirement.id, 1);
const pieceRequirementUsage = new Map<string, number>();
await this.populateComponentFromSkeleton({
componentId: created.id,
componentName: created.name,
componentType:
(requirement.typeComposant as ModelTypeWithSkeleton | null) ??
(created.typeComposant as ModelTypeWithSkeleton | null) ??
null,
machineId,
componentRequirements,
pieceRequirements,
componentRequirementUsage,
pieceRequirementUsage,
});
}
const component = await this.getComponentWithHierarchy(created.id);
return component ?? created;
return created as ComposantWithRelations;
}
async findAll() {
const components = (await this.prisma.composant.findMany({
return (await this.prisma.composant.findMany({
include: COMPONENT_WITH_RELATIONS_INCLUDE,
orderBy: { name: 'asc' },
})) as ComposantWithRelations[];
return buildComponentHierarchy(components);
}
async findOne(id: string) {
return this.getComponentWithHierarchy(id);
}
async findByMachine(machineId: string) {
const components = await this.fetchComponentsByMachine(machineId);
return buildComponentHierarchy(components);
}
async findHierarchy(machineId: string) {
const components = await this.fetchComponentsByMachine(machineId);
return buildComponentHierarchy(components);
return (await this.prisma.composant.findUnique({
where: { id },
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations | null;
}
async update(id: string, updateComposantDto: UpdateComposantDto) {
const updated = (await this.prisma.composant.update({
const data: Prisma.ComposantUpdateInput = {};
if (updateComposantDto.name !== undefined) {
data.name = updateComposantDto.name;
}
if (updateComposantDto.reference !== undefined) {
data.reference = updateComposantDto.reference;
}
if (updateComposantDto.prix !== undefined) {
data.prix = updateComposantDto.prix;
}
if (updateComposantDto.constructeurId !== undefined) {
data.constructeur = updateComposantDto.constructeurId
? { connect: { id: updateComposantDto.constructeurId } }
: { disconnect: true };
}
if (updateComposantDto.typeComposantId !== undefined) {
data.typeComposant = updateComposantDto.typeComposantId
? { connect: { id: updateComposantDto.typeComposantId } }
: { disconnect: true };
}
if (updateComposantDto.structure !== undefined) {
data.structure = updateComposantDto.structure as Prisma.InputJsonValue;
}
return (await this.prisma.composant.update({
where: { id },
data: updateComposantDto,
data,
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations;
return this.getComponentWithHierarchy(updated.id);
}
private async populateComponentFromSkeleton({
componentId,
componentName,
componentType,
machineId,
componentRequirements,
pieceRequirements,
componentRequirementUsage,
pieceRequirementUsage,
}: {
componentId: string;
componentName?: string;
componentType: ModelTypeWithSkeleton | null;
machineId: string;
componentRequirements: ComponentRequirementWithType[];
pieceRequirements: PieceRequirementWithType[];
componentRequirementUsage: Map<string, number>;
pieceRequirementUsage: Map<string, number>;
}) {
const skeleton = this.parseComponentSkeleton(
(componentType as { componentSkeleton?: Prisma.JsonValue | null } | null)
?.componentSkeleton,
);
if (!skeleton) {
return;
}
await this.createComponentCustomFieldValues(
componentId,
componentType?.id ?? null,
skeleton.customFields,
);
await this.createPiecesFromSkeleton({
componentId,
componentName,
machineId,
pieces: skeleton.pieces,
pieceRequirements,
pieceRequirementUsage,
});
for (const subcomponent of skeleton.subcomponents ?? []) {
const requirement = this.resolveComponentRequirement(
subcomponent,
componentRequirements,
componentRequirementUsage,
);
if (!requirement?.typeComposant) {
continue;
}
const name = this.buildComponentName(
subcomponent,
requirement.typeComposant,
componentName,
);
const createdChild = await this.prisma.composant.create({
data: {
name,
machineId,
parentComposantId: componentId,
typeComposantId: requirement.typeComposantId,
typeMachineComponentRequirementId: requirement.id,
},
});
this.incrementRequirementUsage(componentRequirementUsage, requirement.id);
await this.populateComponentFromSkeleton({
componentId: createdChild.id,
componentName: createdChild.name,
componentType: requirement.typeComposant,
machineId,
componentRequirements,
pieceRequirements,
componentRequirementUsage,
pieceRequirementUsage,
});
}
}
private parseComponentSkeleton(
value: unknown,
): ComponentModelStructure | null {
if (!value) {
return null;
}
try {
return ComponentModelStructureSchema.parse(value);
} catch (error) {
return null;
}
}
private async createComponentCustomFieldValues(
componentId: string,
typeComposantId: string | null,
customFields: ComponentModelStructure['customFields'],
) {
if (
!typeComposantId ||
!Array.isArray(customFields) ||
customFields.length === 0
) {
return;
}
const definitions = await this.prisma.customField.findMany({
where: { typeComposantId },
select: { id: true, name: true },
});
if (definitions.length === 0) {
return;
}
const definitionMap = new Map(
definitions.map((field) => [field.name, field.id]),
);
const existingValues = await this.prisma.customFieldValue.findMany({
where: { composantId: componentId },
select: { customFieldId: true },
});
const existingIds = new Set(
existingValues.map((value) => value.customFieldId),
);
for (const field of customFields) {
const key = this.normalizeIdentifier(field?.key);
if (!key) {
continue;
}
const definitionId = definitionMap.get(key);
if (!definitionId || existingIds.has(definitionId)) {
continue;
}
await this.prisma.customFieldValue.create({
data: {
customFieldId: definitionId,
composantId: componentId,
value: this.toCustomFieldValue(field?.value),
},
});
existingIds.add(definitionId);
}
}
private async createPiecesFromSkeleton({
componentId,
componentName,
machineId,
pieces,
pieceRequirements,
pieceRequirementUsage,
}: {
componentId: string;
componentName?: string;
machineId: string;
pieces: ComponentModelStructure['pieces'];
pieceRequirements: PieceRequirementWithType[];
pieceRequirementUsage: Map<string, number>;
}) {
if (!Array.isArray(pieces) || pieces.length === 0) {
return;
}
for (const entry of pieces) {
const requirement = this.resolvePieceRequirement(
entry,
pieceRequirements,
pieceRequirementUsage,
);
if (!requirement?.typePiece) {
continue;
}
const name = this.buildPieceName(
entry,
requirement.typePiece,
componentName,
);
await this.prisma.piece.create({
data: {
name,
machineId,
composantId: componentId,
typePieceId: requirement.typePieceId,
typeMachinePieceRequirementId: requirement.id,
},
});
this.incrementRequirementUsage(pieceRequirementUsage, requirement.id);
}
}
private resolveComponentRequirement(
entry: ComponentModelStructure['subcomponents'][number],
requirements: ComponentRequirementWithType[],
usage: Map<string, number>,
): ComponentRequirementWithType | null {
const typeComposantId = this.normalizeIdentifier(
(entry as { typeComposantId?: string }).typeComposantId,
);
const familyCode = this.normalizeCode(
(entry as { familyCode?: string }).familyCode,
);
const candidates = requirements.filter((requirement) => {
if (typeComposantId && requirement.typeComposantId === typeComposantId) {
return true;
}
if (familyCode && requirement.typeComposant?.code) {
return (
this.normalizeCode(requirement.typeComposant.code) === familyCode
);
}
return false;
});
if (candidates.length === 0) {
if (typeComposantId || familyCode) {
throw new BadRequestException(
`Aucun requirement de composant ne correspond au squelette (${typeComposantId ?? familyCode}).`,
);
}
throw new BadRequestException(
'Le squelette du composant référence un sous-composant sans identifiant de type.',
);
}
for (const candidate of candidates) {
if (this.hasRequirementCapacity(candidate, usage)) {
return candidate;
}
}
throw new BadRequestException(
`La capacité maximale du requirement de composant (${typeComposantId ?? familyCode}) est atteinte pour la machine visée.`,
);
}
private resolvePieceRequirement(
entry: ComponentModelStructure['pieces'][number],
requirements: PieceRequirementWithType[],
usage: Map<string, number>,
): PieceRequirementWithType | null {
const typePieceId = this.normalizeIdentifier(
(entry as { typePieceId?: string }).typePieceId,
);
const familyCode = this.normalizeCode(
(entry as { familyCode?: string }).familyCode,
);
const candidates = requirements.filter((requirement) => {
if (typePieceId && requirement.typePieceId === typePieceId) {
return true;
}
if (familyCode && requirement.typePiece?.code) {
return this.normalizeCode(requirement.typePiece.code) === familyCode;
}
return false;
});
if (candidates.length === 0) {
if (typePieceId || familyCode) {
throw new BadRequestException(
`Aucun requirement de pièce ne correspond au squelette (${typePieceId ?? familyCode}).`,
);
}
throw new BadRequestException(
'Le squelette du composant référence une pièce sans identifiant de type.',
);
}
for (const candidate of candidates) {
if (this.hasRequirementCapacity(candidate, usage)) {
return candidate;
}
}
throw new BadRequestException(
`La capacité maximale du requirement de pièce (${typePieceId ?? familyCode}) est atteinte pour la machine visée.`,
);
}
private hasRequirementCapacity(
requirement: { id: string; maxCount: number | null | undefined },
usage: Map<string, number>,
): boolean {
const max = requirement.maxCount;
if (max === null || max === undefined) {
return true;
}
const current = usage.get(requirement.id) ?? 0;
return current < max;
}
private incrementRequirementUsage(usage: Map<string, number>, id: string) {
usage.set(id, (usage.get(id) ?? 0) + 1);
}
private buildComponentName(
subcomponent: ComponentModelStructure['subcomponents'][number],
typeComposant: ModelTypeWithSkeleton | null,
parentName?: string,
): string {
const alias = this.normalizeIdentifier(
(subcomponent as { alias?: string }).alias,
);
if (alias) {
return alias;
}
if (typeComposant?.name) {
return typeComposant.name;
}
if (parentName) {
return `${parentName} - Sous-composant`;
}
return 'Sous-composant';
}
private buildPieceName(
piece: ComponentModelStructure['pieces'][number],
typePiece: PieceTypeWithSkeleton | null,
componentName?: string,
): string {
const role = this.normalizeIdentifier((piece as { role?: string }).role);
if (role) {
return role;
}
if (typePiece?.name) {
return typePiece.name;
}
if (componentName) {
return `${componentName} - Pièce`;
}
return 'Pièce';
}
private normalizeIdentifier(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
private normalizeCode(value: unknown): string | null {
const identifier = this.normalizeIdentifier(value);
return identifier ? identifier.toLowerCase() : null;
}
private toCustomFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
return String(value);
}
private async resolveMachineIdFromComposant(
composantId: string,
): Promise<string> {
const composant = await this.prisma.composant.findUnique({
where: { id: composantId },
select: {
id: true,
machineId: true,
parentComposantId: true,
},
});
if (!composant) {
throw new BadRequestException(
'Le composant parent spécifié est introuvable.',
);
}
if (composant.machineId) {
return composant.machineId;
}
if (composant.parentComposantId) {
return this.resolveMachineIdFromComposant(composant.parentComposantId);
}
throw new BadRequestException(
'Impossible de déterminer la machine associée au composant parent.',
);
}
async remove(id: string) {

File diff suppressed because it is too large Load Diff

View File

@@ -24,16 +24,6 @@ export class PiecesController {
return this.piecesService.findAll();
}
@Get('machine/:machineId')
findByMachine(@Param('machineId') machineId: string) {
return this.piecesService.findByMachine(machineId);
}
@Get('composant/:composantId')
findByComposant(@Param('composantId') composantId: string) {
return this.piecesService.findByComposant(composantId);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.piecesService.findOne(id);

View File

@@ -1,12 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { PiecesService } from './pieces.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePieceDto } from '../shared/dto/piece.dto';
import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
describe('PiecesService', () => {
let service: PiecesService;
let prisma: any;
let prisma: { piece: any; customField: any; customFieldValue: any };
beforeEach(async () => {
prisma = {
@@ -17,12 +16,6 @@ describe('PiecesService', () => {
update: jest.fn(),
delete: jest.fn(),
},
machine: {
findUnique: jest.fn(),
},
composant: {
findUnique: jest.fn(),
},
customField: {
findMany: jest.fn(),
create: jest.fn(),
@@ -34,134 +27,42 @@ describe('PiecesService', () => {
};
const module: TestingModule = await Test.createTestingModule({
providers: [PiecesService, { provide: PrismaService, useValue: prisma }],
providers: [
PiecesService,
{ provide: PrismaService, useValue: prisma },
],
}).compile();
service = module.get<PiecesService>(PiecesService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create a piece when requirement matches the machine skeleton', async () => {
it('creates a piece', async () => {
const dto: CreatePieceDto = {
name: 'Piece A',
machineId: 'machine-1',
typePieceId: 'type-piece-1',
typeMachinePieceRequirementId: 'req-1',
};
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
pieceRequirements: [
{
id: 'req-1',
typePieceId: 'type-piece-1',
typePiece: {
id: 'type-piece-1',
pieceSkeleton: {
customFields: [
{
name: 'Numéro de série',
value: 'AUTO',
type: 'text',
required: true,
},
],
},
},
},
],
},
});
const created = {
id: 'piece-1',
typePieceId: 'type-piece-1',
typePiece: {
id: 'type-piece-1',
pieceSkeleton: {
customFields: [
{
name: 'Numéro de série',
value: 'AUTO',
type: 'text',
required: true,
},
],
},
},
};
prisma.piece.create.mockResolvedValue(created);
prisma.customField.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'field-1', name: 'Numéro de série' }]);
prisma.customField.create.mockResolvedValue({ id: 'field-1' });
prisma.piece.create.mockResolvedValue({ id: 'piece-1', name: dto.name });
prisma.piece.findUnique.mockResolvedValue({ id: 'piece-1', name: dto.name });
prisma.customField.findMany.mockResolvedValue([]);
prisma.customFieldValue.findMany.mockResolvedValue([]);
prisma.customFieldValue.create.mockResolvedValue({
id: 'value-1',
});
const finalPiece = { ...created, customFieldValues: [] };
prisma.piece.findUnique.mockResolvedValue(finalPiece);
const result = await service.create(dto);
await expect(service.create(dto)).resolves.toEqual(finalPiece);
expect(prisma.piece.create).toHaveBeenCalledWith({
data: expect.objectContaining({
machineId: 'machine-1',
typePieceId: 'type-piece-1',
}),
include: expect.any(Object),
});
expect(prisma.customField.create).toHaveBeenCalledWith({
data: {
name: 'Numéro de série',
type: 'text',
required: true,
options: undefined,
typePieceId: 'type-piece-1',
},
select: { id: true },
});
expect(prisma.customFieldValue.create).toHaveBeenCalledWith({
data: {
customFieldId: 'field-1',
pieceId: 'piece-1',
value: 'AUTO',
},
});
expect(prisma.piece.findUnique).toHaveBeenCalledWith({
where: { id: 'piece-1' },
include: expect.any(Object),
});
expect(prisma.piece.create).toHaveBeenCalled();
expect(result).toMatchObject({ id: 'piece-1' });
});
it('should refuse creation when requirement does not belong to machine skeleton', async () => {
const dto: CreatePieceDto = {
name: 'Piece A',
machineId: 'machine-1',
typePieceId: 'type-piece-1',
typeMachinePieceRequirementId: 'req-2',
};
it('updates a piece', async () => {
const dto: UpdatePieceDto = { name: 'Updated piece' };
prisma.machine.findUnique.mockResolvedValue({
id: 'machine-1',
typeMachine: {
pieceRequirements: [{ id: 'req-1', typePieceId: 'type-piece-1' }],
},
});
prisma.piece.update.mockResolvedValue({ id: 'piece-1', name: 'Updated piece' });
prisma.piece.findUnique.mockResolvedValue({ id: 'piece-1', name: 'Updated piece' });
prisma.customField.findMany.mockResolvedValue([]);
prisma.customFieldValue.findMany.mockResolvedValue([]);
await expect(service.create(dto)).rejects.toBeInstanceOf(
BadRequestException,
);
await service.update('piece-1', dto);
expect(prisma.piece.create).not.toHaveBeenCalled();
expect(prisma.piece.update).toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
@@ -6,124 +6,56 @@ import { PieceModelStructureSchema } from '../shared/schemas/inventory';
import type { PieceModelStructure } from '../shared/types/inventory';
const PIECE_WITH_RELATIONS_INCLUDE = {
machine: true,
composant: true,
typePiece: {
include: {
pieceCustomFields: true,
},
},
documents: true,
constructeur: true,
typeMachinePieceRequirement: {
include: {
typePiece: {
include: {
pieceCustomFields: true,
},
},
},
},
documents: true,
customFieldValues: {
include: {
customField: true,
},
},
machineLinks: {
include: {
machine: true,
typeMachinePieceRequirement: true,
parentLink: true,
},
},
} as const;
@Injectable()
export class PiecesService {
constructor(private prisma: PrismaService) {}
async create(createPieceDto: CreatePieceDto) {
const requirementId = createPieceDto.typeMachinePieceRequirementId ?? null;
if (requirementId && !createPieceDto.machineId) {
throw new BadRequestException(
'Un requirement ne peut pas être utilisé sans machine ciblée.',
);
}
let machineId = createPieceDto.machineId ?? null;
if (createPieceDto.composantId) {
const composantMachineId = await this.resolveMachineIdFromComposant(
createPieceDto.composantId,
);
if (machineId && machineId !== composantMachineId) {
throw new BadRequestException(
'Le composant ciblé appartient à une autre machine que celle fournie.',
);
}
machineId = composantMachineId ?? machineId;
}
let requirement: PieceRequirementWithType | null = null;
if (machineId) {
const machine = await this.prisma.machine.findUnique({
where: { id: machineId },
include: {
typeMachine: {
include: {
pieceRequirements: {
include: {
typePiece: true,
},
},
},
},
},
});
if (!machine || !machine.typeMachine) {
throw new BadRequestException(
'La machine ciblée doit être associée à un type de machine pour valider les requirements.',
);
}
if (requirementId) {
requirement =
(
machine.typeMachine.pieceRequirements as PieceRequirementWithType[]
).find((pieceRequirement) => pieceRequirement.id === requirementId) ??
null;
if (!requirement) {
throw new BadRequestException(
'Le requirement de pièce fourni ne correspond pas au squelette de la machine.',
);
}
if (
createPieceDto.typePieceId &&
createPieceDto.typePieceId !== requirement.typePieceId
) {
throw new BadRequestException(
'Le type de pièce fourni ne correspond pas au requirement pour cette machine.',
);
}
}
}
const typePieceId =
createPieceDto.typePieceId ?? requirement?.typePieceId ?? null;
const data: Prisma.PieceUncheckedCreateInput = {
private buildCreateInput(createPieceDto: CreatePieceDto): Prisma.PieceCreateInput {
const data: Prisma.PieceCreateInput = {
name: createPieceDto.name,
reference: createPieceDto.reference ?? null,
constructeurId: createPieceDto.constructeurId ?? null,
prix: createPieceDto.prix !== undefined ? createPieceDto.prix : null,
machineId,
composantId: createPieceDto.composantId ?? null,
typePieceId,
typeMachinePieceRequirementId: requirement?.id ?? requirementId ?? null,
};
if (createPieceDto.constructeurId) {
data.constructeur = {
connect: { id: createPieceDto.constructeurId },
};
}
if (createPieceDto.typePieceId) {
data.typePiece = {
connect: { id: createPieceDto.typePieceId },
};
}
return data;
}
async create(createPieceDto: CreatePieceDto) {
const created = await this.prisma.piece.create({
data,
data: this.buildCreateInput(createPieceDto),
include: PIECE_WITH_RELATIONS_INCLUDE,
});
@@ -141,6 +73,7 @@ export class PiecesService {
async findAll() {
return this.prisma.piece.findMany({
include: PIECE_WITH_RELATIONS_INCLUDE,
orderBy: { name: 'asc' },
});
}
@@ -151,53 +84,36 @@ export class PiecesService {
});
}
async findByMachine(machineId: string) {
return this.prisma.piece.findMany({
where: { machineId },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
}
private async resolveMachineIdFromComposant(
composantId: string,
): Promise<string> {
const composant = await this.prisma.composant.findUnique({
where: { id: composantId },
select: {
id: true,
machineId: true,
parentComposantId: true,
},
});
if (!composant) {
throw new BadRequestException('Le composant spécifié est introuvable.');
}
if (composant.machineId) {
return composant.machineId;
}
if (composant.parentComposantId) {
return this.resolveMachineIdFromComposant(composant.parentComposantId);
}
throw new BadRequestException(
'Impossible de déterminer la machine associée à ce composant.',
);
}
async findByComposant(composantId: string) {
return this.prisma.piece.findMany({
where: { composantId },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
}
async update(id: string, updatePieceDto: UpdatePieceDto) {
const data: Prisma.PieceUpdateInput = {};
if (updatePieceDto.name !== undefined) {
data.name = updatePieceDto.name;
}
if (updatePieceDto.reference !== undefined) {
data.reference = updatePieceDto.reference;
}
if (updatePieceDto.prix !== undefined) {
data.prix = updatePieceDto.prix;
}
if (updatePieceDto.constructeurId !== undefined) {
data.constructeur = updatePieceDto.constructeurId
? { connect: { id: updatePieceDto.constructeurId } }
: { disconnect: true };
}
if (updatePieceDto.typePieceId !== undefined) {
data.typePiece = updatePieceDto.typePieceId
? { connect: { id: updatePieceDto.typePieceId } }
: { disconnect: true };
}
const updated = await this.prisma.piece.update({
where: { id },
data: updatePieceDto,
data,
include: PIECE_WITH_RELATIONS_INCLUDE,
});
@@ -241,7 +157,6 @@ export class PiecesService {
const customFields = skeleton.customFields ?? [];
await this.ensurePieceCustomFieldDefinitions(typePiece.id, customFields);
await this.createPieceCustomFieldValues(
pieceId,
typePiece.id,
@@ -291,11 +206,7 @@ export class PiecesService {
}
const name = this.normalizeIdentifier(field.name);
if (!name) {
continue;
}
if (existingByName.has(name)) {
if (!name || existingByName.has(name)) {
continue;
}
@@ -426,16 +337,16 @@ export class PiecesService {
return '';
}
return String(value);
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
}
type PieceRequirementWithType = Prisma.TypeMachinePieceRequirementGetPayload<{
include: { typePiece: true };
type PieceTypeWithSkeleton = Prisma.ModelTypeGetPayload<{
include: { pieceCustomFields: true };
}>;
type PieceTypeWithSkeleton = PieceRequirementWithType['typePiece'];
type PieceCustomFieldEntry = NonNullable<
PieceModelStructure['customFields']
>[number];
type PieceCustomFieldEntry = NonNullable<PieceModelStructure['customFields']>[number];

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsNumber } from 'class-validator';
import { IsString, IsOptional, IsNumber, IsObject } from 'class-validator';
import { Transform } from 'class-transformer';
export class CreateComposantDto {
@@ -33,6 +33,10 @@ export class CreateComposantDto {
@IsOptional()
@IsString()
typeMachineComponentRequirementId?: string;
@IsOptional()
@IsObject()
structure?: Record<string, any>;
}
export class UpdateComposantDto {
@@ -56,4 +60,8 @@ export class UpdateComposantDto {
@IsOptional()
@IsString()
typeComposantId?: string;
@IsOptional()
@IsObject()
structure?: Record<string, any>;
}

View File

@@ -1,8 +1,22 @@
import { IsString, IsOptional, IsDecimal, IsArray } from 'class-validator';
import {
IsString,
IsOptional,
IsDecimal,
IsArray,
IsObject,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ValidateNested } from 'class-validator';
export class MachineComponentSelectionDto {
export class MachineComponentLinkPayloadDto {
@IsOptional()
@IsString()
id?: string;
@IsOptional()
@IsString()
linkId?: string;
@IsString()
requirementId: string;
@@ -15,10 +29,59 @@ export class MachineComponentSelectionDto {
composantId?: string;
@IsOptional()
definition?: any;
@IsString()
componentId?: string;
@IsOptional()
@IsString()
parentLinkId?: string;
@IsOptional()
@IsString()
parentComponentLinkId?: string;
@IsOptional()
@IsString()
parentRequirementId?: string;
@IsOptional()
@IsString()
parentComponentRequirementId?: string;
@IsOptional()
@IsString()
parentPieceRequirementId?: string;
@IsOptional()
@IsString()
parentMachineComponentRequirementId?: string;
@IsOptional()
@IsString()
parentMachinePieceRequirementId?: string;
@IsOptional()
@IsString()
parentComponentId?: string;
@IsOptional()
@IsString()
parentPieceId?: string;
@IsOptional()
@IsObject()
overrides?: Record<string, unknown>;
}
export class MachinePieceSelectionDto {
export class MachinePieceLinkPayloadDto {
@IsOptional()
@IsString()
id?: string;
@IsOptional()
@IsString()
linkId?: string;
@IsString()
requirementId: string;
@@ -31,7 +94,52 @@ export class MachinePieceSelectionDto {
pieceId?: string;
@IsOptional()
definition?: any;
@IsString()
composantId?: string;
@IsOptional()
@IsString()
parentLinkId?: string;
@IsOptional()
@IsString()
parentComponentLinkId?: string;
@IsOptional()
@IsString()
parentPieceLinkId?: string;
@IsOptional()
@IsString()
parentRequirementId?: string;
@IsOptional()
@IsString()
parentComponentRequirementId?: string;
@IsOptional()
@IsString()
parentPieceRequirementId?: string;
@IsOptional()
@IsString()
parentMachineComponentRequirementId?: string;
@IsOptional()
@IsString()
parentMachinePieceRequirementId?: string;
@IsOptional()
@IsString()
parentComponentId?: string;
@IsOptional()
@IsString()
parentPieceId?: string;
@IsOptional()
@IsObject()
overrides?: Record<string, unknown>;
}
export class CreateMachineDto {
@@ -60,14 +168,14 @@ export class CreateMachineDto {
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachineComponentSelectionDto)
componentSelections?: MachineComponentSelectionDto[];
@Type(() => MachineComponentLinkPayloadDto)
componentLinks?: MachineComponentLinkPayloadDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachinePieceSelectionDto)
pieceSelections?: MachinePieceSelectionDto[];
@Type(() => MachinePieceLinkPayloadDto)
pieceLinks?: MachinePieceLinkPayloadDto[];
}
export class UpdateMachineDto {
@@ -96,12 +204,15 @@ export class ReconfigureMachineDto {
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachineComponentSelectionDto)
componentSelections?: MachineComponentSelectionDto[];
@Type(() => MachineComponentLinkPayloadDto)
componentLinks?: MachineComponentLinkPayloadDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachinePieceSelectionDto)
pieceSelections?: MachinePieceSelectionDto[];
@Type(() => MachinePieceLinkPayloadDto)
pieceLinks?: MachinePieceLinkPayloadDto[];
}
export type MachineComponentLinkInput = MachineComponentLinkPayloadDto;
export type MachinePieceLinkInput = MachinePieceLinkPayloadDto;

View File

@@ -18,14 +18,8 @@ export class SitesService {
machines: {
include: {
typeMachine: true,
composants: {
include: {
typeComposant: true,
sousComposants: true,
pieces: true,
},
},
pieces: true,
componentLinks: true,
pieceLinks: true,
},
},
documents: true,
@@ -40,14 +34,8 @@ export class SitesService {
machines: {
include: {
typeMachine: true,
composants: {
include: {
typeComposant: true,
sousComposants: true,
pieces: true,
},
},
pieces: true,
componentLinks: true,
pieceLinks: true,
},
},
documents: true,

View File

@@ -109,6 +109,32 @@ type PieceRecord = {
updatedAt: Date;
};
type MachineComponentLinkRecord = {
id: string;
machineId: string;
composantId: string;
parentLinkId: Nullable<string>;
typeMachineComponentRequirementId: Nullable<string>;
nameOverride: Nullable<string>;
referenceOverride: Nullable<string>;
prixOverride: Nullable<string>;
createdAt: Date;
updatedAt: Date;
};
type MachinePieceLinkRecord = {
id: string;
machineId: string;
pieceId: string;
parentLinkId: Nullable<string>;
typeMachinePieceRequirementId: Nullable<string>;
nameOverride: Nullable<string>;
referenceOverride: Nullable<string>;
prixOverride: Nullable<string>;
createdAt: Date;
updatedAt: Date;
};
type ModelTypeRecord = {
id: string;
name: string;
@@ -171,6 +197,8 @@ class InMemoryPrismaService {
private machines: MachineRecord[] = [];
private composants: ComposantRecord[] = [];
private pieces: PieceRecord[] = [];
private machineComponentLinks: MachineComponentLinkRecord[] = [];
private machinePieceLinks: MachinePieceLinkRecord[] = [];
private customFields: CustomFieldRecord[] = [];
private customFieldValues: CustomFieldValueRecord[] = [];
private profiles: ProfileRecord[] = [];
@@ -196,6 +224,8 @@ class InMemoryPrismaService {
this.machines = [];
this.composants = [];
this.pieces = [];
this.machineComponentLinks = [];
this.machinePieceLinks = [];
this.customFields = [];
this.customFieldValues = [];
this.profiles = [];
@@ -720,6 +750,108 @@ class InMemoryPrismaService {
},
};
machineComponentLink = {
create: async ({ data, include }: any) => {
const now = new Date();
const record: MachineComponentLinkRecord = {
id: data.id ?? generateId('mcl'),
machineId: data.machineId,
composantId: data.composantId,
parentLinkId: data.parentLinkId ?? null,
typeMachineComponentRequirementId:
data.typeMachineComponentRequirementId ?? null,
nameOverride:
data.nameOverride !== undefined ? data.nameOverride : null,
referenceOverride:
data.referenceOverride !== undefined ? data.referenceOverride : null,
prixOverride:
data.prixOverride !== undefined && data.prixOverride !== null
? String(data.prixOverride)
: data.prixOverride ?? null,
createdAt: now,
updatedAt: now,
};
this.machineComponentLinks.push(record);
return this.buildMachineComponentLink(record, include ?? {});
},
deleteMany: async ({ where }: any) => {
const before = this.machineComponentLinks.length;
this.machineComponentLinks = this.machineComponentLinks.filter((link) => {
if (where?.machineId !== undefined) {
return link.machineId !== where.machineId;
}
return true;
});
return { count: before - this.machineComponentLinks.length };
},
findMany: async ({ where, include }: any = {}) => {
let links = this.machineComponentLinks;
if (where?.machineId) {
links = links.filter((link) => link.machineId === where.machineId);
}
if (where?.parentLinkId === null) {
links = links.filter((link) => link.parentLinkId === null);
} else if (where?.parentLinkId) {
links = links.filter(
(link) => link.parentLinkId === where.parentLinkId,
);
}
return links.map((link) =>
this.buildMachineComponentLink(link, include ?? {}),
);
},
};
machinePieceLink = {
create: async ({ data, include }: any) => {
const now = new Date();
const record: MachinePieceLinkRecord = {
id: data.id ?? generateId('mpl'),
machineId: data.machineId,
pieceId: data.pieceId,
parentLinkId: data.parentLinkId ?? null,
typeMachinePieceRequirementId:
data.typeMachinePieceRequirementId ?? null,
nameOverride:
data.nameOverride !== undefined ? data.nameOverride : null,
referenceOverride:
data.referenceOverride !== undefined ? data.referenceOverride : null,
prixOverride:
data.prixOverride !== undefined && data.prixOverride !== null
? String(data.prixOverride)
: data.prixOverride ?? null,
createdAt: now,
updatedAt: now,
};
this.machinePieceLinks.push(record);
return this.buildMachinePieceLink(record, include ?? {});
},
deleteMany: async ({ where }: any) => {
const before = this.machinePieceLinks.length;
this.machinePieceLinks = this.machinePieceLinks.filter((link) => {
if (where?.machineId !== undefined) {
return link.machineId !== where.machineId;
}
return true;
});
return { count: before - this.machinePieceLinks.length };
},
findMany: async ({ where, include }: any = {}) => {
let links = this.machinePieceLinks;
if (where?.machineId) {
links = links.filter((link) => link.machineId === where.machineId);
}
if (where?.parentLinkId === null) {
links = links.filter((link) => link.parentLinkId === null);
} else if (where?.parentLinkId) {
links = links.filter(
(link) => link.parentLinkId === where.parentLinkId,
);
}
return links.map((link) => this.buildMachinePieceLink(link, include ?? {}));
},
};
customField = {
create: async ({ data }: any) => {
const now = new Date();
@@ -1181,6 +1313,27 @@ class InMemoryPrismaService {
base.constructeur = null;
}
if (include?.componentLinks) {
const links = this.machineComponentLinks.filter(
(link) => link.machineId === machine.id,
);
base.componentLinks = links.map((link) =>
this.buildMachineComponentLink(
link,
include.componentLinks.include ?? {},
),
);
}
if (include?.pieceLinks) {
const links = this.machinePieceLinks.filter(
(link) => link.machineId === machine.id,
);
base.pieceLinks = links.map((link) =>
this.buildMachinePieceLink(link, include.pieceLinks.include ?? {}),
);
}
if (include?.composants) {
const composants = this.composants.filter(
(component) => component.machineId === machine.id,
@@ -1220,6 +1373,87 @@ class InMemoryPrismaService {
return base;
}
private buildMachineComponentLink(
link: MachineComponentLinkRecord,
include: any,
) {
const base: any = { ...link };
if (include?.composant) {
const composant =
this.composants.find((item) => item.id === link.composantId) ?? null;
base.composant = composant
? this.buildComponent(composant, include.composant.include ?? {})
: null;
}
if (include?.typeMachineComponentRequirement) {
const requirement = link.typeMachineComponentRequirementId
? this.typeMachineComponentRequirements.find(
(item) => item.id === link.typeMachineComponentRequirementId,
) ?? null
: null;
base.typeMachineComponentRequirement = requirement
? {
...requirement,
typeComposant:
include.typeMachineComponentRequirement.include?.typeComposant
? this.typeComposants.find(
(item) => item.id === requirement.typeComposantId,
) ?? null
: undefined,
}
: null;
}
if (include?.pieceLinks) {
const nestedInclude = include.pieceLinks.include ?? {};
const pieces = this.machinePieceLinks.filter(
(pieceLink) => pieceLink.parentLinkId === link.id,
);
base.pieceLinks = pieces.map((pieceLink) =>
this.buildMachinePieceLink(pieceLink, nestedInclude),
);
}
return base;
}
private buildMachinePieceLink(
link: MachinePieceLinkRecord,
include: any,
) {
const base: any = { ...link };
if (include?.piece) {
const piece = this.pieces.find((item) => item.id === link.pieceId) ?? null;
base.piece = piece
? this.buildPiece(piece, include.piece.include ?? {})
: null;
}
if (include?.typeMachinePieceRequirement) {
const requirement = link.typeMachinePieceRequirementId
? this.typeMachinePieceRequirements.find(
(item) => item.id === link.typeMachinePieceRequirementId,
) ?? null
: null;
base.typeMachinePieceRequirement = requirement
? {
...requirement,
typePiece:
include.typeMachinePieceRequirement.include?.typePiece
? this.typePieces.find(
(item) => item.id === requirement.typePieceId,
) ?? null
: undefined,
}
: null;
}
return base;
}
private buildComponent(component: ComposantRecord, include: any) {
const base: any = { ...component };
@@ -1459,42 +1693,46 @@ describe('Inventory flow (e2e)', () => {
const componentRequirementId = typeMachine.componentRequirements[0].id;
const pieceRequirementId = typeMachine.pieceRequirements[0].id;
const baseComponent = await prisma.composant.create({
data: {
name: 'Bloc moteur standard',
reference: 'COMP-BASE',
typeComposantId,
},
});
const basePiece = await prisma.piece.create({
data: {
name: 'Kit maintenance standard',
reference: 'KIT-BASE',
typePieceId,
},
});
const machineResponse = await request(app.getHttpServer())
.post('/machines')
.send({
name: 'Presse HP-2000',
siteId,
typeMachineId: typeMachine.id,
componentSelections: [
componentLinks: [
{
requirementId: componentRequirementId,
definition: {
composantId: baseComponent.id,
overrides: {
name: 'Bloc moteur série X',
reference: 'COMP-001',
customFields: [
{
name: 'Puissance nominale',
type: 'text',
required: true,
value: '7 kW',
},
],
prix: '12000.00',
},
},
],
pieceSelections: [
pieceLinks: [
{
requirementId: pieceRequirementId,
definition: {
pieceId: basePiece.id,
overrides: {
name: 'Kit maintenance niveau 1',
reference: 'KIT-001',
customFields: [
{
name: 'Référence fournisseur',
type: 'text',
value: 'STD-002',
},
],
},
},
],
@@ -1509,31 +1747,19 @@ describe('Inventory flow (e2e)', () => {
expect(machineDetailsResponse.status).toBe(200);
const machine = machineDetailsResponse.body;
expect(machine.composants).toHaveLength(1);
expect(machine.pieces).toHaveLength(1);
expect(machine.componentLinks).toHaveLength(1);
expect(machine.pieceLinks).toHaveLength(1);
const component = machine.composants[0];
expect(component.name).toBe('Bloc moteur série X');
expect(component.customFieldValues[0].value).toBe('7 kW');
const componentLink = machine.componentLinks[0];
expect(componentLink.composantId).toBe(baseComponent.id);
expect(componentLink.overrides.name).toBe('Bloc moteur série X');
expect(componentLink.composant.name).toBe('Bloc moteur série X');
expect(componentLink.originalComposant.name).toBe('Bloc moteur standard');
const piece = machine.pieces[0];
expect(piece.name).toBe('Kit maintenance niveau 1');
expect(piece.customFieldValues[0].value).toBe('STD-002');
const customFieldValueId = component.customFieldValues[0].id;
const updateResponse = await request(app.getHttpServer())
.patch(`/custom-fields/values/${customFieldValueId}`)
.send({ value: '8 kW' });
expect(updateResponse.status).toBe(200);
expect(updateResponse.body.value).toBe('8 kW');
const refreshedMachineResponse = await request(app.getHttpServer()).get(
`/machines/${machine.id}`,
);
expect(refreshedMachineResponse.status).toBe(200);
const refreshedComponent = refreshedMachineResponse.body.composants[0];
expect(refreshedComponent.customFieldValues[0].value).toBe('8 kW');
const pieceLink = machine.pieceLinks[0];
expect(pieceLink.pieceId).toBe(basePiece.id);
expect(pieceLink.overrides.name).toBe('Kit maintenance niveau 1');
expect(pieceLink.piece.name).toBe('Kit maintenance niveau 1');
});
describe('POST /composants', () => {