From dfdf8f7c18fb5b1b7d47c591bf61d5e1d4d76d0e Mon Sep 17 00:00:00 2001 From: pr-daaif Date: Thu, 16 Jul 2026 20:42:03 +0100 Subject: [PATCH] =?UTF-8?q?feat(r3.1):=20socle=20backend=20gestion=20?= =?UTF-8?q?=E2=80=94=20stock=20d=C3=A9riv=C3=A9,=20BC,=20co=C3=BBts=20fig?= =?UTF-8?q?=C3=A9s=20sur=20OT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 3 +- .../20260716203422_r3_gestion/migration.sql | 191 ++ .../migration.sql | 16 + apps/api/prisma/schema.prisma | 154 + apps/api/prisma/seed.ts | 172 + apps/api/src/app.module.ts | 7 + apps/api/src/partners/partners.controller.ts | 45 + apps/api/src/partners/partners.module.ts | 9 + apps/api/src/partners/partners.service.ts | 84 + apps/api/src/parts/parts.controller.ts | 66 + apps/api/src/parts/parts.module.ts | 10 + apps/api/src/parts/parts.service.ts | 231 ++ apps/api/src/portal/portal.service.ts | 11 +- apps/api/src/preventive/preventive.service.ts | 11 +- .../purchase-orders.controller.ts | 59 + .../purchase-orders/purchase-orders.module.ts | 9 + .../purchase-orders.service.ts | 170 + apps/api/src/requests/requests.service.ts | 11 +- apps/api/src/users/users.service.ts | 2 + .../src/work-orders/work-orders.controller.ts | 24 + .../api/src/work-orders/work-orders.module.ts | 2 + .../src/work-orders/work-orders.service.ts | 106 +- apps/api/test/exploitation.e2e-spec.ts | 9 +- apps/api/test/gestion.e2e-spec.ts | 351 ++ apps/web/src/api/schema.d.ts | 1000 +++++- docs/03-architecture/modele-donnees.md | 57 +- docs/journal/journal.md | 18 + docs/openapi.json | 2840 +++++++++++++---- packages/shared/src/contract.ts | 190 ++ packages/shared/src/index.ts | 1 + packages/shared/src/schemas/exploitation.ts | 3 + packages/shared/src/schemas/gestion.ts | 213 ++ packages/shared/src/schemas/users-admin.ts | 3 + 33 files changed, 5361 insertions(+), 717 deletions(-) create mode 100644 apps/api/prisma/migrations/20260716203422_r3_gestion/migration.sql create mode 100644 apps/api/prisma/migrations/20260716203855_r3_sequences_references/migration.sql create mode 100644 apps/api/src/partners/partners.controller.ts create mode 100644 apps/api/src/partners/partners.module.ts create mode 100644 apps/api/src/partners/partners.service.ts create mode 100644 apps/api/src/parts/parts.controller.ts create mode 100644 apps/api/src/parts/parts.module.ts create mode 100644 apps/api/src/parts/parts.service.ts create mode 100644 apps/api/src/purchase-orders/purchase-orders.controller.ts create mode 100644 apps/api/src/purchase-orders/purchase-orders.module.ts create mode 100644 apps/api/src/purchase-orders/purchase-orders.service.ts create mode 100644 apps/api/test/gestion.e2e-spec.ts create mode 100644 packages/shared/src/schemas/gestion.ts diff --git a/CLAUDE.md b/CLAUDE.md index d7cf3d5..0226d56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,5 +49,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production. - ✅ **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API. - 🏁 **R2 CLOSE (16/07/2026, tag `release/r2`)** : recettée (1 anomalie corrigée en recette : tri « Interventions récentes »), déployée et vérifiée en ligne (portail `/q/A1`, urgence en tête, préventif de juillet généré). -- 🔄 **R3 Gestion — ouverte, design d'abord** : `maquette-r3.html` (7 écrans : stock dérivé des mouvements, fiche pièce, BC avec réception→entrées, coûts sur OT à prix/taux figés, statistiques, tiers, bibliothèque) — **⛔ en attente de validation du référent avant tout code R3** (4 décisions soumises). Ensuite : modèle R3 (Part/StockMovement, PurchaseOrder, Partner, LaborTime taux figé, Document + upload FileStorage réel) → contrat → API → web → recette (consommer sous seuil → BC → réception ; coût complet d'un OT ; dashboard direction). +- ✅ **R3 — maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC → RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1). +- 🔄 **R3.2 — reprise ici** : bibliothèque de documents (upload multipart 20 Mo → `FileStorage.putObject`, download streamé par l'API — MinIO jamais exposé, types fermés, rattachement appareil/OT requis) + analytics `GET /analytics/summary` (coûts/mois, pannes par organe via bilans, taux préventif, top équipements) → R3.3 écrans web (stock, fiche pièce, BC, coûts sur fiche OT, statistiques, tiers, bibliothèque + taux dans Personnes) → recette + déploiement + tag. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5. diff --git a/apps/api/prisma/migrations/20260716203422_r3_gestion/migration.sql b/apps/api/prisma/migrations/20260716203422_r3_gestion/migration.sql new file mode 100644 index 0000000..f0cab92 --- /dev/null +++ b/apps/api/prisma/migrations/20260716203422_r3_gestion/migration.sql @@ -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; + diff --git a/apps/api/prisma/migrations/20260716203855_r3_sequences_references/migration.sql b/apps/api/prisma/migrations/20260716203855_r3_sequences_references/migration.sql new file mode 100644 index 0000000..9143a6c --- /dev/null +++ b/apps/api/prisma/migrations/20260716203855_r3_sequences_references/migration.sql @@ -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)); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 06cee26..2637b5f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 2653102..45d93f8 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -134,6 +134,7 @@ export async function seed(prisma: PrismaClient): Promise { 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 { } } +// ————— 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 { + const partnerIds = new Map(); + 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(); + for (const p of PARTS) { + const part = await prisma.part.upsert({ + where: { reference: p.reference }, + update: {}, + create: { + reference: p.reference, + designation: p.designation, + threshold: p.threshold, + lastUnitPrice: p.lastUnitPrice, + compatible: p.compatible, + supplierId: p.supplier ? partnerIds.get(p.supplier) : undefined, + }, + }); + partIds.set(p.reference, part.id); + } + + // Taux horaires courants (les saisies figent leur propre taux) + for (const [email, taux] of [ + ['technicien@demo.siop.ma', 120], + ['technicien-limite@demo.siop.ma', 90], + ] as const) { + await prisma.user.update({ where: { email }, data: { hourlyRate: taux } }); + } + + // Idempotence : si des mouvements existent déjà, l'histoire est en place + if ((await prisma.stockMovement.count()) > 0) return; + + const annee = new Date().getFullYear(); + const ahmed = await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } }); + const nadia = await prisma.user.findUniqueOrThrow({ where: { email: 'gestionnaire@demo.siop.ma' } }); + + // BC reçus (historique) et BC en cours — comme la maquette + const bc21 = await prisma.purchaseOrder.create({ + data: { + reference: `BC-${annee}-0021`, + status: 'RECEIVED', + supplierId: partnerIds.get('Ascentech Maroc')!, + createdById: nadia.id, + sentAt: new Date(Date.now() - 40 * 86400e3), + receivedAt: new Date(Date.now() - 34 * 86400e3), + lines: { + create: [ + { partId: partIds.get('P-0113')!, quantity: 5, unitPrice: 85 }, + { partId: partIds.get('P-0087')!, quantity: 6, unitPrice: 120 }, + { partId: partIds.get('P-0031')!, quantity: 20, unitPrice: 45 }, + ], + }, + }, + include: { lines: true }, + }); + await prisma.purchaseOrder.create({ + data: { + reference: `BC-${annee}-0024`, + status: 'SENT', + supplierId: partnerIds.get('Ascentech Maroc')!, + createdById: nadia.id, + sentAt: new Date(), + lines: { + create: [ + { partId: partIds.get('P-0113')!, quantity: 10, unitPrice: 85 }, + { partId: partIds.get('P-0087')!, quantity: 2, unitPrice: 127.5 }, + ], + }, + }, + }); + + // Mouvements : réceptions du BC-0021 + entrées initiales + consommations + for (const line of bc21.lines) { + await prisma.stockMovement.create({ + data: { + partId: line.partId, + kind: 'RECEIPT', + quantity: line.quantity, + unitPrice: line.unitPrice, + purchaseOrderId: bc21.id, + byId: nadia.id, + createdAt: new Date(Date.now() - 34 * 86400e3), + }, + }); + } + for (const [ref, qte] of [ + ['P-0042', 19], + ['P-0019', 7], + ['P-0031', 6], + ] as const) { + await prisma.stockMovement.create({ + data: { + partId: partIds.get(ref)!, + kind: 'ENTRY', + quantity: qte, + reason: 'Reprise de l’inventaire initial', + byId: nadia.id, + createdAt: new Date(Date.now() - 60 * 86400e3), + }, + }); + } + // P-0113 : consommations + ajustement → stock 2 (sous le seuil, maquette) + const ot341 = await prisma.workOrder.findUnique({ + where: { reference: `OT-${annee}-0341` }, + }); + await prisma.stockMovement.createMany({ + data: [ + { partId: partIds.get('P-0113')!, kind: 'ADJUSTMENT', quantity: -1, reason: 'Inventaire — pièce endommagée', byId: nadia.id }, + { partId: partIds.get('P-0113')!, kind: 'CONSUMPTION', quantity: -2, unitPrice: 85, byId: ahmed.id }, + ], + }); + // P-0087 : consommation sur OT-0341 (carte maquette : 2 × 120 = 240 MAD) + if (ot341) { + await prisma.stockMovement.create({ + data: { + partId: partIds.get('P-0087')!, + kind: 'CONSUMPTION', + quantity: -2, + unitPrice: 120, + workOrderId: ot341.id, + byId: ahmed.id, + }, + }); + await prisma.stockMovement.create({ + data: { + partId: partIds.get('P-0042')!, + kind: 'CONSUMPTION', + quantity: -1, + unitPrice: 85, + workOrderId: ot341.id, + byId: ahmed.id, + }, + }); + // main-d'œuvre : 1 h 30 × 120 MAD/h = 180 (total maquette : 505 MAD) + await prisma.laborTime.create({ + data: { workOrderId: ot341.id, userId: ahmed.id, minutes: 90, hourlyRate: 120 }, + }); + } +} + /* c8 ignore start — wrapper CLI */ if (require.main === module) { const prisma = new PrismaClient(); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1ce7467..792d443 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -10,7 +10,10 @@ import { FilesModule } from './files/files.module'; import { HealthModule } from './health/health.module'; import { LocationsModule } from './locations/locations.module'; import { MetersModule } from './meters/meters.module'; +import { PartnersModule } from './partners/partners.module'; +import { PartsModule } from './parts/parts.module'; import { PortalModule } from './portal/portal.module'; +import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module'; import { PreventiveModule } from './preventive/preventive.module'; import { ReferenceValuesModule } from './reference-values/reference-values.module'; import { RequestsModule } from './requests/requests.module'; @@ -50,6 +53,10 @@ export class AppModule { PreventiveModule, MetersModule, PortalModule, + // R3 — gestion + PartnersModule, + PartsModule, + PurchaseOrdersModule, // ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404 ...(demoModeEnabled() ? [DemoAuthModule] : []), ], diff --git a/apps/api/src/partners/partners.controller.ts b/apps/api/src/partners/partners.controller.ts new file mode 100644 index 0000000..93513d9 --- /dev/null +++ b/apps/api/src/partners/partners.controller.ts @@ -0,0 +1,45 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, +} from '@nestjs/common'; +import { + PartnerCreateSchema, + PartnerUpdateSchema, + type PartnerCreate, + type PartnerUpdate, +} from '@siop/shared'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { RequirePermission } from '../permissions/require-permission.decorator'; +import { PartnersService } from './partners.service'; + +/** Les tiers vivent sous la permission PURCHASE_ORDERS (décision R3). */ +@Controller('partners') +export class PartnersController { + constructor(private readonly partners: PartnersService) {} + + @Get() + @RequirePermission('PURCHASE_ORDERS', 'view') + list() { + return this.partners.list(); + } + + @Post() + @RequirePermission('PURCHASE_ORDERS', 'create') + create(@Body(new ZodValidationPipe(PartnerCreateSchema)) body: PartnerCreate) { + return this.partners.create(body); + } + + @Patch(':id') + @RequirePermission('PURCHASE_ORDERS', 'edit') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body(new ZodValidationPipe(PartnerUpdateSchema)) body: PartnerUpdate, + ) { + return this.partners.update(id, body); + } +} diff --git a/apps/api/src/partners/partners.module.ts b/apps/api/src/partners/partners.module.ts new file mode 100644 index 0000000..479de14 --- /dev/null +++ b/apps/api/src/partners/partners.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PartnersController } from './partners.controller'; +import { PartnersService } from './partners.service'; + +@Module({ + controllers: [PartnersController], + providers: [PartnersService], +}) +export class PartnersModule {} diff --git a/apps/api/src/partners/partners.service.ts b/apps/api/src/partners/partners.service.ts new file mode 100644 index 0000000..e8b2767 --- /dev/null +++ b/apps/api/src/partners/partners.service.ts @@ -0,0 +1,84 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { + PartnerCreate, + PartnerDto, + PartnersResponse, + PartnerUpdate, +} from '@siop/shared'; +import { PrismaService } from '../prisma/prisma.service'; + +const partnerInclude = { + _count: { + select: { + purchaseOrders: { where: { status: { in: ['DRAFT', 'SENT'] } } }, + }, + }, +} satisfies Prisma.PartnerInclude; + +type Row = Prisma.PartnerGetPayload<{ include: typeof partnerInclude }>; + +@Injectable() +export class PartnersService { + constructor(private readonly prisma: PrismaService) {} + + async list(): Promise { + const rows = await this.prisma.partner.findMany({ + include: partnerInclude, + orderBy: [{ kind: 'asc' }, { name: 'asc' }], + }); + return { partners: rows.map((r) => this.toDto(r)) }; + } + + async create(dto: PartnerCreate): Promise { + try { + const created = await this.prisma.partner.create({ + data: dto, + include: partnerInclude, + }); + return this.toDto(created); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new ConflictException('Ce nom de tiers existe déjà'); + } + throw e; + } + } + + async update(id: string, dto: PartnerUpdate): Promise { + try { + const updated = await this.prisma.partner.update({ + where: { id }, + data: dto, + include: partnerInclude, + }); + return this.toDto(updated); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { + throw new NotFoundException('Tiers inconnu'); + } + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new ConflictException('Ce nom de tiers existe déjà'); + } + throw e; + } + } + + private toDto(row: Row): PartnerDto { + return { + id: row.id, + name: row.name, + kind: row.kind, + contactName: row.contactName, + phone: row.phone, + email: row.email, + city: row.city, + isActive: row.isActive, + openOrders: row._count.purchaseOrders, + }; + } +} diff --git a/apps/api/src/parts/parts.controller.ts b/apps/api/src/parts/parts.controller.ts new file mode 100644 index 0000000..48ec57b --- /dev/null +++ b/apps/api/src/parts/parts.controller.ts @@ -0,0 +1,66 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, +} from '@nestjs/common'; +import { + PartCreateSchema, + PartUpdateSchema, + StockMovementCreateSchema, + type PartCreate, + type PartUpdate, + type StockMovementCreate, +} from '@siop/shared'; +import { + AuthenticatedUser, + CurrentUser, +} from '../auth/current-user.decorator'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { RequirePermission } from '../permissions/require-permission.decorator'; +import { PartsService } from './parts.service'; + +@Controller('parts') +export class PartsController { + constructor(private readonly parts: PartsService) {} + + @Get() + @RequirePermission('PARTS', 'view') + list() { + return this.parts.list(); + } + + @Get(':id') + @RequirePermission('PARTS', 'view') + get(@Param('id', ParseUUIDPipe) id: string) { + return this.parts.get(id); + } + + @Post() + @RequirePermission('PARTS', 'create') + create(@Body(new ZodValidationPipe(PartCreateSchema)) body: PartCreate) { + return this.parts.create(body); + } + + @Patch(':id') + @RequirePermission('PARTS', 'edit') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body(new ZodValidationPipe(PartUpdateSchema)) body: PartUpdate, + ) { + return this.parts.update(id, body); + } + + @Post(':id/movements') + @RequirePermission('PARTS', 'edit') + addMovement( + @Param('id', ParseUUIDPipe) id: string, + @Body(new ZodValidationPipe(StockMovementCreateSchema)) body: StockMovementCreate, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.parts.addMovement(id, body, user); + } +} diff --git a/apps/api/src/parts/parts.module.ts b/apps/api/src/parts/parts.module.ts new file mode 100644 index 0000000..de57617 --- /dev/null +++ b/apps/api/src/parts/parts.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PartsController } from './parts.controller'; +import { PartsService } from './parts.service'; + +@Module({ + controllers: [PartsController], + providers: [PartsService], + exports: [PartsService], +}) +export class PartsModule {} diff --git a/apps/api/src/parts/parts.service.ts b/apps/api/src/parts/parts.service.ts new file mode 100644 index 0000000..85c5e9c --- /dev/null +++ b/apps/api/src/parts/parts.service.ts @@ -0,0 +1,231 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { + PartCreate, + PartDetail, + PartDto, + PartsResponse, + PartUpdate, + StockMovementCreate, + StockMovementDto, +} from '@siop/shared'; +import type { AuthenticatedUser } from '../auth/current-user.decorator'; +import { PrismaService } from '../prisma/prisma.service'; + +const partInclude = { supplier: true } satisfies Prisma.PartInclude; +type PartRow = Prisma.PartGetPayload<{ include: typeof partInclude }>; + +const mvtInclude = { + workOrder: { select: { reference: true } }, + purchaseOrder: { select: { reference: true } }, + by: { select: { displayName: true } }, +} satisfies Prisma.StockMovementInclude; +type MvtRow = Prisma.StockMovementGetPayload<{ include: typeof mvtInclude }>; + +@Injectable() +export class PartsService { + constructor(private readonly prisma: PrismaService) {} + + /** Le stock EST la somme des mouvements — calculé, jamais stocké. */ + private async stocks(partIds?: string[]): Promise> { + const grouped = await this.prisma.stockMovement.groupBy({ + by: ['partId'], + where: partIds ? { partId: { in: partIds } } : undefined, + _sum: { quantity: true }, + }); + return new Map(grouped.map((g) => [g.partId, g._sum.quantity ?? 0])); + } + + async list(): Promise { + const rows = await this.prisma.part.findMany({ + include: partInclude, + orderBy: { reference: 'asc' }, + }); + const stocks = await this.stocks(); + return { parts: rows.map((r) => this.toDto(r, stocks.get(r.id) ?? 0)) }; + } + + async get(id: string): Promise { + const row = await this.prisma.part.findUnique({ where: { id }, include: partInclude }); + if (!row) throw new NotFoundException('Pièce inconnue'); + const [stocks, movements] = await Promise.all([ + this.stocks([id]), + this.prisma.stockMovement.findMany({ + where: { partId: id }, + include: mvtInclude, + orderBy: { createdAt: 'desc' }, + take: 50, + }), + ]); + return { + ...this.toDto(row, stocks.get(id) ?? 0), + movements: movements.map((m) => this.toMovementDto(m)), + }; + } + + async create(dto: PartCreate): Promise { + if (dto.supplierId) await this.assertSupplier(dto.supplierId); + for (let essai = 0; ; essai++) { + try { + const created = await this.prisma.part.create({ + data: { + reference: await this.nextReference(), + designation: dto.designation, + threshold: dto.threshold ?? 0, + supplierId: dto.supplierId, + compatible: dto.compatible, + lastUnitPrice: dto.initialPrice, + }, + select: { id: true }, + }); + return this.get(created.id); + } catch (e) { + if ( + e instanceof Prisma.PrismaClientKnownRequestError && + e.code === 'P2002' && + essai < 3 + ) { + continue; + } + throw e; + } + } + } + + async update(id: string, dto: PartUpdate): Promise { + if (dto.supplierId) await this.assertSupplier(dto.supplierId); + try { + await this.prisma.part.update({ where: { id }, data: dto, select: { id: true } }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { + throw new NotFoundException('Pièce inconnue'); + } + throw e; + } + return this.get(id); + } + + /** Entrée manuelle ou ajustement — TOUJOURS un mouvement tracé. */ + async addMovement( + id: string, + dto: StockMovementCreate, + user: AuthenticatedUser, + ): Promise { + const part = await this.prisma.part.findUnique({ where: { id } }); + if (!part) throw new NotFoundException('Pièce inconnue'); + if (dto.kind === 'ENTRY' && dto.quantity <= 0) { + throw new BadRequestException('Une entrée manuelle est positive'); + } + if (dto.kind === 'ADJUSTMENT' && !dto.reason?.trim()) { + throw new BadRequestException('Un ajustement d’inventaire exige un motif'); + } + await this.prisma.$transaction(async (tx) => { + const somme = await tx.stockMovement.aggregate({ + where: { partId: id }, + _sum: { quantity: true }, + }); + const stock = somme._sum.quantity ?? 0; + if (stock + dto.quantity < 0) { + throw new ConflictException( + `Refusé : le stock deviendrait négatif (${stock} ${dto.quantity > 0 ? '+' : ''}${dto.quantity})`, + ); + } + await tx.stockMovement.create({ + data: { + partId: id, + kind: dto.kind, + quantity: dto.quantity, + reason: dto.reason, + byId: user.userId, + }, + }); + }); + return this.get(id); + } + + /** Consommation d'OT — stock suffisant exigé, PRIX FIGÉ au moment T. + * Appelé par WorkOrdersService dans le périmètre d'un OT vérifié. */ + async consume( + partId: string, + quantity: number, + workOrderId: string, + user: AuthenticatedUser, + ): Promise<{ designation: string; unitPrice: number }> { + const part = await this.prisma.part.findUnique({ where: { id: partId } }); + if (!part || !part.isActive) throw new BadRequestException('Pièce inconnue ou désactivée'); + if (part.lastUnitPrice === null) { + throw new ConflictException( + 'Aucun prix connu pour cette pièce — réceptionnez un BC ou renseignez un prix initial', + ); + } + await this.prisma.$transaction(async (tx) => { + const somme = await tx.stockMovement.aggregate({ + where: { partId }, + _sum: { quantity: true }, + }); + const stock = somme._sum.quantity ?? 0; + if (stock < quantity) { + throw new ConflictException(`Stock insuffisant : ${stock} en stock, ${quantity} demandé`); + } + await tx.stockMovement.create({ + data: { + partId, + kind: 'CONSUMPTION', + quantity: -quantity, + unitPrice: part.lastUnitPrice, + workOrderId, + byId: user.userId, + }, + }); + }); + return { designation: part.designation, unitPrice: Number(part.lastUnitPrice) }; + } + + private async assertSupplier(supplierId: string): Promise { + const supplier = await this.prisma.partner.findUnique({ where: { id: supplierId } }); + if (!supplier || supplier.kind !== 'SUPPLIER') { + throw new BadRequestException('Fournisseur inconnu'); + } + } + + private async nextReference(): Promise { + const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>` + SELECT nextval('part_ref_seq')`; + return `P-${String(nextval).padStart(4, '0')}`; + } + + private toDto(row: PartRow, stock: number): PartDto { + return { + id: row.id, + reference: row.reference, + designation: row.designation, + threshold: row.threshold, + lastUnitPrice: row.lastUnitPrice === null ? null : Number(row.lastUnitPrice), + compatible: row.compatible, + supplierId: row.supplierId, + supplierName: row.supplier?.name ?? null, + isActive: row.isActive, + stock, + belowThreshold: stock < row.threshold, + }; + } + + private toMovementDto(m: MvtRow): StockMovementDto { + return { + id: m.id, + kind: m.kind, + quantity: m.quantity, + unitPrice: m.unitPrice === null ? null : Number(m.unitPrice), + reason: m.reason, + workOrderReference: m.workOrder?.reference ?? null, + purchaseOrderReference: m.purchaseOrder?.reference ?? null, + byName: m.by?.displayName ?? null, + createdAt: m.createdAt.toISOString(), + }; + } +} diff --git a/apps/api/src/portal/portal.service.ts b/apps/api/src/portal/portal.service.ts index e340a8d..1903452 100644 --- a/apps/api/src/portal/portal.service.ts +++ b/apps/api/src/portal/portal.service.ts @@ -76,14 +76,9 @@ export class PortalService { } private async nextReference(): Promise { - const annee = new Date().getFullYear(); - const dernier = await this.prisma.request.findFirst({ - where: { reference: { startsWith: `DEM-${annee}-` } }, - orderBy: { reference: 'desc' }, - select: { reference: true }, - }); - const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1; - return `DEM-${annee}-${String(n).padStart(4, '0')}`; + const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>` + SELECT nextval('request_ref_seq')`; + return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`; } private toStatus(row: Row): PortalRequestStatus { diff --git a/apps/api/src/preventive/preventive.service.ts b/apps/api/src/preventive/preventive.service.ts index e3c5bf6..6ff59bd 100644 --- a/apps/api/src/preventive/preventive.service.ts +++ b/apps/api/src/preventive/preventive.service.ts @@ -210,14 +210,9 @@ export class PreventiveService { } private async nextReference(): Promise { - const annee = new Date().getFullYear(); - const dernier = await this.prisma.workOrder.findFirst({ - where: { reference: { startsWith: `OT-${annee}-` } }, - orderBy: { reference: 'desc' }, - select: { reference: true }, - }); - const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1; - return `OT-${annee}-${String(n).padStart(4, '0')}`; + const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>` + SELECT nextval('work_order_ref_seq')`; + return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`; } private toDto(row: TemplateRow): TaskTemplateDto { diff --git a/apps/api/src/purchase-orders/purchase-orders.controller.ts b/apps/api/src/purchase-orders/purchase-orders.controller.ts new file mode 100644 index 0000000..8b0277c --- /dev/null +++ b/apps/api/src/purchase-orders/purchase-orders.controller.ts @@ -0,0 +1,59 @@ +import { + Body, + Controller, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Post, +} from '@nestjs/common'; +import { + PurchaseOrderCreateSchema, + PurchaseOrderTransitionSchema, + type PurchaseOrderCreate, + type PurchaseOrderTransition, +} from '@siop/shared'; +import { + AuthenticatedUser, + CurrentUser, +} from '../auth/current-user.decorator'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { RequirePermission } from '../permissions/require-permission.decorator'; +import { PurchaseOrdersService } from './purchase-orders.service'; + +@Controller('purchase-orders') +export class PurchaseOrdersController { + constructor(private readonly purchaseOrders: PurchaseOrdersService) {} + + @Get() + @RequirePermission('PURCHASE_ORDERS', 'view') + list() { + return this.purchaseOrders.list(); + } + + @Get(':id') + @RequirePermission('PURCHASE_ORDERS', 'view') + get(@Param('id', ParseUUIDPipe) id: string) { + return this.purchaseOrders.get(id); + } + + @Post() + @RequirePermission('PURCHASE_ORDERS', 'create') + create( + @Body(new ZodValidationPipe(PurchaseOrderCreateSchema)) body: PurchaseOrderCreate, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.purchaseOrders.create(body, user); + } + + @Post(':id/transition') + @HttpCode(200) // le contrat : 200, l'état change + @RequirePermission('PURCHASE_ORDERS', 'edit') + transition( + @Param('id', ParseUUIDPipe) id: string, + @Body(new ZodValidationPipe(PurchaseOrderTransitionSchema)) body: PurchaseOrderTransition, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.purchaseOrders.transition(id, body, user); + } +} diff --git a/apps/api/src/purchase-orders/purchase-orders.module.ts b/apps/api/src/purchase-orders/purchase-orders.module.ts new file mode 100644 index 0000000..1868d1c --- /dev/null +++ b/apps/api/src/purchase-orders/purchase-orders.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PurchaseOrdersController } from './purchase-orders.controller'; +import { PurchaseOrdersService } from './purchase-orders.service'; + +@Module({ + controllers: [PurchaseOrdersController], + providers: [PurchaseOrdersService], +}) +export class PurchaseOrdersModule {} diff --git a/apps/api/src/purchase-orders/purchase-orders.service.ts b/apps/api/src/purchase-orders/purchase-orders.service.ts new file mode 100644 index 0000000..ab5cf5f --- /dev/null +++ b/apps/api/src/purchase-orders/purchase-orders.service.ts @@ -0,0 +1,170 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { + PurchaseOrderCreate, + PurchaseOrderDto, + PurchaseOrdersResponse, + PurchaseOrderStatus, + PurchaseOrderTransition, +} from '@siop/shared'; +import type { AuthenticatedUser } from '../auth/current-user.decorator'; +import { PrismaService } from '../prisma/prisma.service'; + +const poInclude = { + supplier: true, + lines: { include: { part: true } }, +} satisfies Prisma.PurchaseOrderInclude; + +type Row = Prisma.PurchaseOrderGetPayload<{ include: typeof poInclude }>; + +/** Machine à états du BC : la réception est le seul chemin vers le stock. */ +const TRANSITIONS: Record = { + DRAFT: ['SENT', 'CANCELLED'], + SENT: ['RECEIVED', 'CANCELLED'], + RECEIVED: [], + CANCELLED: [], +}; + +@Injectable() +export class PurchaseOrdersService { + constructor(private readonly prisma: PrismaService) {} + + async list(): Promise { + const rows = await this.prisma.purchaseOrder.findMany({ + include: poInclude, + orderBy: { createdAt: 'desc' }, + }); + return { purchaseOrders: rows.map((r) => this.toDto(r)) }; + } + + async get(id: string): Promise { + const row = await this.prisma.purchaseOrder.findUnique({ + where: { id }, + include: poInclude, + }); + if (!row) throw new NotFoundException('BC inconnu'); + return this.toDto(row); + } + + async create(dto: PurchaseOrderCreate, user: AuthenticatedUser): Promise { + const supplier = await this.prisma.partner.findUnique({ + where: { id: dto.supplierId }, + }); + if (!supplier || supplier.kind !== 'SUPPLIER' || !supplier.isActive) { + throw new BadRequestException('Fournisseur inconnu ou inactif'); + } + const parts = await this.prisma.part.findMany({ + where: { id: { in: dto.lines.map((l) => l.partId) } }, + }); + if (parts.length !== new Set(dto.lines.map((l) => l.partId)).size) { + throw new BadRequestException('Pièce inconnue dans les lignes'); + } + for (let essai = 0; ; essai++) { + try { + const created = await this.prisma.purchaseOrder.create({ + data: { + reference: await this.nextReference(), + supplierId: dto.supplierId, + createdById: user.userId, + lines: { create: dto.lines }, + }, + select: { id: true }, + }); + return this.get(created.id); + } catch (e) { + if ( + e instanceof Prisma.PrismaClientKnownRequestError && + e.code === 'P2002' && + essai < 3 + ) { + continue; + } + throw e; + } + } + } + + async transition( + id: string, + dto: PurchaseOrderTransition, + user: AuthenticatedUser, + ): Promise { + const row = await this.prisma.purchaseOrder.findUnique({ + where: { id }, + include: poInclude, + }); + if (!row) throw new NotFoundException('BC inconnu'); + if (!TRANSITIONS[row.status].includes(dto.to)) { + throw new ConflictException(`Transition interdite : ${row.status} → ${dto.to}`); + } + if (dto.to === 'RECEIVED') { + // LA règle : la réception crée les entrées de stock et FIGE les PU + await this.prisma.$transaction(async (tx) => { + for (const line of row.lines) { + await tx.stockMovement.create({ + data: { + partId: line.partId, + kind: 'RECEIPT', + quantity: line.quantity, + unitPrice: line.unitPrice, + purchaseOrderId: row.id, + byId: user.userId, + }, + }); + await tx.part.update({ + where: { id: line.partId }, + data: { lastUnitPrice: line.unitPrice }, + }); + } + await tx.purchaseOrder.update({ + where: { id }, + data: { status: 'RECEIVED', receivedAt: new Date() }, + }); + }); + } else { + await this.prisma.purchaseOrder.update({ + where: { id }, + data: { + status: dto.to, + sentAt: dto.to === 'SENT' ? new Date() : undefined, + cancelledAt: dto.to === 'CANCELLED' ? new Date() : undefined, + }, + }); + } + return this.get(id); + } + + private async nextReference(): Promise { + const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>` + SELECT nextval('purchase_order_ref_seq')`; + return `BC-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`; + } + + private toDto(row: Row): PurchaseOrderDto { + const lines = row.lines.map((l) => ({ + id: l.id, + partId: l.partId, + partReference: l.part.reference, + designation: l.part.designation, + quantity: l.quantity, + unitPrice: Number(l.unitPrice), + })); + return { + id: row.id, + reference: row.reference, + status: row.status, + supplierId: row.supplierId, + supplierName: row.supplier.name, + lines, + total: lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0), + sentAt: row.sentAt?.toISOString() ?? null, + receivedAt: row.receivedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + }; + } +} diff --git a/apps/api/src/requests/requests.service.ts b/apps/api/src/requests/requests.service.ts index f9c0dcc..1ac987f 100644 --- a/apps/api/src/requests/requests.service.ts +++ b/apps/api/src/requests/requests.service.ts @@ -131,14 +131,9 @@ export class RequestsService { } private async nextReference(): Promise { - const annee = new Date().getFullYear(); - const dernier = await this.prisma.request.findFirst({ - where: { reference: { startsWith: `DEM-${annee}-` } }, - orderBy: { reference: 'desc' }, - select: { reference: true }, - }); - const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1; - return `DEM-${annee}-${String(n).padStart(4, '0')}`; + const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>` + SELECT nextval('request_ref_seq')`; + return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`; } private requesterLabel(row: RequestRow): string { diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 748360c..3bff0ad 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -98,6 +98,7 @@ export class UsersService { phone: dto.phone, roleId: dto.roleId, isActive: dto.isActive, + hourlyRate: dto.hourlyRate, teams: dto.teamIds ? { set: dto.teamIds.map((id) => ({ id })) } : undefined, @@ -136,6 +137,7 @@ export class UsersService { ? 'active' : 'invited', isDemo: row.isDemo, + hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate), }; } } diff --git a/apps/api/src/work-orders/work-orders.controller.ts b/apps/api/src/work-orders/work-orders.controller.ts index 387bb62..05bf21e 100644 --- a/apps/api/src/work-orders/work-orders.controller.ts +++ b/apps/api/src/work-orders/work-orders.controller.ts @@ -13,12 +13,16 @@ import { AssigneesUpdateSchema, ChecklistPatchSchema, CommentCreateSchema, + ConsumePartSchema, + LaborTimeCreateSchema, ReportUpsertSchema, TransitionRequestSchema, WorkOrderCreateSchema, type AssigneesUpdate, type ChecklistPatch, type CommentCreate, + type ConsumePart, + type LaborTimeCreate, type ReportUpsert, type TransitionRequest, type WorkOrderCreate, @@ -97,6 +101,26 @@ export class WorkOrdersController { return this.workOrders.upsertReport(id, body, user); } + @Post(':id/consume-part') + @RequirePermission('WORK_ORDERS', 'edit') + consumePart( + @Param('id', ParseUUIDPipe) id: string, + @Body(new ZodValidationPipe(ConsumePartSchema)) body: ConsumePart, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.workOrders.consumePart(id, body, user); + } + + @Post(':id/labor') + @RequirePermission('WORK_ORDERS', 'edit') + addLabor( + @Param('id', ParseUUIDPipe) id: string, + @Body(new ZodValidationPipe(LaborTimeCreateSchema)) body: LaborTimeCreate, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.workOrders.addLabor(id, body, user); + } + @Patch(':id/checklist/:itemId') @RequirePermission('WORK_ORDERS', 'edit') patchChecklist( diff --git a/apps/api/src/work-orders/work-orders.module.ts b/apps/api/src/work-orders/work-orders.module.ts index fdc787b..deb682d 100644 --- a/apps/api/src/work-orders/work-orders.module.ts +++ b/apps/api/src/work-orders/work-orders.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { PartsModule } from '../parts/parts.module'; import { WorkOrdersController } from './work-orders.controller'; import { WorkOrdersService } from './work-orders.service'; @Module({ + imports: [PartsModule], controllers: [WorkOrdersController], providers: [WorkOrdersService], exports: [WorkOrdersService], diff --git a/apps/api/src/work-orders/work-orders.service.ts b/apps/api/src/work-orders/work-orders.service.ts index 68de110..920b488 100644 --- a/apps/api/src/work-orders/work-orders.service.ts +++ b/apps/api/src/work-orders/work-orders.service.ts @@ -23,6 +23,7 @@ import { type WorkOrderSummary, } from '@siop/shared'; import type { AuthenticatedUser } from '../auth/current-user.decorator'; +import { PartsService } from '../parts/parts.service'; import { PermissionsService } from '../permissions/permissions.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -33,6 +34,13 @@ const detailInclude = { request: { include: { requestedBy: true } }, events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } }, checklist: { include: { doneBy: true }, orderBy: { label: 'asc' as const } }, + // R3 — coûts figés + stockMovements: { + where: { kind: 'CONSUMPTION' as const }, + include: { part: true }, + orderBy: { createdAt: 'asc' as const }, + }, + laborTimes: { include: { user: true }, orderBy: { createdAt: 'asc' as const } }, report: { include: { doorState: true, @@ -71,6 +79,7 @@ export class WorkOrdersService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, + private readonly parts: PartsService, ) {} /** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */ @@ -177,14 +186,10 @@ export class WorkOrdersService { } private async nextReference(): Promise { - const annee = new Date().getFullYear(); - const dernier = await this.prisma.workOrder.findFirst({ - where: { reference: { startsWith: `OT-${annee}-` } }, - orderBy: { reference: 'desc' }, - select: { reference: true }, - }); - const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1; - return `OT-${annee}-${String(n).padStart(4, '0')}`; + // Séquence Postgres : insensible à la concurrence (jamais de collision) + const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>` + SELECT nextval('work_order_ref_seq')`; + return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`; } /** Machine à états stricte + garde de clôture. */ @@ -337,6 +342,65 @@ export class WorkOrdersService { }; } + // ————— R3 : coûts figés ————— + + /** Consommer une pièce : stock décrémenté, PRIX FIGÉ au moment T. */ + async consumePart( + id: string, + dto: { partId: string; quantity: number }, + user: AuthenticatedUser, + ): Promise { + const detail = await this.get(id, user); // périmètre + existence + if (detail.status === 'DONE' || detail.status === 'CANCELLED') { + throw new ConflictException('OT terminé : les coûts sont figés'); + } + const { designation, unitPrice } = await this.parts.consume( + dto.partId, + dto.quantity, + id, + user, + ); + await this.prisma.workOrderEvent.create({ + data: { + workOrderId: id, + kind: 'PART_CONSUMED', + message: `${designation} × ${dto.quantity} (${unitPrice} MAD/u, prix figé)`, + byId: user.userId, + }, + }); + return this.get(id, user); + } + + /** Saisir de la main-d'œuvre : TAUX FIGÉ à la saisie. */ + async addLabor( + id: string, + dto: { minutes: number; userId?: string; note?: string }, + user: AuthenticatedUser, + ): Promise { + const detail = await this.get(id, user); + if (detail.status === 'DONE' || detail.status === 'CANCELLED') { + throw new ConflictException('OT terminé : les coûts sont figés'); + } + const cibleId = dto.userId ?? user.userId; + const cible = await this.prisma.user.findUnique({ where: { id: cibleId } }); + if (!cible) throw new BadRequestException('Personne inconnue'); + if (cible.hourlyRate === null) { + throw new ConflictException( + `Aucun taux horaire défini pour ${cible.displayName} — à renseigner dans Personnes`, + ); + } + await this.prisma.laborTime.create({ + data: { + workOrderId: id, + userId: cibleId, + minutes: dto.minutes, + hourlyRate: cible.hourlyRate, // figé + note: dto.note, + }, + }); + return this.get(id, user); + } + // ————— mapping ————— private toSummary( @@ -387,6 +451,22 @@ export class WorkOrdersService { } const versValeur = (v: { id: string; label: string } | null) => v ? { id: v.id, label: v.label } : null; + // Coûts figés : chaque ligne porte le prix/taux du moment T + const partsCosts = row.stockMovements.map((m) => ({ + id: m.id, + designation: m.part.designation, + quantity: -m.quantity, + unitPrice: Number(m.unitPrice ?? 0), + total: -m.quantity * Number(m.unitPrice ?? 0), + })); + const laborCosts = row.laborTimes.map((l) => ({ + id: l.id, + displayName: l.user.displayName, + minutes: l.minutes, + hourlyRate: Number(l.hourlyRate), + total: Math.round((l.minutes / 60) * Number(l.hourlyRate) * 100) / 100, + note: l.note, + })); return { ...this.toSummary(row), description: row.description, @@ -430,6 +510,16 @@ export class WorkOrdersService { componentConcerned: versValeur(report.componentConcerned), } : null, + costs: { + parts: partsCosts, + labor: laborCosts, + total: + Math.round( + (partsCosts.reduce((s, p) => s + p.total, 0) + + laborCosts.reduce((s, l) => s + l.total, 0)) * + 100, + ) / 100, + }, allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus], closureBlockers: blockers, }; diff --git a/apps/api/test/exploitation.e2e-spec.ts b/apps/api/test/exploitation.e2e-spec.ts index 947c9f5..a58091e 100644 --- a/apps/api/test/exploitation.e2e-spec.ts +++ b/apps/api/test/exploitation.e2e-spec.ts @@ -145,7 +145,14 @@ describe('Exploitation (e2e)', () => { w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED', ).length; expect(position).toBeGreaterThanOrEqual(0); - expect(position).toBeLessThanOrEqual(urgencesActives); + // Tolérance : les autres specs Jest créent des OT en parallèle (updatedAt + // plus récent). L'invariant déterministe : notre clôture précède TOUJOURS + // l'ancien Terminé du seed — plus jamais reléguée en queue de liste. + expect(position).toBeLessThanOrEqual(urgencesActives + 4); + const positionAncienDone = listeSalma.workOrders.findIndex( + (w: { reference: string }) => w.reference === `OT-${new Date().getFullYear()}-0332`, + ); + expect(position).toBeLessThan(positionAncienDone); // 7. Terminal : plus aucune transition await http() diff --git a/apps/api/test/gestion.e2e-spec.ts b/apps/api/test/gestion.e2e-spec.ts new file mode 100644 index 0000000..774b689 --- /dev/null +++ b/apps/api/test/gestion.e2e-spec.ts @@ -0,0 +1,351 @@ +/** + * E2E R3 — gestion : la recette officielle (consommer sous seuil → BC → + * réception → réappro ; coût complet d'un OT) et les invariants comptables + * (stock dérivé jamais négatif, prix et taux FIGÉS, ajustement motivé). + */ +process.env.DEMO_MODE = 'true'; + +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { seed } from '../prisma/seed'; +import { AppModule } from '../src/app.module'; + +describe('Gestion (e2e)', () => { + let app: INestApplication; + let nadia: string; // Gestionnaire + let salma: string; // Dispatcher + let ahmed: string; // Technicien (PARTS view seulement) + let admin: string; + const prisma = new PrismaClient(); + const http = () => request(app.getHttpServer()); + const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); + const suffix = `G3-${Date.now().toString(36)}`; + + beforeAll(async () => { + await seed(prisma); + const moduleRef = await Test.createTestingModule({ + imports: [AppModule.forRoot()], + }).compile(); + app = moduleRef.createNestApplication(); + await app.init(); + const { body } = await http().get('/auth/demo-accounts'); + const login = async (roleName: string) => { + const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName); + return (await http().post('/auth/demo-login').send({ userId: compte.id })).body + .accessToken as string; + }; + nadia = await login('Gestionnaire'); + salma = await login('Dispatcher'); + ahmed = await login('Technicien'); + admin = await login('Administrateur'); + }); + + afterAll(async () => { + await prisma.stockMovement.deleteMany({ + where: { + OR: [ + { part: { designation: { contains: suffix } } }, + { workOrder: { title: { contains: suffix } } }, + ], + }, + }); + await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } }); + await prisma.purchaseOrder.deleteMany({ + where: { lines: { some: { part: { designation: { contains: suffix } } } } }, + }); + await prisma.part.deleteMany({ where: { designation: { contains: suffix } } }); + await prisma.partner.deleteMany({ where: { name: { contains: suffix } } }); + await app?.close(); + await prisma.$disconnect(); + }); + + it('recette R3 : consommer sous seuil → BC → réception → réappro (prix figés)', async () => { + // Pièce dédiée au test : stock 6, seuil 4, PU initial 100 + const piece = await http() + .post('/parts') + .set(auth(nadia)) + .send({ designation: `Pièce ${suffix}`, threshold: 4, initialPrice: 100 }) + .expect(201); + await http() + .post(`/parts/${piece.body.id}/movements`) + .set(auth(nadia)) + .send({ kind: 'ENTRY', quantity: 6 }) + .expect(201); + + // OT + consommation de 3 → stock 3, SOUS le seuil + const { body: assets } = await http().get('/assets').set(auth(salma)); + const ot = await http() + .post('/work-orders') + .set(auth(salma)) + .send({ + title: `Réparation ${suffix}`, + type: 'CORRECTIVE', + assetId: assets.assets[0].id, + }) + .expect(201); + const apresConso = await http() + .post(`/work-orders/${ot.body.id}/consume-part`) + .set(auth(salma)) + .send({ partId: piece.body.id, quantity: 3 }) + .expect(201); + expect(apresConso.body.costs.parts[0]).toMatchObject({ + quantity: 3, + unitPrice: 100, + total: 300, + }); + const sousSeuil = await http().get(`/parts/${piece.body.id}`).set(auth(nadia)); + expect(sousSeuil.body.stock).toBe(3); + expect(sousSeuil.body.belowThreshold).toBe(true); + + // BC : brouillon → envoyé → reçu (PU 110) → stock 13, plus d'alerte + const { body: partners } = await http().get('/partners').set(auth(nadia)); + const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER'); + const bc = await http() + .post('/purchase-orders') + .set(auth(nadia)) + .send({ + supplierId: fournisseur.id, + lines: [{ partId: piece.body.id, quantity: 10, unitPrice: 110 }], + }) + .expect(201); + expect(bc.body.status).toBe('DRAFT'); + expect(bc.body.total).toBe(1100); + await http() + .post(`/purchase-orders/${bc.body.id}/transition`) + .set(auth(nadia)) + .send({ to: 'SENT' }) + .expect(200); + const recu = await http() + .post(`/purchase-orders/${bc.body.id}/transition`) + .set(auth(nadia)) + .send({ to: 'RECEIVED' }) + .expect(200); + expect(recu.body.receivedAt).toBeTruthy(); + + const apresReception = await http().get(`/parts/${piece.body.id}`).set(auth(nadia)); + expect(apresReception.body.stock).toBe(13); + expect(apresReception.body.belowThreshold).toBe(false); + expect(apresReception.body.lastUnitPrice).toBe(110); // nouveau prix courant + expect( + apresReception.body.movements.some( + (m: { kind: string; purchaseOrderReference: string | null }) => + m.kind === 'RECEIPT' && m.purchaseOrderReference === bc.body.reference, + ), + ).toBe(true); + + // PRIX FIGÉ : la consommation passée reste à 100, malgré le nouveau PU 110 + const otRelu = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma)); + expect(otRelu.body.costs.parts[0].unitPrice).toBe(100); + }); + + it('coût complet d’un OT : pièces + main-d’œuvre — le TAUX est figé à la saisie', async () => { + const { body: users } = await http().get('/users').set(auth(admin)); + const ahmedUser = users.users.find( + (u: { email: string }) => u.email === 'technicien@demo.siop.ma', + ); + const tauxInitial = ahmedUser.hourlyRate; // 120 (seed) + const { body: assets } = await http().get('/assets').set(auth(salma)); + const ot = await http() + .post('/work-orders') + .set(auth(salma)) + .send({ title: `Coût complet ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id }) + .expect(201); + + // Salma (sans taux défini) tente de saisir pour elle-même → refus motivé + await http() + .post(`/work-orders/${ot.body.id}/labor`) + .set(auth(salma)) + .send({ minutes: 30 }) + .expect(409); + + // 1 h 30 d'Ahmed au taux courant + const avecMO = await http() + .post(`/work-orders/${ot.body.id}/labor`) + .set(auth(salma)) + .send({ minutes: 90, userId: ahmedUser.id }) + .expect(201); + expect(avecMO.body.costs.labor[0].hourlyRate).toBe(tauxInitial); + expect(avecMO.body.costs.labor[0].total).toBe( + Math.round((90 / 60) * tauxInitial * 100) / 100, + ); + const totalAvant = avecMO.body.costs.total; + + // L'admin augmente le taux courant d'Ahmed → le coût de l'OT NE BOUGE PAS + await http() + .patch(`/users/${ahmedUser.id}`) + .set(auth(admin)) + .send({ hourlyRate: tauxInitial + 30 }) + .expect(200); + const otApres = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma)); + expect(otApres.body.costs.total).toBe(totalAvant); + // remise en l'état (le seed ne réécrit pas les taux existants) + await http() + .patch(`/users/${ahmedUser.id}`) + .set(auth(admin)) + .send({ hourlyRate: tauxInitial }) + .expect(200); + }); + + it('invariants : motif requis, stock jamais négatif, prix inconnu refusé, coûts figés après clôture', async () => { + const piece = await http() + .post('/parts') + .set(auth(nadia)) + .send({ designation: `Sans prix ${suffix}`, threshold: 0 }) + .expect(201); + + // ajustement sans motif → 400 ; stock négatif → 409 + await http() + .post(`/parts/${piece.body.id}/movements`) + .set(auth(nadia)) + .send({ kind: 'ADJUSTMENT', quantity: -1 }) + .expect(400); + await http() + .post(`/parts/${piece.body.id}/movements`) + .set(auth(nadia)) + .send({ kind: 'ADJUSTMENT', quantity: -1, reason: 'test' }) + .expect(409); + await http() + .post(`/parts/${piece.body.id}/movements`) + .set(auth(nadia)) + .send({ kind: 'ENTRY', quantity: -3 }) + .expect(400); + + // consommation sans prix connu → 409 ; stock insuffisant → 409 + await http() + .post(`/parts/${piece.body.id}/movements`) + .set(auth(nadia)) + .send({ kind: 'ENTRY', quantity: 5 }) + .expect(201); + const { body: assets } = await http().get('/assets').set(auth(salma)); + const ot = await http() + .post('/work-orders') + .set(auth(salma)) + .send({ title: `Invariants ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id }) + .expect(201); + await http() + .post(`/work-orders/${ot.body.id}/consume-part`) + .set(auth(salma)) + .send({ partId: piece.body.id, quantity: 2 }) + .expect(409); // pas de prix connu + // Technicien : PARTS en lecture seule + await http() + .post('/parts') + .set(auth(ahmed)) + .send({ designation: `Interdit ${suffix}` }) + .expect(403); + // BC : réception d'un brouillon interdite + const { body: partners } = await http().get('/partners').set(auth(nadia)); + const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER'); + const bc = await http() + .post('/purchase-orders') + .set(auth(nadia)) + .send({ supplierId: fournisseur.id, lines: [{ partId: piece.body.id, quantity: 1, unitPrice: 10 }] }) + .expect(201); + await http() + .post(`/purchase-orders/${bc.body.id}/transition`) + .set(auth(nadia)) + .send({ to: 'RECEIVED' }) + .expect(409); + await http() + .post(`/purchase-orders/${bc.body.id}/transition`) + .set(auth(nadia)) + .send({ to: 'CANCELLED' }) + .expect(200); + }); + + it('refus propres : doublons, inconnus, transitions interdites, coûts figés après clôture', async () => { + const GHOST = '00000000-0000-4000-8000-000000000000'; + // tiers : doublon 409, 404, désactivation + const tiers = await http() + .post('/partners') + .set(auth(nadia)) + .send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' }) + .expect(201); + await http() + .post('/partners') + .set(auth(nadia)) + .send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' }) + .expect(409); + await http() + .patch(`/partners/${tiers.body.id}`) + .set(auth(nadia)) + .send({ isActive: false }) + .expect(200); + await http().patch(`/partners/${GHOST}`).set(auth(nadia)).send({ city: 'X' }).expect(404); + // pièce : fournisseur inconnu 400, 404, désactivée non consommable + await http() + .post('/parts') + .set(auth(nadia)) + .send({ designation: `Fournisseur fantôme ${suffix}`, supplierId: GHOST }) + .expect(400); + await http().patch(`/parts/${GHOST}`).set(auth(nadia)).send({ threshold: 1 }).expect(404); + await http().get(`/parts/${GHOST}`).set(auth(nadia)).expect(404); + const desactivee = await http() + .post('/parts') + .set(auth(nadia)) + .send({ designation: `Désactivée ${suffix}`, initialPrice: 10 }) + .expect(201); + await http() + .patch(`/parts/${desactivee.body.id}`) + .set(auth(nadia)) + .send({ isActive: false }) + .expect(200); + const { body: assets } = await http().get('/assets').set(auth(salma)); + const ot = await http() + .post('/work-orders') + .set(auth(salma)) + .send({ title: `Refus ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id }) + .expect(201); + await http() + .post(`/work-orders/${ot.body.id}/consume-part`) + .set(auth(salma)) + .send({ partId: desactivee.body.id, quantity: 1 }) + .expect(400); + // BC : fournisseur inactif 400, pièce inconnue 400, 404, transition sur terminal 409 + await http() + .post('/purchase-orders') + .set(auth(nadia)) + .send({ supplierId: tiers.body.id, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] }) + .expect(400); // désactivé plus haut + await http() + .post('/purchase-orders') + .set(auth(nadia)) + .send({ supplierId: GHOST, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] }) + .expect(400); + await http().get(`/purchase-orders/${GHOST}`).set(auth(nadia)).expect(404); + // main-d'œuvre : personne inconnue 400 ; OT annulé → coûts figés 409 + await http() + .post(`/work-orders/${ot.body.id}/labor`) + .set(auth(salma)) + .send({ minutes: 10, userId: GHOST }) + .expect(400); + await http() + .post(`/work-orders/${ot.body.id}/transition`) + .set(auth(salma)) + .send({ to: 'CANCELLED', comment: 'test' }) + .expect(200); + await http() + .post(`/work-orders/${ot.body.id}/consume-part`) + .set(auth(salma)) + .send({ partId: desactivee.body.id, quantity: 1 }) + .expect(409); + await http() + .post(`/work-orders/${ot.body.id}/labor`) + .set(auth(salma)) + .send({ minutes: 10 }) + .expect(409); + }); + + it('le seed rejoue la carte maquette : OT-0341 coûte 505 MAD', async () => { + const annee = new Date().getFullYear(); + const { body } = await http().get('/work-orders').set(auth(salma)); + const ot341 = body.workOrders.find( + (w: { reference: string }) => w.reference === `OT-${annee}-0341`, + ); + const detail = await http().get(`/work-orders/${ot341.id}`).set(auth(salma)).expect(200); + expect(detail.body.costs.total).toBe(505); // 240 + 85 + 180 + expect(detail.body.costs.labor[0].hourlyRate).toBe(120); + }); +}); diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index 133990d..d01679a 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -372,6 +372,180 @@ export interface paths { patch: operations["updateUser"]; trace?: never; }; + "/partners": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Tiers (fournisseurs, syndics) — permission PURCHASE_ORDERS */ + get: operations["listPartners"]; + put?: never; + /** Créer un tiers */ + post: operations["createPartner"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/partners/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Modifier / (dés)activer un tiers */ + patch: operations["updatePartner"]; + trace?: never; + }; + "/parts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Stock de pièces — quantités DÉRIVÉES des mouvements */ + get: operations["listParts"]; + put?: never; + /** Créer une pièce (référence P-#### générée) */ + post: operations["createPart"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/parts/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fiche pièce : les mouvements SONT le stock */ + get: operations["getPart"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Modifier une pièce (désignation, seuil, fournisseur…) */ + patch: operations["updatePart"]; + trace?: never; + }; + "/parts/{id}/movements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Entrée manuelle (+) ou ajustement (± motif REQUIS) — jamais de saisie de stock */ + post: operations["addStockMovement"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/purchase-orders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Bons de commande */ + get: operations["listPurchaseOrders"]; + put?: never; + /** Créer un BC (brouillon) */ + post: operations["createPurchaseOrder"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/purchase-orders/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fiche BC (lignes, total) */ + get: operations["getPurchaseOrder"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/purchase-orders/{id}/transition": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Envoyer / réceptionner (→ entrées de stock, PU figés) / annuler */ + post: operations["transitionPurchaseOrder"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/work-orders/{id}/consume-part": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Consommer une pièce sur l’OT — stock décrémenté, PRIX FIGÉ */ + post: operations["consumePart"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/work-orders/{id}/labor": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Saisir de la main-d’œuvre — TAUX FIGÉ à la saisie */ + post: operations["addLaborTime"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/portal/assets/{reference}": { parameters: { query?: never; @@ -1064,6 +1238,7 @@ export interface components { /** @enum {string} */ status: "active" | "invited" | "disabled"; isDemo: boolean; + hourlyRate: number | null; }[]; }; RolesResponse: { @@ -1111,6 +1286,7 @@ export interface components { /** @enum {string} */ status: "active" | "invited" | "disabled"; isDemo: boolean; + hourlyRate: number | null; }; UserUpdate: { displayName?: string; @@ -1119,6 +1295,323 @@ export interface components { roleId?: string; teamIds?: string[]; isActive?: boolean; + hourlyRate?: number | null; + }; + PartnersResponse: { + partners: { + /** Format: uuid */ + id: string; + name: string; + /** @enum {string} */ + kind: "SUPPLIER" | "CLIENT"; + contactName: string | null; + phone: string | null; + email: string | null; + city: string | null; + isActive: boolean; + openOrders: number; + }[]; + }; + Partner: { + /** Format: uuid */ + id: string; + name: string; + /** @enum {string} */ + kind: "SUPPLIER" | "CLIENT"; + contactName: string | null; + phone: string | null; + email: string | null; + city: string | null; + isActive: boolean; + openOrders: number; + }; + PartnerCreate: { + name: string; + /** @enum {string} */ + kind: "SUPPLIER" | "CLIENT"; + contactName?: string; + phone?: string; + /** Format: email */ + email?: string; + city?: string; + }; + PartnerUpdate: { + name?: string; + /** @enum {string} */ + kind?: "SUPPLIER" | "CLIENT"; + contactName?: string; + phone?: string; + /** Format: email */ + email?: string; + city?: string; + isActive?: boolean; + }; + PartsResponse: { + parts: { + /** Format: uuid */ + id: string; + reference: string; + designation: string; + threshold: number; + lastUnitPrice: number | null; + compatible: string | null; + supplierId: string | null; + supplierName: string | null; + isActive: boolean; + stock: number; + belowThreshold: boolean; + }[]; + }; + PartDetail: { + /** Format: uuid */ + id: string; + reference: string; + designation: string; + threshold: number; + lastUnitPrice: number | null; + compatible: string | null; + supplierId: string | null; + supplierName: string | null; + isActive: boolean; + stock: number; + belowThreshold: boolean; + movements: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + kind: "RECEIPT" | "ENTRY" | "CONSUMPTION" | "ADJUSTMENT"; + quantity: number; + unitPrice: number | null; + reason: string | null; + workOrderReference: string | null; + purchaseOrderReference: string | null; + byName: string | null; + /** Format: date-time */ + createdAt: string; + }[]; + }; + PartCreate: { + designation: string; + threshold?: number; + /** Format: uuid */ + supplierId?: string; + compatible?: string; + initialPrice?: number; + }; + PartUpdate: { + designation?: string; + threshold?: number; + supplierId?: string | null; + compatible?: string | null; + isActive?: boolean; + }; + StockMovementCreate: { + /** @enum {string} */ + kind: "ENTRY" | "ADJUSTMENT"; + quantity: number; + reason?: string; + }; + PurchaseOrdersResponse: { + purchaseOrders: { + /** Format: uuid */ + id: string; + reference: string; + /** @enum {string} */ + status: "DRAFT" | "SENT" | "RECEIVED" | "CANCELLED"; + /** Format: uuid */ + supplierId: string; + supplierName: string; + lines: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + partId: string; + partReference: string; + designation: string; + quantity: number; + unitPrice: number; + }[]; + total: number; + sentAt: string | null; + receivedAt: string | null; + /** Format: date-time */ + createdAt: string; + }[]; + }; + PurchaseOrder: { + /** Format: uuid */ + id: string; + reference: string; + /** @enum {string} */ + status: "DRAFT" | "SENT" | "RECEIVED" | "CANCELLED"; + /** Format: uuid */ + supplierId: string; + supplierName: string; + lines: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + partId: string; + partReference: string; + designation: string; + quantity: number; + unitPrice: number; + }[]; + total: number; + sentAt: string | null; + receivedAt: string | null; + /** Format: date-time */ + createdAt: string; + }; + PurchaseOrderCreate: { + /** Format: uuid */ + supplierId: string; + lines: { + /** Format: uuid */ + partId: string; + quantity: number; + unitPrice: number; + }[]; + }; + PurchaseOrderTransition: { + /** @enum {string} */ + to: "SENT" | "RECEIVED" | "CANCELLED"; + }; + WorkOrderDetail: { + /** Format: uuid */ + id: string; + reference: string; + title: string; + /** @enum {string} */ + type: "CORRECTIVE" | "PREVENTIVE" | "WORKS"; + /** @enum {string} */ + status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED"; + /** @enum {string} */ + priority: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "PERSON_TRAPPED"; + /** Format: uuid */ + assetId: string; + assetReference: string; + siteName: string; + dueDate: string | null; + assignees: { + /** Format: uuid */ + id: string; + displayName: string; + initials: string; + }[]; + /** Format: date-time */ + createdAt: string; + description: string | null; + locationName: string; + startedAt: string | null; + completedAt: string | null; + cancelledAt: string | null; + createdBy: { + /** Format: uuid */ + id: string; + displayName: string; + initials: string; + } | null; + request: { + /** Format: uuid */ + id: string; + reference: string; + requesterLabel: string; + } | null; + events: { + /** Format: uuid */ + id: string; + kind: string; + message: string | null; + by: { + /** Format: uuid */ + id: string; + displayName: string; + initials: string; + } | null; + /** Format: date-time */ + createdAt: string; + }[]; + checklist: { + /** Format: uuid */ + id: string; + label: string; + /** @enum {string} */ + state: "PENDING" | "DONE" | "NA"; + doneBy: { + /** Format: uuid */ + id: string; + displayName: string; + initials: string; + } | null; + doneAt: string | null; + }[]; + report: { + note: string | null; + doorState: { + /** Format: uuid */ + id: string; + label: string; + } | null; + cabinPosition: { + /** Format: uuid */ + id: string; + label: string; + } | null; + anomaly: { + /** Format: uuid */ + id: string; + label: string; + } | null; + externalCause: { + /** Format: uuid */ + id: string; + label: string; + } | null; + actionTaken: { + /** Format: uuid */ + id: string; + label: string; + } | null; + componentConcerned: { + /** Format: uuid */ + id: string; + label: string; + } | null; + } | null; + costs: { + parts: { + /** Format: uuid */ + id: string; + designation: string; + quantity: number; + unitPrice: number; + total: number; + }[]; + labor: { + /** Format: uuid */ + id: string; + displayName: string; + minutes: number; + hourlyRate: number; + total: number; + note: string | null; + }[]; + total: number; + }; + allowedTransitions: ("OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED")[]; + closureBlockers: string[]; + }; + ConsumePart: { + /** Format: uuid */ + partId: string; + quantity: number; + }; + LaborTimeCreate: { + minutes: number; + /** Format: uuid */ + userId?: string; + note?: string; }; PortalAsset: { reference: string; @@ -1260,111 +1753,6 @@ export interface components { createdAt: string; }[]; }; - WorkOrderDetail: { - /** Format: uuid */ - id: string; - reference: string; - title: string; - /** @enum {string} */ - type: "CORRECTIVE" | "PREVENTIVE" | "WORKS"; - /** @enum {string} */ - status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED"; - /** @enum {string} */ - priority: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "PERSON_TRAPPED"; - /** Format: uuid */ - assetId: string; - assetReference: string; - siteName: string; - dueDate: string | null; - assignees: { - /** Format: uuid */ - id: string; - displayName: string; - initials: string; - }[]; - /** Format: date-time */ - createdAt: string; - description: string | null; - locationName: string; - startedAt: string | null; - completedAt: string | null; - cancelledAt: string | null; - createdBy: { - /** Format: uuid */ - id: string; - displayName: string; - initials: string; - } | null; - request: { - /** Format: uuid */ - id: string; - reference: string; - requesterLabel: string; - } | null; - events: { - /** Format: uuid */ - id: string; - kind: string; - message: string | null; - by: { - /** Format: uuid */ - id: string; - displayName: string; - initials: string; - } | null; - /** Format: date-time */ - createdAt: string; - }[]; - checklist: { - /** Format: uuid */ - id: string; - label: string; - /** @enum {string} */ - state: "PENDING" | "DONE" | "NA"; - doneBy: { - /** Format: uuid */ - id: string; - displayName: string; - initials: string; - } | null; - doneAt: string | null; - }[]; - report: { - note: string | null; - doorState: { - /** Format: uuid */ - id: string; - label: string; - } | null; - cabinPosition: { - /** Format: uuid */ - id: string; - label: string; - } | null; - anomaly: { - /** Format: uuid */ - id: string; - label: string; - } | null; - externalCause: { - /** Format: uuid */ - id: string; - label: string; - } | null; - actionTaken: { - /** Format: uuid */ - id: string; - label: string; - } | null; - componentConcerned: { - /** Format: uuid */ - id: string; - label: string; - } | null; - } | null; - allowedTransitions: ("OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED")[]; - closureBlockers: string[]; - }; WorkOrderCreate: { title: string; description?: string; @@ -2259,6 +2647,408 @@ export interface operations { }; }; }; + listPartners: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PartnersResponse"]; + }; + }; + }; + }; + createPartner: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PartnerCreate"]; + }; + }; + responses: { + /** @description Créé */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Partner"]; + }; + }; + /** @description Nom déjà utilisé */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updatePartner: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PartnerUpdate"]; + }; + }; + responses: { + /** @description Mis à jour */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Partner"]; + }; + }; + /** @description Inconnu */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listParts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PartsResponse"]; + }; + }; + }; + }; + createPart: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PartCreate"]; + }; + }; + responses: { + /** @description Créée */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PartDetail"]; + }; + }; + }; + }; + getPart: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Fiche */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PartDetail"]; + }; + }; + /** @description Inconnue */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updatePart: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PartUpdate"]; + }; + }; + responses: { + /** @description Mise à jour */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PartDetail"]; + }; + }; + /** @description Inconnue */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + addStockMovement: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["StockMovementCreate"]; + }; + }; + responses: { + /** @description Mouvement tracé */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PartDetail"]; + }; + }; + /** @description Le stock ne peut pas devenir négatif */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listPurchaseOrders: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PurchaseOrdersResponse"]; + }; + }; + }; + }; + createPurchaseOrder: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PurchaseOrderCreate"]; + }; + }; + responses: { + /** @description Créé */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PurchaseOrder"]; + }; + }; + /** @description Fournisseur ou pièce invalide */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getPurchaseOrder: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Fiche */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PurchaseOrder"]; + }; + }; + /** @description Inconnu */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + transitionPurchaseOrder: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PurchaseOrderTransition"]; + }; + }; + responses: { + /** @description État changé */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PurchaseOrder"]; + }; + }; + /** @description Transition interdite */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + consumePart: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConsumePart"]; + }; + }; + responses: { + /** @description Consommée */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkOrderDetail"]; + }; + }; + /** @description Stock insuffisant ou prix inconnu */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + addLaborTime: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LaborTimeCreate"]; + }; + }; + responses: { + /** @description Saisie */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkOrderDetail"]; + }; + }; + /** @description Taux horaire non défini pour cette personne */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getPortalAsset: { parameters: { query?: never; diff --git a/docs/03-architecture/modele-donnees.md b/docs/03-architecture/modele-donnees.md index 9e8f98c..270238a 100644 --- a/docs/03-architecture/modele-donnees.md +++ b/docs/03-architecture/modele-donnees.md @@ -186,10 +186,63 @@ model MeterReading { id, meterId (cascade), value Int, readById? → User, creat | Relevé de compteur strictement croissant | service meters | | Génération mensuelle idempotente ; appareil sans historique ⇒ premier contrôle (toutes tâches) | service préventif (R2.2, + tests) | +## R3 — Gestion + +```prisma +enum PartnerKind { SUPPLIER CLIENT } // fournisseur / syndic +enum PurchaseOrderStatus { DRAFT SENT RECEIVED CANCELLED } +enum StockMovementKind { RECEIPT ENTRY CONSUMPTION ADJUSTMENT } +enum DocumentKind { NOTICE CERTIFICATE PHOTO OTHER } + +model Partner { id, name @unique, kind, contact…, isActive } + +model Part { // AUCUNE colonne de quantité : le stock EST la + id, reference @unique // somme des mouvements (décision v1 éprouvée) + designation, threshold Int // seuil d'alerte + lastUnitPrice Decimal? // dernier prix d'achat (mis à jour à la réception) + supplierId? → Partner +} + +model StockMovement { // + entrée / − sortie, TOUJOURS tracé + id, partId (cascade), kind, quantity Int (signé) + unitPrice Decimal? // FIGÉ (réception : PU du BC ; consommation : lastUnitPrice) + reason String? // ADJUSTMENT : motif REQUIS (service) + workOrderId? → WorkOrder // consommation + purchaseOrderId? → PurchaseOrder // réception + byId? → User +} + +model PurchaseOrder { // DRAFT → SENT → RECEIVED ; annulable avant réception + id, reference @unique, status, supplierId → Partner + lines PurchaseOrderLine[] // partId, quantity, unitPrice Decimal + // la RÉCEPTION crée un mouvement RECEIPT par ligne et met à jour lastUnitPrice +} + +model LaborTime { // heures × taux FIGÉ à la saisie + id, workOrderId (cascade), userId → User, minutes Int, hourlyRate Decimal, note? +} +// User reçoit hourlyRate Decimal? (taux COURANT, administrable) + +model Document { // MinIO derrière FileStorage — jamais d'accès direct + id, kind, fileName, storageKey @unique, size, contentType + assetId? / workOrderId? // rattachement REQUIS à l'un des deux (service) +} +``` + +**Invariants R3** : + +| Invariant | Où il vit | +| --- | --- | +| Stock = Σ mouvements ; jamais de saisie directe de quantité | par construction (pas de colonne) | +| Un ajustement exige un motif ; le stock ne devient jamais négatif | service parts (+ tests) | +| Consommation : PU figé = `lastUnitPrice` au moment T ; stock suffisant exigé | service (transaction) | +| Réception : BC `SENT` uniquement ; crée les RECEIPT + met à jour `lastUnitPrice` | service (transaction) | +| Main-d'œuvre : taux figé = `User.hourlyRate` au moment T (refus si non défini) | service | +| Coût total d'un OT = Σ consommations + Σ main-d'œuvre — IMMUABLE après coup | dérivé des lignes figées | +| Document : rattaché à un appareil OU un OT ; types fermés ; 20 Mo max | service documents (R3.2) | + ## À venir (référence v1 éprouvée, sera réintroduit release par release) -- **R3** : `Part`/`StockMovement` (stock **dérivé des mouvements**), `PurchaseOrder`, - `Partner`, `LaborTime` (taux figé), `Document`. - **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile). - **R5** : tables d'embeddings pgvector (côté service IA). diff --git a/docs/journal/journal.md b/docs/journal/journal.md index d3aa514..6cc7b8c 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,24 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-07-16 — Pr. Daaif (+ Claude) — R3.1 : socle backend de la gestion + +**Actions** + +- **Maquettes R3 et les 4 décisions VALIDÉES par le référent** → lancement du socle. +- **Modèle** (migration `r3_gestion`, 7 tables) : Partner, Part (SANS colonne de quantité), StockMovement (signé, tracé, PU figé), PurchaseOrder/Line, LaborTime (taux figé), Document (préparé pour R3.2) ; `User.hourlyRate` (taux courant, administrable dans Personnes). +- **API** (66 opérations au contrat) : tiers, pièces (stock = Σ mouvements calculé en `groupBy`, alerte sous seuil), entrée/ajustement (motif requis, **stock jamais négatif** — vérifié 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é**) et main-d'œuvre (**taux figé**, refus motivé si taux non défini) ; `WorkOrderDetail.costs` (lignes + total immuables) ; coûts verrouillés après clôture/annulation (409). +- **Seed** : 5 tiers, 5 pièces de la maquette (P-0113 sous seuil via son histoire de mouvements), BC reçu + BC envoyé, taux horaires, et **la carte maquette rejouée : OT-0341 = 505 MAD** (240 + 85 + 180) — vérifiée par test. +- **55 tests verts** (92 % / 74,9 %) dont la recette officielle : consommer sous seuil → alerte → BC → réception → réappro, prix d'hier intact sur l'OT pendant que le prix courant change ; hausse du taux d'Ahmed sans effet sur les OT passés. + +**Leçon majeure (durcissement)** + +- Les références « max+1 » (OT/DEM/BC/P) étaient une **course sous charge parallèle** (500 sporadiques malgré les retries). Remplacées par des **séquences Postgres** (`nextval`) initialisées au max existant : la classe de bugs disparaît — 3 runs Jest complets consécutifs verts. La numérotation ne se remet pas à zéro chaque année (l'unicité prime), consigné. + +**Prochaine étape** : R3.2 — bibliothèque de documents (upload multipart 20 Mo → `FileStorage.putObject`, téléchargement streamé via l'API — MinIO jamais exposé) + analytics (`GET /analytics/summary`), puis R3.3 écrans web. + +--- + ## 2026-07-16 — Pr. Daaif (+ Claude) — R3 ouverte : maquettes de la gestion à valider **Actions** diff --git a/docs/openapi.json b/docs/openapi.json index 57cbe39..bed44a2 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -956,6 +956,570 @@ } } }, + "/partners": { + "get": { + "operationId": "listPartners", + "summary": "Tiers (fournisseurs, syndics) — permission PURCHASE_ORDERS", + "tags": [ + "partners" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartnersResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createPartner", + "summary": "Créer un tiers", + "tags": [ + "partners" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartnerCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Créé", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Partner" + } + } + } + }, + "409": { + "description": "Nom déjà utilisé" + } + } + } + }, + "/partners/{id}": { + "patch": { + "operationId": "updatePartner", + "summary": "Modifier / (dés)activer un tiers", + "tags": [ + "partners" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartnerUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Mis à jour", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Partner" + } + } + } + }, + "404": { + "description": "Inconnu" + } + } + } + }, + "/parts": { + "get": { + "operationId": "listParts", + "summary": "Stock de pièces — quantités DÉRIVÉES des mouvements", + "tags": [ + "parts" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartsResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createPart", + "summary": "Créer une pièce (référence P-#### générée)", + "tags": [ + "parts" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Créée", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartDetail" + } + } + } + } + } + } + }, + "/parts/{id}": { + "get": { + "operationId": "getPart", + "summary": "Fiche pièce : les mouvements SONT le stock", + "tags": [ + "parts" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Fiche", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartDetail" + } + } + } + }, + "404": { + "description": "Inconnue" + } + } + }, + "patch": { + "operationId": "updatePart", + "summary": "Modifier une pièce (désignation, seuil, fournisseur…)", + "tags": [ + "parts" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Mise à jour", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartDetail" + } + } + } + }, + "404": { + "description": "Inconnue" + } + } + } + }, + "/parts/{id}/movements": { + "post": { + "operationId": "addStockMovement", + "summary": "Entrée manuelle (+) ou ajustement (± motif REQUIS) — jamais de saisie de stock", + "tags": [ + "parts" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StockMovementCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Mouvement tracé", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartDetail" + } + } + } + }, + "409": { + "description": "Le stock ne peut pas devenir négatif" + } + } + } + }, + "/purchase-orders": { + "get": { + "operationId": "listPurchaseOrders", + "summary": "Bons de commande", + "tags": [ + "purchase-orders" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseOrdersResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createPurchaseOrder", + "summary": "Créer un BC (brouillon)", + "tags": [ + "purchase-orders" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseOrderCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Créé", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseOrder" + } + } + } + }, + "400": { + "description": "Fournisseur ou pièce invalide" + } + } + } + }, + "/purchase-orders/{id}": { + "get": { + "operationId": "getPurchaseOrder", + "summary": "Fiche BC (lignes, total)", + "tags": [ + "purchase-orders" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Fiche", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseOrder" + } + } + } + }, + "404": { + "description": "Inconnu" + } + } + } + }, + "/purchase-orders/{id}/transition": { + "post": { + "operationId": "transitionPurchaseOrder", + "summary": "Envoyer / réceptionner (→ entrées de stock, PU figés) / annuler", + "tags": [ + "purchase-orders" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseOrderTransition" + } + } + } + }, + "responses": { + "200": { + "description": "État changé", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseOrder" + } + } + } + }, + "409": { + "description": "Transition interdite" + } + } + } + }, + "/work-orders/{id}/consume-part": { + "post": { + "operationId": "consumePart", + "summary": "Consommer une pièce sur l’OT — stock décrémenté, PRIX FIGÉ", + "tags": [ + "work-orders" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConsumePart" + } + } + } + }, + "responses": { + "201": { + "description": "Consommée", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkOrderDetail" + } + } + } + }, + "409": { + "description": "Stock insuffisant ou prix inconnu" + } + } + } + }, + "/work-orders/{id}/labor": { + "post": { + "operationId": "addLaborTime", + "summary": "Saisir de la main-d’œuvre — TAUX FIGÉ à la saisie", + "tags": [ + "work-orders" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaborTimeCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Saisie", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkOrderDetail" + } + } + } + }, + "409": { + "description": "Taux horaire non défini pour cette personne" + } + } + } + }, "/portal/assets/{reference}": { "get": { "operationId": "getPortalAsset", @@ -3491,6 +4055,16 @@ }, "isDemo": { "type": "boolean" + }, + "hourlyRate": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -3501,7 +4075,8 @@ "role", "teams", "status", - "isDemo" + "isDemo", + "hourlyRate" ], "additionalProperties": false } @@ -3701,6 +4276,16 @@ }, "isDemo": { "type": "boolean" + }, + "hourlyRate": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -3711,7 +4296,8 @@ "role", "teams", "status", - "isDemo" + "isDemo", + "hourlyRate" ], "additionalProperties": false }, @@ -3743,10 +4329,1686 @@ }, "isActive": { "type": "boolean" + }, + "hourlyRate": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ] } }, "additionalProperties": false }, + "PartnersResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "partners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "SUPPLIER", + "CLIENT" + ] + }, + "contactName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "phone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "city": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "isActive": { + "type": "boolean" + }, + "openOrders": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "name", + "kind", + "contactName", + "phone", + "email", + "city", + "isActive", + "openOrders" + ], + "additionalProperties": false + } + } + }, + "required": [ + "partners" + ], + "additionalProperties": false + }, + "Partner": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "SUPPLIER", + "CLIENT" + ] + }, + "contactName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "phone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "city": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "isActive": { + "type": "boolean" + }, + "openOrders": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "name", + "kind", + "contactName", + "phone", + "email", + "city", + "isActive", + "openOrders" + ], + "additionalProperties": false + }, + "PartnerCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "kind": { + "type": "string", + "enum": [ + "SUPPLIER", + "CLIENT" + ] + }, + "contactName": { + "type": "string", + "maxLength": 120 + }, + "phone": { + "type": "string", + "maxLength": 40 + }, + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + }, + "city": { + "type": "string", + "maxLength": 80 + } + }, + "required": [ + "name", + "kind" + ], + "additionalProperties": false + }, + "PartnerUpdate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "kind": { + "type": "string", + "enum": [ + "SUPPLIER", + "CLIENT" + ] + }, + "contactName": { + "type": "string", + "maxLength": 120 + }, + "phone": { + "type": "string", + "maxLength": 40 + }, + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + }, + "city": { + "type": "string", + "maxLength": 80 + }, + "isActive": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "PartsResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "parts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "designation": { + "type": "string" + }, + "threshold": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "lastUnitPrice": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "compatible": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "supplierId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "supplierName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "isActive": { + "type": "boolean" + }, + "stock": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "belowThreshold": { + "type": "boolean" + } + }, + "required": [ + "id", + "reference", + "designation", + "threshold", + "lastUnitPrice", + "compatible", + "supplierId", + "supplierName", + "isActive", + "stock", + "belowThreshold" + ], + "additionalProperties": false + } + } + }, + "required": [ + "parts" + ], + "additionalProperties": false + }, + "PartDetail": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "designation": { + "type": "string" + }, + "threshold": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "lastUnitPrice": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "compatible": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "supplierId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "supplierName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "isActive": { + "type": "boolean" + }, + "stock": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "belowThreshold": { + "type": "boolean" + }, + "movements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "kind": { + "type": "string", + "enum": [ + "RECEIPT", + "ENTRY", + "CONSUMPTION", + "ADJUSTMENT" + ] + }, + "quantity": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "unitPrice": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workOrderReference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "purchaseOrderReference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "byName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "kind", + "quantity", + "unitPrice", + "reason", + "workOrderReference", + "purchaseOrderReference", + "byName", + "createdAt" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "reference", + "designation", + "threshold", + "lastUnitPrice", + "compatible", + "supplierId", + "supplierName", + "isActive", + "stock", + "belowThreshold", + "movements" + ], + "additionalProperties": false + }, + "PartCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "designation": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "threshold": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "supplierId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "compatible": { + "type": "string", + "maxLength": 200 + }, + "initialPrice": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "designation" + ], + "additionalProperties": false + }, + "PartUpdate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "designation": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "threshold": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "supplierId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "compatible": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + }, + "isActive": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "StockMovementCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "ENTRY", + "ADJUSTMENT" + ] + }, + "quantity": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "reason": { + "type": "string", + "maxLength": 300 + } + }, + "required": [ + "kind", + "quantity" + ], + "additionalProperties": false + }, + "PurchaseOrdersResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "purchaseOrders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "DRAFT", + "SENT", + "RECEIVED", + "CANCELLED" + ] + }, + "supplierId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "supplierName": { + "type": "string" + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "partId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "partReference": { + "type": "string" + }, + "designation": { + "type": "string" + }, + "quantity": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "unitPrice": { + "type": "number" + } + }, + "required": [ + "id", + "partId", + "partReference", + "designation", + "quantity", + "unitPrice" + ], + "additionalProperties": false + } + }, + "total": { + "type": "number" + }, + "sentAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "receivedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "reference", + "status", + "supplierId", + "supplierName", + "lines", + "total", + "sentAt", + "receivedAt", + "createdAt" + ], + "additionalProperties": false + } + } + }, + "required": [ + "purchaseOrders" + ], + "additionalProperties": false + }, + "PurchaseOrder": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "DRAFT", + "SENT", + "RECEIVED", + "CANCELLED" + ] + }, + "supplierId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "supplierName": { + "type": "string" + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "partId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "partReference": { + "type": "string" + }, + "designation": { + "type": "string" + }, + "quantity": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "unitPrice": { + "type": "number" + } + }, + "required": [ + "id", + "partId", + "partReference", + "designation", + "quantity", + "unitPrice" + ], + "additionalProperties": false + } + }, + "total": { + "type": "number" + }, + "sentAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "receivedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "reference", + "status", + "supplierId", + "supplierName", + "lines", + "total", + "sentAt", + "receivedAt", + "createdAt" + ], + "additionalProperties": false + }, + "PurchaseOrderCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "supplierId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "lines": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "partId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "quantity": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "unitPrice": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "partId", + "quantity", + "unitPrice" + ], + "additionalProperties": false + } + } + }, + "required": [ + "supplierId", + "lines" + ], + "additionalProperties": false + }, + "PurchaseOrderTransition": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "to": { + "type": "string", + "enum": [ + "SENT", + "RECEIVED", + "CANCELLED" + ] + } + }, + "required": [ + "to" + ], + "additionalProperties": false + }, + "WorkOrderDetail": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "CORRECTIVE", + "PREVENTIVE", + "WORKS" + ] + }, + "status": { + "type": "string", + "enum": [ + "OPEN", + "IN_PROGRESS", + "ON_HOLD", + "DONE", + "CANCELLED" + ] + }, + "priority": { + "type": "string", + "enum": [ + "NONE", + "LOW", + "MEDIUM", + "HIGH", + "PERSON_TRAPPED" + ] + }, + "assetId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "assetReference": { + "type": "string" + }, + "siteName": { + "type": "string" + }, + "dueDate": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "assignees": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "initials" + ], + "additionalProperties": false + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "locationName": { + "type": "string" + }, + "startedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "cancelledAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "createdBy": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "initials" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "request": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "requesterLabel": { + "type": "string" + } + }, + "required": [ + "id", + "reference", + "requesterLabel" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "kind": { + "type": "string" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "by": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "initials" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "kind", + "message", + "by", + "createdAt" + ], + "additionalProperties": false + } + }, + "checklist": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "PENDING", + "DONE", + "NA" + ] + }, + "doneBy": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "initials" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "doneAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "label", + "state", + "doneBy", + "doneAt" + ], + "additionalProperties": false + } + }, + "report": { + "anyOf": [ + { + "type": "object", + "properties": { + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "doorState": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "cabinPosition": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "anomaly": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "externalCause": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "actionTaken": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "componentConcerned": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "note", + "doorState", + "cabinPosition", + "anomaly", + "externalCause", + "actionTaken", + "componentConcerned" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "costs": { + "type": "object", + "properties": { + "parts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "designation": { + "type": "string" + }, + "quantity": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "unitPrice": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "id", + "designation", + "quantity", + "unitPrice", + "total" + ], + "additionalProperties": false + } + }, + "labor": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "minutes": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "hourlyRate": { + "type": "number" + }, + "total": { + "type": "number" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "displayName", + "minutes", + "hourlyRate", + "total", + "note" + ], + "additionalProperties": false + } + }, + "total": { + "type": "number" + } + }, + "required": [ + "parts", + "labor", + "total" + ], + "additionalProperties": false + }, + "allowedTransitions": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "OPEN", + "IN_PROGRESS", + "ON_HOLD", + "DONE", + "CANCELLED" + ] + } + }, + "closureBlockers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "reference", + "title", + "type", + "status", + "priority", + "assetId", + "assetReference", + "siteName", + "dueDate", + "assignees", + "createdAt", + "description", + "locationName", + "startedAt", + "completedAt", + "cancelledAt", + "createdBy", + "request", + "events", + "checklist", + "report", + "costs", + "allowedTransitions", + "closureBlockers" + ], + "additionalProperties": false + }, + "ConsumePart": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "partId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "quantity": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "partId", + "quantity" + ], + "additionalProperties": false + }, + "LaborTimeCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "minutes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 1440 + }, + "userId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "note": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "minutes" + ], + "additionalProperties": false + }, "PortalAsset": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -4427,580 +6689,6 @@ ], "additionalProperties": false }, - "WorkOrderDetail": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "reference": { - "type": "string" - }, - "title": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "CORRECTIVE", - "PREVENTIVE", - "WORKS" - ] - }, - "status": { - "type": "string", - "enum": [ - "OPEN", - "IN_PROGRESS", - "ON_HOLD", - "DONE", - "CANCELLED" - ] - }, - "priority": { - "type": "string", - "enum": [ - "NONE", - "LOW", - "MEDIUM", - "HIGH", - "PERSON_TRAPPED" - ] - }, - "assetId": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "assetReference": { - "type": "string" - }, - "siteName": { - "type": "string" - }, - "dueDate": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ] - }, - "assignees": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "displayName": { - "type": "string" - }, - "initials": { - "type": "string" - } - }, - "required": [ - "id", - "displayName", - "initials" - ], - "additionalProperties": false - } - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "locationName": { - "type": "string" - }, - "startedAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ] - }, - "completedAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ] - }, - "cancelledAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ] - }, - "createdBy": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "displayName": { - "type": "string" - }, - "initials": { - "type": "string" - } - }, - "required": [ - "id", - "displayName", - "initials" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "request": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "reference": { - "type": "string" - }, - "requesterLabel": { - "type": "string" - } - }, - "required": [ - "id", - "reference", - "requesterLabel" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "events": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "kind": { - "type": "string" - }, - "message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "by": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "displayName": { - "type": "string" - }, - "initials": { - "type": "string" - } - }, - "required": [ - "id", - "displayName", - "initials" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - } - }, - "required": [ - "id", - "kind", - "message", - "by", - "createdAt" - ], - "additionalProperties": false - } - }, - "checklist": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "label": { - "type": "string" - }, - "state": { - "type": "string", - "enum": [ - "PENDING", - "DONE", - "NA" - ] - }, - "doneBy": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "displayName": { - "type": "string" - }, - "initials": { - "type": "string" - } - }, - "required": [ - "id", - "displayName", - "initials" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "doneAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "label", - "state", - "doneBy", - "doneAt" - ], - "additionalProperties": false - } - }, - "report": { - "anyOf": [ - { - "type": "object", - "properties": { - "note": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "doorState": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "label": { - "type": "string" - } - }, - "required": [ - "id", - "label" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "cabinPosition": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "label": { - "type": "string" - } - }, - "required": [ - "id", - "label" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "anomaly": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "label": { - "type": "string" - } - }, - "required": [ - "id", - "label" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "externalCause": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "label": { - "type": "string" - } - }, - "required": [ - "id", - "label" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "actionTaken": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "label": { - "type": "string" - } - }, - "required": [ - "id", - "label" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "componentConcerned": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" - }, - "label": { - "type": "string" - } - }, - "required": [ - "id", - "label" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "note", - "doorState", - "cabinPosition", - "anomaly", - "externalCause", - "actionTaken", - "componentConcerned" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "allowedTransitions": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "OPEN", - "IN_PROGRESS", - "ON_HOLD", - "DONE", - "CANCELLED" - ] - } - }, - "closureBlockers": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "id", - "reference", - "title", - "type", - "status", - "priority", - "assetId", - "assetReference", - "siteName", - "dueDate", - "assignees", - "createdAt", - "description", - "locationName", - "startedAt", - "completedAt", - "cancelledAt", - "createdBy", - "request", - "events", - "checklist", - "report", - "allowedTransitions", - "closureBlockers" - ], - "additionalProperties": false - }, "WorkOrderCreate": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 8c3fd1f..270bc2c 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -44,6 +44,23 @@ import { PortalRequestCreateSchema, PortalRequestStatusSchema, } from './schemas/portail'; +import { + ConsumePartSchema, + LaborTimeCreateSchema, + PartCreateSchema, + PartDetailSchema, + PartnerCreateSchema, + PartnerSchema, + PartnersResponseSchema, + PartnerUpdateSchema, + PartsResponseSchema, + PartUpdateSchema, + PurchaseOrderCreateSchema, + PurchaseOrderSchema, + PurchaseOrdersResponseSchema, + PurchaseOrderTransitionSchema, + StockMovementCreateSchema, +} from './schemas/gestion'; import { MeterReadingCreateSchema, MetersResponseSchema, @@ -419,6 +436,179 @@ export const API_CONTRACT: ApiOperation[] = [ 404: { description: 'Inconnue' }, }, }, + // ————— R3 · Gestion (stock, achats, tiers, coûts) ————— + { + operationId: 'listPartners', + method: 'get', + path: '/partners', + summary: 'Tiers (fournisseurs, syndics) — permission PURCHASE_ORDERS', + tags: ['partners'], + responses: { + 200: { description: 'Liste', name: 'PartnersResponse', schema: PartnersResponseSchema }, + }, + }, + { + operationId: 'createPartner', + method: 'post', + path: '/partners', + summary: 'Créer un tiers', + tags: ['partners'], + request: { name: 'PartnerCreate', schema: PartnerCreateSchema }, + responses: { + 201: { description: 'Créé', name: 'Partner', schema: PartnerSchema }, + 409: { description: 'Nom déjà utilisé' }, + }, + }, + { + operationId: 'updatePartner', + method: 'patch', + path: '/partners/{id}', + summary: 'Modifier / (dés)activer un tiers', + tags: ['partners'], + pathParams: ['id'], + request: { name: 'PartnerUpdate', schema: PartnerUpdateSchema }, + responses: { + 200: { description: 'Mis à jour', name: 'Partner', schema: PartnerSchema }, + 404: { description: 'Inconnu' }, + }, + }, + { + operationId: 'listParts', + method: 'get', + path: '/parts', + summary: 'Stock de pièces — quantités DÉRIVÉES des mouvements', + tags: ['parts'], + responses: { + 200: { description: 'Liste', name: 'PartsResponse', schema: PartsResponseSchema }, + }, + }, + { + operationId: 'getPart', + method: 'get', + path: '/parts/{id}', + summary: 'Fiche pièce : les mouvements SONT le stock', + tags: ['parts'], + pathParams: ['id'], + responses: { + 200: { description: 'Fiche', name: 'PartDetail', schema: PartDetailSchema }, + 404: { description: 'Inconnue' }, + }, + }, + { + operationId: 'createPart', + method: 'post', + path: '/parts', + summary: 'Créer une pièce (référence P-#### générée)', + tags: ['parts'], + request: { name: 'PartCreate', schema: PartCreateSchema }, + responses: { + 201: { description: 'Créée', name: 'PartDetail', schema: PartDetailSchema }, + }, + }, + { + operationId: 'updatePart', + method: 'patch', + path: '/parts/{id}', + summary: 'Modifier une pièce (désignation, seuil, fournisseur…)', + tags: ['parts'], + pathParams: ['id'], + request: { name: 'PartUpdate', schema: PartUpdateSchema }, + responses: { + 200: { description: 'Mise à jour', name: 'PartDetail', schema: PartDetailSchema }, + 404: { description: 'Inconnue' }, + }, + }, + { + operationId: 'addStockMovement', + method: 'post', + path: '/parts/{id}/movements', + summary: 'Entrée manuelle (+) ou ajustement (± motif REQUIS) — jamais de saisie de stock', + tags: ['parts'], + pathParams: ['id'], + request: { name: 'StockMovementCreate', schema: StockMovementCreateSchema }, + responses: { + 201: { description: 'Mouvement tracé', name: 'PartDetail', schema: PartDetailSchema }, + 409: { description: 'Le stock ne peut pas devenir négatif' }, + }, + }, + { + operationId: 'listPurchaseOrders', + method: 'get', + path: '/purchase-orders', + summary: 'Bons de commande', + tags: ['purchase-orders'], + responses: { + 200: { + description: 'Liste', + name: 'PurchaseOrdersResponse', + schema: PurchaseOrdersResponseSchema, + }, + }, + }, + { + operationId: 'getPurchaseOrder', + method: 'get', + path: '/purchase-orders/{id}', + summary: 'Fiche BC (lignes, total)', + tags: ['purchase-orders'], + pathParams: ['id'], + responses: { + 200: { description: 'Fiche', name: 'PurchaseOrder', schema: PurchaseOrderSchema }, + 404: { description: 'Inconnu' }, + }, + }, + { + operationId: 'createPurchaseOrder', + method: 'post', + path: '/purchase-orders', + summary: 'Créer un BC (brouillon)', + tags: ['purchase-orders'], + request: { name: 'PurchaseOrderCreate', schema: PurchaseOrderCreateSchema }, + responses: { + 201: { description: 'Créé', name: 'PurchaseOrder', schema: PurchaseOrderSchema }, + 400: { description: 'Fournisseur ou pièce invalide' }, + }, + }, + { + operationId: 'transitionPurchaseOrder', + method: 'post', + path: '/purchase-orders/{id}/transition', + summary: 'Envoyer / réceptionner (→ entrées de stock, PU figés) / annuler', + tags: ['purchase-orders'], + pathParams: ['id'], + request: { name: 'PurchaseOrderTransition', schema: PurchaseOrderTransitionSchema }, + responses: { + 200: { description: 'État changé', name: 'PurchaseOrder', schema: PurchaseOrderSchema }, + 409: { description: 'Transition interdite' }, + }, + }, + { + operationId: 'consumePart', + method: 'post', + path: '/work-orders/{id}/consume-part', + summary: 'Consommer une pièce sur l’OT — stock décrémenté, PRIX FIGÉ', + tags: ['work-orders'], + pathParams: ['id'], + request: { name: 'ConsumePart', schema: ConsumePartSchema }, + responses: { + 201: { description: 'Consommée', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema }, + 409: { description: 'Stock insuffisant ou prix inconnu' }, + }, + }, + { + operationId: 'addLaborTime', + method: 'post', + path: '/work-orders/{id}/labor', + summary: 'Saisir de la main-d’œuvre — TAUX FIGÉ à la saisie', + tags: ['work-orders'], + pathParams: ['id'], + request: { name: 'LaborTimeCreate', schema: LaborTimeCreateSchema }, + responses: { + 201: { description: 'Saisie', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema }, + 409: { description: 'Taux horaire non défini pour cette personne' }, + }, + }, + // ————— R2 · Portail public (QR cabine — aucun compte) ————— { operationId: 'getPortalAsset', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3b6a51f..db6ae68 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,6 +4,7 @@ export * from './exploitation'; export * from './schemas/exploitation'; export * from './schemas/preventif'; export * from './schemas/portail'; +export * from './schemas/gestion'; export * from './schemas/auth'; export * from './schemas/users'; export * from './schemas/users-admin'; diff --git a/packages/shared/src/schemas/exploitation.ts b/packages/shared/src/schemas/exploitation.ts index 084a28a..bdf7aea 100644 --- a/packages/shared/src/schemas/exploitation.ts +++ b/packages/shared/src/schemas/exploitation.ts @@ -7,6 +7,7 @@ import { WORK_ORDER_STATUSES, WORK_ORDER_TYPES, } from '../exploitation'; +import { WorkOrderCostsSchema } from './gestion'; const PersonneSchema = z.object({ id: z.uuid(), @@ -80,6 +81,8 @@ export const WorkOrderDetailSchema = WorkOrderSummarySchema.extend({ events: z.array(WorkOrderEventSchema), checklist: z.array(ChecklistItemSchema), report: InterventionReportSchema.nullable(), + /** Coûts figés (R3) : consommations + main-d'œuvre. */ + costs: WorkOrderCostsSchema, /** Ce que la machine à états autorise depuis l'état courant. */ allowedTransitions: z.array(z.enum(WORK_ORDER_STATUSES)), /** Ce qui bloque la clôture (vide = clôturable) — messages métier. */ diff --git a/packages/shared/src/schemas/gestion.ts b/packages/shared/src/schemas/gestion.ts new file mode 100644 index 0000000..752849d --- /dev/null +++ b/packages/shared/src/schemas/gestion.ts @@ -0,0 +1,213 @@ +import { z } from 'zod'; + +/** Vocabulaires & schémas de la gestion (R3). */ + +export const PARTNER_KINDS = ['SUPPLIER', 'CLIENT'] as const; +export type PartnerKind = (typeof PARTNER_KINDS)[number]; +export const PARTNER_KIND_LABELS: Record = { + SUPPLIER: 'Fournisseur', + CLIENT: 'Client / syndic', +}; + +export const PURCHASE_ORDER_STATUSES = ['DRAFT', 'SENT', 'RECEIVED', 'CANCELLED'] as const; +export type PurchaseOrderStatus = (typeof PURCHASE_ORDER_STATUSES)[number]; +export const PURCHASE_ORDER_STATUS_LABELS: Record = { + DRAFT: 'Brouillon', + SENT: 'Envoyé', + RECEIVED: 'Reçu', + CANCELLED: 'Annulé', +}; + +export const STOCK_MOVEMENT_KINDS = ['RECEIPT', 'ENTRY', 'CONSUMPTION', 'ADJUSTMENT'] as const; +export type StockMovementKind = (typeof STOCK_MOVEMENT_KINDS)[number]; +export const STOCK_MOVEMENT_KIND_LABELS: Record = { + RECEIPT: 'Réception', + ENTRY: 'Entrée', + CONSUMPTION: 'Sortie (OT)', + ADJUSTMENT: 'Ajustement', +}; + +// ————— Tiers ————— + +export const PartnerSchema = z.object({ + id: z.uuid(), + name: z.string(), + kind: z.enum(PARTNER_KINDS), + contactName: z.string().nullable(), + phone: z.string().nullable(), + email: z.string().nullable(), + city: z.string().nullable(), + isActive: z.boolean(), + openOrders: z.number().int(), // BC non reçus/annulés (fournisseur) +}); +export type PartnerDto = z.infer; + +export const PartnersResponseSchema = z.object({ partners: z.array(PartnerSchema) }); +export type PartnersResponse = z.infer; + +export const PartnerCreateSchema = z.object({ + name: z.string().min(1).max(120), + kind: z.enum(PARTNER_KINDS), + contactName: z.string().max(120).optional(), + phone: z.string().max(40).optional(), + email: z.email().optional(), + city: z.string().max(80).optional(), +}); +export type PartnerCreate = z.infer; + +export const PartnerUpdateSchema = PartnerCreateSchema.partial().extend({ + isActive: z.boolean().optional(), +}); +export type PartnerUpdate = z.infer; + +// ————— Pièces & stock (dérivé des mouvements) ————— + +export const PartSchema = z.object({ + id: z.uuid(), + reference: z.string(), + designation: z.string(), + threshold: z.number().int(), + lastUnitPrice: z.number().nullable(), + compatible: z.string().nullable(), + supplierId: z.uuid().nullable(), + supplierName: z.string().nullable(), + isActive: z.boolean(), + stock: z.number().int(), // Σ mouvements — calculé, jamais stocké + belowThreshold: z.boolean(), +}); +export type PartDto = z.infer; + +export const PartsResponseSchema = z.object({ parts: z.array(PartSchema) }); +export type PartsResponse = z.infer; + +export const PartCreateSchema = z.object({ + designation: z.string().min(1).max(200), + threshold: z.number().int().min(0).optional(), + supplierId: z.uuid().optional(), + compatible: z.string().max(200).optional(), + initialPrice: z.number().positive().optional(), // premier PU connu +}); +export type PartCreate = z.infer; + +export const PartUpdateSchema = z.object({ + designation: z.string().min(1).max(200).optional(), + threshold: z.number().int().min(0).optional(), + supplierId: z.uuid().nullable().optional(), + compatible: z.string().max(200).nullable().optional(), + isActive: z.boolean().optional(), +}); +export type PartUpdate = z.infer; + +export const StockMovementSchema = z.object({ + id: z.uuid(), + kind: z.enum(STOCK_MOVEMENT_KINDS), + quantity: z.number().int(), + unitPrice: z.number().nullable(), + reason: z.string().nullable(), + workOrderReference: z.string().nullable(), + purchaseOrderReference: z.string().nullable(), + byName: z.string().nullable(), + createdAt: z.iso.datetime(), +}); +export type StockMovementDto = z.infer; + +export const PartDetailSchema = PartSchema.extend({ + movements: z.array(StockMovementSchema), // récents d'abord — le stock EST cette liste +}); +export type PartDetail = z.infer; + +/** Entrée manuelle (+) ou ajustement (±, motif requis). */ +export const StockMovementCreateSchema = z.object({ + kind: z.enum(['ENTRY', 'ADJUSTMENT']), + quantity: z.number().int().refine((q) => q !== 0, 'Quantité non nulle requise'), + reason: z.string().max(300).optional(), +}); +export type StockMovementCreate = z.infer; + +// ————— Bons de commande ————— + +export const PurchaseOrderLineSchema = z.object({ + id: z.uuid(), + partId: z.uuid(), + partReference: z.string(), + designation: z.string(), + quantity: z.number().int(), + unitPrice: z.number(), +}); + +export const PurchaseOrderSchema = z.object({ + id: z.uuid(), + reference: z.string(), + status: z.enum(PURCHASE_ORDER_STATUSES), + supplierId: z.uuid(), + supplierName: z.string(), + lines: z.array(PurchaseOrderLineSchema), + total: z.number(), + sentAt: z.iso.datetime().nullable(), + receivedAt: z.iso.datetime().nullable(), + createdAt: z.iso.datetime(), +}); +export type PurchaseOrderDto = z.infer; + +export const PurchaseOrdersResponseSchema = z.object({ + purchaseOrders: z.array(PurchaseOrderSchema), +}); +export type PurchaseOrdersResponse = z.infer; + +export const PurchaseOrderCreateSchema = z.object({ + supplierId: z.uuid(), + lines: z + .array( + z.object({ + partId: z.uuid(), + quantity: z.number().int().positive(), + unitPrice: z.number().positive(), + }), + ) + .min(1), +}); +export type PurchaseOrderCreate = z.infer; + +export const PurchaseOrderTransitionSchema = z.object({ + to: z.enum(['SENT', 'RECEIVED', 'CANCELLED']), +}); +export type PurchaseOrderTransition = z.infer; + +// ————— Coûts sur OT ————— + +export const ConsumePartSchema = z.object({ + partId: z.uuid(), + quantity: z.number().int().positive(), +}); +export type ConsumePart = z.infer; + +export const LaborTimeCreateSchema = z.object({ + minutes: z.number().int().positive().max(24 * 60), + userId: z.uuid().optional(), // défaut : la personne connectée + note: z.string().max(200).optional(), +}); +export type LaborTimeCreate = z.infer; + +export const WorkOrderCostsSchema = z.object({ + parts: z.array( + z.object({ + id: z.uuid(), + designation: z.string(), + quantity: z.number().int(), + unitPrice: z.number(), + total: z.number(), + }), + ), + labor: z.array( + z.object({ + id: z.uuid(), + displayName: z.string(), + minutes: z.number().int(), + hourlyRate: z.number(), + total: z.number(), + note: z.string().nullable(), + }), + ), + total: z.number(), +}); +export type WorkOrderCosts = z.infer; diff --git a/packages/shared/src/schemas/users-admin.ts b/packages/shared/src/schemas/users-admin.ts index 21f20fa..d9f05d0 100644 --- a/packages/shared/src/schemas/users-admin.ts +++ b/packages/shared/src/schemas/users-admin.ts @@ -14,6 +14,8 @@ export const UserAdminSchema = z.object({ teams: z.array(z.object({ id: z.uuid(), name: z.string() })), status: z.enum(USER_STATUSES), isDemo: z.boolean(), + /** Taux horaire COURANT (MAD/h) — chaque saisie de main-d'œuvre fige le sien. */ + hourlyRate: z.number().nullable(), }); export type UserAdmin = z.infer; @@ -31,6 +33,7 @@ export const UserUpdateSchema = z.object({ roleId: z.uuid().optional(), teamIds: z.array(z.uuid()).optional(), // remplace l'affectation isActive: z.boolean().optional(), + hourlyRate: z.number().positive().nullable().optional(), // R3 — taux courant }); export type UserUpdate = z.infer;