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

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

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

View File

@@ -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. */

View 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>;

View File

@@ -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>;