mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r3.1): socle backend gestion — stock dérivé, BC, coûts figés sur OT
- migration r3_gestion (7 tables) : Partner, Part (SANS colonne de quantité), StockMovement (signé, tracé, PU figé), PurchaseOrder/Line, LaborTime (taux figé), Document (R3.2) ; User.hourlyRate administrable - API (66 opérations) : tiers ; pièces (stock = Σ mouvements, alerte sous seuil) ; entrée/ajustement (motif requis, stock jamais négatif, en transaction) ; BC Brouillon→Envoyé→Reçu (la réception crée les RECEIPT et met à jour lastUnitPrice) ; consommation sur OT (stock suffisant, PRIX FIGÉ) ; main-d'œuvre (TAUX FIGÉ, refus si taux non défini) ; WorkOrderDetail.costs ; coûts verrouillés après clôture (409) - seed : tiers/pièces/BC/taux de la maquette — OT-0341 = 505 MAD (testé) - durcissement : références OT/DEM/BC/P par SÉQUENCES Postgres (nextval) — fin des courses « max+1 » (500 sporadiques sous charge parallèle) ; 3 runs Jest complets consécutifs verts - 55 tests (92 % stmts / 74,9 % branches) dont la recette officielle : consommer sous seuil → BC → réception → réappro, prix/taux figés prouvés Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
await seedUsers(prisma, roleIds, passwordHash);
|
||||
await seedReferentiel(prisma);
|
||||
await seedExploitation(prisma);
|
||||
await seedGestion(prisma);
|
||||
}
|
||||
|
||||
async function seedUsers(
|
||||
@@ -580,6 +581,177 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ————— R3 — Gestion (tiers, pièces, mouvements, BC, taux — données maquette) —————
|
||||
|
||||
const PARTNERS: {
|
||||
name: string; kind: 'SUPPLIER' | 'CLIENT'; contactName?: string;
|
||||
phone?: string; email?: string; city?: string;
|
||||
}[] = [
|
||||
{ name: 'Ascentech Maroc', kind: 'SUPPLIER', contactName: 'M. Berrini', phone: '05 22 34 56 78', city: 'Casablanca' },
|
||||
{ name: 'SchindlerParts', kind: 'SUPPLIER', email: 'commandes@schindlerparts.ma' },
|
||||
{ name: 'Lubmaroc', kind: 'SUPPLIER', phone: '05 22 11 22 33' },
|
||||
{ name: 'Atlas Property Management', kind: 'CLIENT', contactName: 'Mme Zerhouni', phone: '06 61 98 76 54' },
|
||||
{ name: 'Syndic Al Manar', kind: 'CLIENT', contactName: 'M. Alami (gardien référent)' },
|
||||
];
|
||||
|
||||
const PARTS: {
|
||||
reference: string; designation: string; threshold: number;
|
||||
lastUnitPrice?: number; supplier?: string; compatible?: string;
|
||||
}[] = [
|
||||
{ reference: 'P-0019', designation: 'Cellule barrière porte (paire)', threshold: 4, lastUnitPrice: 640, supplier: 'SchindlerParts' },
|
||||
{ reference: 'P-0031', designation: 'Bouton palier lumineux Ø22', threshold: 10, lastUnitPrice: 45, supplier: 'Ascentech Maroc' },
|
||||
{ reference: 'P-0042', designation: 'Graisse guide (cartouche 400 g)', threshold: 8, lastUnitPrice: 85, supplier: 'Lubmaroc' },
|
||||
{ reference: 'P-0087', designation: 'Coulisseau de guide 16 mm', threshold: 6, lastUnitPrice: 120, supplier: 'Ascentech Maroc' },
|
||||
{ reference: 'P-0113', designation: 'Contact de porte NC-31', threshold: 5, lastUnitPrice: 85, supplier: 'Ascentech Maroc', compatible: 'Otis Gen2 · Schindler 3300' },
|
||||
];
|
||||
|
||||
async function seedGestion(prisma: PrismaClient): Promise<void> {
|
||||
const partnerIds = new Map<string, string>();
|
||||
for (const p of PARTNERS) {
|
||||
const partner = await prisma.partner.upsert({
|
||||
where: { name: p.name },
|
||||
update: {},
|
||||
create: p,
|
||||
});
|
||||
partnerIds.set(p.name, partner.id);
|
||||
}
|
||||
|
||||
const partIds = new Map<string, string>();
|
||||
for (const p of PARTS) {
|
||||
const part = await prisma.part.upsert({
|
||||
where: { reference: p.reference },
|
||||
update: {},
|
||||
create: {
|
||||
reference: p.reference,
|
||||
designation: p.designation,
|
||||
threshold: p.threshold,
|
||||
lastUnitPrice: p.lastUnitPrice,
|
||||
compatible: p.compatible,
|
||||
supplierId: p.supplier ? partnerIds.get(p.supplier) : undefined,
|
||||
},
|
||||
});
|
||||
partIds.set(p.reference, part.id);
|
||||
}
|
||||
|
||||
// Taux horaires courants (les saisies figent leur propre taux)
|
||||
for (const [email, taux] of [
|
||||
['technicien@demo.siop.ma', 120],
|
||||
['technicien-limite@demo.siop.ma', 90],
|
||||
] as const) {
|
||||
await prisma.user.update({ where: { email }, data: { hourlyRate: taux } });
|
||||
}
|
||||
|
||||
// Idempotence : si des mouvements existent déjà, l'histoire est en place
|
||||
if ((await prisma.stockMovement.count()) > 0) return;
|
||||
|
||||
const annee = new Date().getFullYear();
|
||||
const ahmed = await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } });
|
||||
const nadia = await prisma.user.findUniqueOrThrow({ where: { email: 'gestionnaire@demo.siop.ma' } });
|
||||
|
||||
// BC reçus (historique) et BC en cours — comme la maquette
|
||||
const bc21 = await prisma.purchaseOrder.create({
|
||||
data: {
|
||||
reference: `BC-${annee}-0021`,
|
||||
status: 'RECEIVED',
|
||||
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||||
createdById: nadia.id,
|
||||
sentAt: new Date(Date.now() - 40 * 86400e3),
|
||||
receivedAt: new Date(Date.now() - 34 * 86400e3),
|
||||
lines: {
|
||||
create: [
|
||||
{ partId: partIds.get('P-0113')!, quantity: 5, unitPrice: 85 },
|
||||
{ partId: partIds.get('P-0087')!, quantity: 6, unitPrice: 120 },
|
||||
{ partId: partIds.get('P-0031')!, quantity: 20, unitPrice: 45 },
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { lines: true },
|
||||
});
|
||||
await prisma.purchaseOrder.create({
|
||||
data: {
|
||||
reference: `BC-${annee}-0024`,
|
||||
status: 'SENT',
|
||||
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||||
createdById: nadia.id,
|
||||
sentAt: new Date(),
|
||||
lines: {
|
||||
create: [
|
||||
{ partId: partIds.get('P-0113')!, quantity: 10, unitPrice: 85 },
|
||||
{ partId: partIds.get('P-0087')!, quantity: 2, unitPrice: 127.5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mouvements : réceptions du BC-0021 + entrées initiales + consommations
|
||||
for (const line of bc21.lines) {
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: line.partId,
|
||||
kind: 'RECEIPT',
|
||||
quantity: line.quantity,
|
||||
unitPrice: line.unitPrice,
|
||||
purchaseOrderId: bc21.id,
|
||||
byId: nadia.id,
|
||||
createdAt: new Date(Date.now() - 34 * 86400e3),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const [ref, qte] of [
|
||||
['P-0042', 19],
|
||||
['P-0019', 7],
|
||||
['P-0031', 6],
|
||||
] as const) {
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: partIds.get(ref)!,
|
||||
kind: 'ENTRY',
|
||||
quantity: qte,
|
||||
reason: 'Reprise de 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();
|
||||
|
||||
@@ -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] : []),
|
||||
],
|
||||
|
||||
45
apps/api/src/partners/partners.controller.ts
Normal file
45
apps/api/src/partners/partners.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/partners/partners.module.ts
Normal file
9
apps/api/src/partners/partners.module.ts
Normal file
@@ -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 {}
|
||||
84
apps/api/src/partners/partners.service.ts
Normal file
84
apps/api/src/partners/partners.service.ts
Normal file
@@ -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<PartnersResponse> {
|
||||
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<PartnerDto> {
|
||||
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<PartnerDto> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
66
apps/api/src/parts/parts.controller.ts
Normal file
66
apps/api/src/parts/parts.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/parts/parts.module.ts
Normal file
10
apps/api/src/parts/parts.module.ts
Normal file
@@ -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 {}
|
||||
231
apps/api/src/parts/parts.service.ts
Normal file
231
apps/api/src/parts/parts.service.ts
Normal file
@@ -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<Map<string, number>> {
|
||||
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<PartsResponse> {
|
||||
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<PartDetail> {
|
||||
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<PartDetail> {
|
||||
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<PartDetail> {
|
||||
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<PartDetail> {
|
||||
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<void> {
|
||||
const supplier = await this.prisma.partner.findUnique({ where: { id: supplierId } });
|
||||
if (!supplier || supplier.kind !== 'SUPPLIER') {
|
||||
throw new BadRequestException('Fournisseur inconnu');
|
||||
}
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -76,14 +76,9 @@ export class PortalService {
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
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 {
|
||||
|
||||
@@ -210,14 +210,9 @@ export class PreventiveService {
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
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 {
|
||||
|
||||
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal file
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal file
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal file
@@ -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 {}
|
||||
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal file
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal file
@@ -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<PurchaseOrderStatus, PurchaseOrderStatus[]> = {
|
||||
DRAFT: ['SENT', 'CANCELLED'],
|
||||
SENT: ['RECEIVED', 'CANCELLED'],
|
||||
RECEIVED: [],
|
||||
CANCELLED: [],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PurchaseOrdersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<PurchaseOrdersResponse> {
|
||||
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<PurchaseOrderDto> {
|
||||
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<PurchaseOrderDto> {
|
||||
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<PurchaseOrderDto> {
|
||||
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<string> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -131,14 +131,9 @@ export class RequestsService {
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
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 {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string> {
|
||||
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<WorkOrderDetail> {
|
||||
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<WorkOrderDetail> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
351
apps/api/test/gestion.e2e-spec.ts
Normal file
351
apps/api/test/gestion.e2e-spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
1000
apps/web/src/api/schema.d.ts
vendored
1000
apps/web/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
2840
docs/openapi.json
2840
docs/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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. */
|
||||
|
||||
213
packages/shared/src/schemas/gestion.ts
Normal file
213
packages/shared/src/schemas/gestion.ts
Normal file
@@ -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<PartnerKind, string> = {
|
||||
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<PurchaseOrderStatus, string> = {
|
||||
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<StockMovementKind, string> = {
|
||||
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<typeof PartnerSchema>;
|
||||
|
||||
export const PartnersResponseSchema = z.object({ partners: z.array(PartnerSchema) });
|
||||
export type PartnersResponse = z.infer<typeof PartnersResponseSchema>;
|
||||
|
||||
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<typeof PartnerCreateSchema>;
|
||||
|
||||
export const PartnerUpdateSchema = PartnerCreateSchema.partial().extend({
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
export type PartnerUpdate = z.infer<typeof PartnerUpdateSchema>;
|
||||
|
||||
// ————— 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<typeof PartSchema>;
|
||||
|
||||
export const PartsResponseSchema = z.object({ parts: z.array(PartSchema) });
|
||||
export type PartsResponse = z.infer<typeof PartsResponseSchema>;
|
||||
|
||||
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<typeof PartCreateSchema>;
|
||||
|
||||
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<typeof PartUpdateSchema>;
|
||||
|
||||
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<typeof StockMovementSchema>;
|
||||
|
||||
export const PartDetailSchema = PartSchema.extend({
|
||||
movements: z.array(StockMovementSchema), // récents d'abord — le stock EST cette liste
|
||||
});
|
||||
export type PartDetail = z.infer<typeof PartDetailSchema>;
|
||||
|
||||
/** 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<typeof StockMovementCreateSchema>;
|
||||
|
||||
// ————— 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<typeof PurchaseOrderSchema>;
|
||||
|
||||
export const PurchaseOrdersResponseSchema = z.object({
|
||||
purchaseOrders: z.array(PurchaseOrderSchema),
|
||||
});
|
||||
export type PurchaseOrdersResponse = z.infer<typeof PurchaseOrdersResponseSchema>;
|
||||
|
||||
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<typeof PurchaseOrderCreateSchema>;
|
||||
|
||||
export const PurchaseOrderTransitionSchema = z.object({
|
||||
to: z.enum(['SENT', 'RECEIVED', 'CANCELLED']),
|
||||
});
|
||||
export type PurchaseOrderTransition = z.infer<typeof PurchaseOrderTransitionSchema>;
|
||||
|
||||
// ————— Coûts sur OT —————
|
||||
|
||||
export const ConsumePartSchema = z.object({
|
||||
partId: z.uuid(),
|
||||
quantity: z.number().int().positive(),
|
||||
});
|
||||
export type ConsumePart = z.infer<typeof ConsumePartSchema>;
|
||||
|
||||
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<typeof LaborTimeCreateSchema>;
|
||||
|
||||
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<typeof WorkOrderCostsSchema>;
|
||||
@@ -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<typeof UserAdminSchema>;
|
||||
|
||||
@@ -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<typeof UserUpdateSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user