Compare commits

...

10 Commits

Author SHA1 Message Date
Matthieu
6cf2b566ce feat: add product domain and machine integration
- extend Prisma schema with products, product constructs and link tables\n- introduce product service, DTOs and includes with constructeur support\n- integrate product selections across model type skeletons, composants, pièces and machines\n- validate product requirements when building machine skeletons and payloads
2025-11-05 15:34:42 +01:00
Matthieu
e81f71e3e7 fix: prune orphan constructeur links before orientation swap 2025-10-30 12:01:49 +01:00
Matthieu
d05b91d7cd feat: centralize constructeur link synchronization 2025-10-30 11:32:34 +01:00
Matthieu
fe471b9e81 fix: normalize constructeur join tables orientation 2025-10-30 11:32:11 +01:00
Matthieu
9f522a6dbb feat: gérer l'ordre des champs personnalisés 2025-10-28 18:08:08 +01:00
Matthieu
635ea0e84e fix: corrige les associations constructeurs 2025-10-28 16:37:06 +01:00
Matthieu
4db64351b7 feat: autoriser la suppression quand seuls les champs personnalisés restent 2025-10-24 15:50:09 +02:00
Matthieu
b9c9b2c421 fix: préciser les erreurs de suppression des ressources 2025-10-24 15:42:04 +02:00
Matthieu
16a703a4c3 feat: persist type requirement order 2025-10-23 09:36:39 +02:00
Matthieu
582a6fd7e1 feat(backend): enforce unique names and surface duplicate errors 2025-10-13 17:03:36 +02:00
54 changed files with 4362 additions and 337 deletions

View File

@@ -0,0 +1,16 @@
-- Drop previous non-unique index to replace it with a unique constraint
DROP INDEX IF EXISTS "ModelType_category_name_idx";
-- Ensure unique names for machines, components, and pieces
ALTER TABLE "machines"
ADD CONSTRAINT "machines_name_key" UNIQUE ("name");
ALTER TABLE "composants"
ADD CONSTRAINT "composants_name_key" UNIQUE ("name");
ALTER TABLE "pieces"
ADD CONSTRAINT "pieces_name_key" UNIQUE ("name");
-- Enforce unique category/name pairs for model types (component & piece categories)
ALTER TABLE "ModelType"
ADD CONSTRAINT "ModelType_category_name_key" UNIQUE ("category", "name");

View File

@@ -0,0 +1,27 @@
ALTER TABLE "type_machine_component_requirements"
ADD COLUMN "orderIndex" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "type_machine_piece_requirements"
ADD COLUMN "orderIndex" INTEGER NOT NULL DEFAULT 0;
WITH ordered_component_requirements AS (
SELECT
id,
ROW_NUMBER() OVER (PARTITION BY "typeMachineId" ORDER BY "createdAt") - 1 AS idx
FROM "type_machine_component_requirements"
)
UPDATE "type_machine_component_requirements" t
SET "orderIndex" = ordered_component_requirements.idx
FROM ordered_component_requirements
WHERE ordered_component_requirements.id = t.id;
WITH ordered_piece_requirements AS (
SELECT
id,
ROW_NUMBER() OVER (PARTITION BY "typeMachineId" ORDER BY "createdAt") - 1 AS idx
FROM "type_machine_piece_requirements"
)
UPDATE "type_machine_piece_requirements" t
SET "orderIndex" = ordered_piece_requirements.idx
FROM ordered_piece_requirements
WHERE ordered_piece_requirements.id = t.id;

View File

@@ -0,0 +1,64 @@
-- Convert single constructeur relation to many-to-many for machines, composants et pièces
-- Machines → Constructeurs
ALTER TABLE "machines" DROP CONSTRAINT IF EXISTS "machines_constructeurId_fkey";
CREATE TABLE "_MachineConstructeurs" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_MachineConstructeurs_A_fkey" FOREIGN KEY ("A") REFERENCES "machines"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_MachineConstructeurs_B_fkey" FOREIGN KEY ("B") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "_MachineConstructeurs_AB_unique" ON "_MachineConstructeurs"("A", "B");
CREATE INDEX "_MachineConstructeurs_B_index" ON "_MachineConstructeurs"("B");
INSERT INTO "_MachineConstructeurs" ("A", "B")
SELECT "id", "constructeurId"
FROM "machines"
WHERE "constructeurId" IS NOT NULL
ON CONFLICT DO NOTHING;
ALTER TABLE "machines" DROP COLUMN IF EXISTS "constructeurId";
-- Composants → Constructeurs
ALTER TABLE "composants" DROP CONSTRAINT IF EXISTS "composants_constructeurId_fkey";
CREATE TABLE "_ComposantConstructeurs" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_ComposantConstructeurs_A_fkey" FOREIGN KEY ("A") REFERENCES "composants"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_ComposantConstructeurs_B_fkey" FOREIGN KEY ("B") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "_ComposantConstructeurs_AB_unique" ON "_ComposantConstructeurs"("A", "B");
CREATE INDEX "_ComposantConstructeurs_B_index" ON "_ComposantConstructeurs"("B");
INSERT INTO "_ComposantConstructeurs" ("A", "B")
SELECT "id", "constructeurId"
FROM "composants"
WHERE "constructeurId" IS NOT NULL
ON CONFLICT DO NOTHING;
ALTER TABLE "composants" DROP COLUMN IF EXISTS "constructeurId";
-- Pièces → Constructeurs
ALTER TABLE "pieces" DROP CONSTRAINT IF EXISTS "pieces_constructeurId_fkey";
CREATE TABLE "_PieceConstructeurs" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_PieceConstructeurs_A_fkey" FOREIGN KEY ("A") REFERENCES "pieces"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_PieceConstructeurs_B_fkey" FOREIGN KEY ("B") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "_PieceConstructeurs_AB_unique" ON "_PieceConstructeurs"("A", "B");
CREATE INDEX "_PieceConstructeurs_B_index" ON "_PieceConstructeurs"("B");
INSERT INTO "_PieceConstructeurs" ("A", "B")
SELECT "id", "constructeurId"
FROM "pieces"
WHERE "constructeurId" IS NOT NULL
ON CONFLICT DO NOTHING;
ALTER TABLE "pieces" DROP COLUMN IF EXISTS "constructeurId";

View File

@@ -0,0 +1,66 @@
-- Fix the orientation of implicit many-to-many join tables between constructeurs
-- and machines/composants/pièces so that Prisma inserts target IDs into the
-- matching foreign key columns.
-- Machines ↔ Constructeurs
CREATE TABLE "_MachineConstructeurs_new" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_MachineConstructeurs_new_A_fkey" FOREIGN KEY ("A") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_MachineConstructeurs_new_B_fkey" FOREIGN KEY ("B") REFERENCES "machines"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "_MachineConstructeurs_new" ("A", "B")
SELECT "B", "A"
FROM "_MachineConstructeurs";
DROP TABLE "_MachineConstructeurs";
ALTER TABLE "_MachineConstructeurs_new" RENAME TO "_MachineConstructeurs";
ALTER TABLE "_MachineConstructeurs" RENAME CONSTRAINT "_MachineConstructeurs_new_A_fkey" TO "_MachineConstructeurs_A_fkey";
ALTER TABLE "_MachineConstructeurs" RENAME CONSTRAINT "_MachineConstructeurs_new_B_fkey" TO "_MachineConstructeurs_B_fkey";
CREATE UNIQUE INDEX "_MachineConstructeurs_AB_unique" ON "_MachineConstructeurs"("A", "B");
CREATE INDEX "_MachineConstructeurs_B_index" ON "_MachineConstructeurs"("B");
-- Composants ↔ Constructeurs
CREATE TABLE "_ComposantConstructeurs_new" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_ComposantConstructeurs_new_A_fkey" FOREIGN KEY ("A") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_ComposantConstructeurs_new_B_fkey" FOREIGN KEY ("B") REFERENCES "composants"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "_ComposantConstructeurs_new" ("A", "B")
SELECT "B", "A"
FROM "_ComposantConstructeurs";
DROP TABLE "_ComposantConstructeurs";
ALTER TABLE "_ComposantConstructeurs_new" RENAME TO "_ComposantConstructeurs";
ALTER TABLE "_ComposantConstructeurs" RENAME CONSTRAINT "_ComposantConstructeurs_new_A_fkey" TO "_ComposantConstructeurs_A_fkey";
ALTER TABLE "_ComposantConstructeurs" RENAME CONSTRAINT "_ComposantConstructeurs_new_B_fkey" TO "_ComposantConstructeurs_B_fkey";
CREATE UNIQUE INDEX "_ComposantConstructeurs_AB_unique" ON "_ComposantConstructeurs"("A", "B");
CREATE INDEX "_ComposantConstructeurs_B_index" ON "_ComposantConstructeurs"("B");
-- Pièces ↔ Constructeurs
CREATE TABLE "_PieceConstructeurs_new" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_PieceConstructeurs_new_A_fkey" FOREIGN KEY ("A") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_PieceConstructeurs_new_B_fkey" FOREIGN KEY ("B") REFERENCES "pieces"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "_PieceConstructeurs_new" ("A", "B")
SELECT "B", "A"
FROM "_PieceConstructeurs";
DROP TABLE "_PieceConstructeurs";
ALTER TABLE "_PieceConstructeurs_new" RENAME TO "_PieceConstructeurs";
ALTER TABLE "_PieceConstructeurs" RENAME CONSTRAINT "_PieceConstructeurs_new_A_fkey" TO "_PieceConstructeurs_A_fkey";
ALTER TABLE "_PieceConstructeurs" RENAME CONSTRAINT "_PieceConstructeurs_new_B_fkey" TO "_PieceConstructeurs_B_fkey";
CREATE UNIQUE INDEX "_PieceConstructeurs_AB_unique" ON "_PieceConstructeurs"("A", "B");
CREATE INDEX "_PieceConstructeurs_B_index" ON "_PieceConstructeurs"("B");

View File

@@ -0,0 +1,45 @@
-- Restore the original orientation of the machine/composant ↔ constructeur
-- implicit join tables after the previous corrective migration, while keeping
-- the pièce ↔ constructeur table aligned with Prisma's expectations.
-- Machines ↔ Constructeurs must map column A → machine, B → constructeur
CREATE TABLE "_MachineConstructeurs_restored" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_MachineConstructeurs_restored_A_fkey" FOREIGN KEY ("A") REFERENCES "machines"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_MachineConstructeurs_restored_B_fkey" FOREIGN KEY ("B") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "_MachineConstructeurs_restored" ("A", "B")
SELECT "B", "A"
FROM "_MachineConstructeurs";
DROP TABLE "_MachineConstructeurs";
ALTER TABLE "_MachineConstructeurs_restored" RENAME TO "_MachineConstructeurs";
ALTER TABLE "_MachineConstructeurs" RENAME CONSTRAINT "_MachineConstructeurs_restored_A_fkey" TO "_MachineConstructeurs_A_fkey";
ALTER TABLE "_MachineConstructeurs" RENAME CONSTRAINT "_MachineConstructeurs_restored_B_fkey" TO "_MachineConstructeurs_B_fkey";
CREATE UNIQUE INDEX "_MachineConstructeurs_AB_unique" ON "_MachineConstructeurs"("A", "B");
CREATE INDEX "_MachineConstructeurs_B_index" ON "_MachineConstructeurs"("B");
-- Composants ↔ Constructeurs must map column A → composant, B → constructeur
CREATE TABLE "_ComposantConstructeurs_restored" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_ComposantConstructeurs_restored_A_fkey" FOREIGN KEY ("A") REFERENCES "composants"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_ComposantConstructeurs_restored_B_fkey" FOREIGN KEY ("B") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "_ComposantConstructeurs_restored" ("A", "B")
SELECT "B", "A"
FROM "_ComposantConstructeurs";
DROP TABLE "_ComposantConstructeurs";
ALTER TABLE "_ComposantConstructeurs_restored" RENAME TO "_ComposantConstructeurs";
ALTER TABLE "_ComposantConstructeurs" RENAME CONSTRAINT "_ComposantConstructeurs_restored_A_fkey" TO "_ComposantConstructeurs_A_fkey";
ALTER TABLE "_ComposantConstructeurs" RENAME CONSTRAINT "_ComposantConstructeurs_restored_B_fkey" TO "_ComposantConstructeurs_B_fkey";
CREATE UNIQUE INDEX "_ComposantConstructeurs_AB_unique" ON "_ComposantConstructeurs"("A", "B");
CREATE INDEX "_ComposantConstructeurs_B_index" ON "_ComposantConstructeurs"("B");

View File

@@ -0,0 +1,18 @@
-- Introduce an order index for custom fields so their ordering can be persisted.
ALTER TABLE "custom_fields"
ADD COLUMN "orderIndex" INTEGER NOT NULL DEFAULT 0;
WITH ranked AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "typeMachineId", "typeComposantId", "typePieceId"
ORDER BY "createdAt", "id"
) - 1 AS rn
FROM "custom_fields"
)
UPDATE "custom_fields"
SET "orderIndex" = ranked.rn
FROM ranked
WHERE ranked."id" = "custom_fields"."id";

View File

@@ -0,0 +1,14 @@
-- Remove constructeur link rows that reference missing parents or constructeur records.
-- This prevents foreign key violations when the orientation of the join tables is normalized.
DELETE FROM "_MachineConstructeurs"
WHERE "A" NOT IN (SELECT "id" FROM "machines")
OR "B" NOT IN (SELECT "id" FROM "constructeurs");
DELETE FROM "_ComposantConstructeurs"
WHERE "A" NOT IN (SELECT "id" FROM "composants")
OR "B" NOT IN (SELECT "id" FROM "constructeurs");
DELETE FROM "_PieceConstructeurs"
WHERE "A" NOT IN (SELECT "id" FROM "pieces")
OR "B" NOT IN (SELECT "id" FROM "constructeurs");

View File

@@ -0,0 +1,72 @@
-- Restore the implicit many-to-many join table orientation between constructeurs
-- and machines/composants/pièces so Prisma nested writes keep using the expected
-- foreign key columns (A → constructeur, B → parent record).
-- Machines ↔ Constructeurs
CREATE TABLE "_MachineConstructeurs_new" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_MachineConstructeurs_new_A_fkey" FOREIGN KEY ("A") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_MachineConstructeurs_new_B_fkey" FOREIGN KEY ("B") REFERENCES "machines"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "_MachineConstructeurs_new" ("A", "B")
SELECT mc."B", mc."A"
FROM "_MachineConstructeurs" mc
JOIN "machines" m ON mc."B" = m."id"
JOIN "constructeurs" c ON mc."A" = c."id";
DROP TABLE "_MachineConstructeurs";
ALTER TABLE "_MachineConstructeurs_new" RENAME TO "_MachineConstructeurs";
ALTER TABLE "_MachineConstructeurs" RENAME CONSTRAINT "_MachineConstructeurs_new_A_fkey" TO "_MachineConstructeurs_A_fkey";
ALTER TABLE "_MachineConstructeurs" RENAME CONSTRAINT "_MachineConstructeurs_new_B_fkey" TO "_MachineConstructeurs_B_fkey";
CREATE UNIQUE INDEX "_MachineConstructeurs_AB_unique" ON "_MachineConstructeurs"("A", "B");
CREATE INDEX "_MachineConstructeurs_B_index" ON "_MachineConstructeurs"("B");
-- Composants ↔ Constructeurs
CREATE TABLE "_ComposantConstructeurs_new" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_ComposantConstructeurs_new_A_fkey" FOREIGN KEY ("A") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_ComposantConstructeurs_new_B_fkey" FOREIGN KEY ("B") REFERENCES "composants"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "_ComposantConstructeurs_new" ("A", "B")
SELECT cc."B", cc."A"
FROM "_ComposantConstructeurs" cc
JOIN "composants" comp ON cc."B" = comp."id"
JOIN "constructeurs" c ON cc."A" = c."id";
DROP TABLE "_ComposantConstructeurs";
ALTER TABLE "_ComposantConstructeurs_new" RENAME TO "_ComposantConstructeurs";
ALTER TABLE "_ComposantConstructeurs" RENAME CONSTRAINT "_ComposantConstructeurs_new_A_fkey" TO "_ComposantConstructeurs_A_fkey";
ALTER TABLE "_ComposantConstructeurs" RENAME CONSTRAINT "_ComposantConstructeurs_new_B_fkey" TO "_ComposantConstructeurs_B_fkey";
CREATE UNIQUE INDEX "_ComposantConstructeurs_AB_unique" ON "_ComposantConstructeurs"("A", "B");
CREATE INDEX "_ComposantConstructeurs_B_index" ON "_ComposantConstructeurs"("B");
-- Pièces ↔ Constructeurs
CREATE TABLE "_PieceConstructeurs_new" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_PieceConstructeurs_new_A_fkey" FOREIGN KEY ("A") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_PieceConstructeurs_new_B_fkey" FOREIGN KEY ("B") REFERENCES "pieces"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "_PieceConstructeurs_new" ("A", "B")
SELECT pc."B", pc."A"
FROM "_PieceConstructeurs" pc
JOIN "pieces" p ON pc."B" = p."id"
JOIN "constructeurs" c ON pc."A" = c."id";
DROP TABLE "_PieceConstructeurs";
ALTER TABLE "_PieceConstructeurs_new" RENAME TO "_PieceConstructeurs";
ALTER TABLE "_PieceConstructeurs" RENAME CONSTRAINT "_PieceConstructeurs_new_A_fkey" TO "_PieceConstructeurs_A_fkey";
ALTER TABLE "_PieceConstructeurs" RENAME CONSTRAINT "_PieceConstructeurs_new_B_fkey" TO "_PieceConstructeurs_B_fkey";
CREATE UNIQUE INDEX "_PieceConstructeurs_AB_unique" ON "_PieceConstructeurs"("A", "B");
CREATE INDEX "_PieceConstructeurs_B_index" ON "_PieceConstructeurs"("B");

View File

@@ -0,0 +1,73 @@
-- Ensure implicit many-to-many join tables between machines/composants/pièces
-- and constructeurs follow Prisma's expected orientation:
-- A -> parent model (machines/composants/pièces)
-- B -> constructeurs
-- Machines ↔ Constructeurs
CREATE TABLE "_MachineConstructeurs_new" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_MachineConstructeurs_new_A_fkey" FOREIGN KEY ("A") REFERENCES "machines"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_MachineConstructeurs_new_B_fkey" FOREIGN KEY ("B") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "_MachineConstructeurs_new" ("A", "B")
SELECT mc."B", mc."A"
FROM "_MachineConstructeurs" mc
JOIN "machines" m ON mc."B" = m."id"
JOIN "constructeurs" c ON mc."A" = c."id";
DROP TABLE "_MachineConstructeurs";
ALTER TABLE "_MachineConstructeurs_new" RENAME TO "_MachineConstructeurs";
ALTER TABLE "_MachineConstructeurs" RENAME CONSTRAINT "_MachineConstructeurs_new_A_fkey" TO "_MachineConstructeurs_A_fkey";
ALTER TABLE "_MachineConstructeurs" RENAME CONSTRAINT "_MachineConstructeurs_new_B_fkey" TO "_MachineConstructeurs_B_fkey";
CREATE UNIQUE INDEX "_MachineConstructeurs_AB_unique" ON "_MachineConstructeurs"("A", "B");
CREATE INDEX "_MachineConstructeurs_B_index" ON "_MachineConstructeurs"("B");
-- Composants ↔ Constructeurs
CREATE TABLE "_ComposantConstructeurs_new" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_ComposantConstructeurs_new_A_fkey" FOREIGN KEY ("A") REFERENCES "composants"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_ComposantConstructeurs_new_B_fkey" FOREIGN KEY ("B") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "_ComposantConstructeurs_new" ("A", "B")
SELECT cc."B", cc."A"
FROM "_ComposantConstructeurs" cc
JOIN "composants" comp ON cc."B" = comp."id"
JOIN "constructeurs" c ON cc."A" = c."id";
DROP TABLE "_ComposantConstructeurs";
ALTER TABLE "_ComposantConstructeurs_new" RENAME TO "_ComposantConstructeurs";
ALTER TABLE "_ComposantConstructeurs" RENAME CONSTRAINT "_ComposantConstructeurs_new_A_fkey" TO "_ComposantConstructeurs_A_fkey";
ALTER TABLE "_ComposantConstructeurs" RENAME CONSTRAINT "_ComposantConstructeurs_new_B_fkey" TO "_ComposantConstructeurs_B_fkey";
CREATE UNIQUE INDEX "_ComposantConstructeurs_AB_unique" ON "_ComposantConstructeurs"("A", "B");
CREATE INDEX "_ComposantConstructeurs_B_index" ON "_ComposantConstructeurs"("B");
-- Pièces ↔ Constructeurs
CREATE TABLE "_PieceConstructeurs_new" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_PieceConstructeurs_new_A_fkey" FOREIGN KEY ("A") REFERENCES "pieces"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "_PieceConstructeurs_new_B_fkey" FOREIGN KEY ("B") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "_PieceConstructeurs_new" ("A", "B")
SELECT pc."B", pc."A"
FROM "_PieceConstructeurs" pc
JOIN "pieces" p ON pc."B" = p."id"
JOIN "constructeurs" c ON pc."A" = c."id";
DROP TABLE "_PieceConstructeurs";
ALTER TABLE "_PieceConstructeurs_new" RENAME TO "_PieceConstructeurs";
ALTER TABLE "_PieceConstructeurs" RENAME CONSTRAINT "_PieceConstructeurs_new_A_fkey" TO "_PieceConstructeurs_A_fkey";
ALTER TABLE "_PieceConstructeurs" RENAME CONSTRAINT "_PieceConstructeurs_new_B_fkey" TO "_PieceConstructeurs_B_fkey";
CREATE UNIQUE INDEX "_PieceConstructeurs_AB_unique" ON "_PieceConstructeurs"("A", "B");
CREATE INDEX "_PieceConstructeurs_B_index" ON "_PieceConstructeurs"("B");

View File

@@ -0,0 +1,75 @@
-- AlterEnum
ALTER TYPE "ModelCategory" ADD VALUE IF NOT EXISTS 'PRODUCT';
-- AlterTable
ALTER TABLE "ModelType" ADD COLUMN "productSkeleton" JSONB;
ALTER TABLE "composants" ADD COLUMN "productId" TEXT;
ALTER TABLE "pieces" ADD COLUMN "productId" TEXT;
ALTER TABLE "documents" ADD COLUMN "productId" TEXT;
ALTER TABLE "custom_fields" ADD COLUMN "typeProductId" TEXT;
ALTER TABLE "custom_field_values" ADD COLUMN "productId" TEXT;
-- CreateTable
CREATE TABLE "products" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"reference" TEXT,
"supplierPrice" DECIMAL(10,2),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"typeProductId" TEXT,
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "type_machine_product_requirements" (
"id" TEXT NOT NULL,
"label" TEXT,
"minCount" INTEGER NOT NULL DEFAULT 0,
"maxCount" INTEGER,
"required" BOOLEAN NOT NULL DEFAULT false,
"allowNewModels" BOOLEAN NOT NULL DEFAULT true,
"orderIndex" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"typeMachineId" TEXT NOT NULL,
"typeProductId" TEXT NOT NULL,
CONSTRAINT "type_machine_product_requirements_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "_ProductConstructeurs" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "products_name_key" ON "products"("name");
CREATE UNIQUE INDEX "_ProductConstructeurs_AB_unique" ON "_ProductConstructeurs"("A", "B");
CREATE INDEX "_ProductConstructeurs_B_index" ON "_ProductConstructeurs"("B");
-- AddForeignKey
ALTER TABLE "products" ADD CONSTRAINT "products_typeProductId_fkey" FOREIGN KEY ("typeProductId") REFERENCES "ModelType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "composants" ADD CONSTRAINT "composants_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "pieces" ADD CONSTRAINT "pieces_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "documents" ADD CONSTRAINT "documents_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "custom_fields" ADD CONSTRAINT "custom_fields_typeProductId_fkey" FOREIGN KEY ("typeProductId") REFERENCES "ModelType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "custom_field_values" ADD CONSTRAINT "custom_field_values_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "type_machine_product_requirements" ADD CONSTRAINT "type_machine_product_requirements_typeMachineId_fkey" FOREIGN KEY ("typeMachineId") REFERENCES "type_machines"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "type_machine_product_requirements" ADD CONSTRAINT "type_machine_product_requirements_typeProductId_fkey" FOREIGN KEY ("typeProductId") REFERENCES "ModelType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "_ProductConstructeurs" ADD CONSTRAINT "_ProductConstructeurs_A_fkey" FOREIGN KEY ("A") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "_ProductConstructeurs" ADD CONSTRAINT "_ProductConstructeurs_B_fkey" FOREIGN KEY ("B") REFERENCES "constructeurs"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "machine_product_links" (
"id" TEXT NOT NULL,
"machineId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"typeMachineProductRequirementId" TEXT,
"parentLinkId" TEXT,
"parentComponentLinkId" TEXT,
"parentPieceLinkId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "machine_product_links_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "machines"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_typeMachineProductRequirementId_fkey" FOREIGN KEY ("typeMachineProductRequirementId") REFERENCES "type_machine_product_requirements"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_parentLinkId_fkey" FOREIGN KEY ("parentLinkId") REFERENCES "machine_product_links"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_parentComponentLinkId_fkey" FOREIGN KEY ("parentComponentLinkId") REFERENCES "machine_component_links"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "machine_product_links" ADD CONSTRAINT "machine_product_links_parentPieceLinkId_fkey" FOREIGN KEY ("parentPieceLinkId") REFERENCES "machine_piece_links"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX "machine_product_links_machineId_idx" ON "machine_product_links"("machineId");
CREATE INDEX "machine_product_links_productId_idx" ON "machine_product_links"("productId");

View File

@@ -47,13 +47,14 @@ model TypeMachine {
customFields CustomField[] @relation("TypeMachineCustomFields")
componentRequirements TypeMachineComponentRequirement[]
pieceRequirements TypeMachinePieceRequirement[]
productRequirements TypeMachineProductRequirement[]
@@map("type_machines")
}
model Machine {
id String @id @default(cuid())
name String
name String @unique
reference String?
prix Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now())
@@ -66,11 +67,11 @@ model Machine {
typeMachineId String?
typeMachine TypeMachine? @relation(fields: [typeMachineId], references: [id])
constructeurId String?
constructeur Constructeur? @relation(fields: [constructeurId], references: [id], onDelete: SetNull)
constructeurs Constructeur[] @relation("MachineConstructeurs")
componentLinks MachineComponentLink[]
pieceLinks MachinePieceLink[]
productLinks MachineProductLink[]
documents Document[] @relation("MachineDocuments")
customFieldValues CustomFieldValue[] @relation("MachineCustomFieldValues")
@@ -79,7 +80,7 @@ model Machine {
model Composant {
id String @id @default(cuid())
name String
name String @unique
reference String?
prix Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now())
@@ -89,8 +90,10 @@ model Composant {
typeComposantId String?
typeComposant ModelType? @relation("ModelTypeComponentAssignments", fields: [typeComposantId], references: [id])
constructeurId String?
constructeur Constructeur? @relation(fields: [constructeurId], references: [id], onDelete: SetNull)
productId String?
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
constructeurs Constructeur[] @relation("ComposantConstructeurs")
documents Document[] @relation("ComposantDocuments")
customFieldValues CustomFieldValue[] @relation("ComposantCustomFieldValues")
@@ -101,7 +104,7 @@ model Composant {
model Piece {
id String @id @default(cuid())
name String
name String @unique
reference String?
prix Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now())
@@ -110,8 +113,10 @@ model Piece {
typePieceId String?
typePiece ModelType? @relation("ModelTypePieceAssignments", fields: [typePieceId], references: [id])
constructeurId String?
constructeur Constructeur? @relation(fields: [constructeurId], references: [id], onDelete: SetNull)
productId String?
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
constructeurs Constructeur[] @relation("PieceConstructeurs")
documents Document[] @relation("PieceDocuments")
customFieldValues CustomFieldValue[] @relation("PieceCustomFieldValues")
@@ -120,6 +125,27 @@ model Piece {
@@map("pieces")
}
model Product {
id String @id @default(cuid())
name String @unique
reference String?
supplierPrice Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
typeProductId String?
typeProduct ModelType? @relation("ModelTypeProductAssignments", fields: [typeProductId], references: [id])
constructeurs Constructeur[] @relation("ProductConstructeurs")
documents Document[] @relation("ProductDocuments")
customFieldValues CustomFieldValue[] @relation("ProductCustomFieldValues")
pieces Piece[]
composants Composant[]
machineLinks MachineProductLink[]
@@map("products")
}
model MachineComponentLink {
id String @id @default(cuid())
machineId String
@@ -138,6 +164,7 @@ model MachineComponentLink {
childLinks MachineComponentLink[] @relation("MachineComponentLinkHierarchy")
typeMachineComponentRequirement TypeMachineComponentRequirement? @relation("ComponentRequirementLinks", fields: [typeMachineComponentRequirementId], references: [id], onDelete: SetNull)
pieceLinks MachinePieceLink[] @relation("ComponentLinkPieceLinks")
productLinks MachineProductLink[] @relation("ComponentLinkProductLinks")
@@map("machine_component_links")
}
@@ -158,13 +185,37 @@ model MachinePieceLink {
piece Piece @relation(fields: [pieceId], references: [id], onDelete: Cascade)
parentLink MachineComponentLink? @relation("ComponentLinkPieceLinks", fields: [parentLinkId], references: [id], onDelete: Cascade)
typeMachinePieceRequirement TypeMachinePieceRequirement? @relation("PieceRequirementLinks", fields: [typeMachinePieceRequirementId], references: [id], onDelete: SetNull)
productLinks MachineProductLink[] @relation("PieceLinkProductLinks")
@@map("machine_piece_links")
}
model MachineProductLink {
id String @id @default(cuid())
machineId String
productId String
typeMachineProductRequirementId String?
parentLinkId String?
parentComponentLinkId String?
parentPieceLinkId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
typeMachineProductRequirement TypeMachineProductRequirement? @relation("ProductRequirementLinks", fields: [typeMachineProductRequirementId], references: [id], onDelete: SetNull)
parentLink MachineProductLink? @relation("MachineProductLinkHierarchy", fields: [parentLinkId], references: [id], onDelete: Cascade)
childLinks MachineProductLink[] @relation("MachineProductLinkHierarchy")
parentComponentLink MachineComponentLink? @relation("ComponentLinkProductLinks", fields: [parentComponentLinkId], references: [id], onDelete: Cascade)
parentPieceLink MachinePieceLink? @relation("PieceLinkProductLinks", fields: [parentPieceLinkId], references: [id], onDelete: Cascade)
@@map("machine_product_links")
}
enum ModelCategory {
COMPONENT
PIECE
PRODUCT
}
model ModelType {
@@ -176,17 +227,21 @@ model ModelType {
description String? @db.Text
componentSkeleton Json?
pieceSkeleton Json?
productSkeleton Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
composants Composant[] @relation("ModelTypeComponentAssignments")
componentRequirements TypeMachineComponentRequirement[] @relation("ModelTypeComponentRequirements")
customFields CustomField[] @relation("ModelTypeCustomFields")
productCustomFields CustomField[] @relation("ModelTypeProductCustomFields")
pieceRequirements TypeMachinePieceRequirement[] @relation("ModelTypePieceRequirements")
pieces Piece[] @relation("ModelTypePieceAssignments")
pieceCustomFields CustomField[] @relation("ModelTypePieceCustomFields")
products Product[] @relation("ModelTypeProductAssignments")
productRequirements TypeMachineProductRequirement[] @relation("ModelTypeProductRequirements")
@@index([category, name])
@@unique([category, name])
}
model Constructeur {
@@ -197,9 +252,10 @@ model Constructeur {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
machines Machine[]
composants Composant[]
pieces Piece[]
machines Machine[] @relation("MachineConstructeurs")
composants Composant[] @relation("ComposantConstructeurs")
pieces Piece[] @relation("PieceConstructeurs")
products Product[] @relation("ProductConstructeurs")
@@map("constructeurs")
}
@@ -235,6 +291,9 @@ model Document {
pieceId String?
piece Piece? @relation("PieceDocuments", fields: [pieceId], references: [id], onDelete: Cascade)
productId String?
product Product? @relation("ProductDocuments", fields: [productId], references: [id], onDelete: Cascade)
siteId String?
site Site? @relation("SiteDocuments", fields: [siteId], references: [id], onDelete: Cascade)
@@ -248,6 +307,7 @@ model CustomField {
required Boolean @default(false)
defaultValue String?
options String[] // Pour les champs de type SELECT
orderIndex Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -261,6 +321,9 @@ model CustomField {
typePieceId String?
typePiece ModelType? @relation("ModelTypePieceCustomFields", fields: [typePieceId], references: [id], onDelete: Cascade)
typeProductId String?
typeProduct ModelType? @relation("ModelTypeProductCustomFields", fields: [typeProductId], references: [id], onDelete: Cascade)
// Relations avec les valeurs
customFieldValues CustomFieldValue[]
@@ -286,6 +349,9 @@ model CustomFieldValue {
pieceId String?
piece Piece? @relation("PieceCustomFieldValues", fields: [pieceId], references: [id], onDelete: Cascade)
productId String?
product Product? @relation("ProductCustomFieldValues", fields: [productId], references: [id], onDelete: Cascade)
@@map("custom_field_values")
}
@@ -296,6 +362,7 @@ model TypeMachineComponentRequirement {
maxCount Int?
required Boolean @default(true)
allowNewModels Boolean @default(true)
orderIndex Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -317,6 +384,7 @@ model TypeMachinePieceRequirement {
maxCount Int?
required Boolean @default(false)
allowNewModels Boolean @default(true)
orderIndex Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -330,3 +398,24 @@ model TypeMachinePieceRequirement {
@@map("type_machine_piece_requirements")
}
model TypeMachineProductRequirement {
id String @id @default(cuid())
label String?
minCount Int @default(0)
maxCount Int?
required Boolean @default(false)
allowNewModels Boolean @default(true)
orderIndex Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
typeMachineId String
typeMachine TypeMachine @relation(fields: [typeMachineId], references: [id], onDelete: Cascade)
typeProductId String
typeProduct ModelType @relation("ModelTypeProductRequirements", fields: [typeProductId], references: [id])
machineProductLinks MachineProductLink[] @relation("ProductRequirementLinks")
@@map("type_machine_product_requirements")
}

View File

@@ -9,8 +9,10 @@ async function deleteExistingData() {
await prisma.machinePieceLink.deleteMany();
await prisma.machine.deleteMany();
await prisma.customFieldValue.deleteMany();
await prisma.product.deleteMany();
await prisma.composant.deleteMany();
await prisma.piece.deleteMany();
await prisma.typeMachineProductRequirement.deleteMany();
await prisma.modelType.deleteMany({
where: {
@@ -22,6 +24,7 @@ async function deleteExistingData() {
'cooling-module',
'structural-frame',
'hydraulic-power-unit',
'hydraulic-product',
],
},
},
@@ -120,7 +123,7 @@ async function createPiece(options: {
name: string;
reference: string;
price: number;
constructeurId?: string | null;
constructeurIds?: string[] | null;
typeId: string;
fieldValues: Record<string, string>;
}) {
@@ -143,17 +146,35 @@ async function createPiece(options: {
},
);
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,
},
const constructeurIds = Array.isArray(options.constructeurIds)
? Array.from(
new Set(
options.constructeurIds
.filter((value): value is string => typeof value === 'string')
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
)
: [];
const data: any = {
name: options.name,
reference: options.reference,
prix: new Prisma.Decimal(options.price),
typePieceId: options.typeId,
customFieldValues: {
create: customFieldValues,
},
};
if (constructeurIds.length) {
data.constructeurs = {
connect: constructeurIds.map((id) => ({ id })),
};
}
return prisma.piece.create({
data,
});
}
@@ -161,7 +182,7 @@ async function createComponent(options: {
name: string;
reference: string;
price: number;
constructeurId?: string | null;
constructeurIds?: string[] | null;
typeId: string;
fieldValues: Record<string, string>;
structure?: Prisma.InputJsonValue;
@@ -185,22 +206,169 @@ async function createComponent(options: {
},
);
const constructeurIds = Array.isArray(options.constructeurIds)
? Array.from(
new Set(
options.constructeurIds
.filter((value): value is string => typeof value === 'string')
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
)
: [];
const data: any = {
name: options.name,
reference: options.reference,
prix: new Prisma.Decimal(options.price),
typeComposantId: options.typeId,
structure:
options.structure === undefined
? Prisma.JsonNull
: options.structure ?? Prisma.JsonNull,
customFieldValues: {
create: customFieldValues,
},
};
if (constructeurIds.length) {
data.constructeurs = {
connect: constructeurIds.map((id) => ({ id })),
};
}
return prisma.composant.create({
data,
});
}
async function createProductType(
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: 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,
name,
code,
category: 'PRODUCT',
description,
productSkeleton: skeleton
? (skeleton as Prisma.InputJsonValue)
: Prisma.JsonNull,
productCustomFields: {
create: fields.map((field, index) => ({
name: field.name,
type: field.type,
required: field.required ?? false,
options: field.options ?? [],
orderIndex: index,
})),
},
},
});
const customFields = await prisma.customField.findMany({
where: { typeProductId: type.id },
});
const fieldMap: CreatedFields = {};
customFields.forEach((field) => {
fieldMap[field.name] = field.id;
});
return { type, fieldMap };
}
async function createProduct(options: {
name: string;
reference?: string;
supplierPrice?: number | null;
constructeurIds?: string[] | null;
typeId?: string | null;
fieldValues?: Record<string, string>;
}) {
const fieldValues = options.fieldValues ?? {};
const customFields = options.typeId
? await prisma.customField.findMany({
where: { typeProductId: options.typeId },
})
: [];
const customFieldValues = Object.entries(fieldValues).flatMap(
([fieldName, value]) => {
if (typeof value !== 'string') {
return [];
}
const target = customFields.find((field) => field.name === fieldName);
if (!target) {
return [];
}
return [
{
value,
customFieldId: target.id,
},
];
},
);
const constructeurIds = Array.isArray(options.constructeurIds)
? Array.from(
new Set(
options.constructeurIds
.filter((value): value is string => typeof value === 'string')
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
)
: [];
const data: Prisma.ProductCreateInput = {
name: options.name,
reference: options.reference ?? null,
supplierPrice:
options.supplierPrice === undefined || options.supplierPrice === null
? null
: new Prisma.Decimal(options.supplierPrice),
};
if (options.typeId) {
data.typeProduct = {
connect: { id: options.typeId },
};
}
if (constructeurIds.length) {
data.constructeurs = {
connect: constructeurIds.map((id) => ({ id })),
};
}
if (customFieldValues.length) {
data.customFieldValues = {
create: customFieldValues.map((entry) => ({
value: entry.value,
customField: {
connect: { id: entry.customFieldId },
},
})),
};
}
return prisma.product.create({
data,
});
}
async function main() {
@@ -335,6 +503,63 @@ async function main() {
},
});
console.log('Création des types de produits…');
const hydraulicProductFields: {
name: string;
type: 'text' | 'number' | 'select' | 'boolean' | 'date';
required?: boolean;
options?: string[];
}[] = [
{ name: 'Fournisseur', type: 'text', required: true },
{ name: 'Garantie (mois)', type: 'number', required: true },
{
name: 'Délai dapprovisionnement (jours)',
type: 'number',
},
];
const hydraulicProductType = await createProductType(
'Produit hydraulique standard',
'hydraulic-product',
'Produits compatibles avec les centrales hydrauliques',
hydraulicProductFields,
);
console.log('Création des produits…');
const pumpProduct = await createProduct({
name: 'Pompe PX-300 Fournisseur A',
reference: 'PRD-PX-300-A',
supplierPrice: 1520,
typeId: hydraulicProductType.type.id,
fieldValues: {
Fournisseur: 'HydrauParts',
'Garantie (mois)': '24',
'Délai dapprovisionnement (jours)': '21',
},
});
const coolingProduct = await createProduct({
name: 'Module de refroidissement AC-50 - OEM',
reference: 'PRD-AC-50',
supplierPrice: 1980,
typeId: hydraulicProductType.type.id,
fieldValues: {
Fournisseur: 'ThermoTech',
'Garantie (mois)': '18',
'Délai dapprovisionnement (jours)': '28',
},
});
console.log('Association des produits aux pièces…');
await prisma.piece.update({
where: { id: pumpPiece.id },
data: {
product: {
connect: { id: pumpProduct.id },
},
},
});
console.log('Création des types de composants…');
const coolingComponentFields: {
name: string;
@@ -473,6 +698,15 @@ async function main() {
} as Prisma.InputJsonValue,
});
await prisma.composant.update({
where: { id: coolingModule.id },
data: {
product: {
connect: { id: coolingProduct.id },
},
},
});
const structuralFrame = await createComponent({
name: 'Châssis structurel XC-800',
reference: 'FRAME-XC800',

View File

@@ -14,6 +14,7 @@ import { ConstructeursModule } from './constructeurs/constructeurs.module';
import { ProfilesModule } from './profiles/profiles.module';
import { SessionModule } from './session/session.module';
import { ModelTypeModule } from './model-type/model-type.module';
import { ProductsModule } from './products/products.module';
@Module({
imports: [
@@ -32,6 +33,7 @@ import { ModelTypeModule } from './model-type/model-type.module';
ProfilesModule,
SessionModule,
ModelTypeModule,
ProductsModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -6,20 +6,34 @@ const CUSTOM_FIELD_SELECT = {
type: true,
required: true,
options: true,
orderIndex: true,
} as const;
export const COMPONENT_WITH_RELATIONS_INCLUDE = {
typeComposant: {
include: {
customFields: true,
customFields: {
orderBy: { orderIndex: 'asc' },
},
},
},
constructeur: true,
constructeurs: true,
customFieldValues: {
include: {
customField: { select: CUSTOM_FIELD_SELECT },
},
},
product: {
include: {
constructeurs: true,
customFieldValues: {
include: {
customField: { select: CUSTOM_FIELD_SELECT },
},
},
documents: true,
},
},
machineLinks: {
include: {
machine: true,

View File

@@ -4,4 +4,5 @@ export const CUSTOM_FIELD_SELECT = {
type: true,
required: true,
options: true,
orderIndex: true,
} as const;

View File

@@ -0,0 +1,32 @@
import { Prisma } from '@prisma/client';
export const PRODUCT_WITH_RELATIONS_INCLUDE = {
typeProduct: {
include: {
productCustomFields: {
orderBy: { orderIndex: 'asc' },
},
},
},
constructeurs: true,
documents: true,
customFieldValues: {
include: {
customField: true,
},
},
pieces: {
select: {
id: true,
name: true,
reference: true,
},
},
composants: {
select: {
id: true,
name: true,
reference: true,
},
},
} satisfies Prisma.ProductInclude;

View File

@@ -43,7 +43,10 @@ describe('ModelTypeMapper', () => {
description: 'Desc',
notes: 'Desc',
});
expect(input.customFields?.create?.[0]).toMatchObject({ name: 'Field' });
expect(input.customFields?.create?.[0]).toMatchObject({
name: 'Field',
orderIndex: 0,
});
expect((input as any).componentSkeleton).toEqual({
pieces: [
{

View File

@@ -12,12 +12,18 @@ import type {
import { CUSTOM_FIELD_SELECT } from '../constants/custom-field.constant';
export const COMPONENT_TYPE_INCLUDE: Prisma.ModelTypeInclude = {
customFields: { select: CUSTOM_FIELD_SELECT },
customFields: {
select: CUSTOM_FIELD_SELECT,
orderBy: { orderIndex: 'asc' },
},
composants: true,
};
export const PIECE_TYPE_INCLUDE: Prisma.ModelTypeInclude = {
pieceCustomFields: { select: CUSTOM_FIELD_SELECT },
pieceCustomFields: {
select: CUSTOM_FIELD_SELECT,
orderBy: { orderIndex: 'asc' },
},
pieceRequirements: true,
pieces: true,
};
@@ -42,11 +48,12 @@ export class ModelTypeMapper {
notes: description ?? null,
customFields: customFields
? {
create: customFields.map((field) => ({
create: customFields.map((field, index) => ({
name: field.name,
type: field.type,
required: field.required ?? false,
options: field.options,
orderIndex: field.orderIndex ?? index,
})),
}
: undefined,
@@ -97,11 +104,12 @@ export class ModelTypeMapper {
notes: description ?? null,
pieceCustomFields: customFields
? {
create: customFields.map((field) => ({
create: customFields.map((field, index) => ({
name: field.name,
type: field.type,
required: field.required ?? false,
options: field.options,
orderIndex: field.orderIndex ?? index,
})),
}
: undefined,
@@ -165,11 +173,12 @@ export class ModelTypeMapper {
return [];
}
return fields.map((field) => ({
return fields.map((field, index) => ({
name: field.name,
type: field.type,
required: field.required ?? false,
options: field.options,
orderIndex: field.orderIndex ?? index,
}));
}
@@ -180,11 +189,12 @@ export class ModelTypeMapper {
return [];
}
return fields.map((field) => ({
return fields.map((field, index) => ({
name: field.name,
type: field.type,
required: field.required ?? false,
options: field.options,
orderIndex: field.orderIndex ?? index,
}));
}
}

View File

@@ -26,6 +26,16 @@ const baseDto = {
typePieceId: 'piece-id',
},
],
productRequirements: [
{
label: 'Product',
minCount: 1,
maxCount: 3,
required: true,
allowNewModels: true,
typeProductId: 'product-id',
},
],
};
describe('TypeMachineMapper', () => {
@@ -33,12 +43,16 @@ describe('TypeMachineMapper', () => {
const input = TypeMachineMapper.toCreateInput(baseDto as any);
expect(input.customFields?.create).toHaveLength(1);
expect(input.customFields?.create?.[0]).toMatchObject({
orderIndex: 0,
});
expect(input.componentRequirements?.create?.[0]).toMatchObject({
label: 'Comp',
minCount: 2,
maxCount: 4,
required: true,
allowNewModels: false,
orderIndex: 0,
});
expect(input.pieceRequirements?.create?.[0]).toMatchObject({
label: 'Piece',
@@ -46,6 +60,15 @@ describe('TypeMachineMapper', () => {
maxCount: 2,
required: false,
allowNewModels: true,
orderIndex: 0,
});
expect(input.productRequirements?.create?.[0]).toMatchObject({
label: 'Product',
minCount: 1,
maxCount: 3,
required: true,
allowNewModels: true,
orderIndex: 0,
});
});
@@ -59,6 +82,7 @@ describe('TypeMachineMapper', () => {
type: 'string',
required: true,
options: ['a'],
orderIndex: 0,
},
]);
});
@@ -70,16 +94,27 @@ describe('TypeMachineMapper', () => {
const piece = TypeMachineMapper.mapPieceRequirementInputs(
baseDto.pieceRequirements as any,
);
const product = TypeMachineMapper.mapProductRequirementInputs(
baseDto.productRequirements as any,
);
expect(component[0]).toMatchObject({
typeComposantId: 'comp-id',
minCount: 2,
maxCount: 4,
orderIndex: 0,
});
expect(piece[0]).toMatchObject({
typePieceId: 'piece-id',
minCount: 0,
maxCount: 2,
orderIndex: 0,
});
expect(product[0]).toMatchObject({
typeProductId: 'product-id',
minCount: 1,
maxCount: 3,
orderIndex: 0,
});
});
});

View File

@@ -13,15 +13,26 @@ type RequirementDto = {
allowNewModels?: boolean | null;
typeComposantId?: string;
typePieceId?: string;
typeProductId?: string;
orderIndex?: number | null;
};
export const TYPE_MACHINE_DEFAULT_INCLUDE: Prisma.TypeMachineInclude = {
customFields: { select: CUSTOM_FIELD_SELECT },
customFields: {
select: CUSTOM_FIELD_SELECT,
orderBy: { orderIndex: 'asc' },
},
componentRequirements: {
include: { typeComposant: true },
orderBy: { orderIndex: 'asc' },
},
pieceRequirements: {
include: { typePiece: true },
orderBy: { orderIndex: 'asc' },
},
productRequirements: {
include: { typeProduct: true },
orderBy: { orderIndex: 'asc' },
},
};
@@ -34,8 +45,13 @@ export class TypeMachineMapper {
static toCreateInput(
dto: CreateTypeMachineDto,
): Prisma.TypeMachineCreateInput {
const { customFields, componentRequirements, pieceRequirements, ...data } =
dto;
const {
customFields,
componentRequirements,
pieceRequirements,
productRequirements,
...data
} = dto;
return {
...data,
@@ -44,14 +60,20 @@ export class TypeMachineMapper {
componentRequirements,
),
pieceRequirements: this.mapPieceRequirements(pieceRequirements),
productRequirements: this.mapProductRequirements(productRequirements),
};
}
static toUpdateData(
dto: UpdateTypeMachineDto,
): Prisma.TypeMachineUpdateInput {
const { customFields, componentRequirements, pieceRequirements, ...data } =
dto;
const {
customFields,
componentRequirements,
pieceRequirements,
productRequirements,
...data
} = dto;
const payload: Prisma.TypeMachineUpdateInput = { ...data };
@@ -67,6 +89,10 @@ export class TypeMachineMapper {
payload.pieceRequirements = undefined;
}
if (productRequirements !== undefined) {
payload.productRequirements = undefined;
}
return payload;
}
@@ -78,11 +104,12 @@ export class TypeMachineMapper {
}
return {
create: fields.map((field) => ({
create: fields.map((field, index) => ({
name: field.name,
type: field.type,
required: field.required ?? false,
options: field.options,
orderIndex: field.orderIndex ?? index,
})),
};
}
@@ -92,11 +119,12 @@ export class TypeMachineMapper {
return [];
}
return fields.map((field) => ({
return fields.map((field, index) => ({
name: field.name,
type: field.type,
required: field.required ?? false,
options: field.options,
orderIndex: field.orderIndex ?? index,
}));
}
@@ -110,12 +138,13 @@ export class TypeMachineMapper {
}
return {
create: requirements.map((requirement) => ({
create: requirements.map((requirement, index) => ({
label: requirement.label ?? null,
minCount: requirement.minCount ?? 1,
maxCount: requirement.maxCount ?? null,
required: requirement.required ?? true,
allowNewModels: requirement.allowNewModels ?? true,
orderIndex: requirement.orderIndex ?? index,
typeComposant: requirement.typeComposantId
? {
connect: { id: requirement.typeComposantId },
@@ -134,12 +163,13 @@ export class TypeMachineMapper {
return [];
}
return requirements.map((requirement) => ({
return requirements.map((requirement, index) => ({
label: requirement.label ?? null,
minCount: requirement.minCount ?? 1,
maxCount: requirement.maxCount ?? null,
required: requirement.required ?? true,
allowNewModels: requirement.allowNewModels ?? true,
orderIndex: requirement.orderIndex ?? index,
typeComposantId: requirement.typeComposantId!,
}));
}
@@ -154,12 +184,13 @@ export class TypeMachineMapper {
}
return {
create: requirements.map((requirement) => ({
create: requirements.map((requirement, index) => ({
label: requirement.label ?? null,
minCount: requirement.minCount ?? 0,
maxCount: requirement.maxCount ?? null,
required: requirement.required ?? false,
allowNewModels: requirement.allowNewModels ?? true,
orderIndex: requirement.orderIndex ?? index,
typePiece: requirement.typePieceId
? {
connect: { id: requirement.typePieceId },
@@ -178,13 +209,60 @@ export class TypeMachineMapper {
return [];
}
return requirements.map((requirement) => ({
return requirements.map((requirement, index) => ({
label: requirement.label ?? null,
minCount: requirement.minCount ?? 0,
maxCount: requirement.maxCount ?? null,
required: requirement.required ?? false,
allowNewModels: requirement.allowNewModels ?? true,
orderIndex: requirement.orderIndex ?? index,
typePieceId: requirement.typePieceId!,
}));
}
static mapProductRequirements(
requirements?: RequirementDto[] | null,
):
| Prisma.TypeMachineProductRequirementCreateNestedManyWithoutTypeMachineInput
| undefined {
if (!requirements || requirements.length === 0) {
return undefined;
}
return {
create: requirements.map((requirement, index) => ({
label: requirement.label ?? null,
minCount: requirement.minCount ?? 0,
maxCount: requirement.maxCount ?? null,
required: requirement.required ?? false,
allowNewModels: requirement.allowNewModels ?? true,
orderIndex: requirement.orderIndex ?? index,
typeProduct: requirement.typeProductId
? {
connect: { id: requirement.typeProductId },
}
: (() => {
throw new Error(
'typeProductId est requis pour créer une contrainte produit.',
);
})(),
})),
};
}
static mapProductRequirementInputs(requirements?: RequirementDto[] | null) {
if (!requirements || requirements.length === 0) {
return [];
}
return requirements.map((requirement, index) => ({
label: requirement.label ?? null,
minCount: requirement.minCount ?? 0,
maxCount: requirement.maxCount ?? null,
required: requirement.required ?? false,
allowNewModels: requirement.allowNewModels ?? true,
orderIndex: requirement.orderIndex ?? index,
typeProductId: requirement.typeProductId!,
}));
}
}

View File

@@ -45,6 +45,7 @@ describe('ModelTypesRepository', () => {
type: 'string',
required: true,
options: [],
orderIndex: 0,
typeComposantId: 'comp-id',
},
],
@@ -63,6 +64,7 @@ describe('ModelTypesRepository', () => {
type: 'string',
required: false,
options: [],
orderIndex: 0,
typePieceId: 'piece-id',
},
],

View File

@@ -44,6 +44,7 @@ describe('TypeMachinesRepository', () => {
type: 'string',
required: true,
options: [],
orderIndex: 0,
typeMachineId: 'machine-id',
},
],

View File

@@ -17,6 +17,11 @@ type PieceRequirementInput = Omit<
'id' | 'typeMachineId'
>;
type ProductRequirementInput = Omit<
Prisma.TypeMachineProductRequirementCreateManyInput,
'id' | 'typeMachineId'
>;
@Injectable()
export class TypeMachinesRepository {
constructor(private readonly prisma: PrismaService) {}
@@ -132,6 +137,28 @@ export class TypeMachinesRepository {
});
}
async deleteProductRequirements(typeMachineId: string) {
await this.client.typeMachineProductRequirement.deleteMany({
where: { typeMachineId },
});
}
async createProductRequirements(
typeMachineId: string,
requirements: ProductRequirementInput[],
) {
if (!requirements.length) {
return;
}
await this.client.typeMachineProductRequirement.createMany({
data: requirements.map((requirement) => ({
...requirement,
typeMachineId,
})),
});
}
async findMachinesUsingType(typeMachineId: string) {
return this.client.machine.findMany({
where: { typeMachineId },

View File

@@ -0,0 +1,254 @@
import { Prisma } from '@prisma/client';
import type { PrismaService } from '../../prisma/prisma.service';
type PrismaExecutor = Prisma.TransactionClient | PrismaService;
type LinkOrientation = {
parentColumn: 'A' | 'B';
constructeurColumn: 'A' | 'B';
};
const DEFAULT_ORIENTATIONS: Record<string, LinkOrientation> = {
_MachineConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_ComposantConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_PieceConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_ProductConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
};
const sanitizeTableName = (tableName: string): string => {
if (!/^_[A-Za-z0-9]+$/.test(tableName)) {
throw new Error(`Invalid constructeur link table name: ${tableName}`);
}
return `"${tableName}"`;
};
const ORIENTATION_CACHE = new Map<string, LinkOrientation>();
const KNOWN_PARENT_TABLES = new Set([
'machines',
'composants',
'pieces',
'products',
]);
const oppositeColumn = (column: 'A' | 'B'): 'A' | 'B' =>
column === 'A' ? 'B' : 'A';
async function resolveOrientation(
prisma: PrismaExecutor & {
__getConstructeurLinkOrientation?: (
table: string,
) => LinkOrientation | Promise<LinkOrientation>;
},
tableName: string,
): Promise<LinkOrientation> {
const cached = ORIENTATION_CACHE.get(tableName);
if (cached) {
return cached;
}
if (typeof prisma.__getConstructeurLinkOrientation === 'function') {
const orientation =
await prisma.__getConstructeurLinkOrientation(tableName);
ORIENTATION_CACHE.set(tableName, orientation);
return orientation;
}
const rows = await prisma.$queryRaw<
Array<{ column_name: string; foreign_table_name: string }>
>(Prisma.sql`
SELECT
kcu.column_name,
ccu.table_name AS foreign_table_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name
AND tc.table_schema = ccu.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND tc.table_name = ${tableName}
`);
let parentColumn: 'A' | 'B' | null = null;
let constructeurColumn: 'A' | 'B' | null = null;
for (const row of rows) {
const column = row.column_name?.toUpperCase?.() as 'A' | 'B' | undefined;
if (column !== 'A' && column !== 'B') {
continue;
}
if (row.foreign_table_name === 'constructeurs') {
constructeurColumn = column;
continue;
}
if (KNOWN_PARENT_TABLES.has(row.foreign_table_name)) {
parentColumn = column;
continue;
}
if (!parentColumn) {
parentColumn = column;
}
}
if (parentColumn && !constructeurColumn) {
constructeurColumn = oppositeColumn(parentColumn);
} else if (!parentColumn && constructeurColumn) {
parentColumn = oppositeColumn(constructeurColumn);
}
if (!parentColumn || !constructeurColumn) {
const fallback = DEFAULT_ORIENTATIONS[tableName];
if (fallback) {
parentColumn ??= fallback.parentColumn;
constructeurColumn ??= fallback.constructeurColumn;
}
}
if (!parentColumn || !constructeurColumn) {
const columns = rows
.map((row) => row.column_name?.toUpperCase?.() as 'A' | 'B' | undefined)
.filter(
(column): column is 'A' | 'B' => column === 'A' || column === 'B',
);
if (columns.length === 2) {
if (!parentColumn) {
parentColumn = columns[0];
}
if (!constructeurColumn) {
const alternative = columns.find((column) => column !== parentColumn);
if (alternative) {
constructeurColumn = alternative;
}
}
}
}
if (!parentColumn || !constructeurColumn) {
throw new Error(
`Impossible de déterminer l'orientation de la table ${tableName}.`,
);
}
const orientation: LinkOrientation = {
parentColumn,
constructeurColumn,
};
ORIENTATION_CACHE.set(tableName, orientation);
return orientation;
}
export async function syncConstructeurLinks(
prisma: PrismaExecutor,
tableName: string,
parentId: string,
constructeurIds: string[],
): Promise<string[]> {
const executor = prisma as PrismaExecutor & {
__syncConstructeurLinks?: (
table: string,
parent: string,
ids: string[],
) => Promise<void> | void;
__getConstructeurLinkOrientation?: (
table: string,
) => LinkOrientation | Promise<LinkOrientation>;
};
const table = Prisma.raw(sanitizeTableName(tableName));
const uniqueConstructeurIds = Array.from(
new Set(
constructeurIds
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter((value) => value.length > 0),
),
);
let targetConstructeurIds = uniqueConstructeurIds;
const constructeurDelegate = (executor as any)?.constructeur as
| {
findMany?: (args: {
where: { id: { in: string[] } };
select: { id: boolean };
}) => Promise<Array<{ id: string }>>;
}
| undefined;
if (targetConstructeurIds.length > 0 && constructeurDelegate?.findMany) {
const existing = await constructeurDelegate.findMany({
where: { id: { in: targetConstructeurIds } },
select: { id: true },
});
const existingIds = new Set(existing.map(({ id }) => id));
targetConstructeurIds = targetConstructeurIds.filter((id) =>
existingIds.has(id),
);
}
const orientation = await resolveOrientation(executor, tableName);
if (typeof executor.__syncConstructeurLinks === 'function') {
await executor.__syncConstructeurLinks(
tableName,
parentId,
targetConstructeurIds,
);
return targetConstructeurIds;
}
await prisma.$executeRaw(
Prisma.sql`DELETE FROM ${table} WHERE ${Prisma.raw(
`"${orientation.parentColumn}"`,
)} = ${parentId}`,
);
if (targetConstructeurIds.length === 0) {
return [];
}
const valueTuples = targetConstructeurIds.map(
(constructeurId) => Prisma.sql`(${parentId}, ${constructeurId})`,
);
await prisma.$executeRaw(
Prisma.sql`
INSERT INTO ${table} (
${Prisma.raw(`"${orientation.parentColumn}"`)},
${Prisma.raw(`"${orientation.constructeurColumn}"`)}
)
VALUES ${Prisma.join(valueTuples)}
ON CONFLICT DO NOTHING
`,
);
return fetchConstructeurIds(executor, tableName, parentId, orientation);
}
export async function fetchConstructeurIds(
prisma: PrismaExecutor,
tableName: string,
parentId: string,
orientationOverride?: LinkOrientation,
): Promise<string[]> {
const orientation =
orientationOverride ?? (await resolveOrientation(prisma as any, tableName));
const table = Prisma.raw(sanitizeTableName(tableName));
const rows = await prisma.$queryRaw<Array<{ constructeurId: string | null }>>(
Prisma.sql`
SELECT ${Prisma.raw(
`"${orientation.constructeurColumn}"`,
)} AS "constructeurId"
FROM ${table}
WHERE ${Prisma.raw(`"${orientation.parentColumn}"`)} = ${parentId}
`,
);
return rows
.map((row) =>
typeof row.constructeurId === 'string' ? row.constructeurId : null,
)
.filter((id): id is string => Boolean(id));
}

View File

@@ -76,6 +76,59 @@ export function normalizeComponentModelStructure(
},
);
const products = toArray((structure as any)?.products).map((product) => {
const candidate = product as Record<string, unknown> | null | undefined;
if (candidate?.typeProductId) {
const normalized: ComponentModelStructure['products'][number] = {
typeProductId:
ensureString(candidate.typeProductId).trim() || 'UNKNOWN',
role: sanitizeRole(candidate.role),
};
if (candidate?.familyCode) {
const familyCode = ensureString(candidate.familyCode).trim();
if (familyCode) {
(normalized as Record<string, unknown>).familyCode = familyCode;
}
}
if (candidate?.typeProductLabel) {
const label = ensureString(candidate.typeProductLabel).trim();
if (label) {
(normalized as Record<string, unknown>).typeProductLabel = label;
}
}
if (candidate?.reference) {
const reference = ensureString(candidate.reference).trim();
if (reference) {
(normalized as Record<string, unknown>).reference = reference;
}
}
return normalized;
}
if (candidate?.familyCode) {
return {
familyCode: ensureString(candidate.familyCode).trim() || 'UNKNOWN',
role: sanitizeRole(candidate.role),
} as ComponentModelStructure['products'][number];
}
return {
familyCode:
ensureString(
candidate?.familyCode ??
candidate?.name ??
candidate?.typeProductLabel ??
'UNKNOWN',
).trim() || 'UNKNOWN',
role: sanitizeRole(candidate?.role),
} as ComponentModelStructure['products'][number];
});
const rawSubcomponents = toArray(
(structure as any)?.subcomponents ?? (structure as any)?.subComponents,
);
@@ -115,6 +168,7 @@ export function normalizeComponentModelStructure(
return {
pieces,
products,
customFields,
subcomponents,
};

View File

@@ -1,7 +1,10 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ComposantsService } from './composants.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateComposantDto, UpdateComposantDto } from '../shared/dto/composant.dto';
import {
CreateComposantDto,
UpdateComposantDto,
} from '../shared/dto/composant.dto';
describe('ComposantsService', () => {
let service: ComposantsService;
@@ -32,6 +35,7 @@ describe('ComposantsService', () => {
const dto: CreateComposantDto = {
name: 'Comp A',
typeComposantId: 'type-1',
productId: ' product-1 ',
};
prisma.composant.create.mockResolvedValue({ id: 'comp-1', name: dto.name });
@@ -39,16 +43,25 @@ describe('ComposantsService', () => {
const result = await service.create(dto);
expect(prisma.composant.create).toHaveBeenCalled();
expect(prisma.composant.create.mock.calls[0][0].data.product).toEqual({
connect: { id: 'product-1' },
});
expect(result).toMatchObject({ id: 'comp-1' });
});
it('updates a component', async () => {
const dto: UpdateComposantDto = { name: 'Updated' };
const dto: UpdateComposantDto = { name: 'Updated', productId: '' };
prisma.composant.update.mockResolvedValue({ id: 'comp-1', name: 'Updated' });
prisma.composant.update.mockResolvedValue({
id: 'comp-1',
name: 'Updated',
});
await service.update('comp-1', dto);
expect(prisma.composant.update).toHaveBeenCalled();
expect(prisma.composant.update.mock.calls[0][0].data.product).toEqual({
disconnect: true,
});
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -9,14 +9,18 @@ import {
COMPONENT_WITH_RELATIONS_INCLUDE,
ComposantWithRelations,
} from '../common/constants/component-includes';
import { syncConstructeurLinks } from '../common/utils/constructeur-link.util';
@Injectable()
export class ComposantsService {
constructor(private prisma: PrismaService) {}
private buildCreateInput(
private async buildCreateInput(
createComposantDto: CreateComposantDto,
): Prisma.ComposantCreateInput {
): Promise<{
data: Prisma.ComposantCreateInput;
constructeurIds: string[];
}> {
const data: Prisma.ComposantCreateInput = {
name: createComposantDto.name,
reference: createComposantDto.reference ?? null,
@@ -24,11 +28,11 @@ export class ComposantsService {
createComposantDto.prix !== undefined ? createComposantDto.prix : null,
};
if (createComposantDto.constructeurId) {
data.constructeur = {
connect: { id: createComposantDto.constructeurId },
};
}
const constructeurIds = this.normalizeConstructeurIds(
createComposantDto.constructeurIds,
);
const resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
if (createComposantDto.typeComposantId) {
data.typeComposant = {
@@ -36,20 +40,58 @@ export class ComposantsService {
};
}
if (createComposantDto.productId) {
const normalizedProductId = createComposantDto.productId.trim();
if (normalizedProductId) {
data.product = {
connect: { id: normalizedProductId },
};
}
}
if (createComposantDto.structure !== undefined) {
data.structure = createComposantDto.structure as Prisma.InputJsonValue;
}
return data;
return { data, constructeurIds: resolvedConstructeurIds };
}
async create(createComposantDto: CreateComposantDto) {
const created = await this.prisma.composant.create({
data: this.buildCreateInput(createComposantDto),
include: COMPONENT_WITH_RELATIONS_INCLUDE,
});
try {
const { data, constructeurIds } =
await this.buildCreateInput(createComposantDto);
const created = await this.prisma.composant.create({
data,
include: COMPONENT_WITH_RELATIONS_INCLUDE,
});
return created as ComposantWithRelations;
let syncedConstructeurIds: string[] = [];
if (constructeurIds.length > 0) {
syncedConstructeurIds = await syncConstructeurLinks(
this.prisma,
'_ComposantConstructeurs',
created.id,
constructeurIds,
);
}
const refreshed = (await this.prisma.composant.findUnique({
where: { id: created.id },
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations | null;
if (refreshed && syncedConstructeurIds.length > 0) {
(
refreshed as ComposantWithRelations & {
constructeurIds?: string[];
}
).constructeurIds = [...syncedConstructeurIds];
}
return refreshed;
} catch (error) {
this.handlePrismaError(error);
}
}
async findAll() {
@@ -81,10 +123,13 @@ export class ComposantsService {
data.prix = updateComposantDto.prix;
}
if (updateComposantDto.constructeurId !== undefined) {
data.constructeur = updateComposantDto.constructeurId
? { connect: { id: updateComposantDto.constructeurId } }
: { disconnect: true };
let resolvedConstructeurIds: string[] | undefined;
if (updateComposantDto.constructeurIds !== undefined) {
const constructeurIds = this.normalizeConstructeurIds(
updateComposantDto.constructeurIds,
);
resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
}
if (updateComposantDto.typeComposantId !== undefined) {
@@ -93,20 +138,156 @@ export class ComposantsService {
: { disconnect: true };
}
if (updateComposantDto.productId !== undefined) {
const normalizedProductId =
typeof updateComposantDto.productId === 'string'
? updateComposantDto.productId.trim()
: null;
data.product = normalizedProductId
? { connect: { id: normalizedProductId } }
: { disconnect: true };
}
if (updateComposantDto.structure !== undefined) {
data.structure = updateComposantDto.structure as Prisma.InputJsonValue;
}
return (await this.prisma.composant.update({
where: { id },
data,
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations;
let syncedConstructeurIds: string[] | undefined;
try {
await this.prisma.$transaction(async (tx) => {
await tx.composant.update({
where: { id },
data,
});
if (resolvedConstructeurIds !== undefined) {
syncedConstructeurIds = await syncConstructeurLinks(
tx,
'_ComposantConstructeurs',
id,
resolvedConstructeurIds,
);
}
});
const refreshed = (await this.prisma.composant.findUnique({
where: { id },
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations | null;
if (refreshed && syncedConstructeurIds) {
(
refreshed as ComposantWithRelations & {
constructeurIds?: string[];
}
).constructeurIds = [...syncedConstructeurIds];
}
return refreshed;
} catch (error) {
this.handlePrismaError(error);
}
}
async remove(id: string) {
const [machineLinksCount, documentsCount, customFieldValuesCount] =
await Promise.all([
this.prisma.machineComponentLink.count({
where: { composantId: id },
}),
this.prisma.document.count({
where: { composantId: id },
}),
this.prisma.customFieldValue.count({
where: { composantId: id },
}),
]);
const blockingReasons: string[] = [];
if (machineLinksCount > 0) {
blockingReasons.push(
`${machineLinksCount} liaison${machineLinksCount > 1 ? 's' : ''} machine`,
);
}
if (documentsCount > 0) {
blockingReasons.push(
`${documentsCount} document${documentsCount > 1 ? 's' : ''}`,
);
}
if (blockingReasons.length > 0) {
const messageParts = [
`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(
', ',
)}.`,
];
if (customFieldValuesCount > 0) {
messageParts.push(
`Les ${customFieldValuesCount} valeur${
customFieldValuesCount > 1 ? 's' : ''
} de champ personnalisé seront supprimées automatiquement une fois ces éléments détachés.`,
);
}
throw new ConflictException(
`${messageParts.join(' ')} Supprimez ou détachez les éléments indiqués avant de réessayer.`,
);
}
if (customFieldValuesCount > 0) {
await this.prisma.customFieldValue.deleteMany({
where: { composantId: id },
});
}
return this.prisma.composant.delete({
where: { id },
});
}
private normalizeConstructeurIds(ids?: string[] | null): string[] {
if (!Array.isArray(ids)) {
return [];
}
const cleaned = ids
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
return Array.from(new Set(cleaned));
}
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && this.isNameConstraint(error)) {
throw new ConflictException('Un composant avec ce nom existe déjà.');
}
}
throw error;
}
private isNameConstraint(error: Prisma.PrismaClientKnownRequestError) {
const { target } = error.meta ?? {};
if (Array.isArray(target)) {
return target.includes('name');
}
if (typeof target === 'string') {
return target === 'name';
}
return false;
}
private async resolveExistingConstructeurIds(
ids: string[],
): Promise<string[]> {
if (!ids.length) {
return [];
}
const existing = await this.prisma.constructeur.findMany({
where: { id: { in: ids } },
select: { id: true },
});
const existingIds = new Set(existing.map(({ id }) => id));
return ids.filter((id) => existingIds.has(id));
}
}

View File

@@ -36,6 +36,8 @@ export class CustomFieldsService {
return 'composantId' as const;
case CustomFieldEntityType.PIECE:
return 'pieceId' as const;
case CustomFieldEntityType.PRODUCT:
return 'productId' as const;
default:
throw new BadRequestException(
"Type d'entité de champ personnalisé invalide.",
@@ -114,6 +116,28 @@ export class CustomFieldsService {
valueKey: 'pieceId' as const,
};
}
case CustomFieldEntityType.PRODUCT: {
const product = await this.prisma.product.findUnique({
where: { id: entityId },
select: { typeProductId: true },
});
if (!product) {
throw new NotFoundException('Produit introuvable.');
}
if (!product.typeProductId) {
throw new BadRequestException(
'Le produit ne possède pas de type associé pour les champs personnalisés.',
);
}
return {
typeId: product.typeProductId,
customFieldTypeField: 'typeProductId' as const,
valueKey: 'productId' as const,
};
}
default:
throw new BadRequestException(
"Type d'entité de champ personnalisé invalide.",
@@ -209,12 +233,20 @@ export class CustomFieldsService {
if (existingField) {
targetCustomFieldId = existingField.id;
} else {
const normalizedType = (customFieldType || 'text').trim() || 'text';
const normalizedRequired = !!customFieldRequired;
const orderScope = { [customFieldTypeField]: typeId } as const;
const nextOrderIndex = await this.prisma.customField.count({
where: orderScope,
});
const createdField = await this.prisma.customField.create({
data: {
name: normalizedName,
type: (customFieldType || 'text').trim() || 'text',
required: !!customFieldRequired,
type: normalizedType,
required: normalizedRequired,
options: normalizedOptions,
orderIndex: nextOrderIndex,
[customFieldTypeField]: typeId,
},
});

View File

@@ -42,6 +42,11 @@ export class DocumentsController {
return this.documentsService.findByPiece(pieceId);
}
@Get('product/:productId')
findByProduct(@Param('productId') productId: string) {
return this.documentsService.findByProduct(productId);
}
@Get('site/:siteId')
findBySite(@Param('siteId') siteId: string) {
return this.documentsService.findBySite(siteId);

View File

@@ -16,6 +16,7 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
@@ -27,6 +28,7 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
@@ -39,6 +41,7 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
@@ -51,6 +54,7 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
@@ -63,6 +67,20 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
}
async findByProduct(productId: string) {
return this.prisma.document.findMany({
where: { productId },
include: {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});
@@ -75,6 +93,7 @@ export class DocumentsService {
machine: true,
composant: true,
piece: true,
product: true,
site: true,
},
});

View File

@@ -59,11 +59,10 @@ describe('MachinesService', () => {
prix: null,
createdAt: timestamp,
updatedAt: timestamp,
constructeurId: null,
constructeurs: [],
typePieceId: null,
documents: [],
customFieldValues: [],
constructeur: null,
typePiece: null,
},
typeMachinePieceRequirement: null,
@@ -81,7 +80,7 @@ describe('MachinesService', () => {
id: 'piece-root',
name: 'Root piece',
},
} as any;
};
const componentChildLink = {
id: 'component-child',
@@ -101,11 +100,10 @@ describe('MachinesService', () => {
prix: null,
createdAt: timestamp,
updatedAt: timestamp,
constructeurId: null,
constructeurs: [],
typeComposantId: null,
documents: [],
customFieldValues: [],
constructeur: null,
typeComposant: null,
},
typeMachineComponentRequirement: null,
@@ -125,7 +123,7 @@ describe('MachinesService', () => {
name: 'Root component',
},
pieceLinks: [componentPieceLink],
} as any;
};
return {
id: 'machine-1',
@@ -135,11 +133,10 @@ describe('MachinesService', () => {
createdAt: timestamp,
updatedAt: timestamp,
typeMachineId: null,
constructeurId: null,
constructeurs: [],
siteId: 'site-1',
site: null,
typeMachine: null,
constructeur: null,
componentLinks: [componentRootLink, componentChildLink],
pieceLinks: [rootPieceLink, componentPieceLink],
customFieldValues: [],
@@ -165,9 +162,7 @@ describe('MachinesService', () => {
expect(rootLink.pieceLinks[0].parent?.overrides.name).toBe(
'Root component override',
);
expect(rootLink.pieceLinks[0].originalPiece.name).toBe(
'Piece component',
);
expect(rootLink.pieceLinks[0].originalPiece.name).toBe('Piece component');
expect(rootLink.pieceLinks[0].piece.name).toBe('Component piece name');
expect(rootLink.pieceLinks[0].overrides.reference).toBe('CP-001');
expect(rootLink.overrides.name).toBe('Root component override');
@@ -193,9 +188,7 @@ describe('MachinesService', () => {
const root = result?.componentLinks[0];
expect(root?.childLinks[0].parent?.id).toBe('component-root');
expect(root?.childLinks[0].parent?.composantId).toBe('component-root');
expect(root?.childLinks[0].originalComposant.name).toBe(
'Child component',
);
expect(root?.childLinks[0].originalComposant.name).toBe('Child component');
expect(root?.childLinks[0].composant.name).toBe('Child component');
expect(root?.pieceLinks[0].parent?.id).toBe('component-root');
expect(root?.pieceLinks[0].parent?.composantId).toBe('component-root');
@@ -209,4 +202,52 @@ describe('MachinesService', () => {
expect(result?.pieceLinks[0].piece.name).toBe('Root piece name');
expect(result?.pieceLinks[0].overrides.reference).toBe('RP-001');
});
describe('validateProductRequirements', () => {
const buildRequirement = (overrides: Partial<any> = {}) =>
({
id: 'req-1',
label: 'Hydraulic kits',
minCount: 1,
maxCount: 2,
required: true,
allowNewModels: true,
typeProductId: 'product-type-1',
typeProduct: { name: 'Hydraulic kit' },
...overrides,
}) as any;
const callValidate = (
requirement: any,
componentUsage: Record<string, number>,
pieceUsage: Record<string, number>,
) => {
const map = new Map([[requirement.id, requirement]]);
const componentMap = new Map(Object.entries(componentUsage));
const pieceMap = new Map(Object.entries(pieceUsage));
(service as any).validateProductRequirements(map, componentMap, pieceMap);
};
it('does nothing when usage satisfies min and max constraints', () => {
expect(() =>
callValidate(buildRequirement(), { 'product-type-1': 1 }, {}),
).not.toThrow();
});
it('throws when minimum requirement is not met', () => {
expect(() => callValidate(buildRequirement(), {}, {})).toThrow(
/requiert au moins 1 sélection/i,
);
});
it('throws when usage exceeds maximum', () => {
expect(() =>
callValidate(
buildRequirement({ maxCount: 2 }),
{ 'product-type-1': 2 },
{ 'product-type-1': 1 },
),
).toThrow(/ne peut pas dépasser 2 sélection/i);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { IsEnum, IsOptional, IsString, Length, Matches } from 'class-validator';
export enum ModelCategory {
COMPONENT = 'COMPONENT',
PIECE = 'PIECE',
PRODUCT = 'PRODUCT',
}
export class CreateModelTypeDto {

View File

@@ -11,6 +11,7 @@ import { UpdateModelTypeDto } from './dto/update-model-type.dto';
import {
ComponentModelStructureSchema,
PieceModelStructureSchema,
ProductModelStructureSchema,
} from '../shared/schemas/inventory';
type SortField = 'name' | 'code' | 'createdAt';
@@ -112,12 +113,22 @@ export class ModelTypeService {
if (normalizedStructure !== undefined) {
const skeletonValue =
normalizedStructure === null ? Prisma.JsonNull : normalizedStructure;
if (rest.category === ModelCategory.COMPONENT) {
data.componentSkeleton = skeletonValue;
data.pieceSkeleton = Prisma.JsonNull;
} else {
data.pieceSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
switch (rest.category) {
case ModelCategory.COMPONENT:
data.componentSkeleton = skeletonValue;
data.pieceSkeleton = Prisma.JsonNull;
data.productSkeleton = Prisma.JsonNull;
break;
case ModelCategory.PIECE:
data.pieceSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
data.productSkeleton = Prisma.JsonNull;
break;
case ModelCategory.PRODUCT:
data.productSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
data.pieceSkeleton = Prisma.JsonNull;
break;
}
}
@@ -172,12 +183,22 @@ export class ModelTypeService {
if (normalizedStructure !== undefined) {
const skeletonValue =
normalizedStructure === null ? Prisma.JsonNull : normalizedStructure;
if (targetCategory === ModelCategory.COMPONENT) {
data.componentSkeleton = skeletonValue;
data.pieceSkeleton = Prisma.JsonNull;
} else {
data.pieceSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
switch (targetCategory) {
case ModelCategory.COMPONENT:
data.componentSkeleton = skeletonValue;
data.pieceSkeleton = Prisma.JsonNull;
data.productSkeleton = Prisma.JsonNull;
break;
case ModelCategory.PIECE:
data.pieceSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
data.productSkeleton = Prisma.JsonNull;
break;
case ModelCategory.PRODUCT:
data.productSkeleton = skeletonValue;
data.componentSkeleton = Prisma.JsonNull;
data.pieceSkeleton = Prisma.JsonNull;
break;
}
}
@@ -212,8 +233,14 @@ export class ModelTypeService {
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && this.isUniqueCodeConstraint(error)) {
throw new ConflictException('Ce code est déjà utilisé.');
if (error.code === 'P2002') {
if (this.isUniqueCodeConstraint(error)) {
throw new ConflictException('Ce code est déjà utilisé.');
}
if (this.isUniqueNameConstraint(error)) {
throw new ConflictException('Une catégorie avec ce nom existe déjà.');
}
}
if (error.code === 'P2025') {
@@ -235,6 +262,17 @@ export class ModelTypeService {
return false;
}
private isUniqueNameConstraint(error: Prisma.PrismaClientKnownRequestError) {
const { target } = error.meta ?? {};
if (Array.isArray(target)) {
return target.includes('name') && target.includes('category');
}
if (typeof target === 'string') {
return target.includes('name') && target.includes('category');
}
return false;
}
private normalizeStructure(
category: ModelCategory,
structure: unknown,
@@ -253,7 +291,12 @@ export class ModelTypeService {
structure,
) as Prisma.InputJsonValue;
}
return PieceModelStructureSchema.parse(
if (category === ModelCategory.PIECE) {
return PieceModelStructureSchema.parse(
structure,
) as Prisma.InputJsonValue;
}
return ProductModelStructureSchema.parse(
structure,
) as Prisma.InputJsonValue;
} catch (error) {
@@ -264,10 +307,24 @@ export class ModelTypeService {
}
private mapModelType(modelType: PrismaModelType) {
const structure =
modelType.category === ModelCategory.COMPONENT
? (modelType.componentSkeleton ?? null)
: (modelType.pieceSkeleton ?? null);
let structure: Prisma.InputJsonValue | null;
switch (modelType.category as ModelCategory) {
case ModelCategory.COMPONENT:
structure = (modelType.componentSkeleton ??
null) as Prisma.InputJsonValue | null;
break;
case ModelCategory.PIECE:
structure = (modelType.pieceSkeleton ??
null) as Prisma.InputJsonValue | null;
break;
case ModelCategory.PRODUCT:
structure = (modelType.productSkeleton ??
null) as Prisma.InputJsonValue | null;
break;
default:
structure = null;
break;
}
return {
...modelType,

View File

@@ -19,6 +19,7 @@ describe('PiecesService', () => {
customField: {
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
customFieldValue: {
findMany: jest.fn(),
@@ -27,10 +28,7 @@ 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);
@@ -40,29 +38,45 @@ describe('PiecesService', () => {
const dto: CreatePieceDto = {
name: 'Piece A',
typePieceId: 'type-piece-1',
productId: ' product-1 ',
};
prisma.piece.create.mockResolvedValue({ id: 'piece-1', name: dto.name });
prisma.piece.findUnique.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([]);
const result = await service.create(dto);
expect(prisma.piece.create).toHaveBeenCalled();
expect(prisma.piece.create.mock.calls[0][0].data.product).toEqual({
connect: { id: 'product-1' },
});
expect(result).toMatchObject({ id: 'piece-1' });
});
it('updates a piece', async () => {
const dto: UpdatePieceDto = { name: 'Updated piece' };
const dto: UpdatePieceDto = { name: 'Updated piece', productId: '' };
prisma.piece.update.mockResolvedValue({ id: 'piece-1', name: 'Updated piece' });
prisma.piece.findUnique.mockResolvedValue({ id: 'piece-1', name: 'Updated piece' });
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 service.update('piece-1', dto);
expect(prisma.piece.update).toHaveBeenCalled();
expect(prisma.piece.update.mock.calls[0][0].data.product).toEqual({
disconnect: true,
});
});
});

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { syncConstructeurLinks } from '../common/utils/constructeur-link.util';
import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
import { PieceModelStructureSchema } from '../shared/schemas/inventory';
import type { PieceModelStructure } from '../shared/types/inventory';
@@ -8,16 +9,30 @@ import type { PieceModelStructure } from '../shared/types/inventory';
const PIECE_WITH_RELATIONS_INCLUDE = {
typePiece: {
include: {
pieceCustomFields: true,
pieceCustomFields: {
orderBy: { orderIndex: 'asc' },
},
},
},
constructeur: true,
constructeurs: true,
documents: true,
customFieldValues: {
include: {
customField: true,
},
},
product: {
include: {
typeProduct: true,
constructeurs: true,
customFieldValues: {
include: {
customField: true,
},
},
documents: true,
},
},
machineLinks: {
include: {
machine: true,
@@ -31,18 +46,20 @@ const PIECE_WITH_RELATIONS_INCLUDE = {
export class PiecesService {
constructor(private prisma: PrismaService) {}
private buildCreateInput(createPieceDto: CreatePieceDto): Prisma.PieceCreateInput {
private async buildCreateInput(
createPieceDto: CreatePieceDto,
): Promise<{ data: Prisma.PieceCreateInput; constructeurIds: string[] }> {
const data: Prisma.PieceCreateInput = {
name: createPieceDto.name,
reference: createPieceDto.reference ?? null,
prix: createPieceDto.prix !== undefined ? createPieceDto.prix : null,
};
if (createPieceDto.constructeurId) {
data.constructeur = {
connect: { id: createPieceDto.constructeurId },
};
}
const constructeurIds = this.normalizeConstructeurIds(
createPieceDto.constructeurIds,
);
const resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
if (createPieceDto.typePieceId) {
data.typePiece = {
@@ -50,24 +67,69 @@ export class PiecesService {
};
}
return data;
if (createPieceDto.productId) {
const normalizedProductId = createPieceDto.productId.trim();
if (normalizedProductId) {
data.product = {
connect: { id: normalizedProductId },
};
}
}
return { data, constructeurIds: resolvedConstructeurIds };
}
async create(createPieceDto: CreatePieceDto) {
const created = await this.prisma.piece.create({
data: this.buildCreateInput(createPieceDto),
include: PIECE_WITH_RELATIONS_INCLUDE,
});
try {
const { data, constructeurIds } =
await this.buildCreateInput(createPieceDto);
await this.applyPieceSkeleton({
pieceId: created.id,
typePiece: created.typePiece as PieceTypeWithSkeleton | null,
});
const { pieceId, syncedConstructeurIds } = await this.prisma.$transaction(
async (tx) => {
const created = await tx.piece.create({
data,
include: PIECE_WITH_RELATIONS_INCLUDE,
});
return this.prisma.piece.findUnique({
where: { id: created.id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
let synced: string[] = [];
if (constructeurIds.length > 0) {
synced = await syncConstructeurLinks(
tx,
'_PieceConstructeurs',
created.id,
constructeurIds,
);
}
await this.applyPieceSkeleton({
pieceId: created.id,
typePiece: created.typePiece as PieceTypeWithSkeleton | null,
product: created.product,
prisma: tx,
});
return {
pieceId: created.id,
syncedConstructeurIds: synced,
};
},
);
const refreshed = await this.prisma.piece.findUnique({
where: { id: pieceId },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (refreshed && syncedConstructeurIds.length > 0) {
(
refreshed as typeof refreshed & { constructeurIds?: string[] }
).constructeurIds = [...syncedConstructeurIds];
}
return refreshed;
} catch (error) {
this.handlePrismaError(error);
}
}
async findAll() {
@@ -99,10 +161,13 @@ export class PiecesService {
data.prix = updatePieceDto.prix;
}
if (updatePieceDto.constructeurId !== undefined) {
data.constructeur = updatePieceDto.constructeurId
? { connect: { id: updatePieceDto.constructeurId } }
: { disconnect: true };
let resolvedConstructeurIds: string[] | undefined;
if (updatePieceDto.constructeurIds !== undefined) {
const constructeurIds = this.normalizeConstructeurIds(
updatePieceDto.constructeurIds,
);
resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
}
if (updatePieceDto.typePieceId !== undefined) {
@@ -111,24 +176,112 @@ export class PiecesService {
: { disconnect: true };
}
const updated = await this.prisma.piece.update({
where: { id },
data,
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (updatePieceDto.productId !== undefined) {
const normalizedProductId =
typeof updatePieceDto.productId === 'string'
? updatePieceDto.productId.trim()
: null;
data.product = normalizedProductId
? { connect: { id: normalizedProductId } }
: { disconnect: true };
}
await this.applyPieceSkeleton({
pieceId: updated.id,
typePiece: updated.typePiece as PieceTypeWithSkeleton | null,
});
let syncedConstructeurIds: string[] | undefined;
try {
await this.prisma.$transaction(async (tx) => {
const updated = await tx.piece.update({
where: { id },
data,
include: PIECE_WITH_RELATIONS_INCLUDE,
});
return this.prisma.piece.findUnique({
where: { id: updated.id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (resolvedConstructeurIds !== undefined) {
syncedConstructeurIds = await syncConstructeurLinks(
tx,
'_PieceConstructeurs',
id,
resolvedConstructeurIds,
);
}
await this.applyPieceSkeleton({
pieceId: updated.id,
typePiece: updated.typePiece as PieceTypeWithSkeleton | null,
product: updated.product,
prisma: tx,
});
});
const refreshed = await this.prisma.piece.findUnique({
where: { id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
if (refreshed && syncedConstructeurIds) {
(
refreshed as typeof refreshed & { constructeurIds?: string[] }
).constructeurIds = [...syncedConstructeurIds];
}
return refreshed;
} catch (error) {
this.handlePrismaError(error);
}
}
async remove(id: string) {
const [machineLinksCount, documentsCount, customFieldValuesCount] =
await Promise.all([
this.prisma.machinePieceLink.count({
where: { pieceId: id },
}),
this.prisma.document.count({
where: { pieceId: id },
}),
this.prisma.customFieldValue.count({
where: { pieceId: id },
}),
]);
const blockingReasons: string[] = [];
if (machineLinksCount > 0) {
blockingReasons.push(
`${machineLinksCount} liaison${machineLinksCount > 1 ? 's' : ''} machine`,
);
}
if (documentsCount > 0) {
blockingReasons.push(
`${documentsCount} document${documentsCount > 1 ? 's' : ''}`,
);
}
if (blockingReasons.length > 0) {
const messageParts = [
`Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join(
', ',
)}.`,
];
if (customFieldValuesCount > 0) {
messageParts.push(
`Les ${customFieldValuesCount} valeur${
customFieldValuesCount > 1 ? 's' : ''
} de champ personnalisé seront supprimées automatiquement une fois ces éléments détachés.`,
);
}
throw new ConflictException(
`${messageParts.join(' ')} Supprimez ou détachez les éléments indiqués avant de réessayer.`,
);
}
if (customFieldValuesCount > 0) {
await this.prisma.customFieldValue.deleteMany({
where: { pieceId: id },
});
}
return this.prisma.piece.delete({
where: { id },
});
@@ -137,9 +290,16 @@ export class PiecesService {
private async applyPieceSkeleton({
pieceId,
typePiece,
product,
prisma,
}: {
pieceId: string;
typePiece: PieceTypeWithSkeleton | null;
product: {
typeProductId: string | null;
typeProduct?: { code: string | null } | null;
} | null;
prisma: Prisma.TransactionClient | PrismaService;
}) {
if (!typePiece?.id) {
return;
@@ -155,13 +315,142 @@ export class PiecesService {
}
const customFields = skeleton.customFields ?? [];
const productRequirements: PieceProductRequirement[] = Array.isArray(
skeleton.products,
)
? skeleton.products.filter(
(entry): entry is PieceProductRequirement => !!entry,
)
: [];
await this.ensurePieceCustomFieldDefinitions(typePiece.id, customFields);
await this.ensurePieceCustomFieldDefinitions(
prisma,
typePiece.id,
customFields,
);
await this.createPieceCustomFieldValues(
prisma,
pieceId,
typePiece.id,
customFields,
);
if (productRequirements.length > 0) {
await this.ensurePieceProductCompliance({
prisma,
pieceId,
product,
requirements: productRequirements,
});
}
}
private async ensurePieceProductCompliance({
prisma,
pieceId,
product,
requirements,
}: {
prisma: Prisma.TransactionClient | PrismaService;
pieceId: string;
product: {
typeProductId: string | null;
typeProduct?: { code: string | null } | null;
} | null;
requirements: PieceProductRequirement[];
}) {
const effectiveProduct =
product ??
(
await prisma.piece.findUnique({
where: { id: pieceId },
select: {
product: {
select: {
typeProductId: true,
typeProduct: {
select: { code: true },
},
},
},
},
})
)?.product;
if (!effectiveProduct) {
throw new ConflictException(
'Ce type de pièce impose la sélection dun produit catalogue.',
);
}
const matches = requirements.some((requirement) =>
this.doesProductMatchRequirement(effectiveProduct, requirement),
);
if (!matches) {
throw new ConflictException(
'Le produit associé ne respecte pas les exigences définies par le squelette.',
);
}
}
private doesProductMatchRequirement(
product: {
typeProductId: string | null;
typeProduct?: { code: string | null } | null;
},
requirement: PieceProductRequirement,
): boolean {
if (!requirement) {
return false;
}
if ('typeProductId' in requirement && requirement.typeProductId) {
const expectedId = requirement.typeProductId.trim();
if (!expectedId) {
return false;
}
const currentId = product.typeProductId
? product.typeProductId.trim()
: '';
return currentId === expectedId;
}
if ('familyCode' in requirement && requirement.familyCode) {
const expectedCode = requirement.familyCode.trim().toLowerCase();
if (!expectedCode) {
return false;
}
const productCode =
product.typeProduct?.code?.trim().toLowerCase() ?? null;
return productCode === expectedCode;
}
return false;
}
private normalizeConstructeurIds(ids?: string[] | null): string[] {
if (!Array.isArray(ids)) {
return [];
}
const cleaned = ids
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
return Array.from(new Set(cleaned));
}
private async resolveExistingConstructeurIds(
ids: string[],
): Promise<string[]> {
if (!ids.length) {
return [];
}
const existing = await this.prisma.constructeur.findMany({
where: { id: { in: ids } },
select: { id: true },
});
const existingIds = new Set(existing.map(({ id }) => id));
return ids.filter((id) => existingIds.has(id));
}
private parsePieceSkeleton(value: unknown): PieceModelStructure | null {
@@ -177,6 +466,7 @@ export class PiecesService {
}
private async ensurePieceCustomFieldDefinitions(
prisma: Prisma.TransactionClient | PrismaService,
typePieceId: string,
customFields: PieceModelStructure['customFields'],
) {
@@ -188,25 +478,37 @@ export class PiecesService {
return;
}
const existing = await this.prisma.customField.findMany({
const existing = await prisma.customField.findMany({
where: { typePieceId },
select: { id: true, name: true },
select: { id: true, name: true, orderIndex: true },
});
const existingByName = new Map(
existing.map((field) => [
this.normalizeIdentifier(field.name) ?? field.name,
field.id,
field,
]),
);
for (const field of customFields) {
for (let index = 0; index < customFields.length; index += 1) {
const field = customFields[index];
if (!field) {
continue;
}
const name = this.normalizeIdentifier(field.name);
if (!name || existingByName.has(name)) {
if (!name) {
continue;
}
const existingField = existingByName.get(name);
if (existingField) {
if (existingField.orderIndex !== index) {
await prisma.customField.update({
where: { id: existingField.id },
data: { orderIndex: index },
});
}
continue;
}
@@ -214,22 +516,24 @@ export class PiecesService {
const required = Boolean(field.required);
const options = this.normalizeOptions(field);
const created = await this.prisma.customField.create({
const created = await prisma.customField.create({
data: {
name,
type,
required,
options,
orderIndex: index,
typePieceId,
},
select: { id: true },
select: { id: true, name: true, orderIndex: true },
});
existingByName.set(name, created.id);
existingByName.set(name, created);
}
}
private async createPieceCustomFieldValues(
prisma: Prisma.TransactionClient | PrismaService,
pieceId: string,
typePieceId: string,
customFields: PieceModelStructure['customFields'],
@@ -242,7 +546,7 @@ export class PiecesService {
return;
}
const definitions = await this.prisma.customField.findMany({
const definitions = await prisma.customField.findMany({
where: { typePieceId },
select: { id: true, name: true },
});
@@ -258,7 +562,7 @@ export class PiecesService {
]),
);
const existingValues = await this.prisma.customFieldValue.findMany({
const existingValues = await prisma.customFieldValue.findMany({
where: { pieceId },
select: { customFieldId: true },
});
@@ -282,7 +586,7 @@ export class PiecesService {
continue;
}
await this.prisma.customFieldValue.create({
await prisma.customFieldValue.create({
data: {
customFieldId: definitionId,
pieceId,
@@ -343,10 +647,37 @@ export class PiecesService {
return JSON.stringify(value);
}
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && this.isNameConstraint(error)) {
throw new ConflictException('Une pièce avec ce nom existe déjà.');
}
}
throw error;
}
private isNameConstraint(error: Prisma.PrismaClientKnownRequestError) {
const { target } = error.meta ?? {};
if (Array.isArray(target)) {
return target.includes('name');
}
if (typeof target === 'string') {
return target === 'name';
}
return false;
}
}
type PieceTypeWithSkeleton = Prisma.ModelTypeGetPayload<{
include: { pieceCustomFields: true };
}>;
type PieceCustomFieldEntry = NonNullable<PieceModelStructure['customFields']>[number];
type PieceCustomFieldEntry = NonNullable<
PieceModelStructure['customFields']
>[number];
type PieceProductRequirement = NonNullable<
PieceModelStructure['products']
>[number];

View File

@@ -0,0 +1,49 @@
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export enum ProductSortField {
NAME = 'name',
REFERENCE = 'reference',
CREATED_AT = 'createdAt',
SUPPLIER_PRICE = 'supplierPrice',
}
export enum SortDirection {
ASC = 'asc',
DESC = 'desc',
}
export class ListProductsQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsString()
typeProductId?: string;
@IsOptional()
@IsString()
constructeurId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
@IsOptional()
@IsEnum(ProductSortField)
sort?: ProductSortField;
@IsOptional()
@IsEnum(SortDirection)
dir?: SortDirection;
}

View File

@@ -0,0 +1,43 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ProductsService } from './products.service';
import { CreateProductDto, UpdateProductDto } from '../shared/dto/product.dto';
import { ListProductsQueryDto } from './dto/list-products.dto';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
list(@Query() query: ListProductsQueryDto) {
return this.productsService.list(query);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
@Post()
create(@Body() createProductDto: CreateProductDto) {
return this.productsService.create(createProductDto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateProductDto: UpdateProductDto) {
return this.productsService.update(id, updateProductDto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.productsService.remove(id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
})
export class ProductsModule {}

View File

@@ -0,0 +1,240 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException } from '@nestjs/common';
import { Prisma, Product } from '@prisma/client';
import { ProductsService } from './products.service';
import { PrismaService } from '../prisma/prisma.service';
import { syncConstructeurLinks } from '../common/utils/constructeur-link.util';
import { CreateProductDto, UpdateProductDto } from '../shared/dto/product.dto';
import { ProductSortField, SortDirection } from './dto/list-products.dto';
jest.mock('../common/utils/constructeur-link.util', () => ({
syncConstructeurLinks: jest.fn().mockResolvedValue([]),
}));
describe('ProductsService', () => {
let service: ProductsService;
let prisma: {
product: any;
constructeur: any;
piece: any;
composant: any;
document: any;
$transaction: jest.Mock;
};
const mockSyncConstructeurLinks = syncConstructeurLinks as jest.Mock;
beforeEach(async () => {
prisma = {
product: {
findMany: jest.fn(),
count: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
constructeur: {
findMany: jest.fn(),
},
piece: {
count: jest.fn(),
},
composant: {
count: jest.fn(),
},
document: {
count: jest.fn(),
},
$transaction: jest.fn((arg: any) => {
if (Array.isArray(arg)) {
return Promise.all(arg);
}
if (typeof arg === 'function') {
return arg(prisma);
}
return Promise.resolve();
}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ProductsService,
{ provide: PrismaService, useValue: prisma },
],
}).compile();
service = module.get<ProductsService>(ProductsService);
mockSyncConstructeurLinks.mockClear();
});
describe('list', () => {
it('returns products with mapped constructeur ids and pagination meta', async () => {
const product: Product & {
constructeurs: Array<{ id: string }>;
documents: any[];
customFieldValues: any[];
pieces: any[];
composants: any[];
typeProduct: null;
} = {
id: 'prod-1',
name: 'Product 1',
reference: 'P-001',
supplierPrice: new Prisma.Decimal(120),
createdAt: new Date(),
updatedAt: new Date(),
typeProductId: null,
constructeurs: [{ id: 'const-1' }],
documents: [],
customFieldValues: [],
pieces: [],
composants: [],
typeProduct: null,
};
prisma.product.findMany.mockResolvedValue([product]);
prisma.product.count.mockResolvedValue(1);
const result = await service.list({
q: ' Product ',
limit: 200,
sort: ProductSortField.NAME,
dir: SortDirection.ASC,
});
expect(prisma.product.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: expect.any(Array),
}),
take: 100, // capped
orderBy: { name: 'asc' },
}),
);
expect(result.total).toBe(1);
expect(result.items[0]).toMatchObject({
id: 'prod-1',
constructeurIds: ['const-1'],
});
});
});
describe('create', () => {
it('persists a product and synchronizes constructeurs', async () => {
const dto: CreateProductDto = {
name: 'New Product',
supplierPrice: 150.5,
constructeurIds: ['const-1', 'const-1', ''],
typeProductId: 'type-1',
};
prisma.constructeur.findMany.mockResolvedValue([{ id: 'const-1' }]);
prisma.product.create.mockResolvedValue({
id: 'prod-1',
});
prisma.product.findUnique.mockResolvedValue({
id: 'prod-1',
name: dto.name,
reference: null,
supplierPrice: new Prisma.Decimal(150.5),
typeProductId: dto.typeProductId,
constructeurs: [{ id: 'const-1' }],
documents: [],
customFieldValues: [],
pieces: [],
composants: [],
typeProduct: null,
});
mockSyncConstructeurLinks.mockResolvedValue(['const-1']);
const created = await service.create(dto);
expect(prisma.product.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: 'New Product',
supplierPrice: expect.any(Prisma.Decimal),
typeProduct: { connect: { id: 'type-1' } },
}),
}),
);
expect(
String(prisma.product.create.mock.calls[0][0].data.supplierPrice),
).toBe('150.5');
expect(mockSyncConstructeurLinks).toHaveBeenCalledWith(
expect.any(Object),
'_ProductConstructeurs',
'prod-1',
['const-1'],
);
expect(created.constructeurIds).toEqual(['const-1']);
});
});
describe('update', () => {
it('updates product fields and synchronizes constructeurs when provided', async () => {
const dto: UpdateProductDto = {
supplierPrice: null,
constructeurIds: ['const-2'],
typeProductId: '',
};
prisma.constructeur.findMany.mockResolvedValue([{ id: 'const-2' }]);
prisma.product.findUnique.mockResolvedValue({
id: 'prod-1',
name: 'Existing product',
reference: null,
supplierPrice: null,
typeProductId: null,
constructeurs: [{ id: 'const-2' }],
documents: [],
customFieldValues: [],
pieces: [],
composants: [],
typeProduct: null,
});
await service.update('prod-1', dto);
expect(prisma.product.update).toHaveBeenCalledWith({
where: { id: 'prod-1' },
data: expect.objectContaining({
supplierPrice: null,
typeProduct: { disconnect: true },
}),
});
expect(mockSyncConstructeurLinks).toHaveBeenCalledWith(
expect.any(Object),
'_ProductConstructeurs',
'prod-1',
['const-2'],
);
});
});
describe('remove', () => {
it('throws when product is still referenced', async () => {
prisma.piece.count.mockResolvedValue(1);
prisma.composant.count.mockResolvedValue(0);
prisma.document.count.mockResolvedValue(2);
await expect(service.remove('prod-1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.product.delete).not.toHaveBeenCalled();
});
it('deletes product when no references remain', async () => {
prisma.piece.count.mockResolvedValue(0);
prisma.composant.count.mockResolvedValue(0);
prisma.document.count.mockResolvedValue(0);
prisma.product.delete.mockResolvedValue(undefined);
await service.remove('prod-1');
expect(prisma.product.delete).toHaveBeenCalledWith({
where: { id: 'prod-1' },
});
});
});
});

View File

@@ -0,0 +1,322 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto, UpdateProductDto } from '../shared/dto/product.dto';
import { PRODUCT_WITH_RELATIONS_INCLUDE } from '../common/constants/product-includes';
import { syncConstructeurLinks } from '../common/utils/constructeur-link.util';
import {
ListProductsQueryDto,
ProductSortField,
SortDirection,
} from './dto/list-products.dto';
type ProductWithRelations = Prisma.ProductGetPayload<{
include: typeof PRODUCT_WITH_RELATIONS_INCLUDE;
}>;
@Injectable()
export class ProductsService {
private readonly allowedSortFields: ProductSortField[] = [
ProductSortField.CREATED_AT,
ProductSortField.NAME,
ProductSortField.REFERENCE,
ProductSortField.SUPPLIER_PRICE,
];
constructor(private readonly prisma: PrismaService) {}
async list(params: ListProductsQueryDto) {
const {
q,
typeProductId,
constructeurId,
limit = 20,
offset = 0,
sort = ProductSortField.CREATED_AT,
dir = SortDirection.DESC,
} = params;
const cappedLimit = Math.min(Math.max(limit, 1), 100);
const safeOffset = Math.max(offset, 0);
const orderByField = this.allowedSortFields.includes(sort)
? sort
: ProductSortField.CREATED_AT;
const orderByDir: SortDirection =
dir === SortDirection.ASC ? SortDirection.ASC : SortDirection.DESC;
const where: Prisma.ProductWhereInput = {};
if (q?.trim()) {
const term = q.trim();
where.OR = [
{ name: { contains: term, mode: 'insensitive' } },
{ reference: { contains: term, mode: 'insensitive' } },
];
}
if (typeProductId) {
where.typeProductId = typeProductId;
}
if (constructeurId) {
where.constructeurs = {
some: { id: constructeurId },
};
}
const [items, total] = await this.prisma.$transaction([
this.prisma.product.findMany({
where,
include: PRODUCT_WITH_RELATIONS_INCLUDE,
orderBy: {
[orderByField]: orderByDir,
},
skip: safeOffset,
take: cappedLimit,
}),
this.prisma.product.count({ where }),
]);
return {
items: items.map((item) => this.mapProduct(item)),
total,
offset: safeOffset,
limit: cappedLimit,
};
}
async findOne(id: string) {
const product = await this.prisma.product.findUnique({
where: { id },
include: PRODUCT_WITH_RELATIONS_INCLUDE,
});
if (!product) {
throw new NotFoundException('Produit introuvable.');
}
return this.mapProduct(product);
}
async create(createProductDto: CreateProductDto) {
try {
const data: Prisma.ProductCreateInput = {
name: createProductDto.name,
reference: createProductDto.reference ?? null,
supplierPrice:
createProductDto.supplierPrice === undefined ||
createProductDto.supplierPrice === null
? null
: new Prisma.Decimal(createProductDto.supplierPrice),
};
if (createProductDto.typeProductId) {
data.typeProduct = {
connect: { id: createProductDto.typeProductId },
};
}
const constructeurIds = this.normalizeConstructeurIds(
createProductDto.constructeurIds,
);
const resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
const created = await this.prisma.product.create({
data,
include: PRODUCT_WITH_RELATIONS_INCLUDE,
});
let syncedConstructeurIds: string[] = [];
if (resolvedConstructeurIds.length > 0) {
syncedConstructeurIds = await syncConstructeurLinks(
this.prisma,
'_ProductConstructeurs',
created.id,
resolvedConstructeurIds,
);
}
const refreshed = await this.prisma.product.findUnique({
where: { id: created.id },
include: PRODUCT_WITH_RELATIONS_INCLUDE,
});
if (!refreshed) {
return this.mapProduct(created);
}
const mapped = this.mapProduct(refreshed);
if (syncedConstructeurIds.length > 0) {
mapped.constructeurIds = [...syncedConstructeurIds];
}
return mapped;
} catch (error) {
this.handlePrismaError(error);
}
}
async update(id: string, updateProductDto: UpdateProductDto) {
try {
const data: Prisma.ProductUpdateInput = {};
if (updateProductDto.name !== undefined) {
data.name = updateProductDto.name;
}
if (updateProductDto.reference !== undefined) {
data.reference = updateProductDto.reference;
}
if (updateProductDto.supplierPrice !== undefined) {
data.supplierPrice =
updateProductDto.supplierPrice === null
? null
: new Prisma.Decimal(updateProductDto.supplierPrice);
}
if (updateProductDto.typeProductId !== undefined) {
data.typeProduct = updateProductDto.typeProductId
? { connect: { id: updateProductDto.typeProductId } }
: { disconnect: true };
}
let resolvedConstructeurIds: string[] | undefined;
if (updateProductDto.constructeurIds !== undefined) {
const constructeurIds = this.normalizeConstructeurIds(
updateProductDto.constructeurIds,
);
resolvedConstructeurIds =
await this.resolveExistingConstructeurIds(constructeurIds);
}
let syncedConstructeurIds: string[] | undefined;
await this.prisma.$transaction(async (tx) => {
await tx.product.update({
where: { id },
data,
});
if (resolvedConstructeurIds !== undefined) {
syncedConstructeurIds = await syncConstructeurLinks(
tx,
'_ProductConstructeurs',
id,
resolvedConstructeurIds,
);
}
});
const refreshed = await this.prisma.product.findUnique({
where: { id },
include: PRODUCT_WITH_RELATIONS_INCLUDE,
});
if (!refreshed) {
throw new NotFoundException('Produit introuvable.');
}
const mapped = this.mapProduct(refreshed);
if (syncedConstructeurIds) {
mapped.constructeurIds = [...syncedConstructeurIds];
}
return mapped;
} catch (error) {
this.handlePrismaError(error);
}
}
async remove(id: string) {
const [pieceCount, componentCount, documentCount] = await Promise.all([
this.prisma.piece.count({
where: { productId: id },
}),
this.prisma.composant.count({
where: { productId: id },
}),
this.prisma.document.count({
where: { productId: id },
}),
]);
const blockingReasons: string[] = [];
if (pieceCount > 0) {
blockingReasons.push(`${pieceCount} pièce${pieceCount > 1 ? 's' : ''}`);
}
if (componentCount > 0) {
blockingReasons.push(
`${componentCount} composant${componentCount > 1 ? 's' : ''}`,
);
}
if (documentCount > 0) {
blockingReasons.push(
`${documentCount} document${documentCount > 1 ? 's' : ''}`,
);
}
if (blockingReasons.length > 0) {
throw new ConflictException(
`Impossible de supprimer ce produit car il est encore lié à ${blockingReasons.join(
', ',
)}.`,
);
}
await this.prisma.product.delete({
where: { id },
});
}
private normalizeConstructeurIds(ids?: string[] | null): string[] {
if (!Array.isArray(ids)) {
return [];
}
return Array.from(
new Set(
ids
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter((value) => value.length > 0),
),
);
}
private async resolveExistingConstructeurIds(ids: string[]) {
if (ids.length === 0) {
return [];
}
const existing = await this.prisma.constructeur.findMany({
where: { id: { in: ids } },
select: { id: true },
});
const existingIds = new Set(existing.map(({ id }) => id));
return ids.filter((id) => existingIds.has(id));
}
private mapProduct(product: ProductWithRelations) {
return {
...product,
constructeurIds: product.constructeurs.map((item) => item.id),
};
}
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
throw new ConflictException('Un produit avec ce nom existe déjà.');
}
if (error.code === 'P2025') {
throw new NotFoundException('Produit introuvable.');
}
}
throw error;
}
}

View File

@@ -1,4 +1,10 @@
import { IsString, IsOptional, IsNumber, IsObject } from 'class-validator';
import {
IsString,
IsOptional,
IsNumber,
IsObject,
IsArray,
} from 'class-validator';
import { Transform } from 'class-transformer';
export class CreateComposantDto {
@@ -18,8 +24,10 @@ export class CreateComposantDto {
reference?: string;
@IsOptional()
@IsString()
constructeurId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
constructeurIds?: string[];
@IsOptional()
@Transform(({ value }) => (value === '' ? null : value))
@@ -37,6 +45,10 @@ export class CreateComposantDto {
@IsOptional()
@IsObject()
structure?: Record<string, any>;
@IsOptional()
@IsString()
productId?: string;
}
export class UpdateComposantDto {
@@ -49,8 +61,9 @@ export class UpdateComposantDto {
reference?: string;
@IsOptional()
@IsString()
constructeurId?: string;
@IsArray()
@IsString({ each: true })
constructeurIds?: string[];
@IsOptional()
@Transform(({ value }) => (value === '' ? null : value))
@@ -64,4 +77,9 @@ export class UpdateComposantDto {
@IsOptional()
@IsObject()
structure?: Record<string, any>;
@IsOptional()
@Transform(({ value }) => (value === '' ? null : value))
@IsString()
productId?: string | null;
}

View File

@@ -11,6 +11,7 @@ export enum CustomFieldEntityType {
MACHINE = 'machine',
COMPOSANT = 'composant',
PIECE = 'piece',
PRODUCT = 'product',
}
export class CustomFieldEntityParamsDto {
@@ -76,6 +77,10 @@ export class CreateCustomFieldValueDto {
@IsOptional()
@IsString()
pieceId?: string;
@IsOptional()
@IsString()
productId?: string;
}
export class UpdateCustomFieldValueDto {

View File

@@ -31,6 +31,10 @@ export class CreateDocumentDto {
@IsOptional()
@IsString()
siteId?: string;
@IsOptional()
@IsString()
productId?: string;
}
export class UpdateDocumentDto {
@@ -57,4 +61,20 @@ export class UpdateDocumentDto {
@IsOptional()
@IsString()
siteId?: string;
@IsOptional()
@IsString()
machineId?: string;
@IsOptional()
@IsString()
composantId?: string;
@IsOptional()
@IsString()
pieceId?: string;
@IsOptional()
@IsString()
productId?: string;
}

View File

@@ -28,6 +28,10 @@ export class MachineComponentLinkPayloadDto {
@IsString()
composantId?: string;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
componentId?: string;
@@ -97,6 +101,10 @@ export class MachinePieceLinkPayloadDto {
@IsString()
composantId?: string;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
parentLinkId?: string;
@@ -142,6 +150,59 @@ export class MachinePieceLinkPayloadDto {
overrides?: Record<string, unknown>;
}
export class MachineProductLinkPayloadDto {
@IsOptional()
@IsString()
id?: string;
@IsOptional()
@IsString()
linkId?: string;
@IsString()
requirementId: string;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
typeProductId?: 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;
}
export class CreateMachineDto {
@IsString()
name: string;
@@ -154,8 +215,9 @@ export class CreateMachineDto {
reference?: string;
@IsOptional()
@IsString()
constructeurId?: string;
@IsArray()
@IsString({ each: true })
constructeurIds?: string[];
@IsOptional()
@IsDecimal()
@@ -176,6 +238,12 @@ export class CreateMachineDto {
@ValidateNested({ each: true })
@Type(() => MachinePieceLinkPayloadDto)
pieceLinks?: MachinePieceLinkPayloadDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachineProductLinkPayloadDto)
productLinks?: MachineProductLinkPayloadDto[];
}
export class UpdateMachineDto {
@@ -188,8 +256,9 @@ export class UpdateMachineDto {
reference?: string;
@IsOptional()
@IsString()
constructeurId?: string;
@IsArray()
@IsString({ each: true })
constructeurIds?: string[];
@IsOptional()
@IsDecimal()
@@ -212,7 +281,14 @@ export class ReconfigureMachineDto {
@ValidateNested({ each: true })
@Type(() => MachinePieceLinkPayloadDto)
pieceLinks?: MachinePieceLinkPayloadDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MachineProductLinkPayloadDto)
productLinks?: MachineProductLinkPayloadDto[];
}
export type MachineComponentLinkInput = MachineComponentLinkPayloadDto;
export type MachinePieceLinkInput = MachinePieceLinkPayloadDto;
export type MachineProductLinkInput = MachineProductLinkPayloadDto;

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsNumber } from 'class-validator';
import { IsString, IsOptional, IsNumber, IsArray } from 'class-validator';
import { Transform } from 'class-transformer';
export class CreatePieceDto {
@@ -18,8 +18,10 @@ export class CreatePieceDto {
reference?: string;
@IsOptional()
@IsString()
constructeurId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
constructeurIds?: string[];
@IsOptional()
@Transform(({ value }) => (value === '' ? null : value))
@@ -33,6 +35,10 @@ export class CreatePieceDto {
@IsOptional()
@IsString()
typeMachinePieceRequirementId?: string;
@IsOptional()
@IsString()
productId?: string;
}
export class UpdatePieceDto {
@@ -45,8 +51,10 @@ export class UpdatePieceDto {
reference?: string;
@IsOptional()
@IsString()
constructeurId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
constructeurIds?: string[];
@IsOptional()
@Transform(({ value }) => (value === '' ? null : value))
@@ -56,4 +64,9 @@ export class UpdatePieceDto {
@IsOptional()
@IsString()
typePieceId?: string;
@IsOptional()
@Transform(({ value }) => (value === '' ? null : value))
@IsString()
productId?: string | null;
}

View File

@@ -0,0 +1,28 @@
import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator';
import { Transform } from 'class-transformer';
import { PartialType } from '@nestjs/mapped-types';
export class CreateProductDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
reference?: string;
@IsOptional()
@Transform(({ value }) => (value === '' ? null : value))
@IsNumber({}, { message: 'supplierPrice must be a valid number' })
supplierPrice?: number | null;
@IsOptional()
@IsString()
typeProductId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
constructeurIds?: string[];
}
export class UpdateProductDto extends PartialType(CreateProductDto) {}

View File

@@ -36,6 +36,10 @@ export class CreateCustomFieldDto {
@IsOptional()
@IsArray()
options?: string[]; // Pour les champs de type SELECT
@IsOptional()
@IsInt()
orderIndex?: number;
}
export class UpdateCustomFieldDto {
@@ -54,6 +58,10 @@ export class UpdateCustomFieldDto {
@IsOptional()
@IsArray()
options?: string[];
@IsOptional()
@IsInt()
orderIndex?: number;
}
export class TypeMachineComponentRequirementDto {
@@ -79,6 +87,10 @@ export class TypeMachineComponentRequirementDto {
@IsOptional()
@IsBoolean()
allowNewModels?: boolean;
@IsOptional()
@IsInt()
orderIndex?: number;
}
export class TypeMachinePieceRequirementDto {
@@ -104,6 +116,39 @@ export class TypeMachinePieceRequirementDto {
@IsOptional()
@IsBoolean()
allowNewModels?: boolean;
@IsOptional()
@IsInt()
orderIndex?: number;
}
export class TypeMachineProductRequirementDto {
@IsString()
typeProductId: string;
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsInt()
minCount?: number;
@IsOptional()
@IsInt()
maxCount?: number | null;
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@IsBoolean()
allowNewModels?: boolean;
@IsOptional()
@IsInt()
orderIndex?: number;
}
export class CreateTypeMachineDto {
@@ -145,6 +190,12 @@ export class CreateTypeMachineDto {
@ValidateNested({ each: true })
@Type(() => TypeMachinePieceRequirementDto)
pieceRequirements?: TypeMachinePieceRequirementDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TypeMachineProductRequirementDto)
productRequirements?: TypeMachineProductRequirementDto[];
}
export class UpdateTypeMachineDto {
@@ -187,6 +238,12 @@ export class UpdateTypeMachineDto {
@ValidateNested({ each: true })
@Type(() => TypeMachinePieceRequirementDto)
pieceRequirements?: TypeMachinePieceRequirementDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TypeMachineProductRequirementDto)
productRequirements?: TypeMachineProductRequirementDto[];
}
export class CreateTypeComposantDto {

View File

@@ -2,7 +2,9 @@ import { normalizeComponentModelStructure } from '../../component-models/structu
import type {
ComponentModelStructure,
PieceModelCustomField,
PieceModelProduct,
PieceModelStructure,
ProductModelStructure,
} from '../types/inventory';
export class ComponentModelStructureValidationError extends Error {
@@ -28,6 +30,67 @@ function sanitizeOptionalString(value: unknown): string | undefined {
return String(value);
}
function validateProducts(
products: ComponentModelStructure['products'],
): ComponentModelStructure['products'] {
return products.map((product, index) => {
if ('typeProductId' in product) {
const typeProductId = assertString(
product.typeProductId,
`products[${index}].typeProductId`,
).trim();
if (!typeProductId) {
throw new ComponentModelStructureValidationError(
`products[${index}].typeProductId ne peut pas être vide`,
);
}
const payload: ComponentModelStructure['products'][number] = {
typeProductId,
role: sanitizeOptionalString(product.role),
};
if ('familyCode' in product && product.familyCode) {
const familyCode = assertString(
product.familyCode,
`products[${index}].familyCode`,
).trim();
if (familyCode) {
(payload as Record<string, unknown>).familyCode = familyCode;
}
}
if ('reference' in product && product.reference) {
(payload as Record<string, unknown>).reference = sanitizeOptionalString(
product.reference,
);
}
if ('typeProductLabel' in product && product.typeProductLabel) {
(payload as Record<string, unknown>).typeProductLabel =
sanitizeOptionalString(product.typeProductLabel);
}
return payload;
}
if ('familyCode' in product) {
const familyCode = assertString(
product.familyCode,
`products[${index}].familyCode`,
).trim();
if (!familyCode) {
throw new ComponentModelStructureValidationError(
`products[${index}].familyCode ne peut pas être vide`,
);
}
return {
familyCode,
role: sanitizeOptionalString(product.role),
};
}
throw new ComponentModelStructureValidationError(
`products[${index}] doit définir "familyCode" ou "typeProductId"`,
);
});
}
function validatePieces(
pieces: ComponentModelStructure['pieces'],
): ComponentModelStructure['pieces'] {
@@ -148,6 +211,7 @@ export const ComponentModelStructureSchema = {
const normalized = normalizeComponentModelStructure(input);
return {
products: validateProducts(normalized.products),
pieces: validatePieces(normalized.pieces),
customFields: validateCustomFields(normalized.customFields),
subcomponents: validateSubcomponents(normalized.subcomponents),
@@ -230,10 +294,57 @@ function normalizePieceModelCustomFields(
return normalized;
}
function normalizePieceModelProducts(products: unknown): PieceModelProduct[] {
if (!Array.isArray(products)) {
return [];
}
return products.map((entry, index) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new PieceModelStructureValidationError(
`products[${index}] doit être un objet`,
);
}
const record = entry as Record<string, unknown>;
const rawTypeProductId =
typeof record.typeProductId === 'string'
? record.typeProductId
: typeof (record.typeProduct as { id?: unknown })?.id === 'string'
? (record.typeProduct as { id: string }).id
: undefined;
const typeProductId = rawTypeProductId ? rawTypeProductId.trim() : '';
const rawFamilyCode =
typeof record.familyCode === 'string'
? record.familyCode
: typeof (record.typeProduct as { code?: unknown })?.code === 'string'
? (record.typeProduct as { code: string }).code
: undefined;
const familyCode = rawFamilyCode ? rawFamilyCode.trim() : '';
const rawRole = typeof record.role === 'string' ? record.role.trim() : '';
const role = rawRole ? rawRole : undefined;
if (typeProductId) {
return role ? { typeProductId, role } : { typeProductId };
}
if (familyCode) {
return role ? { familyCode, role } : { familyCode };
}
throw new PieceModelStructureValidationError(
`products[${index}] doit définir "familyCode" ou "typeProductId"`,
);
});
}
export const PieceModelStructureSchema = {
parse(input: unknown): PieceModelStructure {
if (input === undefined || input === null) {
return { customFields: [] };
return { customFields: [], products: [] };
}
if (typeof input !== 'object' || Array.isArray(input)) {
@@ -250,6 +361,11 @@ export const PieceModelStructureSchema = {
structure.customFields = customFields;
}
const products = normalizePieceModelProducts(record.products);
if (products.length > 0 || 'products' in record) {
structure.products = products;
}
const normalizedTypePiece = toStringOrNull(record.typePieceId);
if (normalizedTypePiece) {
structure.typePieceId = normalizedTypePiece;
@@ -260,3 +376,34 @@ export const PieceModelStructureSchema = {
return structure;
},
};
export class ProductModelStructureValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProductModelStructureValidationError';
}
}
export const ProductModelStructureSchema = {
parse(input: unknown): ProductModelStructure {
if (input === undefined || input === null) {
return { customFields: [] };
}
if (typeof input !== 'object' || Array.isArray(input)) {
throw new ProductModelStructureValidationError(
'La structure de produit doit être un objet JSON.',
);
}
const record = input as Record<string, unknown>;
const structure: ProductModelStructure = { ...record };
const customFields = normalizePieceModelCustomFields(record.customFields);
if (customFields.length > 0 || 'customFields' in record) {
structure.customFields = customFields;
}
return structure;
},
};

View File

@@ -0,0 +1,6 @@
export type ConstructeurSummary = {
id: string;
name: string | null;
email: string | null;
phone: string | null;
};

View File

@@ -16,6 +16,20 @@ export type ComponentModelStructure = {
}
>;
/**
* Familles de produits autorisées (ou identifiant de famille) — pas de quantité ici.
*/
products: Array<
| {
familyCode: string;
role?: string;
}
| {
typeProductId: string;
role?: string;
}
>;
/**
* Valeurs par défaut au niveau "modèle" (libres, mais clé obligatoire).
*/
@@ -48,7 +62,25 @@ export type PieceModelCustomField = {
options?: unknown;
};
export type PieceModelProduct =
| {
familyCode: string;
role?: string;
}
| {
typeProductId: string;
role?: string;
};
export type PieceModelStructure = {
customFields?: PieceModelCustomField[];
products?: PieceModelProduct[];
[key: string]: unknown;
};
export type ProductModelCustomField = PieceModelCustomField;
export type ProductModelStructure = {
customFields?: ProductModelCustomField[];
[key: string]: unknown;
};

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { TypeMachinesRepository } from '../../common/repositories/type-machines.repository';
import {
TYPE_MACHINE_DEFAULT_INCLUDE,
@@ -17,7 +18,12 @@ export class TypeMachineService {
async create(dto: CreateTypeMachineDto) {
const data = TypeMachineMapper.toCreateInput(dto);
return this.repository.create(data, TYPE_MACHINE_DEFAULT_INCLUDE);
try {
return await this.repository.create(data, TYPE_MACHINE_DEFAULT_INCLUDE);
} catch (error) {
this.handlePrismaError(error);
throw error;
}
}
async findAll() {
@@ -53,7 +59,24 @@ export class TypeMachineService {
await this.repository.createPieceRequirements(id, requirements);
}
return this.repository.update(id, updateData, TYPE_MACHINE_DEFAULT_INCLUDE);
if (dto.productRequirements !== undefined) {
await this.repository.deleteProductRequirements(id);
const requirements = TypeMachineMapper.mapProductRequirementInputs(
dto.productRequirements,
);
await this.repository.createProductRequirements(id, requirements);
}
try {
return await this.repository.update(
id,
updateData,
TYPE_MACHINE_DEFAULT_INCLUDE,
);
} catch (error) {
this.handlePrismaError(error);
throw error;
}
}
async remove(id: string) {
@@ -69,4 +92,19 @@ export class TypeMachineService {
await this.repository.deleteCustomFields(id);
return this.repository.delete(id);
}
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === 'P2002' &&
Array.isArray(error.meta?.target) &&
error.meta.target.includes('name')
) {
throw new ConflictException(
'Nom déjà utilisé pour un type de machine.',
);
}
}
throw error;
}
}

View File

@@ -73,7 +73,7 @@ type MachineRecord = {
id: string;
name: string;
reference: Nullable<string>;
constructeurId: Nullable<string>;
constructeurIds: string[];
prix: Nullable<string>;
siteId: string;
typeMachineId: Nullable<string>;
@@ -90,7 +90,7 @@ type ComposantRecord = {
parentComposantId: Nullable<string>;
typeComposantId: Nullable<string>;
typeMachineComponentRequirementId: Nullable<string>;
constructeurId: Nullable<string>;
constructeurIds: string[];
createdAt: Date;
updatedAt: Date;
};
@@ -104,7 +104,16 @@ type PieceRecord = {
composantId: Nullable<string>;
typePieceId: Nullable<string>;
typeMachinePieceRequirementId: Nullable<string>;
constructeurId: Nullable<string>;
constructeurIds: string[];
createdAt: Date;
updatedAt: Date;
};
type ConstructeurRecord = {
id: string;
name: string;
email: Nullable<string>;
phone: Nullable<string>;
createdAt: Date;
updatedAt: Date;
};
@@ -202,6 +211,15 @@ class InMemoryPrismaService {
private customFields: CustomFieldRecord[] = [];
private customFieldValues: CustomFieldValueRecord[] = [];
private profiles: ProfileRecord[] = [];
private constructeurs: ConstructeurRecord[] = [];
private readonly constructeurLinkOrientation: Record<
string,
{ parentColumn: 'A' | 'B'; constructeurColumn: 'A' | 'B' }
> = {
_MachineConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_ComposantConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
_PieceConstructeurs: { parentColumn: 'A', constructeurColumn: 'B' },
};
async onModuleInit() {}
async onModuleDestroy() {}
@@ -229,6 +247,7 @@ class InMemoryPrismaService {
this.customFields = [];
this.customFieldValues = [];
this.profiles = [];
this.constructeurs = [];
}
private readonly modelTypeDelegate = {
@@ -638,11 +657,12 @@ class InMemoryPrismaService {
machine = {
create: async ({ data, include }: any) => {
const now = new Date();
const constructeurIds = this.extractConstructeurIds(data.constructeurs);
const record: MachineRecord = {
id: generateId('machine'),
name: data.name,
reference: data.reference ?? null,
constructeurId: data.constructeurId ?? null,
constructeurIds,
prix: data.prix ?? null,
siteId: data.siteId,
typeMachineId: data.typeMachineId ?? null,
@@ -683,6 +703,7 @@ class InMemoryPrismaService {
composant = {
create: async ({ data }: any) => {
const now = new Date();
const constructeurIds = this.extractConstructeurIds(data.constructeurs);
const record: ComposantRecord = {
id: generateId('component'),
name: data.name,
@@ -693,7 +714,7 @@ class InMemoryPrismaService {
typeComposantId: data.typeComposantId ?? null,
typeMachineComponentRequirementId:
data.typeMachineComponentRequirementId ?? null,
constructeurId: data.constructeurId ?? null,
constructeurIds,
createdAt: now,
updatedAt: now,
};
@@ -719,6 +740,7 @@ class InMemoryPrismaService {
piece = {
create: async ({ data }: any) => {
const now = new Date();
const constructeurIds = this.extractConstructeurIds(data.constructeurs);
const record: PieceRecord = {
id: generateId('piece'),
name: data.name,
@@ -729,7 +751,7 @@ class InMemoryPrismaService {
typePieceId: data.typePieceId ?? null,
typeMachinePieceRequirementId:
data.typeMachinePieceRequirementId ?? null,
constructeurId: data.constructeurId ?? null,
constructeurIds,
createdAt: now,
updatedAt: now,
};
@@ -767,7 +789,7 @@ class InMemoryPrismaService {
prixOverride:
data.prixOverride !== undefined && data.prixOverride !== null
? String(data.prixOverride)
: data.prixOverride ?? null,
: (data.prixOverride ?? null),
createdAt: now,
updatedAt: now,
};
@@ -819,7 +841,7 @@ class InMemoryPrismaService {
prixOverride:
data.prixOverride !== undefined && data.prixOverride !== null
? String(data.prixOverride)
: data.prixOverride ?? null,
: (data.prixOverride ?? null),
createdAt: now,
updatedAt: now,
};
@@ -848,7 +870,9 @@ class InMemoryPrismaService {
(link) => link.parentLinkId === where.parentLinkId,
);
}
return links.map((link) => this.buildMachinePieceLink(link, include ?? {}));
return links.map((link) =>
this.buildMachinePieceLink(link, include ?? {}),
);
},
};
@@ -979,6 +1003,120 @@ class InMemoryPrismaService {
},
};
constructeur = {
findMany: async ({ where, select, orderBy }: any = {}) => {
let records = [...this.constructeurs];
if (where?.id?.in) {
const ids = Array.isArray(where.id.in) ? where.id.in : [];
records = records.filter((item) => ids.includes(item.id));
}
if (where?.OR && Array.isArray(where.OR)) {
const termCandidate = where.OR.find(
(clause: any) => clause?.name?.contains,
);
const term =
termCandidate?.name?.contains ??
termCandidate?.email?.contains ??
termCandidate?.phone?.contains ??
'';
const normalized = term.toLowerCase().trim();
if (normalized) {
records = records.filter((record) => {
const nameMatch = record.name.toLowerCase().includes(normalized);
const emailMatch =
record.email?.toLowerCase().includes(normalized) ?? false;
const phoneMatch =
record.phone?.toLowerCase().includes(normalized) ?? false;
return nameMatch || emailMatch || phoneMatch;
});
}
}
if (orderBy?.name) {
records = [...records].sort((a, b) =>
orderBy.name === 'desc'
? b.name.localeCompare(a.name)
: a.name.localeCompare(b.name),
);
}
return records.map((record) => this.applySelect(record, select));
},
findUnique: async ({ where, select }: any) => {
const record = this.constructeurs.find((item) => item.id === where?.id);
return record ? this.applySelect(record, select) : null;
},
findFirst: async ({ where, select }: any = {}) => {
if (where?.name?.equals) {
const target = where.name.equals.toLowerCase();
const record = this.constructeurs.find(
(item) => item.name.toLowerCase() === target,
);
return record ? this.applySelect(record, select) : null;
}
return null;
},
create: async ({ data, select }: any) => {
const now = new Date();
const record: ConstructeurRecord = {
id: data.id ?? generateId('constructeur'),
name: data.name?.trim?.() ?? '',
email: data.email?.trim?.() ?? null,
phone: data.phone?.trim?.() ?? null,
createdAt: now,
updatedAt: now,
};
this.constructeurs.push(record);
return this.applySelect(record, select);
},
update: async ({ where, data, select }: any) => {
const record = this.constructeurs.find((item) => item.id === where?.id);
if (!record) {
throw new Error('Constructeur not found');
}
if (data.name !== undefined) {
record.name =
this.applyUpdateValue<string>(data.name)?.trim?.() ?? record.name;
}
if (data.email !== undefined) {
record.email =
this.applyUpdateValue<string | null>(data.email)?.trim?.() ?? null;
}
if (data.phone !== undefined) {
record.phone =
this.applyUpdateValue<string | null>(data.phone)?.trim?.() ?? null;
}
record.updatedAt = new Date();
return this.applySelect(record, select);
},
delete: async ({ where, select }: any) => {
const index = this.constructeurs.findIndex(
(item) => item.id === where?.id,
);
if (index === -1) {
throw new Error('Constructeur not found');
}
const [deleted] = this.constructeurs.splice(index, 1);
return this.applySelect(deleted, select);
},
};
async __getConstructeurLinkOrientation(table: string) {
return (
this.constructeurLinkOrientation[table] ?? {
parentColumn: 'A',
constructeurColumn: 'B',
}
);
}
profile = {
count: async ({ where }: any) => {
return this.profiles.filter((profile) => {
@@ -1279,6 +1417,71 @@ class InMemoryPrismaService {
return base;
}
private extractConstructeurIds(input: any): string[] {
if (!input) {
return [];
}
const source = Array.isArray(input.set)
? input.set
: Array.isArray(input.connect)
? input.connect
: [];
return source
.map((entry: any) => (typeof entry?.id === 'string' ? entry.id : null))
.filter((id: string | null): id is string => Boolean(id));
}
private mapConstructeurs(ids: string[] = []) {
if (!Array.isArray(ids) || ids.length === 0) {
return [];
}
return ids
.map((id) =>
this.constructeurs.find((constructeur) => constructeur.id === id),
)
.filter((item): item is (typeof this.constructeurs)[number] =>
Boolean(item),
)
.map((item) => ({ ...item }));
}
async __syncConstructeurLinks(
table: string,
parentId: string,
ids: string[],
): Promise<void> {
const filtered = ids.filter((id) =>
this.constructeurs.some((item) => item.id === id),
);
switch (table) {
case '_MachineConstructeurs':
this.assignConstructeurs(this.machines, parentId, filtered);
break;
case '_ComposantConstructeurs':
this.assignConstructeurs(this.composants, parentId, filtered);
break;
case '_PieceConstructeurs':
this.assignConstructeurs(this.pieces, parentId, filtered);
break;
default:
throw new Error(`Unsupported constructeur link table: ${table}`);
}
}
private assignConstructeurs<
T extends { id: string; constructeurIds: string[] },
>(collection: T[], parentId: string, ids: string[]) {
const record = collection.find((item) => item.id === parentId);
if (!record) {
return;
}
record.constructeurIds = [...ids];
}
private buildMachine(machine: MachineRecord, include: any) {
const base: any = { ...machine };
@@ -1309,8 +1512,8 @@ class InMemoryPrismaService {
}
}
if (include?.constructeur) {
base.constructeur = null;
if (include?.constructeurs) {
base.constructeurs = this.mapConstructeurs(machine.constructeurIds);
}
if (include?.componentLinks) {
@@ -1389,19 +1592,19 @@ class InMemoryPrismaService {
if (include?.typeMachineComponentRequirement) {
const requirement = link.typeMachineComponentRequirementId
? this.typeMachineComponentRequirements.find(
? (this.typeMachineComponentRequirements.find(
(item) => item.id === link.typeMachineComponentRequirementId,
) ?? null
) ?? null)
: null;
base.typeMachineComponentRequirement = requirement
? {
...requirement,
typeComposant:
include.typeMachineComponentRequirement.include?.typeComposant
? this.typeComposants.find(
(item) => item.id === requirement.typeComposantId,
) ?? null
: undefined,
typeComposant: include.typeMachineComponentRequirement.include
?.typeComposant
? (this.typeComposants.find(
(item) => item.id === requirement.typeComposantId,
) ?? null)
: undefined,
}
: null;
}
@@ -1419,14 +1622,12 @@ class InMemoryPrismaService {
return base;
}
private buildMachinePieceLink(
link: MachinePieceLinkRecord,
include: any,
) {
private buildMachinePieceLink(link: MachinePieceLinkRecord, include: any) {
const base: any = { ...link };
if (include?.piece) {
const piece = this.pieces.find((item) => item.id === link.pieceId) ?? null;
const piece =
this.pieces.find((item) => item.id === link.pieceId) ?? null;
base.piece = piece
? this.buildPiece(piece, include.piece.include ?? {})
: null;
@@ -1434,19 +1635,18 @@ class InMemoryPrismaService {
if (include?.typeMachinePieceRequirement) {
const requirement = link.typeMachinePieceRequirementId
? this.typeMachinePieceRequirements.find(
? (this.typeMachinePieceRequirements.find(
(item) => item.id === link.typeMachinePieceRequirementId,
) ?? null
) ?? null)
: null;
base.typeMachinePieceRequirement = requirement
? {
...requirement,
typePiece:
include.typeMachinePieceRequirement.include?.typePiece
? this.typePieces.find(
(item) => item.id === requirement.typePieceId,
) ?? null
: undefined,
typePiece: include.typeMachinePieceRequirement.include?.typePiece
? (this.typePieces.find(
(item) => item.id === requirement.typePieceId,
) ?? null)
: undefined,
}
: null;
}
@@ -1505,8 +1705,8 @@ class InMemoryPrismaService {
);
}
if (include?.constructeur) {
base.constructeur = null;
if (include?.constructeurs) {
base.constructeurs = this.mapConstructeurs(component.constructeurIds);
}
if (include?.pieces) {
@@ -1536,8 +1736,8 @@ class InMemoryPrismaService {
);
}
if (include?.constructeur) {
base.constructeur = null;
if (include?.constructeurs) {
base.constructeurs = this.mapConstructeurs(piece.constructeurIds);
}
if (include?.typeMachinePieceRequirement) {