mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user