import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; import { auditRepo, inventoryRepo, productsRepo, type SerializedAsset, type StockMovement, type StockReservation, } from "@eden/database"; import type { CreateAssetDto } from "./dto/create-asset.dto.js"; import type { ReserveAssetDto } from "./dto/reserve-asset.dto.js"; import type { InstallAssetDto } from "./dto/install-asset.dto.js"; import type { ReturnAssetDto } from "./dto/return-asset.dto.js"; import type { CreateReservationDto } from "./dto/create-reservation.dto.js"; import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js"; const { AssetNotFoundError, InvalidAssetTransitionError, NoOpenAssignmentError, ReservationNotFoundError, InvalidReservationTransitionError, } = inventoryRepo; @Injectable() export class InventoryService { // --------------------------------------------------------------------- // serialized_assets // --------------------------------------------------------------------- listAssets(status?: string, warehouseId?: string, productId?: string): Promise { return inventoryRepo.listSerializedAssets({ status: status as SerializedAsset["status"] | undefined, warehouseId, productId, }); } async getAsset(id: string): Promise { const asset = await inventoryRepo.getSerializedAssetById(id); if (!asset) throw new NotFoundException("Ativo não encontrado"); return asset; } async createAsset(dto: CreateAssetDto, user: EdenAuthenticatedUser): Promise { if (!dto.serial && !dto.patrimonyTag) { throw new BadRequestException("Informe ao menos serial ou patrimonyTag"); } const product = await productsRepo.getProductById(dto.productId); if (!product) throw new BadRequestException(`Produto ${dto.productId} não encontrado`); const warehouse = await inventoryRepo.getWarehouseById(dto.warehouseId); if (!warehouse) throw new BadRequestException(`Armazém ${dto.warehouseId} não encontrado`); let result: Awaited>; try { result = await inventoryRepo.createAssetWithPurchaseMovement({ productId: dto.productId, warehouseId: dto.warehouseId, serial: dto.serial ?? null, patrimonyTag: dto.patrimonyTag ?? null, macAddress: dto.macAddress ?? null, warrantyExpiresAt: dto.warrantyExpiresAt ?? null, notes: dto.notes ?? null, createdBy: user.id, }); } catch (err) { // 23505 = unique_violation — the DB-enforced "serial/patrimony/MAC // never active twice" invariant (see migration). if (isUniqueViolation(err)) { throw new ConflictException( "Serial, patrimônio ou MAC já está em uso por outro ativo ativo", ); } throw err; } await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "inventory.asset_created", resourceType: "serialized_asset", resourceId: result.asset.id, metadata: { productId: dto.productId, warehouseId: dto.warehouseId, serial: dto.serial ?? null }, }); return result.asset; } async reserveAsset(id: string, dto: ReserveAssetDto, user: EdenAuthenticatedUser): Promise { try { const result = await inventoryRepo.reserveAsset(id, { referenceType: dto.referenceType ?? null, referenceId: dto.referenceId ?? null, notes: dto.notes ?? null, createdBy: user.id, }); await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "inventory.asset_reserved", resourceType: "serialized_asset", resourceId: id, metadata: { reservationId: result.reservation.id }, }); return result.asset; } catch (err) { if (err instanceof AssetNotFoundError) throw new NotFoundException("Ativo não encontrado"); if (err instanceof InvalidAssetTransitionError) { throw new ConflictException( `Ativo com status '${err.fromStatus}' não pode ser reservado (esperado: in_stock)`, ); } throw err; } } async installAsset(id: string, dto: InstallAssetDto, user: EdenAuthenticatedUser): Promise { try { const result = await inventoryRepo.installAsset(id, { contractId: dto.contractId, assignmentType: dto.assignmentType, notes: dto.notes ?? null, createdBy: user.id, }); await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "inventory.asset_installed", resourceType: "serialized_asset", resourceId: id, metadata: { contractId: dto.contractId, assignmentType: dto.assignmentType }, }); return result.asset; } catch (err) { if (err instanceof AssetNotFoundError) throw new NotFoundException("Ativo não encontrado"); if (err instanceof InvalidAssetTransitionError) { throw new ConflictException( `Ativo com status '${err.fromStatus}' não pode ser instalado/comodato (esperado: in_stock ou reserved)`, ); } throw err; } } async returnAsset(id: string, dto: ReturnAssetDto, user: EdenAuthenticatedUser): Promise { const warehouse = await inventoryRepo.getWarehouseById(dto.warehouseId); if (!warehouse) throw new BadRequestException(`Armazém ${dto.warehouseId} não encontrado`); try { const result = await inventoryRepo.returnAsset(id, { warehouseId: dto.warehouseId, notes: dto.notes ?? null, createdBy: user.id, }); await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "inventory.asset_returned", resourceType: "serialized_asset", resourceId: id, metadata: { warehouseId: dto.warehouseId }, }); return result.asset; } catch (err) { if (err instanceof AssetNotFoundError) throw new NotFoundException("Ativo não encontrado"); if (err instanceof InvalidAssetTransitionError) { throw new ConflictException( `Ativo com status '${err.fromStatus}' não pode ser devolvido (esperado: installed ou comodato)`, ); } if (err instanceof NoOpenAssignmentError) { throw new ConflictException("Ativo não tem vínculo aberto para encerrar"); } throw err; } } // --------------------------------------------------------------------- // stock_movements // --------------------------------------------------------------------- listMovements( warehouseId?: string, productId?: string, referenceType?: string, referenceId?: string, ): Promise { return inventoryRepo.listStockMovements({ warehouseId, productId, referenceType, referenceId }); } // --------------------------------------------------------------------- // stock_reservations // --------------------------------------------------------------------- listReservations(status?: string, warehouseId?: string, productId?: string): Promise { return inventoryRepo.listStockReservations({ status: status as StockReservation["status"] | undefined, warehouseId, productId, }); } async createReservation(dto: CreateReservationDto, user: EdenAuthenticatedUser): Promise { const product = await productsRepo.getProductById(dto.productId); if (!product) throw new BadRequestException(`Produto ${dto.productId} não encontrado`); const warehouse = await inventoryRepo.getWarehouseById(dto.warehouseId); if (!warehouse) throw new BadRequestException(`Armazém ${dto.warehouseId} não encontrado`); const result = await inventoryRepo.createGenericReservation({ warehouseId: dto.warehouseId, productId: dto.productId, quantity: dto.quantity, referenceType: dto.referenceType ?? null, referenceId: dto.referenceId ?? null, notes: dto.notes ?? null, createdBy: user.id, }); await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "inventory.reservation_created", resourceType: "stock_reservation", resourceId: result.reservation.id, metadata: { productId: dto.productId, warehouseId: dto.warehouseId, quantity: dto.quantity }, }); return result.reservation; } async releaseReservation(id: string, user: EdenAuthenticatedUser): Promise { try { const result = await inventoryRepo.releaseReservation(id, user.id); await auditRepo.appendAuditEvent({ actorType: "user", actorId: user.id, action: "inventory.reservation_released", resourceType: "stock_reservation", resourceId: id, metadata: {}, }); return result.reservation; } catch (err) { if (err instanceof ReservationNotFoundError) throw new NotFoundException("Reserva não encontrada"); if (err instanceof InvalidReservationTransitionError) { throw new ConflictException( `Reserva com status '${err.fromStatus}' não pode ser liberada (esperado: active)`, ); } throw err; } } } function isUniqueViolation(err: unknown): boolean { return typeof err === "object" && err !== null && (err as { code?: string }).code === "23505"; }