workspace-v1/projects/erp-construccion/docs/02-definicion-modulos/MAI-004-compras-inventarios/especificaciones/ET-COMP-001-backend.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

93 KiB

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
  2. Módulos NestJS
  3. Entities y DTOs
  4. Services y Business Logic
  5. Controllers y Endpoints
  6. Endpoints para App Móvil MOB-002
  7. WebSocket para Sincronización en Tiempo Real
  8. Flujos de Aprobación
  9. Validaciones y Reglas de Negocio
  10. Seguridad y Autenticación
  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

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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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<Requisition>,
    @InjectRepository(RequisitionItem)
    private requisitionItemsRepository: Repository<RequisitionItem>,
    private budgetValidationService: BudgetValidationService,
    private notificationService: NotificationService,
    private approvalService: RequisitionApprovalService,
    private eventEmitter: EventEmitter2,
  ) {}

  async create(
    createRequisitionDto: CreateRequisitionDto,
    userId: string,
    constructoraId: string,
  ): Promise<Requisition> {
    // 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<Requisition> {
    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<Requisition> {
    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<Requisition> {
    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<Requisition> {
    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<Requisition[]> {
    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<Requisition> {
    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<Requisition> {
    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<void> {
    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<string> {
    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

// 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<InventoryStock>,
    @InjectRepository(InventoryMovement)
    private movementsRepository: Repository<InventoryMovement>,
    private costCalculationService: CostCalculationService,
    private kardexService: KardexService,
    private notificationService: NotificationService,
    private webSocketGateway: WebSocketGateway,
    private eventEmitter: EventEmitter2,
  ) {}

  async getStock(warehouseId: string, materialId: string): Promise<InventoryStock> {
    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<InventoryMovement> {
    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<void> {
    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<void> {
    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<InventoryStock[]> {
    return this.stockRepository.find({
      where: { warehouseId },
      relations: ['material', 'warehouse'],
      order: { material: { name: 'ASC' } },
    });
  }

  async getLowStockItems(warehouseId: string): Promise<InventoryStock[]> {
    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<InventoryMovement> {
    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<InventoryMovement[]> {
    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<string> {
    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<string> {
    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

// 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

// 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

// 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

// 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

// 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<string, Set<string>>(); // userId -> Set of socketIds
  private warehouseRooms = new Map<string, Set<string>>(); // 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

// 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

// 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

// 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<RequisitionApproval>,
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}

  async initiate(requisitionId: string, estimatedTotal: number, projectId: string): Promise<void> {
    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<boolean> {
    // 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<void> {
    // 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<User | null> {
    // 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<RequisitionApproval[]> {
    return this.approvalsRepository.find({
      where: { requisitionId },
      relations: ['approver'],
      order: { level: 'ASC' },
    });
  }

  async getPendingApprovals(userId: string): Promise<RequisitionApproval[]> {
    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

// 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<BudgetItem>,
  ) {}

  async validateBudgetAvailability(
    budgetItemId: string,
    amount: number,
  ): Promise<boolean> {
    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<number> {
    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<void> {
    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<void> {
    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<void> {
    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

// 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<User>,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get<string>('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

// 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<UserRole[]>(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

// 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<any> {
    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

// 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<Requisition>;
  let requisitionItemsRepository: Repository<RequisitionItem>;

  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>(RequisitionsService);
    requisitionsRepository = module.get<Repository<Requisition>>(
      getRepositoryToken(Requisition),
    );
    requisitionItemsRepository = module.get<Repository<RequisitionItem>>(
      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

// 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>(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