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

@@ -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] : []),
],

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

View 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 {}

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

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

View 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 {}

View 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 dinventaire 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(),
};
}
}

View File

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

View File

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

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

View 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 {}

View 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(),
};
}
}

View File

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

View File

@@ -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),
};
}
}

View File

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

View File

@@ -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],

View File

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