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:
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal file
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user