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