mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +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:
@@ -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] : []),
|
||||
],
|
||||
|
||||
45
apps/api/src/partners/partners.controller.ts
Normal file
45
apps/api/src/partners/partners.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/partners/partners.module.ts
Normal file
9
apps/api/src/partners/partners.module.ts
Normal 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 {}
|
||||
84
apps/api/src/partners/partners.service.ts
Normal file
84
apps/api/src/partners/partners.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
66
apps/api/src/parts/parts.controller.ts
Normal file
66
apps/api/src/parts/parts.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/parts/parts.module.ts
Normal file
10
apps/api/src/parts/parts.module.ts
Normal 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 {}
|
||||
231
apps/api/src/parts/parts.service.ts
Normal file
231
apps/api/src/parts/parts.service.ts
Normal 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 d’inventaire 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal file
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal file
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal 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 {}
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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