feat(r3.1): socle backend gestion — stock dérivé, BC, coûts figés sur OT

- migration r3_gestion (7 tables) : Partner, Part (SANS colonne de
  quantité), StockMovement (signé, tracé, PU figé), PurchaseOrder/Line,
  LaborTime (taux figé), Document (R3.2) ; User.hourlyRate administrable
- API (66 opérations) : tiers ; pièces (stock = Σ mouvements, alerte sous
  seuil) ; entrée/ajustement (motif requis, stock jamais négatif, en
  transaction) ; BC Brouillon→Envoyé→Reçu (la réception crée les RECEIPT
  et met à jour lastUnitPrice) ; consommation sur OT (stock suffisant,
  PRIX FIGÉ) ; main-d'œuvre (TAUX FIGÉ, refus si taux non défini) ;
  WorkOrderDetail.costs ; coûts verrouillés après clôture (409)
- seed : tiers/pièces/BC/taux de la maquette — OT-0341 = 505 MAD (testé)
- durcissement : références OT/DEM/BC/P par SÉQUENCES Postgres (nextval)
  — fin des courses « max+1 » (500 sporadiques sous charge parallèle) ;
  3 runs Jest complets consécutifs verts
- 55 tests (92 % stmts / 74,9 % branches) dont la recette officielle :
  consommer sous seuil → BC → réception → réappro, prix/taux figés prouvés

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 20:42:03 +01:00
parent d5041c6f05
commit dfdf8f7c18
33 changed files with 5361 additions and 717 deletions

View File

@@ -0,0 +1,191 @@
-- CreateEnum
CREATE TYPE "PartnerKind" AS ENUM ('SUPPLIER', 'CLIENT');
-- CreateEnum
CREATE TYPE "PurchaseOrderStatus" AS ENUM ('DRAFT', 'SENT', 'RECEIVED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "StockMovementKind" AS ENUM ('RECEIPT', 'ENTRY', 'CONSUMPTION', 'ADJUSTMENT');
-- CreateEnum
CREATE TYPE "DocumentKind" AS ENUM ('NOTICE', 'CERTIFICATE', 'PHOTO', 'OTHER');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "hourlyRate" DECIMAL(8,2);
-- CreateTable
CREATE TABLE "Partner" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"kind" "PartnerKind" NOT NULL,
"contactName" TEXT,
"phone" TEXT,
"email" TEXT,
"city" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Partner_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Part" (
"id" UUID NOT NULL,
"reference" TEXT NOT NULL,
"designation" TEXT NOT NULL,
"threshold" INTEGER NOT NULL DEFAULT 0,
"lastUnitPrice" DECIMAL(10,2),
"compatible" TEXT,
"supplierId" UUID,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Part_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockMovement" (
"id" UUID NOT NULL,
"partId" UUID NOT NULL,
"kind" "StockMovementKind" NOT NULL,
"quantity" INTEGER NOT NULL,
"unitPrice" DECIMAL(10,2),
"reason" TEXT,
"workOrderId" UUID,
"purchaseOrderId" UUID,
"byId" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PurchaseOrder" (
"id" UUID NOT NULL,
"reference" TEXT NOT NULL,
"status" "PurchaseOrderStatus" NOT NULL DEFAULT 'DRAFT',
"supplierId" UUID NOT NULL,
"sentAt" TIMESTAMP(3),
"receivedAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"createdById" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PurchaseOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PurchaseOrderLine" (
"id" UUID NOT NULL,
"purchaseOrderId" UUID NOT NULL,
"partId" UUID NOT NULL,
"quantity" INTEGER NOT NULL,
"unitPrice" DECIMAL(10,2) NOT NULL,
CONSTRAINT "PurchaseOrderLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LaborTime" (
"id" UUID NOT NULL,
"workOrderId" UUID NOT NULL,
"userId" UUID NOT NULL,
"minutes" INTEGER NOT NULL,
"hourlyRate" DECIMAL(8,2) NOT NULL,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LaborTime_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Document" (
"id" UUID NOT NULL,
"kind" "DocumentKind" NOT NULL,
"fileName" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"contentType" TEXT NOT NULL,
"assetId" UUID,
"workOrderId" UUID,
"uploadedById" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Partner_name_key" ON "Partner"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Part_reference_key" ON "Part"("reference");
-- CreateIndex
CREATE INDEX "StockMovement_partId_idx" ON "StockMovement"("partId");
-- CreateIndex
CREATE INDEX "StockMovement_workOrderId_idx" ON "StockMovement"("workOrderId");
-- CreateIndex
CREATE UNIQUE INDEX "PurchaseOrder_reference_key" ON "PurchaseOrder"("reference");
-- CreateIndex
CREATE INDEX "PurchaseOrderLine_purchaseOrderId_idx" ON "PurchaseOrderLine"("purchaseOrderId");
-- CreateIndex
CREATE INDEX "LaborTime_workOrderId_idx" ON "LaborTime"("workOrderId");
-- CreateIndex
CREATE UNIQUE INDEX "Document_storageKey_key" ON "Document"("storageKey");
-- CreateIndex
CREATE INDEX "Document_assetId_idx" ON "Document"("assetId");
-- CreateIndex
CREATE INDEX "Document_workOrderId_idx" ON "Document"("workOrderId");
-- AddForeignKey
ALTER TABLE "Part" ADD CONSTRAINT "Part_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_byId_fkey" FOREIGN KEY ("byId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,16 @@
-- Références OT/DEM/BC/P : séquences Postgres — la concurrence ne peut plus
-- produire de collision (leçon des générations parallèles).
-- La numérotation ne se remet pas à zéro chaque année : l'unicité prime.
CREATE SEQUENCE IF NOT EXISTS "work_order_ref_seq";
CREATE SEQUENCE IF NOT EXISTS "request_ref_seq";
CREATE SEQUENCE IF NOT EXISTS "purchase_order_ref_seq";
CREATE SEQUENCE IF NOT EXISTS "part_ref_seq";
SELECT setval('work_order_ref_seq',
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "WorkOrder"), 1000));
SELECT setval('request_ref_seq',
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Request"), 1000));
SELECT setval('purchase_order_ref_seq',
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "PurchaseOrder"), 1000));
SELECT setval('part_ref_seq',
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Part"), 1000));

View File

@@ -56,6 +56,12 @@ model User {
requests Request[]
checklistDone ChecklistItem[]
meterReadings MeterReading[]
// R3 — gestion : taux horaire COURANT (le taux d'une saisie est figé dans LaborTime)
hourlyRate Decimal? @db.Decimal(8, 2)
laborTimes LaborTime[]
stockMovements StockMovement[]
purchaseOrders PurchaseOrder[]
documents Document[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -127,6 +133,7 @@ model Asset {
workOrders WorkOrder[]
requests Request[]
meters Meter[]
documents Document[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -225,6 +232,9 @@ model WorkOrder {
checklist ChecklistItem[]
report InterventionReport?
request Request?
stockMovements StockMovement[]
laborTimes LaborTime[]
documents Document[]
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
// l'idempotence de la génération (les OT correctifs restent à null).
periodKey String?
@@ -358,3 +368,147 @@ model MeterReading {
@@index([meterId])
}
// ————— R3 — Gestion (docs/03-architecture/modele-donnees.md §R3) —————
enum PartnerKind {
SUPPLIER
CLIENT // syndic / propriétaire
}
enum PurchaseOrderStatus {
DRAFT
SENT
RECEIVED
CANCELLED
}
enum StockMovementKind {
RECEIPT // réception de BC (+, PU figé)
ENTRY // entrée manuelle (+)
CONSUMPTION // consommation d'OT (, PU figé)
ADJUSTMENT // inventaire (±, motif REQUIS)
}
enum DocumentKind {
NOTICE
CERTIFICATE
PHOTO
OTHER
}
model Partner {
id String @id @default(uuid()) @db.Uuid
name String @unique
kind PartnerKind
contactName String?
phone String?
email String?
city String?
isActive Boolean @default(true)
parts Part[]
purchaseOrders PurchaseOrder[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Part {
id String @id @default(uuid()) @db.Uuid
reference String @unique // P-0113
designation String
threshold Int @default(0) // seuil d'alerte
// Dernier prix d'achat — figé sur chaque mouvement au moment T.
lastUnitPrice Decimal? @db.Decimal(10, 2)
compatible String?
supplierId String? @db.Uuid
supplier Partner? @relation(fields: [supplierId], references: [id])
isActive Boolean @default(true)
movements StockMovement[]
orderLines PurchaseOrderLine[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Le stock EST la somme des mouvements — aucune colonne de quantité.
model StockMovement {
id String @id @default(uuid()) @db.Uuid
partId String @db.Uuid
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
kind StockMovementKind
quantity Int // signé : + entrée, sortie
unitPrice Decimal? @db.Decimal(10, 2) // figé (réception, consommation)
reason String? // ajustement : motif requis (service)
workOrderId String? @db.Uuid
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
purchaseOrderId String? @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
byId String? @db.Uuid
by User? @relation(fields: [byId], references: [id])
createdAt DateTime @default(now())
@@index([partId])
@@index([workOrderId])
}
model PurchaseOrder {
id String @id @default(uuid()) @db.Uuid
reference String @unique // BC-2026-0024
status PurchaseOrderStatus @default(DRAFT)
supplierId String @db.Uuid
supplier Partner @relation(fields: [supplierId], references: [id])
lines PurchaseOrderLine[]
movements StockMovement[]
sentAt DateTime?
receivedAt DateTime?
cancelledAt DateTime?
createdById String? @db.Uuid
createdBy User? @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PurchaseOrderLine {
id String @id @default(uuid()) @db.Uuid
purchaseOrderId String @db.Uuid
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
partId String @db.Uuid
part Part @relation(fields: [partId], references: [id])
quantity Int
unitPrice Decimal @db.Decimal(10, 2)
@@index([purchaseOrderId])
}
// Main-d'œuvre : le taux est FIGÉ à la saisie (le coût d'un OT ne bouge plus).
model LaborTime {
id String @id @default(uuid()) @db.Uuid
workOrderId String @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
userId String @db.Uuid
user User @relation(fields: [userId], references: [id])
minutes Int
hourlyRate Decimal @db.Decimal(8, 2)
note String?
createdAt DateTime @default(now())
@@index([workOrderId])
}
model Document {
id String @id @default(uuid()) @db.Uuid
kind DocumentKind
fileName String
storageKey String @unique // clé MinIO (FileStorage)
size Int
contentType String
assetId String? @db.Uuid
asset Asset? @relation(fields: [assetId], references: [id])
workOrderId String? @db.Uuid
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
uploadedById String? @db.Uuid
uploadedBy User? @relation(fields: [uploadedById], references: [id])
createdAt DateTime @default(now())
@@index([assetId])
@@index([workOrderId])
}

View File

@@ -134,6 +134,7 @@ export async function seed(prisma: PrismaClient): Promise<void> {
await seedUsers(prisma, roleIds, passwordHash);
await seedReferentiel(prisma);
await seedExploitation(prisma);
await seedGestion(prisma);
}
async function seedUsers(
@@ -580,6 +581,177 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
}
}
// ————— R3 — Gestion (tiers, pièces, mouvements, BC, taux — données maquette) —————
const PARTNERS: {
name: string; kind: 'SUPPLIER' | 'CLIENT'; contactName?: string;
phone?: string; email?: string; city?: string;
}[] = [
{ name: 'Ascentech Maroc', kind: 'SUPPLIER', contactName: 'M. Berrini', phone: '05 22 34 56 78', city: 'Casablanca' },
{ name: 'SchindlerParts', kind: 'SUPPLIER', email: 'commandes@schindlerparts.ma' },
{ name: 'Lubmaroc', kind: 'SUPPLIER', phone: '05 22 11 22 33' },
{ name: 'Atlas Property Management', kind: 'CLIENT', contactName: 'Mme Zerhouni', phone: '06 61 98 76 54' },
{ name: 'Syndic Al Manar', kind: 'CLIENT', contactName: 'M. Alami (gardien référent)' },
];
const PARTS: {
reference: string; designation: string; threshold: number;
lastUnitPrice?: number; supplier?: string; compatible?: string;
}[] = [
{ reference: 'P-0019', designation: 'Cellule barrière porte (paire)', threshold: 4, lastUnitPrice: 640, supplier: 'SchindlerParts' },
{ reference: 'P-0031', designation: 'Bouton palier lumineux Ø22', threshold: 10, lastUnitPrice: 45, supplier: 'Ascentech Maroc' },
{ reference: 'P-0042', designation: 'Graisse guide (cartouche 400 g)', threshold: 8, lastUnitPrice: 85, supplier: 'Lubmaroc' },
{ reference: 'P-0087', designation: 'Coulisseau de guide 16 mm', threshold: 6, lastUnitPrice: 120, supplier: 'Ascentech Maroc' },
{ reference: 'P-0113', designation: 'Contact de porte NC-31', threshold: 5, lastUnitPrice: 85, supplier: 'Ascentech Maroc', compatible: 'Otis Gen2 · Schindler 3300' },
];
async function seedGestion(prisma: PrismaClient): Promise<void> {
const partnerIds = new Map<string, string>();
for (const p of PARTNERS) {
const partner = await prisma.partner.upsert({
where: { name: p.name },
update: {},
create: p,
});
partnerIds.set(p.name, partner.id);
}
const partIds = new Map<string, string>();
for (const p of PARTS) {
const part = await prisma.part.upsert({
where: { reference: p.reference },
update: {},
create: {
reference: p.reference,
designation: p.designation,
threshold: p.threshold,
lastUnitPrice: p.lastUnitPrice,
compatible: p.compatible,
supplierId: p.supplier ? partnerIds.get(p.supplier) : undefined,
},
});
partIds.set(p.reference, part.id);
}
// Taux horaires courants (les saisies figent leur propre taux)
for (const [email, taux] of [
['technicien@demo.siop.ma', 120],
['technicien-limite@demo.siop.ma', 90],
] as const) {
await prisma.user.update({ where: { email }, data: { hourlyRate: taux } });
}
// Idempotence : si des mouvements existent déjà, l'histoire est en place
if ((await prisma.stockMovement.count()) > 0) return;
const annee = new Date().getFullYear();
const ahmed = await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } });
const nadia = await prisma.user.findUniqueOrThrow({ where: { email: 'gestionnaire@demo.siop.ma' } });
// BC reçus (historique) et BC en cours — comme la maquette
const bc21 = await prisma.purchaseOrder.create({
data: {
reference: `BC-${annee}-0021`,
status: 'RECEIVED',
supplierId: partnerIds.get('Ascentech Maroc')!,
createdById: nadia.id,
sentAt: new Date(Date.now() - 40 * 86400e3),
receivedAt: new Date(Date.now() - 34 * 86400e3),
lines: {
create: [
{ partId: partIds.get('P-0113')!, quantity: 5, unitPrice: 85 },
{ partId: partIds.get('P-0087')!, quantity: 6, unitPrice: 120 },
{ partId: partIds.get('P-0031')!, quantity: 20, unitPrice: 45 },
],
},
},
include: { lines: true },
});
await prisma.purchaseOrder.create({
data: {
reference: `BC-${annee}-0024`,
status: 'SENT',
supplierId: partnerIds.get('Ascentech Maroc')!,
createdById: nadia.id,
sentAt: new Date(),
lines: {
create: [
{ partId: partIds.get('P-0113')!, quantity: 10, unitPrice: 85 },
{ partId: partIds.get('P-0087')!, quantity: 2, unitPrice: 127.5 },
],
},
},
});
// Mouvements : réceptions du BC-0021 + entrées initiales + consommations
for (const line of bc21.lines) {
await prisma.stockMovement.create({
data: {
partId: line.partId,
kind: 'RECEIPT',
quantity: line.quantity,
unitPrice: line.unitPrice,
purchaseOrderId: bc21.id,
byId: nadia.id,
createdAt: new Date(Date.now() - 34 * 86400e3),
},
});
}
for (const [ref, qte] of [
['P-0042', 19],
['P-0019', 7],
['P-0031', 6],
] as const) {
await prisma.stockMovement.create({
data: {
partId: partIds.get(ref)!,
kind: 'ENTRY',
quantity: qte,
reason: 'Reprise de linventaire initial',
byId: nadia.id,
createdAt: new Date(Date.now() - 60 * 86400e3),
},
});
}
// P-0113 : consommations + ajustement → stock 2 (sous le seuil, maquette)
const ot341 = await prisma.workOrder.findUnique({
where: { reference: `OT-${annee}-0341` },
});
await prisma.stockMovement.createMany({
data: [
{ partId: partIds.get('P-0113')!, kind: 'ADJUSTMENT', quantity: -1, reason: 'Inventaire — pièce endommagée', byId: nadia.id },
{ partId: partIds.get('P-0113')!, kind: 'CONSUMPTION', quantity: -2, unitPrice: 85, byId: ahmed.id },
],
});
// P-0087 : consommation sur OT-0341 (carte maquette : 2 × 120 = 240 MAD)
if (ot341) {
await prisma.stockMovement.create({
data: {
partId: partIds.get('P-0087')!,
kind: 'CONSUMPTION',
quantity: -2,
unitPrice: 120,
workOrderId: ot341.id,
byId: ahmed.id,
},
});
await prisma.stockMovement.create({
data: {
partId: partIds.get('P-0042')!,
kind: 'CONSUMPTION',
quantity: -1,
unitPrice: 85,
workOrderId: ot341.id,
byId: ahmed.id,
},
});
// main-d'œuvre : 1 h 30 × 120 MAD/h = 180 (total maquette : 505 MAD)
await prisma.laborTime.create({
data: { workOrderId: ot341.id, userId: ahmed.id, minutes: 90, hourlyRate: 120 },
});
}
}
/* c8 ignore start — wrapper CLI */
if (require.main === module) {
const prisma = new PrismaClient();