mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
- 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>
166 lines
5.4 KiB
TypeScript
166 lines
5.4 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import type {
|
|
RequestApprove,
|
|
RequestCreate,
|
|
RequestReject,
|
|
RequestsResponse,
|
|
RequestSummary,
|
|
WorkOrderDetail,
|
|
} from '@siop/shared';
|
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
|
import { PermissionsService } from '../permissions/permissions.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
|
|
|
const requestInclude = {
|
|
asset: { include: { location: { include: { parent: true } } } },
|
|
requestedBy: true,
|
|
workOrder: true,
|
|
} satisfies Prisma.RequestInclude;
|
|
|
|
type RequestRow = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
|
|
|
|
@Injectable()
|
|
export class RequestsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly permissions: PermissionsService,
|
|
private readonly workOrders: WorkOrdersService,
|
|
) {}
|
|
|
|
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
|
const viewOther = await this.permissions.can(user.roleId, 'REQUESTS', 'viewOther');
|
|
return viewOther ? {} : { requestedById: user.userId };
|
|
}
|
|
|
|
async list(user: AuthenticatedUser): Promise<RequestsResponse> {
|
|
const rows = await this.prisma.request.findMany({
|
|
where: await this.scope(user),
|
|
include: requestInclude,
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
rows.sort((a, b) => Number(b.isPersonTrapped) - Number(a.isPersonTrapped));
|
|
return { requests: rows.map((r) => this.toDto(r)) };
|
|
}
|
|
|
|
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
|
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
|
if (!asset) throw new BadRequestException('Équipement inconnu');
|
|
for (let essai = 0; ; essai++) {
|
|
try {
|
|
const created = await this.prisma.request.create({
|
|
data: {
|
|
reference: await this.nextReference(),
|
|
description: dto.description,
|
|
isPersonTrapped: dto.isPersonTrapped ?? false,
|
|
assetId: dto.assetId,
|
|
requestedById: user.userId,
|
|
},
|
|
include: requestInclude,
|
|
});
|
|
return this.toDto(created);
|
|
} catch (e) {
|
|
if (
|
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
|
e.code === 'P2002' &&
|
|
essai < 3
|
|
) {
|
|
continue;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Approuver = créer l'OT lié (1-1). Une demande ne se traite qu'une fois. */
|
|
async approve(
|
|
id: string,
|
|
dto: RequestApprove,
|
|
user: AuthenticatedUser,
|
|
): Promise<WorkOrderDetail> {
|
|
const request = await this.prisma.request.findUnique({
|
|
where: { id },
|
|
include: requestInclude,
|
|
});
|
|
if (!request) throw new NotFoundException('Demande inconnue');
|
|
if (request.status !== 'RECEIVED') {
|
|
throw new ConflictException('Cette demande a déjà été traitée');
|
|
}
|
|
const created = await this.workOrders.createRaw({
|
|
title: dto.title ?? request.description.slice(0, 120),
|
|
description: `${request.description}\n\n(Demande ${request.reference} — ${this.requesterLabel(request)})`,
|
|
type: 'CORRECTIVE',
|
|
priority: dto.priority ?? (request.isPersonTrapped ? 'PERSON_TRAPPED' : 'MEDIUM'),
|
|
assetId: request.assetId,
|
|
dueDate: dto.dueDate,
|
|
assigneeIds: dto.assigneeIds,
|
|
createdById: user.userId,
|
|
eventKind: 'FROM_REQUEST',
|
|
eventMessage: `OT créé depuis la demande ${request.reference}`,
|
|
});
|
|
await this.prisma.request.update({
|
|
where: { id },
|
|
data: { status: 'APPROVED', workOrderId: created.id },
|
|
});
|
|
return this.workOrders.get(created.id, user);
|
|
}
|
|
|
|
async reject(
|
|
id: string,
|
|
dto: RequestReject,
|
|
user: AuthenticatedUser,
|
|
): Promise<RequestSummary> {
|
|
void user;
|
|
const request = await this.prisma.request.findUnique({ where: { id } });
|
|
if (!request) throw new NotFoundException('Demande inconnue');
|
|
if (request.status !== 'RECEIVED') {
|
|
throw new ConflictException('Cette demande a déjà été traitée');
|
|
}
|
|
const updated = await this.prisma.request.update({
|
|
where: { id },
|
|
data: { status: 'REJECTED', rejectionReason: dto.reason },
|
|
include: requestInclude,
|
|
});
|
|
return this.toDto(updated);
|
|
}
|
|
|
|
private async nextReference(): Promise<string> {
|
|
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 {
|
|
return row.requestedBy?.displayName ?? row.requesterName ?? 'Portail';
|
|
}
|
|
|
|
private toDto(row: RequestRow): RequestSummary {
|
|
return {
|
|
id: row.id,
|
|
reference: row.reference,
|
|
description: row.description,
|
|
isPersonTrapped: row.isPersonTrapped,
|
|
status: row.status,
|
|
rejectionReason: row.rejectionReason,
|
|
assetId: row.assetId,
|
|
assetReference: row.asset.reference,
|
|
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
|
|
requesterLabel: this.requesterLabel(row),
|
|
workOrder: row.workOrder
|
|
? {
|
|
id: row.workOrder.id,
|
|
reference: row.workOrder.reference,
|
|
status: row.workOrder.status,
|
|
}
|
|
: null,
|
|
createdAt: row.createdAt.toISOString(),
|
|
};
|
|
}
|
|
}
|