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>
171 lines
5.2 KiB
TypeScript
171 lines
5.2 KiB
TypeScript
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(),
|
|
};
|
|
}
|
|
}
|