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
93 KiB
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
- Arquitectura Backend
- Módulos NestJS
- Entities y DTOs
- Services y Business Logic
- Controllers y Endpoints
- Endpoints para App Móvil MOB-002
- WebSocket para Sincronización en Tiempo Real
- Flujos de Aprobación
- Validaciones y Reglas de Negocio
- Seguridad y Autenticación
- 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:
- Arquitectura modular con 4 módulos principales: Requisitions, PurchaseOrders, Warehouses e Inventory
- Entities completas con TypeORM para PostgreSQL con relaciones y validaciones
- DTOs robustos con class-validator para validación de entrada
- Services con lógica de negocio completa incluyendo flujos de aprobación y validaciones presupuestales
- Controllers RESTful con endpoints documentados con Swagger
- Endpoints móviles especializados para la app MOB-002 con soporte para fotos, firmas y GPS
- WebSocket Gateway para notificaciones en tiempo real de actualizaciones de stock, movimientos y aprobaciones
- Sistema de aprobación multinivel configurable por montos
- Validaciones presupuestales integradas con control de presupuesto comprometido y ejercido
- Seguridad completa con JWT, RBAC y aislamiento por tenant
- 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