# ET-COMP-001: Especificación Técnica Backend - Compras e Inventarios **Épica:** MAI-004 - Compras e Inventarios **Versión:** 1.0 **Fecha:** 2025-12-06 **Stack:** NestJS 10+, TypeORM, WebSocket --- ## Índice 1. [Arquitectura Backend](#1-arquitectura-backend) 2. [Módulos NestJS](#2-módulos-nestjs) 3. [Entities y DTOs](#3-entities-y-dtos) 4. [Services y Business Logic](#4-services-y-business-logic) 5. [Controllers y Endpoints](#5-controllers-y-endpoints) 6. [Endpoints para App Móvil MOB-002](#6-endpoints-para-app-móvil-mob-002) 7. [WebSocket para Sincronización en Tiempo Real](#7-websocket-para-sincronización-en-tiempo-real) 8. [Flujos de Aprobación](#8-flujos-de-aprobación) 9. [Validaciones y Reglas de Negocio](#9-validaciones-y-reglas-de-negocio) 10. [Seguridad y Autenticación](#10-seguridad-y-autenticación) 11. [Testing](#11-testing) --- ## 1. Arquitectura Backend ### 1.1 Estructura de Módulos ``` src/ ├── modules/ │ ├── requisitions/ # Módulo de Requisiciones │ │ ├── dto/ │ │ ├── entities/ │ │ ├── services/ │ │ ├── controllers/ │ │ ├── repositories/ │ │ ├── events/ │ │ └── requisitions.module.ts │ │ │ ├── purchase-orders/ # Módulo de Órdenes de Compra │ │ ├── dto/ │ │ ├── entities/ │ │ ├── services/ │ │ ├── controllers/ │ │ ├── repositories/ │ │ ├── events/ │ │ └── purchase-orders.module.ts │ │ │ ├── warehouses/ # Módulo de Almacenes │ │ ├── dto/ │ │ ├── entities/ │ │ ├── services/ │ │ ├── controllers/ │ │ ├── repositories/ │ │ ├── events/ │ │ └── warehouses.module.ts │ │ │ ├── inventory/ # Módulo de Inventario │ │ ├── dto/ │ │ ├── entities/ │ │ ├── services/ │ │ ├── controllers/ │ │ ├── repositories/ │ │ ├── events/ │ │ └── inventory.module.ts │ │ │ └── shared/ # Servicios compartidos │ ├── approval-workflow/ │ ├── budget-validation/ │ ├── notification/ │ ├── pdf-generator/ │ └── websocket/ │ ├── common/ │ ├── decorators/ │ ├── filters/ │ ├── guards/ │ ├── interceptors/ │ ├── pipes/ │ └── utils/ │ └── config/ ├── database.config.ts ├── jwt.config.ts └── websocket.config.ts ``` ### 1.2 Stack Tecnológico ```yaml framework: NestJS 10+ language: TypeScript 5.3+ database: PostgreSQL 15+ orm: TypeORM 0.3+ validation: class-validator + class-transformer authentication: Passport + JWT authorization: CASL (RBAC) real_time: Socket.IO (WebSocket) notifications: EventEmitter2 + BullMQ files: Multer + AWS S3 / MinIO pdf: PDFKit / Puppeteer caching: Redis logging: Winston testing: Jest + Supertest documentation: Swagger / OpenAPI ``` --- ## 2. Módulos NestJS ### 2.1 RequisitionsModule ```typescript // src/modules/requisitions/requisitions.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { RequisitionsController } from './controllers/requisitions.controller'; import { RequisitionsService } from './services/requisitions.service'; import { RequisitionApprovalService } from './services/requisition-approval.service'; import { Requisition } from './entities/requisition.entity'; import { RequisitionItem } from './entities/requisition-item.entity'; import { RequisitionApproval } from './entities/requisition-approval.entity'; import { BudgetValidationModule } from '../shared/budget-validation/budget-validation.module'; import { NotificationModule } from '../shared/notification/notification.module'; import { ApprovalWorkflowModule } from '../shared/approval-workflow/approval-workflow.module'; @Module({ imports: [ TypeOrmModule.forFeature([ Requisition, RequisitionItem, RequisitionApproval, ]), BudgetValidationModule, NotificationModule, ApprovalWorkflowModule, ], controllers: [RequisitionsController], providers: [ RequisitionsService, RequisitionApprovalService, ], exports: [RequisitionsService], }) export class RequisitionsModule {} ``` ### 2.2 PurchaseOrdersModule ```typescript // src/modules/purchase-orders/purchase-orders.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PurchaseOrdersController } from './controllers/purchase-orders.controller'; import { PurchaseOrdersService } from './services/purchase-orders.service'; import { PurchaseOrderReceiptsService } from './services/purchase-order-receipts.service'; import { PurchaseOrder } from './entities/purchase-order.entity'; import { PurchaseOrderItem } from './entities/purchase-order-item.entity'; import { PurchaseOrderReceipt } from './entities/purchase-order-receipt.entity'; import { PdfGeneratorModule } from '../shared/pdf-generator/pdf-generator.module'; import { NotificationModule } from '../shared/notification/notification.module'; import { InventoryModule } from '../inventory/inventory.module'; @Module({ imports: [ TypeOrmModule.forFeature([ PurchaseOrder, PurchaseOrderItem, PurchaseOrderReceipt, ]), PdfGeneratorModule, NotificationModule, InventoryModule, ], controllers: [PurchaseOrdersController], providers: [ PurchaseOrdersService, PurchaseOrderReceiptsService, ], exports: [PurchaseOrdersService], }) export class PurchaseOrdersModule {} ``` ### 2.3 WarehousesModule ```typescript // src/modules/warehouses/warehouses.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { WarehousesController } from './controllers/warehouses.controller'; import { WarehousesService } from './services/warehouses.service'; import { WarehouseLocationsService } from './services/warehouse-locations.service'; import { StockLevelsService } from './services/stock-levels.service'; import { Warehouse } from './entities/warehouse.entity'; import { WarehouseLocation } from './entities/warehouse-location.entity'; import { StockLevel } from './entities/stock-level.entity'; import { NotificationModule } from '../shared/notification/notification.module'; @Module({ imports: [ TypeOrmModule.forFeature([ Warehouse, WarehouseLocation, StockLevel, ]), NotificationModule, ], controllers: [WarehousesController], providers: [ WarehousesService, WarehouseLocationsService, StockLevelsService, ], exports: [ WarehousesService, StockLevelsService, ], }) export class WarehousesModule {} ``` ### 2.4 InventoryModule ```typescript // src/modules/inventory/inventory.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { InventoryController } from './controllers/inventory.controller'; import { InventoryMovementsController } from './controllers/inventory-movements.controller'; import { KardexController } from './controllers/kardex.controller'; import { InventoryService } from './services/inventory.service'; import { InventoryMovementsService } from './services/inventory-movements.service'; import { KardexService } from './services/kardex.service'; import { StockTransfersService } from './services/stock-transfers.service'; import { StockAdjustmentsService } from './services/stock-adjustments.service'; import { CostCalculationService } from './services/cost-calculation.service'; import { InventoryStock } from './entities/inventory-stock.entity'; import { InventoryMovement } from './entities/inventory-movement.entity'; import { StockTransfer } from './entities/stock-transfer.entity'; import { StockAdjustment } from './entities/stock-adjustment.entity'; import { Kardex } from './entities/kardex.entity'; import { WarehousesModule } from '../warehouses/warehouses.module'; import { NotificationModule } from '../shared/notification/notification.module'; import { WebSocketModule } from '../shared/websocket/websocket.module'; @Module({ imports: [ TypeOrmModule.forFeature([ InventoryStock, InventoryMovement, StockTransfer, StockAdjustment, Kardex, ]), WarehousesModule, NotificationModule, WebSocketModule, ], controllers: [ InventoryController, InventoryMovementsController, KardexController, ], providers: [ InventoryService, InventoryMovementsService, KardexService, StockTransfersService, StockAdjustmentsService, CostCalculationService, ], exports: [ InventoryService, InventoryMovementsService, KardexService, ], }) export class InventoryModule {} ``` --- ## 3. Entities y DTOs ### 3.1 Requisition Entity ```typescript // src/modules/requisitions/entities/requisition.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn, CreateDateColumn, UpdateDateColumn, } from 'typeorm'; import { User } from '../../auth/entities/user.entity'; import { Project } from '../../projects/entities/project.entity'; import { Constructora } from '../../tenants/entities/constructora.entity'; import { RequisitionItem } from './requisition-item.entity'; import { RequisitionApproval } from './requisition-approval.entity'; export enum RequisitionStatus { DRAFT = 'draft', PENDING = 'pending', APPROVED = 'approved', REJECTED = 'rejected', CONVERTED = 'converted', } export enum RequisitionUrgency { NORMAL = 'normal', URGENT = 'urgent', } @Entity('requisitions', { schema: 'purchasing_management' }) export class Requisition { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'varchar', length: 20, unique: true }) code: string; @Column({ name: 'constructora_id' }) constructoraId: string; @ManyToOne(() => Constructora) @JoinColumn({ name: 'constructora_id' }) constructora: Constructora; @Column({ name: 'project_id' }) projectId: string; @ManyToOne(() => Project) @JoinColumn({ name: 'project_id' }) project: Project; @Column({ name: 'requested_by' }) requestedBy: string; @ManyToOne(() => User) @JoinColumn({ name: 'requested_by' }) requester: User; @Column({ type: 'date', name: 'required_date' }) requiredDate: Date; @Column({ type: 'enum', enum: RequisitionUrgency, default: RequisitionUrgency.NORMAL, }) urgency: RequisitionUrgency; @Column({ type: 'text', nullable: true }) justification: string; @Column({ type: 'decimal', precision: 15, scale: 2, name: 'estimated_total' }) estimatedTotal: number; @Column({ type: 'enum', enum: RequisitionStatus, default: RequisitionStatus.DRAFT, }) status: RequisitionStatus; @Column({ type: 'text', nullable: true, name: 'rejected_reason' }) rejectedReason: string; @Column({ type: 'uuid', nullable: true, name: 'converted_to_po_id' }) convertedToPOId: string; @OneToMany(() => RequisitionItem, (item) => item.requisition, { cascade: true, }) items: RequisitionItem[]; @OneToMany(() => RequisitionApproval, (approval) => approval.requisition, { cascade: true, }) approvals: RequisitionApproval[]; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` ### 3.2 Requisition Item Entity ```typescript // src/modules/requisitions/entities/requisition-item.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, } from 'typeorm'; import { Requisition } from './requisition.entity'; import { Material } from '../../catalogs/entities/material.entity'; import { BudgetItem } from '../../budgets/entities/budget-item.entity'; @Entity('requisition_items', { schema: 'purchasing_management' }) export class RequisitionItem { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'requisition_id' }) requisitionId: string; @ManyToOne(() => Requisition, (requisition) => requisition.items, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'requisition_id' }) requisition: Requisition; @Column({ name: 'material_id' }) materialId: string; @ManyToOne(() => Material) @JoinColumn({ name: 'material_id' }) material: Material; @Column({ type: 'varchar', length: 255 }) description: string; @Column({ type: 'decimal', precision: 15, scale: 4 }) quantity: number; @Column({ type: 'varchar', length: 20 }) unit: string; @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true, name: 'budgeted_price', }) budgetedPrice: number; @Column({ name: 'budget_item_id', nullable: true }) budgetItemId: string; @ManyToOne(() => BudgetItem, { nullable: true }) @JoinColumn({ name: 'budget_item_id' }) budgetItem: BudgetItem; @Column({ type: 'text', nullable: true }) notes: string; } ``` ### 3.3 Requisition Approval Entity ```typescript // src/modules/requisitions/entities/requisition-approval.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, } from 'typeorm'; import { Requisition } from './requisition.entity'; import { User } from '../../auth/entities/user.entity'; export enum ApprovalStatus { PENDING = 'pending', APPROVED = 'approved', REJECTED = 'rejected', } @Entity('requisition_approvals', { schema: 'purchasing_management' }) export class RequisitionApproval { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'requisition_id' }) requisitionId: string; @ManyToOne(() => Requisition, (requisition) => requisition.approvals, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'requisition_id' }) requisition: Requisition; @Column({ type: 'int' }) level: number; @Column({ type: 'varchar', length: 50, name: 'approver_role' }) approverRole: string; @Column({ name: 'approver_id' }) approverId: string; @ManyToOne(() => User) @JoinColumn({ name: 'approver_id' }) approver: User; @Column({ type: 'enum', enum: ApprovalStatus, default: ApprovalStatus.PENDING, }) status: ApprovalStatus; @Column({ type: 'text', nullable: true }) comments: string; @Column({ type: 'timestamp', nullable: true, name: 'approved_at' }) approvedAt: Date; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } ``` ### 3.4 Purchase Order Entity ```typescript // src/modules/purchase-orders/entities/purchase-order.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn, CreateDateColumn, UpdateDateColumn, } from 'typeorm'; import { Supplier } from '../../suppliers/entities/supplier.entity'; import { Project } from '../../projects/entities/project.entity'; import { Constructora } from '../../tenants/entities/constructora.entity'; import { Requisition } from '../../requisitions/entities/requisition.entity'; import { User } from '../../auth/entities/user.entity'; import { PurchaseOrderItem } from './purchase-order-item.entity'; import { PurchaseOrderReceipt } from './purchase-order-receipt.entity'; export enum PurchaseOrderStatus { DRAFT = 'draft', PENDING = 'pending', APPROVED = 'approved', SENT = 'sent', PARTIALLY_RECEIVED = 'partially_received', RECEIVED = 'received', CANCELLED = 'cancelled', } @Entity('purchase_orders', { schema: 'purchasing_management' }) export class PurchaseOrder { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'varchar', length: 20, unique: true }) code: string; @Column({ name: 'constructora_id' }) constructoraId: string; @ManyToOne(() => Constructora) @JoinColumn({ name: 'constructora_id' }) constructora: Constructora; @Column({ name: 'supplier_id' }) supplierId: string; @ManyToOne(() => Supplier) @JoinColumn({ name: 'supplier_id' }) supplier: Supplier; @Column({ name: 'project_id' }) projectId: string; @ManyToOne(() => Project) @JoinColumn({ name: 'project_id' }) project: Project; @Column({ name: 'requisition_id', nullable: true }) requisitionId: string; @ManyToOne(() => Requisition, { nullable: true }) @JoinColumn({ name: 'requisition_id' }) requisition: Requisition; @Column({ type: 'date', name: 'order_date' }) orderDate: Date; @Column({ type: 'date', name: 'delivery_date' }) deliveryDate: Date; @Column({ type: 'text', name: 'delivery_address' }) deliveryAddress: string; @Column({ type: 'decimal', precision: 15, scale: 2 }) subtotal: number; @Column({ type: 'decimal', precision: 15, scale: 2 }) tax: number; @Column({ type: 'decimal', precision: 15, scale: 2 }) total: number; @Column({ type: 'varchar', length: 50, nullable: true, name: 'payment_terms' }) paymentTerms: string; @Column({ type: 'int', default: 30, name: 'payment_terms_days' }) paymentTermsDays: number; @Column({ type: 'decimal', precision: 5, scale: 2, default: 0, name: 'early_payment_discount', }) earlyPaymentDiscount: number; @Column({ type: 'boolean', default: false, name: 'requires_advance' }) requiresAdvance: boolean; @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true, name: 'advance_percentage', }) advancePercentage: number; @Column({ type: 'boolean', default: true, name: 'includes_unloading' }) includesUnloading: boolean; @Column({ type: 'int', default: 30, name: 'warranty_days' }) warrantyDays: number; @Column({ type: 'text', nullable: true, name: 'special_conditions' }) specialConditions: string; @Column({ type: 'enum', enum: PurchaseOrderStatus, default: PurchaseOrderStatus.DRAFT, }) status: PurchaseOrderStatus; @Column({ name: 'approved_by', nullable: true }) approvedBy: string; @ManyToOne(() => User, { nullable: true }) @JoinColumn({ name: 'approved_by' }) approver: User; @Column({ type: 'timestamp', nullable: true, name: 'approved_at' }) approvedAt: Date; @Column({ type: 'timestamp', nullable: true, name: 'sent_to_supplier_at' }) sentToSupplierAt: Date; @OneToMany(() => PurchaseOrderItem, (item) => item.purchaseOrder, { cascade: true, }) items: PurchaseOrderItem[]; @OneToMany(() => PurchaseOrderReceipt, (receipt) => receipt.purchaseOrder) receipts: PurchaseOrderReceipt[]; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` ### 3.5 Warehouse Entity ```typescript // src/modules/warehouses/entities/warehouse.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn, CreateDateColumn, UpdateDateColumn, } from 'typeorm'; import { Project } from '../../projects/entities/project.entity'; import { Constructora } from '../../tenants/entities/constructora.entity'; import { User } from '../../auth/entities/user.entity'; import { WarehouseLocation } from './warehouse-location.entity'; import { InventoryStock } from '../../inventory/entities/inventory-stock.entity'; export enum WarehouseType { MAIN = 'main', SECONDARY = 'secondary', TEMPORARY = 'temporary', } @Entity('warehouses', { schema: 'inventory_management' }) export class Warehouse { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'varchar', length: 20, unique: true }) code: string; @Column({ type: 'varchar', length: 255 }) name: string; @Column({ name: 'constructora_id' }) constructoraId: string; @ManyToOne(() => Constructora) @JoinColumn({ name: 'constructora_id' }) constructora: Constructora; @Column({ name: 'project_id' }) projectId: string; @ManyToOne(() => Project) @JoinColumn({ name: 'project_id' }) project: Project; @Column({ type: 'enum', enum: WarehouseType, default: WarehouseType.MAIN, }) type: WarehouseType; @Column({ type: 'text', nullable: true }) description: string; @Column({ type: 'text', nullable: true }) address: string; @Column({ name: 'responsible_id', nullable: true }) responsibleId: string; @ManyToOne(() => User, { nullable: true }) @JoinColumn({ name: 'responsible_id' }) responsible: User; @Column({ type: 'boolean', default: true }) active: boolean; @OneToMany(() => WarehouseLocation, (location) => location.warehouse, { cascade: true, }) locations: WarehouseLocation[]; @OneToMany(() => InventoryStock, (stock) => stock.warehouse) stocks: InventoryStock[]; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` ### 3.6 Inventory Stock Entity ```typescript // src/modules/inventory/entities/inventory-stock.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, } from 'typeorm'; import { Warehouse } from '../../warehouses/entities/warehouse.entity'; import { Material } from '../../catalogs/entities/material.entity'; @Entity('inventory_stock', { schema: 'inventory_management' }) export class InventoryStock { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'warehouse_id' }) warehouseId: string; @ManyToOne(() => Warehouse) @JoinColumn({ name: 'warehouse_id' }) warehouse: Warehouse; @Column({ name: 'material_id' }) materialId: string; @ManyToOne(() => Material) @JoinColumn({ name: 'material_id' }) material: Material; @Column({ type: 'decimal', precision: 15, scale: 4, default: 0 }) quantity: number; @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'unit_cost' }) unitCost: number; @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'total_value' }) totalValue: number; @Column({ type: 'decimal', precision: 15, scale: 4, nullable: true, name: 'min_level' }) minLevel: number; @Column({ type: 'decimal', precision: 15, scale: 4, nullable: true, name: 'max_level' }) maxLevel: number; @Column({ type: 'decimal', precision: 15, scale: 4, nullable: true, name: 'reorder_point' }) reorderPoint: number; @Column({ type: 'varchar', length: 50, nullable: true, name: 'last_lot' }) lastLot: string; @Column({ type: 'timestamp', nullable: true, name: 'last_movement_at' }) lastMovementAt: Date; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` ### 3.7 Inventory Movement Entity ```typescript // src/modules/inventory/entities/inventory-movement.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, } from 'typeorm'; import { Warehouse } from '../../warehouses/entities/warehouse.entity'; import { Material } from '../../catalogs/entities/material.entity'; import { User } from '../../auth/entities/user.entity'; import { PurchaseOrderReceipt } from '../../purchase-orders/entities/purchase-order-receipt.entity'; import { BudgetItem } from '../../budgets/entities/budget-item.entity'; export enum MovementType { ENTRY = 'entry', EXIT = 'exit', TRANSFER_OUT = 'transfer_out', TRANSFER_IN = 'transfer_in', ADJUSTMENT = 'adjustment', } @Entity('inventory_movements', { schema: 'inventory_management' }) export class InventoryMovement { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'varchar', length: 20, unique: true }) code: string; @Column({ name: 'warehouse_id' }) warehouseId: string; @ManyToOne(() => Warehouse) @JoinColumn({ name: 'warehouse_id' }) warehouse: Warehouse; @Column({ name: 'material_id' }) materialId: string; @ManyToOne(() => Material) @JoinColumn({ name: 'material_id' }) material: Material; @Column({ type: 'enum', enum: MovementType, }) type: MovementType; @Column({ type: 'decimal', precision: 15, scale: 4 }) quantity: number; @Column({ type: 'decimal', precision: 15, scale: 2, name: 'unit_cost' }) unitCost: number; @Column({ type: 'decimal', precision: 15, scale: 2, name: 'total_cost' }) totalCost: number; @Column({ type: 'varchar', length: 50, nullable: true }) lot: string; @Column({ type: 'date', nullable: true, name: 'expiry_date' }) expiryDate: Date; @Column({ type: 'date', name: 'movement_date' }) movementDate: Date; @Column({ name: 'created_by' }) createdBy: string; @ManyToOne(() => User) @JoinColumn({ name: 'created_by' }) creator: User; // Referencias opcionales según tipo de movimiento @Column({ name: 'receipt_id', nullable: true }) receiptId: string; @ManyToOne(() => PurchaseOrderReceipt, { nullable: true }) @JoinColumn({ name: 'receipt_id' }) receipt: PurchaseOrderReceipt; @Column({ name: 'budget_item_id', nullable: true }) budgetItemId: string; @ManyToOne(() => BudgetItem, { nullable: true }) @JoinColumn({ name: 'budget_item_id' }) budgetItem: BudgetItem; @Column({ name: 'transfer_id', nullable: true }) transferId: string; @Column({ type: 'text', nullable: true }) notes: string; @Column({ type: 'varchar', length: 255, nullable: true, name: 'signature_url' }) signatureUrl: string; @Column({ type: 'simple-array', nullable: true, name: 'photo_urls' }) photoUrls: string[]; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } ``` ### 3.8 DTOs - Create Requisition ```typescript // src/modules/requisitions/dto/create-requisition.dto.ts import { IsString, IsUUID, IsDate, IsEnum, IsOptional, IsArray, ValidateNested, IsNumber, Min } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { RequisitionUrgency } from '../entities/requisition.entity'; export class CreateRequisitionItemDto { @ApiProperty({ description: 'ID del material' }) @IsUUID() materialId: string; @ApiProperty({ description: 'Descripción del material' }) @IsString() description: string; @ApiProperty({ description: 'Cantidad solicitada' }) @IsNumber() @Min(0.0001) quantity: number; @ApiProperty({ description: 'Unidad de medida' }) @IsString() unit: string; @ApiProperty({ description: 'Precio presupuestado', required: false }) @IsOptional() @IsNumber() budgetedPrice?: number; @ApiProperty({ description: 'ID de partida presupuestal', required: false }) @IsOptional() @IsUUID() budgetItemId?: string; @ApiProperty({ description: 'Notas adicionales', required: false }) @IsOptional() @IsString() notes?: string; } export class CreateRequisitionDto { @ApiProperty({ description: 'ID del proyecto/obra' }) @IsUUID() projectId: string; @ApiProperty({ description: 'Fecha requerida de entrega' }) @IsDate() @Type(() => Date) requiredDate: Date; @ApiProperty({ description: 'Nivel de urgencia', enum: RequisitionUrgency, default: RequisitionUrgency.NORMAL }) @IsEnum(RequisitionUrgency) @IsOptional() urgency?: RequisitionUrgency; @ApiProperty({ description: 'Justificación de la requisición' }) @IsString() justification: string; @ApiProperty({ description: 'Items de la requisición', type: [CreateRequisitionItemDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => CreateRequisitionItemDto) items: CreateRequisitionItemDto[]; } ``` ### 3.9 DTOs - Create Purchase Order ```typescript // src/modules/purchase-orders/dto/create-purchase-order.dto.ts import { IsString, IsUUID, IsDate, IsOptional, IsArray, ValidateNested, IsNumber, IsBoolean, Min } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; export class CreatePurchaseOrderItemDto { @ApiProperty({ description: 'ID del material' }) @IsUUID() materialId: string; @ApiProperty({ description: 'Descripción del material' }) @IsString() description: string; @ApiProperty({ description: 'Cantidad a comprar' }) @IsNumber() @Min(0.0001) quantity: number; @ApiProperty({ description: 'Unidad de medida' }) @IsString() unit: string; @ApiProperty({ description: 'Precio unitario' }) @IsNumber() @Min(0) unitPrice: number; @ApiProperty({ description: 'ID de partida presupuestal', required: false }) @IsOptional() @IsUUID() budgetItemId?: string; } export class CreatePurchaseOrderDto { @ApiProperty({ description: 'ID del proveedor' }) @IsUUID() supplierId: string; @ApiProperty({ description: 'ID del proyecto/obra' }) @IsUUID() projectId: string; @ApiProperty({ description: 'ID de requisición (si aplica)', required: false }) @IsOptional() @IsUUID() requisitionId?: string; @ApiProperty({ description: 'Fecha de la orden' }) @IsDate() @Type(() => Date) orderDate: Date; @ApiProperty({ description: 'Fecha de entrega esperada' }) @IsDate() @Type(() => Date) deliveryDate: Date; @ApiProperty({ description: 'Dirección de entrega' }) @IsString() deliveryAddress: string; @ApiProperty({ description: 'Términos de pago', required: false }) @IsOptional() @IsString() paymentTerms?: string; @ApiProperty({ description: 'Días de crédito', default: 30 }) @IsOptional() @IsNumber() paymentTermsDays?: number; @ApiProperty({ description: 'Descuento por pronto pago (%)', required: false }) @IsOptional() @IsNumber() earlyPaymentDiscount?: number; @ApiProperty({ description: 'Requiere anticipo', default: false }) @IsOptional() @IsBoolean() requiresAdvance?: boolean; @ApiProperty({ description: 'Porcentaje de anticipo', required: false }) @IsOptional() @IsNumber() advancePercentage?: number; @ApiProperty({ description: 'Incluye descarga', default: true }) @IsOptional() @IsBoolean() includesUnloading?: boolean; @ApiProperty({ description: 'Días de garantía', default: 30 }) @IsOptional() @IsNumber() warrantyDays?: number; @ApiProperty({ description: 'Condiciones especiales', required: false }) @IsOptional() @IsString() specialConditions?: string; @ApiProperty({ description: 'Items de la orden', type: [CreatePurchaseOrderItemDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => CreatePurchaseOrderItemDto) items: CreatePurchaseOrderItemDto[]; } ``` ### 3.10 DTOs - Create Inventory Movement ```typescript // src/modules/inventory/dto/create-inventory-movement.dto.ts import { IsString, IsUUID, IsDate, IsEnum, IsOptional, IsNumber, IsArray, Min } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { MovementType } from '../entities/inventory-movement.entity'; export class CreateInventoryMovementDto { @ApiProperty({ description: 'ID del almacén' }) @IsUUID() warehouseId: string; @ApiProperty({ description: 'ID del material' }) @IsUUID() materialId: string; @ApiProperty({ description: 'Tipo de movimiento', enum: MovementType }) @IsEnum(MovementType) type: MovementType; @ApiProperty({ description: 'Cantidad del movimiento' }) @IsNumber() @Min(0.0001) quantity: number; @ApiProperty({ description: 'Costo unitario' }) @IsNumber() @Min(0) unitCost: number; @ApiProperty({ description: 'Número de lote', required: false }) @IsOptional() @IsString() lot?: string; @ApiProperty({ description: 'Fecha de vencimiento', required: false }) @IsOptional() @IsDate() @Type(() => Date) expiryDate?: Date; @ApiProperty({ description: 'Fecha del movimiento' }) @IsDate() @Type(() => Date) movementDate: Date; @ApiProperty({ description: 'ID de recepción (para entradas)', required: false }) @IsOptional() @IsUUID() receiptId?: string; @ApiProperty({ description: 'ID de partida presupuestal (para salidas)', required: false }) @IsOptional() @IsUUID() budgetItemId?: string; @ApiProperty({ description: 'ID de transferencia', required: false }) @IsOptional() @IsUUID() transferId?: string; @ApiProperty({ description: 'Notas del movimiento', required: false }) @IsOptional() @IsString() notes?: string; @ApiProperty({ description: 'URL de firma digital', required: false }) @IsOptional() @IsString() signatureUrl?: string; @ApiProperty({ description: 'URLs de fotos de evidencia', type: [String], required: false }) @IsOptional() @IsArray() @IsString({ each: true }) photoUrls?: string[]; } ``` --- ## 4. Services y Business Logic ### 4.1 RequisitionsService ```typescript // src/modules/requisitions/services/requisitions.service.ts import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Requisition, RequisitionStatus } from '../entities/requisition.entity'; import { RequisitionItem } from '../entities/requisition-item.entity'; import { CreateRequisitionDto } from '../dto/create-requisition.dto'; import { UpdateRequisitionDto } from '../dto/update-requisition.dto'; import { BudgetValidationService } from '../../shared/budget-validation/budget-validation.service'; import { NotificationService } from '../../shared/notification/notification.service'; import { RequisitionApprovalService } from './requisition-approval.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class RequisitionsService { constructor( @InjectRepository(Requisition) private requisitionsRepository: Repository, @InjectRepository(RequisitionItem) private requisitionItemsRepository: Repository, private budgetValidationService: BudgetValidationService, private notificationService: NotificationService, private approvalService: RequisitionApprovalService, private eventEmitter: EventEmitter2, ) {} async create( createRequisitionDto: CreateRequisitionDto, userId: string, constructoraId: string, ): Promise { // Generar código único const code = await this.generateRequisitionCode(constructoraId); // Calcular total estimado const estimatedTotal = createRequisitionDto.items.reduce( (sum, item) => sum + (item.budgetedPrice || 0) * item.quantity, 0, ); // Crear requisición const requisition = this.requisitionsRepository.create({ code, constructoraId, projectId: createRequisitionDto.projectId, requestedBy: userId, requiredDate: createRequisitionDto.requiredDate, urgency: createRequisitionDto.urgency, justification: createRequisitionDto.justification, estimatedTotal, status: RequisitionStatus.DRAFT, }); // Guardar requisición const savedRequisition = await this.requisitionsRepository.save(requisition); // Crear items const items = createRequisitionDto.items.map((itemDto) => this.requisitionItemsRepository.create({ requisitionId: savedRequisition.id, ...itemDto, }), ); await this.requisitionItemsRepository.save(items); // Emitir evento this.eventEmitter.emit('requisition.created', savedRequisition); return this.findOne(savedRequisition.id); } async submit(id: string, userId: string): Promise { const requisition = await this.findOne(id); if (requisition.status !== RequisitionStatus.DRAFT) { throw new BadRequestException('Solo se pueden enviar requisiciones en borrador'); } // Validar presupuesto disponible para cada item const items = await this.requisitionItemsRepository.find({ where: { requisitionId: id }, relations: ['budgetItem'], }); for (const item of items) { if (item.budgetItemId) { const isValid = await this.budgetValidationService.validateBudgetAvailability( item.budgetItemId, item.quantity * (item.budgetedPrice || 0), ); if (!isValid) { throw new BadRequestException( `No hay presupuesto disponible para: ${item.description}`, ); } } } // Cambiar estado a pending requisition.status = RequisitionStatus.PENDING; await this.requisitionsRepository.save(requisition); // Iniciar flujo de aprobación await this.approvalService.initiate(requisition.id, userId); // Notificar a aprobadores await this.notificationService.notifyApprovers(requisition); // Emitir evento this.eventEmitter.emit('requisition.submitted', requisition); return this.findOne(id); } async approve(id: string, userId: string, comments?: string): Promise { const requisition = await this.findOne(id); if (requisition.status !== RequisitionStatus.PENDING) { throw new BadRequestException('La requisición no está pendiente de aprobación'); } // Procesar aprobación const isFullyApproved = await this.approvalService.approve( requisition.id, userId, comments, ); if (isFullyApproved) { requisition.status = RequisitionStatus.APPROVED; await this.requisitionsRepository.save(requisition); // Notificar al solicitante await this.notificationService.notifyRequisitionApproved(requisition); // Emitir evento this.eventEmitter.emit('requisition.approved', requisition); } return this.findOne(id); } async reject(id: string, userId: string, reason: string): Promise { const requisition = await this.findOne(id); if (requisition.status !== RequisitionStatus.PENDING) { throw new BadRequestException('La requisición no está pendiente de aprobación'); } // Procesar rechazo await this.approvalService.reject(requisition.id, userId, reason); requisition.status = RequisitionStatus.REJECTED; requisition.rejectedReason = reason; await this.requisitionsRepository.save(requisition); // Notificar al solicitante await this.notificationService.notifyRequisitionRejected(requisition, reason); // Emitir evento this.eventEmitter.emit('requisition.rejected', requisition); return this.findOne(id); } async convertToPurchaseOrder(id: string, poId: string): Promise { const requisition = await this.findOne(id); if (requisition.status !== RequisitionStatus.APPROVED) { throw new BadRequestException('Solo se pueden convertir requisiciones aprobadas'); } requisition.status = RequisitionStatus.CONVERTED; requisition.convertedToPOId = poId; await this.requisitionsRepository.save(requisition); // Emitir evento this.eventEmitter.emit('requisition.converted', { requisition, poId }); return this.findOne(id); } async findAll(constructoraId: string, filters?: any): Promise { const query = this.requisitionsRepository .createQueryBuilder('req') .where('req.constructoraId = :constructoraId', { constructoraId }) .leftJoinAndSelect('req.project', 'project') .leftJoinAndSelect('req.requester', 'requester') .leftJoinAndSelect('req.items', 'items') .leftJoinAndSelect('req.approvals', 'approvals') .orderBy('req.createdAt', 'DESC'); if (filters?.projectId) { query.andWhere('req.projectId = :projectId', { projectId: filters.projectId }); } if (filters?.status) { query.andWhere('req.status = :status', { status: filters.status }); } if (filters?.requestedBy) { query.andWhere('req.requestedBy = :requestedBy', { requestedBy: filters.requestedBy }); } return query.getMany(); } async findOne(id: string): Promise { const requisition = await this.requisitionsRepository.findOne({ where: { id }, relations: ['project', 'requester', 'items', 'items.material', 'items.budgetItem', 'approvals', 'approvals.approver'], }); if (!requisition) { throw new NotFoundException(`Requisición con ID ${id} no encontrada`); } return requisition; } async update(id: string, updateRequisitionDto: UpdateRequisitionDto): Promise { const requisition = await this.findOne(id); if (requisition.status !== RequisitionStatus.DRAFT) { throw new BadRequestException('Solo se pueden editar requisiciones en borrador'); } Object.assign(requisition, updateRequisitionDto); await this.requisitionsRepository.save(requisition); return this.findOne(id); } async remove(id: string): Promise { const requisition = await this.findOne(id); if (requisition.status !== RequisitionStatus.DRAFT) { throw new BadRequestException('Solo se pueden eliminar requisiciones en borrador'); } await this.requisitionsRepository.remove(requisition); } private async generateRequisitionCode(constructoraId: string): Promise { const year = new Date().getFullYear(); const prefix = `REQ-${year}`; const lastRequisition = await this.requisitionsRepository .createQueryBuilder('req') .where('req.constructoraId = :constructoraId', { constructoraId }) .andWhere('req.code LIKE :prefix', { prefix: `${prefix}%` }) .orderBy('req.createdAt', 'DESC') .getOne(); let sequence = 1; if (lastRequisition) { const lastSequence = parseInt(lastRequisition.code.split('-').pop() || '0'); sequence = lastSequence + 1; } return `${prefix}-${sequence.toString().padStart(5, '0')}`; } } ``` ### 4.2 InventoryService ```typescript // src/modules/inventory/services/inventory.service.ts import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { InventoryStock } from '../entities/inventory-stock.entity'; import { InventoryMovement, MovementType } from '../entities/inventory-movement.entity'; import { CreateInventoryMovementDto } from '../dto/create-inventory-movement.dto'; import { CostCalculationService } from './cost-calculation.service'; import { KardexService } from './kardex.service'; import { NotificationService } from '../../shared/notification/notification.service'; import { WebSocketGateway } from '../../shared/websocket/websocket.gateway'; import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class InventoryService { constructor( @InjectRepository(InventoryStock) private stockRepository: Repository, @InjectRepository(InventoryMovement) private movementsRepository: Repository, private costCalculationService: CostCalculationService, private kardexService: KardexService, private notificationService: NotificationService, private webSocketGateway: WebSocketGateway, private eventEmitter: EventEmitter2, ) {} async getStock(warehouseId: string, materialId: string): Promise { let stock = await this.stockRepository.findOne({ where: { warehouseId, materialId }, relations: ['warehouse', 'material'], }); if (!stock) { // Crear registro de stock si no existe stock = this.stockRepository.create({ warehouseId, materialId, quantity: 0, unitCost: 0, totalValue: 0, }); stock = await this.stockRepository.save(stock); } return stock; } async createMovement( createMovementDto: CreateInventoryMovementDto, userId: string, ): Promise { const { warehouseId, materialId, type, quantity, unitCost } = createMovementDto; // Validar existencia de stock para salidas if (type === MovementType.EXIT || type === MovementType.TRANSFER_OUT) { const stock = await this.getStock(warehouseId, materialId); if (stock.quantity < quantity) { throw new BadRequestException( `Stock insuficiente. Disponible: ${stock.quantity}, Solicitado: ${quantity}`, ); } } // Generar código único const code = await this.generateMovementCode(type); // Calcular costo total const totalCost = quantity * unitCost; // Crear movimiento const movement = this.movementsRepository.create({ ...createMovementDto, code, totalCost, createdBy: userId, }); const savedMovement = await this.movementsRepository.save(movement); // Actualizar stock await this.updateStock(warehouseId, materialId, type, quantity, unitCost); // Registrar en kardex await this.kardexService.registerMovement(savedMovement); // Verificar niveles mínimos y enviar alertas await this.checkStockLevels(warehouseId, materialId); // Notificar en tiempo real vía WebSocket this.webSocketGateway.notifyStockUpdate(warehouseId, materialId); // Emitir evento this.eventEmitter.emit('inventory.movement.created', savedMovement); return this.findMovement(savedMovement.id); } async updateStock( warehouseId: string, materialId: string, movementType: MovementType, quantity: number, unitCost: number, ): Promise { const stock = await this.getStock(warehouseId, materialId); let newQuantity = stock.quantity; let newUnitCost = stock.unitCost; let newTotalValue = stock.totalValue; switch (movementType) { case MovementType.ENTRY: case MovementType.TRANSFER_IN: // Entrada: incrementar stock y recalcular costo promedio ponderado newTotalValue = stock.totalValue + quantity * unitCost; newQuantity = stock.quantity + quantity; newUnitCost = newQuantity > 0 ? newTotalValue / newQuantity : 0; break; case MovementType.EXIT: case MovementType.TRANSFER_OUT: // Salida: decrementar stock usando costo actual newQuantity = stock.quantity - quantity; newTotalValue = newQuantity * stock.unitCost; newUnitCost = stock.unitCost; // El costo unitario no cambia en salidas break; case MovementType.ADJUSTMENT: // Ajuste: puede ser positivo o negativo const adjustment = quantity; // Puede ser negativo newQuantity = stock.quantity + adjustment; if (newQuantity < 0) { throw new BadRequestException('El ajuste resultaría en stock negativo'); } // Recalcular valor total manteniendo costo unitario newTotalValue = newQuantity * stock.unitCost; break; } // Actualizar stock stock.quantity = newQuantity; stock.unitCost = newUnitCost; stock.totalValue = newTotalValue; stock.lastMovementAt = new Date(); if (movementType === MovementType.ENTRY) { stock.lastLot = await this.getLastLot(warehouseId, materialId); } await this.stockRepository.save(stock); } async checkStockLevels(warehouseId: string, materialId: string): Promise { const stock = await this.getStock(warehouseId, materialId); // Verificar nivel mínimo if (stock.minLevel && stock.quantity <= stock.minLevel) { await this.notificationService.notifyLowStock(stock); this.eventEmitter.emit('inventory.low.stock', stock); } // Verificar punto de reorden if (stock.reorderPoint && stock.quantity <= stock.reorderPoint) { await this.notificationService.notifyReorderPoint(stock); this.eventEmitter.emit('inventory.reorder.point', stock); } } async getWarehouseStock(warehouseId: string): Promise { return this.stockRepository.find({ where: { warehouseId }, relations: ['material', 'warehouse'], order: { material: { name: 'ASC' } }, }); } async getLowStockItems(warehouseId: string): Promise { return this.stockRepository .createQueryBuilder('stock') .where('stock.warehouseId = :warehouseId', { warehouseId }) .andWhere('stock.quantity <= stock.minLevel') .leftJoinAndSelect('stock.material', 'material') .leftJoinAndSelect('stock.warehouse', 'warehouse') .orderBy('stock.quantity', 'ASC') .getMany(); } async findMovement(id: string): Promise { const movement = await this.movementsRepository.findOne({ where: { id }, relations: [ 'warehouse', 'material', 'creator', 'receipt', 'budgetItem', ], }); if (!movement) { throw new NotFoundException(`Movimiento con ID ${id} no encontrado`); } return movement; } async getMovementsByWarehouse( warehouseId: string, startDate?: Date, endDate?: Date, ): Promise { const query = this.movementsRepository .createQueryBuilder('mov') .where('mov.warehouseId = :warehouseId', { warehouseId }) .leftJoinAndSelect('mov.material', 'material') .leftJoinAndSelect('mov.creator', 'creator') .orderBy('mov.movementDate', 'DESC') .addOrderBy('mov.createdAt', 'DESC'); if (startDate) { query.andWhere('mov.movementDate >= :startDate', { startDate }); } if (endDate) { query.andWhere('mov.movementDate <= :endDate', { endDate }); } return query.getMany(); } private async generateMovementCode(type: MovementType): Promise { const prefixes = { [MovementType.ENTRY]: 'ENT', [MovementType.EXIT]: 'SAL', [MovementType.TRANSFER_OUT]: 'TRA', [MovementType.TRANSFER_IN]: 'TRA', [MovementType.ADJUSTMENT]: 'AJU', }; const prefix = prefixes[type]; const year = new Date().getFullYear(); const code = `${prefix}-${year}`; const lastMovement = await this.movementsRepository .createQueryBuilder('mov') .where('mov.code LIKE :code', { code: `${code}%` }) .orderBy('mov.createdAt', 'DESC') .getOne(); let sequence = 1; if (lastMovement) { const lastSequence = parseInt(lastMovement.code.split('-').pop() || '0'); sequence = lastSequence + 1; } return `${code}-${sequence.toString().padStart(6, '0')}`; } private async getLastLot(warehouseId: string, materialId: string): Promise { const lastEntry = await this.movementsRepository .createQueryBuilder('mov') .where('mov.warehouseId = :warehouseId', { warehouseId }) .andWhere('mov.materialId = :materialId', { materialId }) .andWhere('mov.type = :type', { type: MovementType.ENTRY }) .andWhere('mov.lot IS NOT NULL') .orderBy('mov.createdAt', 'DESC') .getOne(); return lastEntry?.lot || ''; } } ``` --- ## 5. Controllers y Endpoints ### 5.1 RequisitionsController ```typescript // src/modules/requisitions/controllers/requisitions.controller.ts import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards, Request, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { RequisitionsService } from '../services/requisitions.service'; import { CreateRequisitionDto } from '../dto/create-requisition.dto'; import { UpdateRequisitionDto } from '../dto/update-requisition.dto'; import { ApproveRequisitionDto } from '../dto/approve-requisition.dto'; import { RejectRequisitionDto } from '../dto/reject-requisition.dto'; import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; import { RolesGuard } from '../../../common/guards/roles.guard'; import { Roles } from '../../../common/decorators/roles.decorator'; import { UserRole } from '../../auth/enums/user-role.enum'; @ApiTags('Requisiciones') @ApiBearerAuth() @Controller('api/v1/requisitions') @UseGuards(JwtAuthGuard, RolesGuard) export class RequisitionsController { constructor(private readonly requisitionsService: RequisitionsService) {} @Post() @Roles(UserRole.RESIDENTE, UserRole.SUPERINTENDENTE, UserRole.JEFE_COMPRAS) @ApiOperation({ summary: 'Crear nueva requisición' }) @ApiResponse({ status: 201, description: 'Requisición creada exitosamente' }) create(@Body() createRequisitionDto: CreateRequisitionDto, @Request() req) { return this.requisitionsService.create( createRequisitionDto, req.user.id, req.user.constructoraId, ); } @Post(':id/submit') @Roles(UserRole.RESIDENTE, UserRole.SUPERINTENDENTE, UserRole.JEFE_COMPRAS) @ApiOperation({ summary: 'Enviar requisición para aprobación' }) @ApiResponse({ status: 200, description: 'Requisición enviada a aprobación' }) submit(@Param('id') id: string, @Request() req) { return this.requisitionsService.submit(id, req.user.id); } @Post(':id/approve') @Roles(UserRole.SUPERINTENDENTE, UserRole.JEFE_COMPRAS, UserRole.DIRECTOR) @ApiOperation({ summary: 'Aprobar requisición' }) @ApiResponse({ status: 200, description: 'Requisición aprobada' }) approve( @Param('id') id: string, @Body() approveDto: ApproveRequisitionDto, @Request() req, ) { return this.requisitionsService.approve(id, req.user.id, approveDto.comments); } @Post(':id/reject') @Roles(UserRole.SUPERINTENDENTE, UserRole.JEFE_COMPRAS, UserRole.DIRECTOR) @ApiOperation({ summary: 'Rechazar requisición' }) @ApiResponse({ status: 200, description: 'Requisición rechazada' }) reject( @Param('id') id: string, @Body() rejectDto: RejectRequisitionDto, @Request() req, ) { return this.requisitionsService.reject(id, req.user.id, rejectDto.reason); } @Get() @ApiOperation({ summary: 'Listar requisiciones' }) @ApiResponse({ status: 200, description: 'Lista de requisiciones' }) findAll(@Query() filters, @Request() req) { return this.requisitionsService.findAll(req.user.constructoraId, filters); } @Get(':id') @ApiOperation({ summary: 'Obtener requisición por ID' }) @ApiResponse({ status: 200, description: 'Detalles de la requisición' }) findOne(@Param('id') id: string) { return this.requisitionsService.findOne(id); } @Patch(':id') @Roles(UserRole.RESIDENTE, UserRole.SUPERINTENDENTE, UserRole.JEFE_COMPRAS) @ApiOperation({ summary: 'Actualizar requisición' }) @ApiResponse({ status: 200, description: 'Requisición actualizada' }) update( @Param('id') id: string, @Body() updateRequisitionDto: UpdateRequisitionDto, ) { return this.requisitionsService.update(id, updateRequisitionDto); } @Delete(':id') @Roles(UserRole.RESIDENTE, UserRole.JEFE_COMPRAS) @ApiOperation({ summary: 'Eliminar requisición' }) @ApiResponse({ status: 200, description: 'Requisición eliminada' }) remove(@Param('id') id: string) { return this.requisitionsService.remove(id); } } ``` ### 5.2 InventoryController ```typescript // src/modules/inventory/controllers/inventory.controller.ts import { Controller, Get, Post, Body, Param, Query, UseGuards, Request, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { InventoryService } from '../services/inventory.service'; import { CreateInventoryMovementDto } from '../dto/create-inventory-movement.dto'; import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; import { RolesGuard } from '../../../common/guards/roles.guard'; import { Roles } from '../../../common/decorators/roles.decorator'; import { UserRole } from '../../auth/enums/user-role.enum'; @ApiTags('Inventario') @ApiBearerAuth() @Controller('api/v1/inventory') @UseGuards(JwtAuthGuard, RolesGuard) export class InventoryController { constructor(private readonly inventoryService: InventoryService) {} @Get('warehouses/:warehouseId/stock') @ApiOperation({ summary: 'Obtener stock de un almacén' }) @ApiResponse({ status: 200, description: 'Stock del almacén' }) getWarehouseStock(@Param('warehouseId') warehouseId: string) { return this.inventoryService.getWarehouseStock(warehouseId); } @Get('warehouses/:warehouseId/stock/:materialId') @ApiOperation({ summary: 'Obtener stock de un material en almacén' }) @ApiResponse({ status: 200, description: 'Stock del material' }) getStock( @Param('warehouseId') warehouseId: string, @Param('materialId') materialId: string, ) { return this.inventoryService.getStock(warehouseId, materialId); } @Get('warehouses/:warehouseId/low-stock') @ApiOperation({ summary: 'Obtener materiales con stock bajo' }) @ApiResponse({ status: 200, description: 'Materiales con stock bajo' }) getLowStockItems(@Param('warehouseId') warehouseId: string) { return this.inventoryService.getLowStockItems(warehouseId); } @Post('movements') @Roles(UserRole.ALMACENISTA, UserRole.JEFE_ALMACEN) @ApiOperation({ summary: 'Crear movimiento de inventario' }) @ApiResponse({ status: 201, description: 'Movimiento creado exitosamente' }) createMovement( @Body() createMovementDto: CreateInventoryMovementDto, @Request() req, ) { return this.inventoryService.createMovement(createMovementDto, req.user.id); } @Get('warehouses/:warehouseId/movements') @ApiOperation({ summary: 'Obtener movimientos de un almacén' }) @ApiResponse({ status: 200, description: 'Movimientos del almacén' }) getMovementsByWarehouse( @Param('warehouseId') warehouseId: string, @Query('startDate') startDate?: Date, @Query('endDate') endDate?: Date, ) { return this.inventoryService.getMovementsByWarehouse( warehouseId, startDate, endDate, ); } @Get('movements/:id') @ApiOperation({ summary: 'Obtener movimiento por ID' }) @ApiResponse({ status: 200, description: 'Detalles del movimiento' }) findMovement(@Param('id') id: string) { return this.inventoryService.findMovement(id); } } ``` --- ## 6. Endpoints para App Móvil MOB-002 ### 6.1 Mobile Inventory Controller ```typescript // src/modules/inventory/controllers/mobile-inventory.controller.ts import { Controller, Get, Post, Body, Param, UseGuards, Request, UploadedFiles, UseInterceptors, } from '@nestjs/common'; import { FilesInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger'; import { InventoryService } from '../services/inventory.service'; import { CreateMobileReceiptDto } from '../dto/create-mobile-receipt.dto'; import { CreateMobileExitDto } from '../dto/create-mobile-exit.dto'; import { CreateStockCountDto } from '../dto/create-stock-count.dto'; import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; import { FileUploadService } from '../../shared/file-upload/file-upload.service'; @ApiTags('Mobile - Inventario') @ApiBearerAuth() @Controller('api/v1/mobile/inventory') @UseGuards(JwtAuthGuard) export class MobileInventoryController { constructor( private readonly inventoryService: InventoryService, private readonly fileUploadService: FileUploadService, ) {} @Post('receipts') @UseInterceptors(FilesInterceptor('photos', 5)) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: '[MÓVIL] Registrar recepción de material' }) @ApiResponse({ status: 201, description: 'Recepción registrada exitosamente' }) async createReceipt( @Body() createReceiptDto: CreateMobileReceiptDto, @UploadedFiles() files: Express.Multer.File[], @Request() req, ) { // Subir fotos a S3/MinIO const photoUrls = await Promise.all( files.map((file) => this.fileUploadService.uploadImage(file, 'receipts')), ); return this.inventoryService.createMobileReceipt({ ...createReceiptDto, photoUrls, userId: req.user.id, }); } @Post('exits') @UseInterceptors(FilesInterceptor('photos', 3)) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: '[MÓVIL] Registrar salida de material' }) @ApiResponse({ status: 201, description: 'Salida registrada exitosamente' }) async createExit( @Body() createExitDto: CreateMobileExitDto, @UploadedFiles() files: Express.Multer.File[], @Request() req, ) { // Subir fotos a S3/MinIO const photoUrls = await Promise.all( files.map((file) => this.fileUploadService.uploadImage(file, 'exits')), ); return this.inventoryService.createMobileExit({ ...createExitDto, photoUrls, userId: req.user.id, }); } @Post('stock-counts') @ApiOperation({ summary: '[MÓVIL] Registrar conteo físico' }) @ApiResponse({ status: 201, description: 'Conteo registrado exitosamente' }) createStockCount( @Body() createCountDto: CreateStockCountDto, @Request() req, ) { return this.inventoryService.createStockCount({ ...createCountDto, userId: req.user.id, }); } @Get('warehouses/:warehouseId/pending-receipts') @ApiOperation({ summary: '[MÓVIL] Obtener recepciones pendientes' }) @ApiResponse({ status: 200, description: 'Recepciones pendientes' }) getPendingReceipts(@Param('warehouseId') warehouseId: string) { return this.inventoryService.getPendingReceipts(warehouseId); } @Get('warehouses/:warehouseId/pending-exits') @ApiOperation({ summary: '[MÓVIL] Obtener salidas pendientes' }) @ApiResponse({ status: 200, description: 'Salidas pendientes' }) getPendingExits(@Param('warehouseId') warehouseId: string) { return this.inventoryService.getPendingExits(warehouseId); } @Get('materials/:materialId/search') @ApiOperation({ summary: '[MÓVIL] Buscar material por código/nombre' }) @ApiResponse({ status: 200, description: 'Material encontrado' }) searchMaterial(@Param('materialId') materialId: string) { return this.inventoryService.searchMaterial(materialId); } @Get('warehouses/:warehouseId/sync-data') @ApiOperation({ summary: '[MÓVIL] Obtener datos para sincronización offline' }) @ApiResponse({ status: 200, description: 'Datos para sincronización' }) getSyncData( @Param('warehouseId') warehouseId: string, @Request() req, ) { return this.inventoryService.getSyncData(warehouseId, req.user.id); } } ``` ### 6.2 DTOs para App Móvil ```typescript // src/modules/inventory/dto/create-mobile-receipt.dto.ts import { IsString, IsUUID, IsNumber, IsOptional, IsArray, IsDateString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class CreateMobileReceiptDto { @ApiProperty({ description: 'ID de la orden de compra' }) @IsUUID() purchaseOrderId: string; @ApiProperty({ description: 'ID del almacén' }) @IsUUID() warehouseId: string; @ApiProperty({ description: 'ID del material' }) @IsUUID() materialId: string; @ApiProperty({ description: 'Cantidad recibida' }) @IsNumber() receivedQuantity: number; @ApiProperty({ description: 'Cantidad aceptada' }) @IsNumber() acceptedQuantity: number; @ApiProperty({ description: 'Cantidad rechazada', required: false }) @IsOptional() @IsNumber() rejectedQuantity?: number; @ApiProperty({ description: 'Motivo de rechazo', required: false }) @IsOptional() @IsString() rejectionReason?: string; @ApiProperty({ description: 'Número de lote', required: false }) @IsOptional() @IsString() lot?: string; @ApiProperty({ description: 'Ubicación en almacén', required: false }) @IsOptional() @IsUUID() locationId?: string; @ApiProperty({ description: 'Nota de entrega del proveedor', required: false }) @IsOptional() @IsString() deliveryNote?: string; @ApiProperty({ description: 'Empresa de transporte', required: false }) @IsOptional() @IsString() transportCompany?: string; @ApiProperty({ description: 'Notas adicionales', required: false }) @IsOptional() @IsString() notes?: string; @ApiProperty({ description: 'URL de firma digital', required: false }) @IsOptional() @IsString() signatureUrl?: string; @ApiProperty({ description: 'Coordenadas GPS', required: false }) @IsOptional() gpsCoordinates?: { latitude: number; longitude: number }; @ApiProperty({ description: 'Timestamp local del dispositivo' }) @IsDateString() localTimestamp: string; } // src/modules/inventory/dto/create-mobile-exit.dto.ts export class CreateMobileExitDto { @ApiProperty({ description: 'ID del almacén' }) @IsUUID() warehouseId: string; @ApiProperty({ description: 'ID del material' }) @IsUUID() materialId: string; @ApiProperty({ description: 'Cantidad entregada' }) @IsNumber() quantity: number; @ApiProperty({ description: 'ID de partida presupuestal', required: false }) @IsOptional() @IsUUID() budgetItemId?: string; @ApiProperty({ description: 'Frente de obra', required: false }) @IsOptional() @IsString() workFront?: string; @ApiProperty({ description: 'ID de quien recibe' }) @IsUUID() receivedById: string; @ApiProperty({ description: 'Nombre de quien recibe' }) @IsString() receivedByName: string; @ApiProperty({ description: 'URL de firma digital' }) @IsString() signatureUrl: string; @ApiProperty({ description: 'Notas adicionales', required: false }) @IsOptional() @IsString() notes?: string; @ApiProperty({ description: 'Coordenadas GPS', required: false }) @IsOptional() gpsCoordinates?: { latitude: number; longitude: number }; @ApiProperty({ description: 'Timestamp local del dispositivo' }) @IsDateString() localTimestamp: string; } // src/modules/inventory/dto/create-stock-count.dto.ts export class StockCountItemDto { @ApiProperty({ description: 'ID del material' }) @IsUUID() materialId: string; @ApiProperty({ description: 'Cantidad física contada' }) @IsNumber() physicalQuantity: number; @ApiProperty({ description: 'Cantidad en sistema' }) @IsNumber() systemQuantity: number; @ApiProperty({ description: 'Diferencia' }) @IsNumber() difference: number; } export class CreateStockCountDto { @ApiProperty({ description: 'ID del almacén' }) @IsUUID() warehouseId: string; @ApiProperty({ description: 'Tipo de conteo: completo o cíclico' }) @IsString() countType: 'full' | 'cycle'; @ApiProperty({ description: 'Items contados', type: [StockCountItemDto] }) @IsArray() items: StockCountItemDto[]; @ApiProperty({ description: 'Notas del conteo', required: false }) @IsOptional() @IsString() notes?: string; @ApiProperty({ description: 'Timestamp local del dispositivo' }) @IsDateString() localTimestamp: string; } ``` --- ## 7. WebSocket para Sincronización en Tiempo Real ### 7.1 WebSocket Gateway ```typescript // src/modules/shared/websocket/websocket.gateway.ts import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, ConnectedSocket, MessageBody, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { Logger, UseGuards } from '@nestjs/common'; import { WsJwtGuard } from '../../../common/guards/ws-jwt.guard'; @WebSocketGateway({ cors: { origin: '*', }, namespace: 'inventory', }) @UseGuards(WsJwtGuard) export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; private logger = new Logger('WebSocketGateway'); private userSockets = new Map>(); // userId -> Set of socketIds private warehouseRooms = new Map>(); // warehouseId -> Set of socketIds handleConnection(client: Socket) { this.logger.log(`Client connected: ${client.id}`); } handleDisconnect(client: Socket) { this.logger.log(`Client disconnected: ${client.id}`); this.removeClientFromAllRooms(client.id); } @SubscribeMessage('join-warehouse') handleJoinWarehouse( @ConnectedSocket() client: Socket, @MessageBody() data: { warehouseId: string; userId: string }, ) { const { warehouseId, userId } = data; // Join warehouse room client.join(`warehouse:${warehouseId}`); // Track user socket if (!this.userSockets.has(userId)) { this.userSockets.set(userId, new Set()); } this.userSockets.get(userId).add(client.id); // Track warehouse room if (!this.warehouseRooms.has(warehouseId)) { this.warehouseRooms.set(warehouseId, new Set()); } this.warehouseRooms.get(warehouseId).add(client.id); this.logger.log(`Client ${client.id} joined warehouse ${warehouseId}`); return { success: true, message: `Joined warehouse ${warehouseId}` }; } @SubscribeMessage('leave-warehouse') handleLeaveWarehouse( @ConnectedSocket() client: Socket, @MessageBody() data: { warehouseId: string }, ) { const { warehouseId } = data; client.leave(`warehouse:${warehouseId}`); if (this.warehouseRooms.has(warehouseId)) { this.warehouseRooms.get(warehouseId).delete(client.id); } this.logger.log(`Client ${client.id} left warehouse ${warehouseId}`); return { success: true, message: `Left warehouse ${warehouseId}` }; } // Notificar actualización de stock a todos los clientes de un almacén notifyStockUpdate(warehouseId: string, materialId: string, stock?: any) { this.server.to(`warehouse:${warehouseId}`).emit('stock-updated', { warehouseId, materialId, stock, timestamp: new Date().toISOString(), }); this.logger.log( `Stock update notification sent for warehouse ${warehouseId}, material ${materialId}`, ); } // Notificar nuevo movimiento de inventario notifyNewMovement(warehouseId: string, movement: any) { this.server.to(`warehouse:${warehouseId}`).emit('new-movement', { warehouseId, movement, timestamp: new Date().toISOString(), }); this.logger.log(`New movement notification sent for warehouse ${warehouseId}`); } // Notificar alerta de stock bajo notifyLowStockAlert(warehouseId: string, material: any) { this.server.to(`warehouse:${warehouseId}`).emit('low-stock-alert', { warehouseId, material, timestamp: new Date().toISOString(), }); this.logger.log(`Low stock alert sent for warehouse ${warehouseId}`); } // Notificar nueva requisición pendiente notifyNewRequisition(projectId: string, requisition: any) { this.server.emit('new-requisition', { projectId, requisition, timestamp: new Date().toISOString(), }); this.logger.log(`New requisition notification sent for project ${projectId}`); } // Notificar aprobación de requisición notifyRequisitionApproved(userId: string, requisition: any) { const socketIds = this.userSockets.get(userId); if (socketIds) { socketIds.forEach((socketId) => { this.server.to(socketId).emit('requisition-approved', { requisition, timestamp: new Date().toISOString(), }); }); } this.logger.log(`Requisition approval notification sent to user ${userId}`); } // Notificar rechazo de requisición notifyRequisitionRejected(userId: string, requisition: any, reason: string) { const socketIds = this.userSockets.get(userId); if (socketIds) { socketIds.forEach((socketId) => { this.server.to(socketId).emit('requisition-rejected', { requisition, reason, timestamp: new Date().toISOString(), }); }); } this.logger.log(`Requisition rejection notification sent to user ${userId}`); } // Notificar nueva orden de compra notifyNewPurchaseOrder(warehouseId: string, purchaseOrder: any) { this.server.to(`warehouse:${warehouseId}`).emit('new-purchase-order', { purchaseOrder, timestamp: new Date().toISOString(), }); this.logger.log(`New purchase order notification sent for warehouse ${warehouseId}`); } private removeClientFromAllRooms(socketId: string) { // Remove from user sockets this.userSockets.forEach((sockets, userId) => { sockets.delete(socketId); if (sockets.size === 0) { this.userSockets.delete(userId); } }); // Remove from warehouse rooms this.warehouseRooms.forEach((sockets, warehouseId) => { sockets.delete(socketId); if (sockets.size === 0) { this.warehouseRooms.delete(warehouseId); } }); } } ``` ### 7.2 WebSocket Module ```typescript // src/modules/shared/websocket/websocket.module.ts import { Module } from '@nestjs/common'; import { WebSocketGateway } from './websocket.gateway'; @Module({ providers: [WebSocketGateway], exports: [WebSocketGateway], }) export class WebSocketModule {} ``` ### 7.3 WebSocket Events ```typescript // Eventos emitidos por el servidor: // 1. stock-updated { warehouseId: string; materialId: string; stock: { quantity: number; unitCost: number; totalValue: number; lastMovementAt: string; }; timestamp: string; } // 2. new-movement { warehouseId: string; movement: { id: string; code: string; type: string; materialId: string; materialName: string; quantity: number; createdBy: string; }; timestamp: string; } // 3. low-stock-alert { warehouseId: string; material: { id: string; name: string; currentQuantity: number; minLevel: number; reorderPoint: number; }; timestamp: string; } // 4. new-requisition { projectId: string; requisition: { id: string; code: string; requester: string; itemsCount: number; estimatedTotal: number; }; timestamp: string; } // 5. requisition-approved { requisition: { id: string; code: string; status: string; approvedBy: string; }; timestamp: string; } // 6. requisition-rejected { requisition: { id: string; code: string; status: string; }; reason: string; timestamp: string; } // 7. new-purchase-order { purchaseOrder: { id: string; code: string; supplier: string; deliveryDate: string; itemsCount: number; }; timestamp: string; } ``` --- ## 8. Flujos de Aprobación ### 8.1 Approval Workflow Service ```typescript // src/modules/shared/approval-workflow/approval-workflow.service.ts import { Injectable, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { RequisitionApproval, ApprovalStatus } from '../../requisitions/entities/requisition-approval.entity'; import { User } from '../../auth/entities/user.entity'; import { UserRole } from '../../auth/enums/user-role.enum'; interface ApprovalLevel { level: number; role: UserRole; minAmount?: number; maxAmount?: number; } @Injectable() export class ApprovalWorkflowService { constructor( @InjectRepository(RequisitionApproval) private approvalsRepository: Repository, @InjectRepository(User) private usersRepository: Repository, ) {} async initiate(requisitionId: string, estimatedTotal: number, projectId: string): Promise { const approvalLevels = this.getApprovalLevels(estimatedTotal); for (const level of approvalLevels) { // Obtener aprobador según rol y proyecto const approver = await this.getApproverForRole(level.role, projectId); if (!approver) { throw new BadRequestException( `No se encontró aprobador para el rol: ${level.role}`, ); } // Crear registro de aprobación const approval = this.approvalsRepository.create({ requisitionId, level: level.level, approverRole: level.role, approverId: approver.id, status: ApprovalStatus.PENDING, }); await this.approvalsRepository.save(approval); } } async approve( requisitionId: string, userId: string, comments?: string, ): Promise { // Obtener aprobación pendiente del usuario const approval = await this.approvalsRepository.findOne({ where: { requisitionId, approverId: userId, status: ApprovalStatus.PENDING, }, }); if (!approval) { throw new BadRequestException( 'No tiene una aprobación pendiente para esta requisición', ); } // Verificar que sea el siguiente nivel en el flujo const previousApprovals = await this.approvalsRepository.find({ where: { requisitionId }, order: { level: 'ASC' }, }); const currentLevelIndex = previousApprovals.findIndex((a) => a.id === approval.id); // Verificar que todos los niveles anteriores estén aprobados for (let i = 0; i < currentLevelIndex; i++) { if (previousApprovals[i].status !== ApprovalStatus.APPROVED) { throw new BadRequestException( 'Debe esperar a que se aprueben los niveles anteriores', ); } } // Aprobar approval.status = ApprovalStatus.APPROVED; approval.comments = comments; approval.approvedAt = new Date(); await this.approvalsRepository.save(approval); // Verificar si es la última aprobación const allApprovals = await this.approvalsRepository.find({ where: { requisitionId }, }); const isFullyApproved = allApprovals.every( (a) => a.status === ApprovalStatus.APPROVED, ); return isFullyApproved; } async reject(requisitionId: string, userId: string, reason: string): Promise { // Obtener aprobación pendiente del usuario const approval = await this.approvalsRepository.findOne({ where: { requisitionId, approverId: userId, status: ApprovalStatus.PENDING, }, }); if (!approval) { throw new BadRequestException( 'No tiene una aprobación pendiente para esta requisición', ); } // Rechazar approval.status = ApprovalStatus.REJECTED; approval.comments = reason; approval.approvedAt = new Date(); await this.approvalsRepository.save(approval); } private getApprovalLevels(estimatedTotal: number): ApprovalLevel[] { // Definir niveles de aprobación según monto if (estimatedTotal <= 10000) { return [ { level: 1, role: UserRole.SUPERINTENDENTE }, { level: 2, role: UserRole.JEFE_COMPRAS }, ]; } else if (estimatedTotal <= 50000) { return [ { level: 1, role: UserRole.SUPERINTENDENTE }, { level: 2, role: UserRole.JEFE_COMPRAS }, { level: 3, role: UserRole.DIRECTOR }, ]; } else { return [ { level: 1, role: UserRole.SUPERINTENDENTE }, { level: 2, role: UserRole.JEFE_COMPRAS }, { level: 3, role: UserRole.DIRECTOR }, { level: 4, role: UserRole.DIRECTOR_GENERAL }, ]; } } private async getApproverForRole(role: UserRole, projectId: string): Promise { // Buscar usuario con el rol específico asignado al proyecto return this.usersRepository .createQueryBuilder('user') .innerJoin('user.projectAssignments', 'assignment') .where('assignment.projectId = :projectId', { projectId }) .andWhere('user.role = :role', { role }) .andWhere('user.active = :active', { active: true }) .getOne(); } async getApprovalStatus(requisitionId: string): Promise { return this.approvalsRepository.find({ where: { requisitionId }, relations: ['approver'], order: { level: 'ASC' }, }); } async getPendingApprovals(userId: string): Promise { return this.approvalsRepository.find({ where: { approverId: userId, status: ApprovalStatus.PENDING, }, relations: ['requisition', 'requisition.project', 'requisition.requester'], order: { createdAt: 'DESC' }, }); } } ``` --- ## 9. Validaciones y Reglas de Negocio ### 9.1 Budget Validation Service ```typescript // src/modules/shared/budget-validation/budget-validation.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { BudgetItem } from '../../budgets/entities/budget-item.entity'; @Injectable() export class BudgetValidationService { constructor( @InjectRepository(BudgetItem) private budgetItemsRepository: Repository, ) {} async validateBudgetAvailability( budgetItemId: string, amount: number, ): Promise { const budgetItem = await this.budgetItemsRepository.findOne({ where: { id: budgetItemId }, }); if (!budgetItem) { return false; } // Calcular disponible = presupuestado - ejercido - comprometido const available = budgetItem.budgetedAmount - budgetItem.exercisedAmount - budgetItem.committedAmount; return available >= amount; } async getAvailableBudget(budgetItemId: string): Promise { const budgetItem = await this.budgetItemsRepository.findOne({ where: { id: budgetItemId }, }); if (!budgetItem) { return 0; } return ( budgetItem.budgetedAmount - budgetItem.exercisedAmount - budgetItem.committedAmount ); } async commitBudget(budgetItemId: string, amount: number): Promise { const budgetItem = await this.budgetItemsRepository.findOne({ where: { id: budgetItemId }, }); if (!budgetItem) { throw new Error(`Budget item ${budgetItemId} not found`); } budgetItem.committedAmount += amount; await this.budgetItemsRepository.save(budgetItem); } async releaseCommittedBudget(budgetItemId: string, amount: number): Promise { const budgetItem = await this.budgetItemsRepository.findOne({ where: { id: budgetItemId }, }); if (!budgetItem) { throw new Error(`Budget item ${budgetItemId} not found`); } budgetItem.committedAmount -= amount; await this.budgetItemsRepository.save(budgetItem); } async exerciseBudget(budgetItemId: string, amount: number): Promise { const budgetItem = await this.budgetItemsRepository.findOne({ where: { id: budgetItemId }, }); if (!budgetItem) { throw new Error(`Budget item ${budgetItemId} not found`); } // Mover de comprometido a ejercido budgetItem.committedAmount -= amount; budgetItem.exercisedAmount += amount; await this.budgetItemsRepository.save(budgetItem); } } ``` --- ## 10. Seguridad y Autenticación ### 10.1 JWT Strategy ```typescript // src/common/strategies/jwt.strategy.ts import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../../modules/auth/entities/user.entity'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private configService: ConfigService, @InjectRepository(User) private usersRepository: Repository, ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: configService.get('JWT_SECRET'), }); } async validate(payload: any) { const user = await this.usersRepository.findOne({ where: { id: payload.sub, active: true }, relations: ['constructora', 'role', 'permissions'], }); if (!user) { throw new UnauthorizedException('Usuario no autorizado'); } return { id: user.id, email: user.email, role: user.role, constructoraId: user.constructoraId, permissions: user.permissions, }; } } ``` ### 10.2 Roles Guard ```typescript // src/common/guards/roles.guard.ts import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { UserRole } from '../../modules/auth/enums/user-role.enum'; import { ROLES_KEY } from '../decorators/roles.decorator'; @Injectable() export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ context.getHandler(), context.getClass(), ]); if (!requiredRoles) { return true; } const { user } = context.switchToHttp().getRequest(); return requiredRoles.some((role) => user.role === role); } } ``` ### 10.3 Tenant Isolation ```typescript // src/common/interceptors/tenant-isolation.interceptor.ts import { Injectable, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; @Injectable() export class TenantIsolationInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const user = request.user; // Agregar filtro de tenant a todas las queries if (user?.constructoraId) { request.tenantId = user.constructoraId; } return next.handle(); } } ``` --- ## 11. Testing ### 11.1 Unit Tests - RequisitionsService ```typescript // src/modules/requisitions/services/requisitions.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { RequisitionsService } from './requisitions.service'; import { Requisition, RequisitionStatus } from '../entities/requisition.entity'; import { RequisitionItem } from '../entities/requisition-item.entity'; import { BudgetValidationService } from '../../shared/budget-validation/budget-validation.service'; import { NotificationService } from '../../shared/notification/notification.service'; import { RequisitionApprovalService } from './requisition-approval.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; describe('RequisitionsService', () => { let service: RequisitionsService; let requisitionsRepository: Repository; let requisitionItemsRepository: Repository; const mockRequisitionsRepository = { create: jest.fn(), save: jest.fn(), findOne: jest.fn(), find: jest.fn(), createQueryBuilder: jest.fn(), }; const mockRequisitionItemsRepository = { create: jest.fn(), save: jest.fn(), find: jest.fn(), }; const mockBudgetValidationService = { validateBudgetAvailability: jest.fn(), }; const mockNotificationService = { notifyApprovers: jest.fn(), notifyRequisitionApproved: jest.fn(), notifyRequisitionRejected: jest.fn(), }; const mockApprovalService = { initiate: jest.fn(), approve: jest.fn(), reject: jest.fn(), }; const mockEventEmitter = { emit: jest.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ RequisitionsService, { provide: getRepositoryToken(Requisition), useValue: mockRequisitionsRepository, }, { provide: getRepositoryToken(RequisitionItem), useValue: mockRequisitionItemsRepository, }, { provide: BudgetValidationService, useValue: mockBudgetValidationService, }, { provide: NotificationService, useValue: mockNotificationService, }, { provide: RequisitionApprovalService, useValue: mockApprovalService, }, { provide: EventEmitter2, useValue: mockEventEmitter, }, ], }).compile(); service = module.get(RequisitionsService); requisitionsRepository = module.get>( getRepositoryToken(Requisition), ); requisitionItemsRepository = module.get>( getRepositoryToken(RequisitionItem), ); }); it('should be defined', () => { expect(service).toBeDefined(); }); describe('create', () => { it('should create a new requisition', async () => { const createDto = { projectId: '123', requiredDate: new Date(), justification: 'Test', items: [ { materialId: '456', description: 'Material 1', quantity: 10, unit: 'PZA', budgetedPrice: 100, }, ], }; const savedRequisition = { id: '789', code: 'REQ-2025-00001', status: RequisitionStatus.DRAFT, estimatedTotal: 1000, ...createDto, }; mockRequisitionsRepository.create.mockReturnValue(savedRequisition); mockRequisitionsRepository.save.mockResolvedValue(savedRequisition); mockRequisitionItemsRepository.create.mockReturnValue({}); mockRequisitionItemsRepository.save.mockResolvedValue([]); mockRequisitionsRepository.findOne.mockResolvedValue(savedRequisition); const result = await service.create(createDto, 'user123', 'constructora123'); expect(result).toBeDefined(); expect(result.status).toBe(RequisitionStatus.DRAFT); expect(mockEventEmitter.emit).toHaveBeenCalledWith( 'requisition.created', expect.any(Object), ); }); }); describe('submit', () => { it('should submit requisition for approval', async () => { const requisition = { id: '789', code: 'REQ-2025-00001', status: RequisitionStatus.DRAFT, estimatedTotal: 5000, }; const items = [ { id: '1', budgetItemId: 'budget123', budgetedPrice: 100, quantity: 10, description: 'Test', }, ]; mockRequisitionsRepository.findOne.mockResolvedValue(requisition); mockRequisitionItemsRepository.find.mockResolvedValue(items); mockBudgetValidationService.validateBudgetAvailability.mockResolvedValue(true); mockRequisitionsRepository.save.mockResolvedValue({ ...requisition, status: RequisitionStatus.PENDING, }); const result = await service.submit('789', 'user123'); expect(result.status).toBe(RequisitionStatus.PENDING); expect(mockApprovalService.initiate).toHaveBeenCalled(); expect(mockNotificationService.notifyApprovers).toHaveBeenCalled(); expect(mockEventEmitter.emit).toHaveBeenCalledWith( 'requisition.submitted', expect.any(Object), ); }); it('should throw error if no budget available', async () => { const requisition = { id: '789', status: RequisitionStatus.DRAFT, }; const items = [ { budgetItemId: 'budget123', budgetedPrice: 100, quantity: 10, }, ]; mockRequisitionsRepository.findOne.mockResolvedValue(requisition); mockRequisitionItemsRepository.find.mockResolvedValue(items); mockBudgetValidationService.validateBudgetAvailability.mockResolvedValue(false); await expect(service.submit('789', 'user123')).rejects.toThrow(); }); }); }); ``` ### 11.2 Integration Tests - Inventory ```typescript // src/modules/inventory/services/inventory.service.integration.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; import { InventoryService } from './inventory.service'; import { InventoryStock } from '../entities/inventory-stock.entity'; import { InventoryMovement, MovementType } from '../entities/inventory-movement.entity'; describe('InventoryService Integration Tests', () => { let service: InventoryService; let module: TestingModule; beforeAll(async () => { module = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env.test', }), TypeOrmModule.forRoot({ type: 'postgres', host: 'localhost', port: 5432, username: 'test', password: 'test', database: 'test_db', entities: [InventoryStock, InventoryMovement], synchronize: true, }), TypeOrmModule.forFeature([InventoryStock, InventoryMovement]), ], providers: [InventoryService], }).compile(); service = module.get(InventoryService); }); afterAll(async () => { await module.close(); }); it('should create entry movement and update stock', async () => { const movementDto = { warehouseId: 'warehouse-1', materialId: 'material-1', type: MovementType.ENTRY, quantity: 100, unitCost: 50, movementDate: new Date(), }; const movement = await service.createMovement(movementDto, 'user-1'); expect(movement).toBeDefined(); expect(movement.quantity).toBe(100); const stock = await service.getStock('warehouse-1', 'material-1'); expect(stock.quantity).toBe(100); expect(stock.unitCost).toBe(50); }); it('should create exit movement and decrease stock', async () => { // First create entry await service.createMovement( { warehouseId: 'warehouse-2', materialId: 'material-2', type: MovementType.ENTRY, quantity: 200, unitCost: 30, movementDate: new Date(), }, 'user-1', ); // Then create exit const exitMovement = await service.createMovement( { warehouseId: 'warehouse-2', materialId: 'material-2', type: MovementType.EXIT, quantity: 50, unitCost: 30, movementDate: new Date(), }, 'user-1', ); expect(exitMovement.quantity).toBe(50); const stock = await service.getStock('warehouse-2', 'material-2'); expect(stock.quantity).toBe(150); }); }); ``` --- ## Resumen Esta especificación técnica backend cubre: 1. **Arquitectura modular** con 4 módulos principales: Requisitions, PurchaseOrders, Warehouses e Inventory 2. **Entities completas** con TypeORM para PostgreSQL con relaciones y validaciones 3. **DTOs robustos** con class-validator para validación de entrada 4. **Services** con lógica de negocio completa incluyendo flujos de aprobación y validaciones presupuestales 5. **Controllers RESTful** con endpoints documentados con Swagger 6. **Endpoints móviles especializados** para la app MOB-002 con soporte para fotos, firmas y GPS 7. **WebSocket Gateway** para notificaciones en tiempo real de actualizaciones de stock, movimientos y aprobaciones 8. **Sistema de aprobación multinivel** configurable por montos 9. **Validaciones presupuestales** integradas con control de presupuesto comprometido y ejercido 10. **Seguridad completa** con JWT, RBAC y aislamiento por tenant 11. **Suite de testing** con unit tests e integration tests **Stack:** - NestJS 10+ - TypeORM 0.3+ - PostgreSQL 15+ - Socket.IO (WebSocket) - class-validator + class-transformer - Passport JWT - Jest + Supertest --- **Generado:** 2025-12-06 **Versión:** 1.0 **Mantenedores:** @backend-team @tech-lead