3327 lines
93 KiB
Markdown
3327 lines
93 KiB
Markdown
# ET-COMP-001: Especificación Técnica Backend - Compras e Inventarios
|
|
|
|
**Épica:** MAI-004 - Compras e Inventarios
|
|
**Versión:** 1.0
|
|
**Fecha:** 2025-12-06
|
|
**Stack:** NestJS 10+, TypeORM, WebSocket
|
|
|
|
---
|
|
|
|
## Índice
|
|
|
|
1. [Arquitectura Backend](#1-arquitectura-backend)
|
|
2. [Módulos NestJS](#2-módulos-nestjs)
|
|
3. [Entities y DTOs](#3-entities-y-dtos)
|
|
4. [Services y Business Logic](#4-services-y-business-logic)
|
|
5. [Controllers y Endpoints](#5-controllers-y-endpoints)
|
|
6. [Endpoints para App Móvil MOB-002](#6-endpoints-para-app-móvil-mob-002)
|
|
7. [WebSocket para Sincronización en Tiempo Real](#7-websocket-para-sincronización-en-tiempo-real)
|
|
8. [Flujos de Aprobación](#8-flujos-de-aprobación)
|
|
9. [Validaciones y Reglas de Negocio](#9-validaciones-y-reglas-de-negocio)
|
|
10. [Seguridad y Autenticación](#10-seguridad-y-autenticación)
|
|
11. [Testing](#11-testing)
|
|
|
|
---
|
|
|
|
## 1. Arquitectura Backend
|
|
|
|
### 1.1 Estructura de Módulos
|
|
|
|
```
|
|
src/
|
|
├── modules/
|
|
│ ├── requisitions/ # Módulo de Requisiciones
|
|
│ │ ├── dto/
|
|
│ │ ├── entities/
|
|
│ │ ├── services/
|
|
│ │ ├── controllers/
|
|
│ │ ├── repositories/
|
|
│ │ ├── events/
|
|
│ │ └── requisitions.module.ts
|
|
│ │
|
|
│ ├── purchase-orders/ # Módulo de Órdenes de Compra
|
|
│ │ ├── dto/
|
|
│ │ ├── entities/
|
|
│ │ ├── services/
|
|
│ │ ├── controllers/
|
|
│ │ ├── repositories/
|
|
│ │ ├── events/
|
|
│ │ └── purchase-orders.module.ts
|
|
│ │
|
|
│ ├── warehouses/ # Módulo de Almacenes
|
|
│ │ ├── dto/
|
|
│ │ ├── entities/
|
|
│ │ ├── services/
|
|
│ │ ├── controllers/
|
|
│ │ ├── repositories/
|
|
│ │ ├── events/
|
|
│ │ └── warehouses.module.ts
|
|
│ │
|
|
│ ├── inventory/ # Módulo de Inventario
|
|
│ │ ├── dto/
|
|
│ │ ├── entities/
|
|
│ │ ├── services/
|
|
│ │ ├── controllers/
|
|
│ │ ├── repositories/
|
|
│ │ ├── events/
|
|
│ │ └── inventory.module.ts
|
|
│ │
|
|
│ └── shared/ # Servicios compartidos
|
|
│ ├── approval-workflow/
|
|
│ ├── budget-validation/
|
|
│ ├── notification/
|
|
│ ├── pdf-generator/
|
|
│ └── websocket/
|
|
│
|
|
├── common/
|
|
│ ├── decorators/
|
|
│ ├── filters/
|
|
│ ├── guards/
|
|
│ ├── interceptors/
|
|
│ ├── pipes/
|
|
│ └── utils/
|
|
│
|
|
└── config/
|
|
├── database.config.ts
|
|
├── jwt.config.ts
|
|
└── websocket.config.ts
|
|
```
|
|
|
|
### 1.2 Stack Tecnológico
|
|
|
|
```yaml
|
|
framework: NestJS 10+
|
|
language: TypeScript 5.3+
|
|
database: PostgreSQL 15+
|
|
orm: TypeORM 0.3+
|
|
validation: class-validator + class-transformer
|
|
authentication: Passport + JWT
|
|
authorization: CASL (RBAC)
|
|
real_time: Socket.IO (WebSocket)
|
|
notifications: EventEmitter2 + BullMQ
|
|
files: Multer + AWS S3 / MinIO
|
|
pdf: PDFKit / Puppeteer
|
|
caching: Redis
|
|
logging: Winston
|
|
testing: Jest + Supertest
|
|
documentation: Swagger / OpenAPI
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Módulos NestJS
|
|
|
|
### 2.1 RequisitionsModule
|
|
|
|
```typescript
|
|
// src/modules/requisitions/requisitions.module.ts
|
|
import { Module } from '@nestjs/common';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
import { RequisitionsController } from './controllers/requisitions.controller';
|
|
import { RequisitionsService } from './services/requisitions.service';
|
|
import { RequisitionApprovalService } from './services/requisition-approval.service';
|
|
import { Requisition } from './entities/requisition.entity';
|
|
import { RequisitionItem } from './entities/requisition-item.entity';
|
|
import { RequisitionApproval } from './entities/requisition-approval.entity';
|
|
import { BudgetValidationModule } from '../shared/budget-validation/budget-validation.module';
|
|
import { NotificationModule } from '../shared/notification/notification.module';
|
|
import { ApprovalWorkflowModule } from '../shared/approval-workflow/approval-workflow.module';
|
|
|
|
@Module({
|
|
imports: [
|
|
TypeOrmModule.forFeature([
|
|
Requisition,
|
|
RequisitionItem,
|
|
RequisitionApproval,
|
|
]),
|
|
BudgetValidationModule,
|
|
NotificationModule,
|
|
ApprovalWorkflowModule,
|
|
],
|
|
controllers: [RequisitionsController],
|
|
providers: [
|
|
RequisitionsService,
|
|
RequisitionApprovalService,
|
|
],
|
|
exports: [RequisitionsService],
|
|
})
|
|
export class RequisitionsModule {}
|
|
```
|
|
|
|
### 2.2 PurchaseOrdersModule
|
|
|
|
```typescript
|
|
// src/modules/purchase-orders/purchase-orders.module.ts
|
|
import { Module } from '@nestjs/common';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
import { PurchaseOrdersController } from './controllers/purchase-orders.controller';
|
|
import { PurchaseOrdersService } from './services/purchase-orders.service';
|
|
import { PurchaseOrderReceiptsService } from './services/purchase-order-receipts.service';
|
|
import { PurchaseOrder } from './entities/purchase-order.entity';
|
|
import { PurchaseOrderItem } from './entities/purchase-order-item.entity';
|
|
import { PurchaseOrderReceipt } from './entities/purchase-order-receipt.entity';
|
|
import { PdfGeneratorModule } from '../shared/pdf-generator/pdf-generator.module';
|
|
import { NotificationModule } from '../shared/notification/notification.module';
|
|
import { InventoryModule } from '../inventory/inventory.module';
|
|
|
|
@Module({
|
|
imports: [
|
|
TypeOrmModule.forFeature([
|
|
PurchaseOrder,
|
|
PurchaseOrderItem,
|
|
PurchaseOrderReceipt,
|
|
]),
|
|
PdfGeneratorModule,
|
|
NotificationModule,
|
|
InventoryModule,
|
|
],
|
|
controllers: [PurchaseOrdersController],
|
|
providers: [
|
|
PurchaseOrdersService,
|
|
PurchaseOrderReceiptsService,
|
|
],
|
|
exports: [PurchaseOrdersService],
|
|
})
|
|
export class PurchaseOrdersModule {}
|
|
```
|
|
|
|
### 2.3 WarehousesModule
|
|
|
|
```typescript
|
|
// src/modules/warehouses/warehouses.module.ts
|
|
import { Module } from '@nestjs/common';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
import { WarehousesController } from './controllers/warehouses.controller';
|
|
import { WarehousesService } from './services/warehouses.service';
|
|
import { WarehouseLocationsService } from './services/warehouse-locations.service';
|
|
import { StockLevelsService } from './services/stock-levels.service';
|
|
import { Warehouse } from './entities/warehouse.entity';
|
|
import { WarehouseLocation } from './entities/warehouse-location.entity';
|
|
import { StockLevel } from './entities/stock-level.entity';
|
|
import { NotificationModule } from '../shared/notification/notification.module';
|
|
|
|
@Module({
|
|
imports: [
|
|
TypeOrmModule.forFeature([
|
|
Warehouse,
|
|
WarehouseLocation,
|
|
StockLevel,
|
|
]),
|
|
NotificationModule,
|
|
],
|
|
controllers: [WarehousesController],
|
|
providers: [
|
|
WarehousesService,
|
|
WarehouseLocationsService,
|
|
StockLevelsService,
|
|
],
|
|
exports: [
|
|
WarehousesService,
|
|
StockLevelsService,
|
|
],
|
|
})
|
|
export class WarehousesModule {}
|
|
```
|
|
|
|
### 2.4 InventoryModule
|
|
|
|
```typescript
|
|
// src/modules/inventory/inventory.module.ts
|
|
import { Module } from '@nestjs/common';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
import { InventoryController } from './controllers/inventory.controller';
|
|
import { InventoryMovementsController } from './controllers/inventory-movements.controller';
|
|
import { KardexController } from './controllers/kardex.controller';
|
|
import { InventoryService } from './services/inventory.service';
|
|
import { InventoryMovementsService } from './services/inventory-movements.service';
|
|
import { KardexService } from './services/kardex.service';
|
|
import { StockTransfersService } from './services/stock-transfers.service';
|
|
import { StockAdjustmentsService } from './services/stock-adjustments.service';
|
|
import { CostCalculationService } from './services/cost-calculation.service';
|
|
import { InventoryStock } from './entities/inventory-stock.entity';
|
|
import { InventoryMovement } from './entities/inventory-movement.entity';
|
|
import { StockTransfer } from './entities/stock-transfer.entity';
|
|
import { StockAdjustment } from './entities/stock-adjustment.entity';
|
|
import { Kardex } from './entities/kardex.entity';
|
|
import { WarehousesModule } from '../warehouses/warehouses.module';
|
|
import { NotificationModule } from '../shared/notification/notification.module';
|
|
import { WebSocketModule } from '../shared/websocket/websocket.module';
|
|
|
|
@Module({
|
|
imports: [
|
|
TypeOrmModule.forFeature([
|
|
InventoryStock,
|
|
InventoryMovement,
|
|
StockTransfer,
|
|
StockAdjustment,
|
|
Kardex,
|
|
]),
|
|
WarehousesModule,
|
|
NotificationModule,
|
|
WebSocketModule,
|
|
],
|
|
controllers: [
|
|
InventoryController,
|
|
InventoryMovementsController,
|
|
KardexController,
|
|
],
|
|
providers: [
|
|
InventoryService,
|
|
InventoryMovementsService,
|
|
KardexService,
|
|
StockTransfersService,
|
|
StockAdjustmentsService,
|
|
CostCalculationService,
|
|
],
|
|
exports: [
|
|
InventoryService,
|
|
InventoryMovementsService,
|
|
KardexService,
|
|
],
|
|
})
|
|
export class InventoryModule {}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Entities y DTOs
|
|
|
|
### 3.1 Requisition Entity
|
|
|
|
```typescript
|
|
// src/modules/requisitions/entities/requisition.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToOne,
|
|
OneToMany,
|
|
JoinColumn,
|
|
CreateDateColumn,
|
|
UpdateDateColumn,
|
|
} from 'typeorm';
|
|
import { User } from '../../auth/entities/user.entity';
|
|
import { Project } from '../../projects/entities/project.entity';
|
|
import { Constructora } from '../../tenants/entities/constructora.entity';
|
|
import { RequisitionItem } from './requisition-item.entity';
|
|
import { RequisitionApproval } from './requisition-approval.entity';
|
|
|
|
export enum RequisitionStatus {
|
|
DRAFT = 'draft',
|
|
PENDING = 'pending',
|
|
APPROVED = 'approved',
|
|
REJECTED = 'rejected',
|
|
CONVERTED = 'converted',
|
|
}
|
|
|
|
export enum RequisitionUrgency {
|
|
NORMAL = 'normal',
|
|
URGENT = 'urgent',
|
|
}
|
|
|
|
@Entity('requisitions', { schema: 'purchasing_management' })
|
|
export class Requisition {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ type: 'varchar', length: 20, unique: true })
|
|
code: string;
|
|
|
|
@Column({ name: 'constructora_id' })
|
|
constructoraId: string;
|
|
|
|
@ManyToOne(() => Constructora)
|
|
@JoinColumn({ name: 'constructora_id' })
|
|
constructora: Constructora;
|
|
|
|
@Column({ name: 'project_id' })
|
|
projectId: string;
|
|
|
|
@ManyToOne(() => Project)
|
|
@JoinColumn({ name: 'project_id' })
|
|
project: Project;
|
|
|
|
@Column({ name: 'requested_by' })
|
|
requestedBy: string;
|
|
|
|
@ManyToOne(() => User)
|
|
@JoinColumn({ name: 'requested_by' })
|
|
requester: User;
|
|
|
|
@Column({ type: 'date', name: 'required_date' })
|
|
requiredDate: Date;
|
|
|
|
@Column({
|
|
type: 'enum',
|
|
enum: RequisitionUrgency,
|
|
default: RequisitionUrgency.NORMAL,
|
|
})
|
|
urgency: RequisitionUrgency;
|
|
|
|
@Column({ type: 'text', nullable: true })
|
|
justification: string;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 2, name: 'estimated_total' })
|
|
estimatedTotal: number;
|
|
|
|
@Column({
|
|
type: 'enum',
|
|
enum: RequisitionStatus,
|
|
default: RequisitionStatus.DRAFT,
|
|
})
|
|
status: RequisitionStatus;
|
|
|
|
@Column({ type: 'text', nullable: true, name: 'rejected_reason' })
|
|
rejectedReason: string;
|
|
|
|
@Column({ type: 'uuid', nullable: true, name: 'converted_to_po_id' })
|
|
convertedToPOId: string;
|
|
|
|
@OneToMany(() => RequisitionItem, (item) => item.requisition, {
|
|
cascade: true,
|
|
})
|
|
items: RequisitionItem[];
|
|
|
|
@OneToMany(() => RequisitionApproval, (approval) => approval.requisition, {
|
|
cascade: true,
|
|
})
|
|
approvals: RequisitionApproval[];
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
|
|
@UpdateDateColumn({ name: 'updated_at' })
|
|
updatedAt: Date;
|
|
}
|
|
```
|
|
|
|
### 3.2 Requisition Item Entity
|
|
|
|
```typescript
|
|
// src/modules/requisitions/entities/requisition-item.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToOne,
|
|
JoinColumn,
|
|
} from 'typeorm';
|
|
import { Requisition } from './requisition.entity';
|
|
import { Material } from '../../catalogs/entities/material.entity';
|
|
import { BudgetItem } from '../../budgets/entities/budget-item.entity';
|
|
|
|
@Entity('requisition_items', { schema: 'purchasing_management' })
|
|
export class RequisitionItem {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ name: 'requisition_id' })
|
|
requisitionId: string;
|
|
|
|
@ManyToOne(() => Requisition, (requisition) => requisition.items, {
|
|
onDelete: 'CASCADE',
|
|
})
|
|
@JoinColumn({ name: 'requisition_id' })
|
|
requisition: Requisition;
|
|
|
|
@Column({ name: 'material_id' })
|
|
materialId: string;
|
|
|
|
@ManyToOne(() => Material)
|
|
@JoinColumn({ name: 'material_id' })
|
|
material: Material;
|
|
|
|
@Column({ type: 'varchar', length: 255 })
|
|
description: string;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 4 })
|
|
quantity: number;
|
|
|
|
@Column({ type: 'varchar', length: 20 })
|
|
unit: string;
|
|
|
|
@Column({
|
|
type: 'decimal',
|
|
precision: 15,
|
|
scale: 2,
|
|
nullable: true,
|
|
name: 'budgeted_price',
|
|
})
|
|
budgetedPrice: number;
|
|
|
|
@Column({ name: 'budget_item_id', nullable: true })
|
|
budgetItemId: string;
|
|
|
|
@ManyToOne(() => BudgetItem, { nullable: true })
|
|
@JoinColumn({ name: 'budget_item_id' })
|
|
budgetItem: BudgetItem;
|
|
|
|
@Column({ type: 'text', nullable: true })
|
|
notes: string;
|
|
}
|
|
```
|
|
|
|
### 3.3 Requisition Approval Entity
|
|
|
|
```typescript
|
|
// src/modules/requisitions/entities/requisition-approval.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToOne,
|
|
JoinColumn,
|
|
CreateDateColumn,
|
|
} from 'typeorm';
|
|
import { Requisition } from './requisition.entity';
|
|
import { User } from '../../auth/entities/user.entity';
|
|
|
|
export enum ApprovalStatus {
|
|
PENDING = 'pending',
|
|
APPROVED = 'approved',
|
|
REJECTED = 'rejected',
|
|
}
|
|
|
|
@Entity('requisition_approvals', { schema: 'purchasing_management' })
|
|
export class RequisitionApproval {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ name: 'requisition_id' })
|
|
requisitionId: string;
|
|
|
|
@ManyToOne(() => Requisition, (requisition) => requisition.approvals, {
|
|
onDelete: 'CASCADE',
|
|
})
|
|
@JoinColumn({ name: 'requisition_id' })
|
|
requisition: Requisition;
|
|
|
|
@Column({ type: 'int' })
|
|
level: number;
|
|
|
|
@Column({ type: 'varchar', length: 50, name: 'approver_role' })
|
|
approverRole: string;
|
|
|
|
@Column({ name: 'approver_id' })
|
|
approverId: string;
|
|
|
|
@ManyToOne(() => User)
|
|
@JoinColumn({ name: 'approver_id' })
|
|
approver: User;
|
|
|
|
@Column({
|
|
type: 'enum',
|
|
enum: ApprovalStatus,
|
|
default: ApprovalStatus.PENDING,
|
|
})
|
|
status: ApprovalStatus;
|
|
|
|
@Column({ type: 'text', nullable: true })
|
|
comments: string;
|
|
|
|
@Column({ type: 'timestamp', nullable: true, name: 'approved_at' })
|
|
approvedAt: Date;
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
}
|
|
```
|
|
|
|
### 3.4 Purchase Order Entity
|
|
|
|
```typescript
|
|
// src/modules/purchase-orders/entities/purchase-order.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToOne,
|
|
OneToMany,
|
|
JoinColumn,
|
|
CreateDateColumn,
|
|
UpdateDateColumn,
|
|
} from 'typeorm';
|
|
import { Supplier } from '../../suppliers/entities/supplier.entity';
|
|
import { Project } from '../../projects/entities/project.entity';
|
|
import { Constructora } from '../../tenants/entities/constructora.entity';
|
|
import { Requisition } from '../../requisitions/entities/requisition.entity';
|
|
import { User } from '../../auth/entities/user.entity';
|
|
import { PurchaseOrderItem } from './purchase-order-item.entity';
|
|
import { PurchaseOrderReceipt } from './purchase-order-receipt.entity';
|
|
|
|
export enum PurchaseOrderStatus {
|
|
DRAFT = 'draft',
|
|
PENDING = 'pending',
|
|
APPROVED = 'approved',
|
|
SENT = 'sent',
|
|
PARTIALLY_RECEIVED = 'partially_received',
|
|
RECEIVED = 'received',
|
|
CANCELLED = 'cancelled',
|
|
}
|
|
|
|
@Entity('purchase_orders', { schema: 'purchasing_management' })
|
|
export class PurchaseOrder {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ type: 'varchar', length: 20, unique: true })
|
|
code: string;
|
|
|
|
@Column({ name: 'constructora_id' })
|
|
constructoraId: string;
|
|
|
|
@ManyToOne(() => Constructora)
|
|
@JoinColumn({ name: 'constructora_id' })
|
|
constructora: Constructora;
|
|
|
|
@Column({ name: 'supplier_id' })
|
|
supplierId: string;
|
|
|
|
@ManyToOne(() => Supplier)
|
|
@JoinColumn({ name: 'supplier_id' })
|
|
supplier: Supplier;
|
|
|
|
@Column({ name: 'project_id' })
|
|
projectId: string;
|
|
|
|
@ManyToOne(() => Project)
|
|
@JoinColumn({ name: 'project_id' })
|
|
project: Project;
|
|
|
|
@Column({ name: 'requisition_id', nullable: true })
|
|
requisitionId: string;
|
|
|
|
@ManyToOne(() => Requisition, { nullable: true })
|
|
@JoinColumn({ name: 'requisition_id' })
|
|
requisition: Requisition;
|
|
|
|
@Column({ type: 'date', name: 'order_date' })
|
|
orderDate: Date;
|
|
|
|
@Column({ type: 'date', name: 'delivery_date' })
|
|
deliveryDate: Date;
|
|
|
|
@Column({ type: 'text', name: 'delivery_address' })
|
|
deliveryAddress: string;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 2 })
|
|
subtotal: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 2 })
|
|
tax: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 2 })
|
|
total: number;
|
|
|
|
@Column({ type: 'varchar', length: 50, nullable: true, name: 'payment_terms' })
|
|
paymentTerms: string;
|
|
|
|
@Column({ type: 'int', default: 30, name: 'payment_terms_days' })
|
|
paymentTermsDays: number;
|
|
|
|
@Column({
|
|
type: 'decimal',
|
|
precision: 5,
|
|
scale: 2,
|
|
default: 0,
|
|
name: 'early_payment_discount',
|
|
})
|
|
earlyPaymentDiscount: number;
|
|
|
|
@Column({ type: 'boolean', default: false, name: 'requires_advance' })
|
|
requiresAdvance: boolean;
|
|
|
|
@Column({
|
|
type: 'decimal',
|
|
precision: 5,
|
|
scale: 2,
|
|
nullable: true,
|
|
name: 'advance_percentage',
|
|
})
|
|
advancePercentage: number;
|
|
|
|
@Column({ type: 'boolean', default: true, name: 'includes_unloading' })
|
|
includesUnloading: boolean;
|
|
|
|
@Column({ type: 'int', default: 30, name: 'warranty_days' })
|
|
warrantyDays: number;
|
|
|
|
@Column({ type: 'text', nullable: true, name: 'special_conditions' })
|
|
specialConditions: string;
|
|
|
|
@Column({
|
|
type: 'enum',
|
|
enum: PurchaseOrderStatus,
|
|
default: PurchaseOrderStatus.DRAFT,
|
|
})
|
|
status: PurchaseOrderStatus;
|
|
|
|
@Column({ name: 'approved_by', nullable: true })
|
|
approvedBy: string;
|
|
|
|
@ManyToOne(() => User, { nullable: true })
|
|
@JoinColumn({ name: 'approved_by' })
|
|
approver: User;
|
|
|
|
@Column({ type: 'timestamp', nullable: true, name: 'approved_at' })
|
|
approvedAt: Date;
|
|
|
|
@Column({ type: 'timestamp', nullable: true, name: 'sent_to_supplier_at' })
|
|
sentToSupplierAt: Date;
|
|
|
|
@OneToMany(() => PurchaseOrderItem, (item) => item.purchaseOrder, {
|
|
cascade: true,
|
|
})
|
|
items: PurchaseOrderItem[];
|
|
|
|
@OneToMany(() => PurchaseOrderReceipt, (receipt) => receipt.purchaseOrder)
|
|
receipts: PurchaseOrderReceipt[];
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
|
|
@UpdateDateColumn({ name: 'updated_at' })
|
|
updatedAt: Date;
|
|
}
|
|
```
|
|
|
|
### 3.5 Warehouse Entity
|
|
|
|
```typescript
|
|
// src/modules/warehouses/entities/warehouse.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToOne,
|
|
OneToMany,
|
|
JoinColumn,
|
|
CreateDateColumn,
|
|
UpdateDateColumn,
|
|
} from 'typeorm';
|
|
import { Project } from '../../projects/entities/project.entity';
|
|
import { Constructora } from '../../tenants/entities/constructora.entity';
|
|
import { User } from '../../auth/entities/user.entity';
|
|
import { WarehouseLocation } from './warehouse-location.entity';
|
|
import { InventoryStock } from '../../inventory/entities/inventory-stock.entity';
|
|
|
|
export enum WarehouseType {
|
|
MAIN = 'main',
|
|
SECONDARY = 'secondary',
|
|
TEMPORARY = 'temporary',
|
|
}
|
|
|
|
@Entity('warehouses', { schema: 'inventory_management' })
|
|
export class Warehouse {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ type: 'varchar', length: 20, unique: true })
|
|
code: string;
|
|
|
|
@Column({ type: 'varchar', length: 255 })
|
|
name: string;
|
|
|
|
@Column({ name: 'constructora_id' })
|
|
constructoraId: string;
|
|
|
|
@ManyToOne(() => Constructora)
|
|
@JoinColumn({ name: 'constructora_id' })
|
|
constructora: Constructora;
|
|
|
|
@Column({ name: 'project_id' })
|
|
projectId: string;
|
|
|
|
@ManyToOne(() => Project)
|
|
@JoinColumn({ name: 'project_id' })
|
|
project: Project;
|
|
|
|
@Column({
|
|
type: 'enum',
|
|
enum: WarehouseType,
|
|
default: WarehouseType.MAIN,
|
|
})
|
|
type: WarehouseType;
|
|
|
|
@Column({ type: 'text', nullable: true })
|
|
description: string;
|
|
|
|
@Column({ type: 'text', nullable: true })
|
|
address: string;
|
|
|
|
@Column({ name: 'responsible_id', nullable: true })
|
|
responsibleId: string;
|
|
|
|
@ManyToOne(() => User, { nullable: true })
|
|
@JoinColumn({ name: 'responsible_id' })
|
|
responsible: User;
|
|
|
|
@Column({ type: 'boolean', default: true })
|
|
active: boolean;
|
|
|
|
@OneToMany(() => WarehouseLocation, (location) => location.warehouse, {
|
|
cascade: true,
|
|
})
|
|
locations: WarehouseLocation[];
|
|
|
|
@OneToMany(() => InventoryStock, (stock) => stock.warehouse)
|
|
stocks: InventoryStock[];
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
|
|
@UpdateDateColumn({ name: 'updated_at' })
|
|
updatedAt: Date;
|
|
}
|
|
```
|
|
|
|
### 3.6 Inventory Stock Entity
|
|
|
|
```typescript
|
|
// src/modules/inventory/entities/inventory-stock.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToOne,
|
|
JoinColumn,
|
|
CreateDateColumn,
|
|
UpdateDateColumn,
|
|
} from 'typeorm';
|
|
import { Warehouse } from '../../warehouses/entities/warehouse.entity';
|
|
import { Material } from '../../catalogs/entities/material.entity';
|
|
|
|
@Entity('inventory_stock', { schema: 'inventory_management' })
|
|
export class InventoryStock {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ name: 'warehouse_id' })
|
|
warehouseId: string;
|
|
|
|
@ManyToOne(() => Warehouse)
|
|
@JoinColumn({ name: 'warehouse_id' })
|
|
warehouse: Warehouse;
|
|
|
|
@Column({ name: 'material_id' })
|
|
materialId: string;
|
|
|
|
@ManyToOne(() => Material)
|
|
@JoinColumn({ name: 'material_id' })
|
|
material: Material;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 4, default: 0 })
|
|
quantity: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'unit_cost' })
|
|
unitCost: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, name: 'total_value' })
|
|
totalValue: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 4, nullable: true, name: 'min_level' })
|
|
minLevel: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 4, nullable: true, name: 'max_level' })
|
|
maxLevel: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 4, nullable: true, name: 'reorder_point' })
|
|
reorderPoint: number;
|
|
|
|
@Column({ type: 'varchar', length: 50, nullable: true, name: 'last_lot' })
|
|
lastLot: string;
|
|
|
|
@Column({ type: 'timestamp', nullable: true, name: 'last_movement_at' })
|
|
lastMovementAt: Date;
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
|
|
@UpdateDateColumn({ name: 'updated_at' })
|
|
updatedAt: Date;
|
|
}
|
|
```
|
|
|
|
### 3.7 Inventory Movement Entity
|
|
|
|
```typescript
|
|
// src/modules/inventory/entities/inventory-movement.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
ManyToOne,
|
|
JoinColumn,
|
|
CreateDateColumn,
|
|
} from 'typeorm';
|
|
import { Warehouse } from '../../warehouses/entities/warehouse.entity';
|
|
import { Material } from '../../catalogs/entities/material.entity';
|
|
import { User } from '../../auth/entities/user.entity';
|
|
import { PurchaseOrderReceipt } from '../../purchase-orders/entities/purchase-order-receipt.entity';
|
|
import { BudgetItem } from '../../budgets/entities/budget-item.entity';
|
|
|
|
export enum MovementType {
|
|
ENTRY = 'entry',
|
|
EXIT = 'exit',
|
|
TRANSFER_OUT = 'transfer_out',
|
|
TRANSFER_IN = 'transfer_in',
|
|
ADJUSTMENT = 'adjustment',
|
|
}
|
|
|
|
@Entity('inventory_movements', { schema: 'inventory_management' })
|
|
export class InventoryMovement {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ type: 'varchar', length: 20, unique: true })
|
|
code: string;
|
|
|
|
@Column({ name: 'warehouse_id' })
|
|
warehouseId: string;
|
|
|
|
@ManyToOne(() => Warehouse)
|
|
@JoinColumn({ name: 'warehouse_id' })
|
|
warehouse: Warehouse;
|
|
|
|
@Column({ name: 'material_id' })
|
|
materialId: string;
|
|
|
|
@ManyToOne(() => Material)
|
|
@JoinColumn({ name: 'material_id' })
|
|
material: Material;
|
|
|
|
@Column({
|
|
type: 'enum',
|
|
enum: MovementType,
|
|
})
|
|
type: MovementType;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 4 })
|
|
quantity: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 2, name: 'unit_cost' })
|
|
unitCost: number;
|
|
|
|
@Column({ type: 'decimal', precision: 15, scale: 2, name: 'total_cost' })
|
|
totalCost: number;
|
|
|
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
|
lot: string;
|
|
|
|
@Column({ type: 'date', nullable: true, name: 'expiry_date' })
|
|
expiryDate: Date;
|
|
|
|
@Column({ type: 'date', name: 'movement_date' })
|
|
movementDate: Date;
|
|
|
|
@Column({ name: 'created_by' })
|
|
createdBy: string;
|
|
|
|
@ManyToOne(() => User)
|
|
@JoinColumn({ name: 'created_by' })
|
|
creator: User;
|
|
|
|
// Referencias opcionales según tipo de movimiento
|
|
@Column({ name: 'receipt_id', nullable: true })
|
|
receiptId: string;
|
|
|
|
@ManyToOne(() => PurchaseOrderReceipt, { nullable: true })
|
|
@JoinColumn({ name: 'receipt_id' })
|
|
receipt: PurchaseOrderReceipt;
|
|
|
|
@Column({ name: 'budget_item_id', nullable: true })
|
|
budgetItemId: string;
|
|
|
|
@ManyToOne(() => BudgetItem, { nullable: true })
|
|
@JoinColumn({ name: 'budget_item_id' })
|
|
budgetItem: BudgetItem;
|
|
|
|
@Column({ name: 'transfer_id', nullable: true })
|
|
transferId: string;
|
|
|
|
@Column({ type: 'text', nullable: true })
|
|
notes: string;
|
|
|
|
@Column({ type: 'varchar', length: 255, nullable: true, name: 'signature_url' })
|
|
signatureUrl: string;
|
|
|
|
@Column({ type: 'simple-array', nullable: true, name: 'photo_urls' })
|
|
photoUrls: string[];
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
}
|
|
```
|
|
|
|
### 3.8 DTOs - Create Requisition
|
|
|
|
```typescript
|
|
// src/modules/requisitions/dto/create-requisition.dto.ts
|
|
import { IsString, IsUUID, IsDate, IsEnum, IsOptional, IsArray, ValidateNested, IsNumber, Min } from 'class-validator';
|
|
import { Type } from 'class-transformer';
|
|
import { ApiProperty } from '@nestjs/swagger';
|
|
import { RequisitionUrgency } from '../entities/requisition.entity';
|
|
|
|
export class CreateRequisitionItemDto {
|
|
@ApiProperty({ description: 'ID del material' })
|
|
@IsUUID()
|
|
materialId: string;
|
|
|
|
@ApiProperty({ description: 'Descripción del material' })
|
|
@IsString()
|
|
description: string;
|
|
|
|
@ApiProperty({ description: 'Cantidad solicitada' })
|
|
@IsNumber()
|
|
@Min(0.0001)
|
|
quantity: number;
|
|
|
|
@ApiProperty({ description: 'Unidad de medida' })
|
|
@IsString()
|
|
unit: string;
|
|
|
|
@ApiProperty({ description: 'Precio presupuestado', required: false })
|
|
@IsOptional()
|
|
@IsNumber()
|
|
budgetedPrice?: number;
|
|
|
|
@ApiProperty({ description: 'ID de partida presupuestal', required: false })
|
|
@IsOptional()
|
|
@IsUUID()
|
|
budgetItemId?: string;
|
|
|
|
@ApiProperty({ description: 'Notas adicionales', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
notes?: string;
|
|
}
|
|
|
|
export class CreateRequisitionDto {
|
|
@ApiProperty({ description: 'ID del proyecto/obra' })
|
|
@IsUUID()
|
|
projectId: string;
|
|
|
|
@ApiProperty({ description: 'Fecha requerida de entrega' })
|
|
@IsDate()
|
|
@Type(() => Date)
|
|
requiredDate: Date;
|
|
|
|
@ApiProperty({
|
|
description: 'Nivel de urgencia',
|
|
enum: RequisitionUrgency,
|
|
default: RequisitionUrgency.NORMAL
|
|
})
|
|
@IsEnum(RequisitionUrgency)
|
|
@IsOptional()
|
|
urgency?: RequisitionUrgency;
|
|
|
|
@ApiProperty({ description: 'Justificación de la requisición' })
|
|
@IsString()
|
|
justification: string;
|
|
|
|
@ApiProperty({
|
|
description: 'Items de la requisición',
|
|
type: [CreateRequisitionItemDto]
|
|
})
|
|
@IsArray()
|
|
@ValidateNested({ each: true })
|
|
@Type(() => CreateRequisitionItemDto)
|
|
items: CreateRequisitionItemDto[];
|
|
}
|
|
```
|
|
|
|
### 3.9 DTOs - Create Purchase Order
|
|
|
|
```typescript
|
|
// src/modules/purchase-orders/dto/create-purchase-order.dto.ts
|
|
import { IsString, IsUUID, IsDate, IsOptional, IsArray, ValidateNested, IsNumber, IsBoolean, Min } from 'class-validator';
|
|
import { Type } from 'class-transformer';
|
|
import { ApiProperty } from '@nestjs/swagger';
|
|
|
|
export class CreatePurchaseOrderItemDto {
|
|
@ApiProperty({ description: 'ID del material' })
|
|
@IsUUID()
|
|
materialId: string;
|
|
|
|
@ApiProperty({ description: 'Descripción del material' })
|
|
@IsString()
|
|
description: string;
|
|
|
|
@ApiProperty({ description: 'Cantidad a comprar' })
|
|
@IsNumber()
|
|
@Min(0.0001)
|
|
quantity: number;
|
|
|
|
@ApiProperty({ description: 'Unidad de medida' })
|
|
@IsString()
|
|
unit: string;
|
|
|
|
@ApiProperty({ description: 'Precio unitario' })
|
|
@IsNumber()
|
|
@Min(0)
|
|
unitPrice: number;
|
|
|
|
@ApiProperty({ description: 'ID de partida presupuestal', required: false })
|
|
@IsOptional()
|
|
@IsUUID()
|
|
budgetItemId?: string;
|
|
}
|
|
|
|
export class CreatePurchaseOrderDto {
|
|
@ApiProperty({ description: 'ID del proveedor' })
|
|
@IsUUID()
|
|
supplierId: string;
|
|
|
|
@ApiProperty({ description: 'ID del proyecto/obra' })
|
|
@IsUUID()
|
|
projectId: string;
|
|
|
|
@ApiProperty({ description: 'ID de requisición (si aplica)', required: false })
|
|
@IsOptional()
|
|
@IsUUID()
|
|
requisitionId?: string;
|
|
|
|
@ApiProperty({ description: 'Fecha de la orden' })
|
|
@IsDate()
|
|
@Type(() => Date)
|
|
orderDate: Date;
|
|
|
|
@ApiProperty({ description: 'Fecha de entrega esperada' })
|
|
@IsDate()
|
|
@Type(() => Date)
|
|
deliveryDate: Date;
|
|
|
|
@ApiProperty({ description: 'Dirección de entrega' })
|
|
@IsString()
|
|
deliveryAddress: string;
|
|
|
|
@ApiProperty({ description: 'Términos de pago', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
paymentTerms?: string;
|
|
|
|
@ApiProperty({ description: 'Días de crédito', default: 30 })
|
|
@IsOptional()
|
|
@IsNumber()
|
|
paymentTermsDays?: number;
|
|
|
|
@ApiProperty({ description: 'Descuento por pronto pago (%)', required: false })
|
|
@IsOptional()
|
|
@IsNumber()
|
|
earlyPaymentDiscount?: number;
|
|
|
|
@ApiProperty({ description: 'Requiere anticipo', default: false })
|
|
@IsOptional()
|
|
@IsBoolean()
|
|
requiresAdvance?: boolean;
|
|
|
|
@ApiProperty({ description: 'Porcentaje de anticipo', required: false })
|
|
@IsOptional()
|
|
@IsNumber()
|
|
advancePercentage?: number;
|
|
|
|
@ApiProperty({ description: 'Incluye descarga', default: true })
|
|
@IsOptional()
|
|
@IsBoolean()
|
|
includesUnloading?: boolean;
|
|
|
|
@ApiProperty({ description: 'Días de garantía', default: 30 })
|
|
@IsOptional()
|
|
@IsNumber()
|
|
warrantyDays?: number;
|
|
|
|
@ApiProperty({ description: 'Condiciones especiales', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
specialConditions?: string;
|
|
|
|
@ApiProperty({
|
|
description: 'Items de la orden',
|
|
type: [CreatePurchaseOrderItemDto]
|
|
})
|
|
@IsArray()
|
|
@ValidateNested({ each: true })
|
|
@Type(() => CreatePurchaseOrderItemDto)
|
|
items: CreatePurchaseOrderItemDto[];
|
|
}
|
|
```
|
|
|
|
### 3.10 DTOs - Create Inventory Movement
|
|
|
|
```typescript
|
|
// src/modules/inventory/dto/create-inventory-movement.dto.ts
|
|
import { IsString, IsUUID, IsDate, IsEnum, IsOptional, IsNumber, IsArray, Min } from 'class-validator';
|
|
import { Type } from 'class-transformer';
|
|
import { ApiProperty } from '@nestjs/swagger';
|
|
import { MovementType } from '../entities/inventory-movement.entity';
|
|
|
|
export class CreateInventoryMovementDto {
|
|
@ApiProperty({ description: 'ID del almacén' })
|
|
@IsUUID()
|
|
warehouseId: string;
|
|
|
|
@ApiProperty({ description: 'ID del material' })
|
|
@IsUUID()
|
|
materialId: string;
|
|
|
|
@ApiProperty({
|
|
description: 'Tipo de movimiento',
|
|
enum: MovementType
|
|
})
|
|
@IsEnum(MovementType)
|
|
type: MovementType;
|
|
|
|
@ApiProperty({ description: 'Cantidad del movimiento' })
|
|
@IsNumber()
|
|
@Min(0.0001)
|
|
quantity: number;
|
|
|
|
@ApiProperty({ description: 'Costo unitario' })
|
|
@IsNumber()
|
|
@Min(0)
|
|
unitCost: number;
|
|
|
|
@ApiProperty({ description: 'Número de lote', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
lot?: string;
|
|
|
|
@ApiProperty({ description: 'Fecha de vencimiento', required: false })
|
|
@IsOptional()
|
|
@IsDate()
|
|
@Type(() => Date)
|
|
expiryDate?: Date;
|
|
|
|
@ApiProperty({ description: 'Fecha del movimiento' })
|
|
@IsDate()
|
|
@Type(() => Date)
|
|
movementDate: Date;
|
|
|
|
@ApiProperty({ description: 'ID de recepción (para entradas)', required: false })
|
|
@IsOptional()
|
|
@IsUUID()
|
|
receiptId?: string;
|
|
|
|
@ApiProperty({ description: 'ID de partida presupuestal (para salidas)', required: false })
|
|
@IsOptional()
|
|
@IsUUID()
|
|
budgetItemId?: string;
|
|
|
|
@ApiProperty({ description: 'ID de transferencia', required: false })
|
|
@IsOptional()
|
|
@IsUUID()
|
|
transferId?: string;
|
|
|
|
@ApiProperty({ description: 'Notas del movimiento', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
notes?: string;
|
|
|
|
@ApiProperty({ description: 'URL de firma digital', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
signatureUrl?: string;
|
|
|
|
@ApiProperty({ description: 'URLs de fotos de evidencia', type: [String], required: false })
|
|
@IsOptional()
|
|
@IsArray()
|
|
@IsString({ each: true })
|
|
photoUrls?: string[];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Services y Business Logic
|
|
|
|
### 4.1 RequisitionsService
|
|
|
|
```typescript
|
|
// src/modules/requisitions/services/requisitions.service.ts
|
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { Requisition, RequisitionStatus } from '../entities/requisition.entity';
|
|
import { RequisitionItem } from '../entities/requisition-item.entity';
|
|
import { CreateRequisitionDto } from '../dto/create-requisition.dto';
|
|
import { UpdateRequisitionDto } from '../dto/update-requisition.dto';
|
|
import { BudgetValidationService } from '../../shared/budget-validation/budget-validation.service';
|
|
import { NotificationService } from '../../shared/notification/notification.service';
|
|
import { RequisitionApprovalService } from './requisition-approval.service';
|
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
|
|
@Injectable()
|
|
export class RequisitionsService {
|
|
constructor(
|
|
@InjectRepository(Requisition)
|
|
private requisitionsRepository: Repository<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
|
|
|
|
```typescript
|
|
// src/modules/inventory/services/inventory.service.ts
|
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { InventoryStock } from '../entities/inventory-stock.entity';
|
|
import { InventoryMovement, MovementType } from '../entities/inventory-movement.entity';
|
|
import { CreateInventoryMovementDto } from '../dto/create-inventory-movement.dto';
|
|
import { CostCalculationService } from './cost-calculation.service';
|
|
import { KardexService } from './kardex.service';
|
|
import { NotificationService } from '../../shared/notification/notification.service';
|
|
import { WebSocketGateway } from '../../shared/websocket/websocket.gateway';
|
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
|
|
@Injectable()
|
|
export class InventoryService {
|
|
constructor(
|
|
@InjectRepository(InventoryStock)
|
|
private stockRepository: Repository<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
|
|
|
|
```typescript
|
|
// src/modules/requisitions/controllers/requisitions.controller.ts
|
|
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Body,
|
|
Patch,
|
|
Param,
|
|
Delete,
|
|
Query,
|
|
UseGuards,
|
|
Request,
|
|
} from '@nestjs/common';
|
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { RequisitionsService } from '../services/requisitions.service';
|
|
import { CreateRequisitionDto } from '../dto/create-requisition.dto';
|
|
import { UpdateRequisitionDto } from '../dto/update-requisition.dto';
|
|
import { ApproveRequisitionDto } from '../dto/approve-requisition.dto';
|
|
import { RejectRequisitionDto } from '../dto/reject-requisition.dto';
|
|
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
|
import { RolesGuard } from '../../../common/guards/roles.guard';
|
|
import { Roles } from '../../../common/decorators/roles.decorator';
|
|
import { UserRole } from '../../auth/enums/user-role.enum';
|
|
|
|
@ApiTags('Requisiciones')
|
|
@ApiBearerAuth()
|
|
@Controller('api/v1/requisitions')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
export class RequisitionsController {
|
|
constructor(private readonly requisitionsService: RequisitionsService) {}
|
|
|
|
@Post()
|
|
@Roles(UserRole.RESIDENTE, UserRole.SUPERINTENDENTE, UserRole.JEFE_COMPRAS)
|
|
@ApiOperation({ summary: 'Crear nueva requisición' })
|
|
@ApiResponse({ status: 201, description: 'Requisición creada exitosamente' })
|
|
create(@Body() createRequisitionDto: CreateRequisitionDto, @Request() req) {
|
|
return this.requisitionsService.create(
|
|
createRequisitionDto,
|
|
req.user.id,
|
|
req.user.constructoraId,
|
|
);
|
|
}
|
|
|
|
@Post(':id/submit')
|
|
@Roles(UserRole.RESIDENTE, UserRole.SUPERINTENDENTE, UserRole.JEFE_COMPRAS)
|
|
@ApiOperation({ summary: 'Enviar requisición para aprobación' })
|
|
@ApiResponse({ status: 200, description: 'Requisición enviada a aprobación' })
|
|
submit(@Param('id') id: string, @Request() req) {
|
|
return this.requisitionsService.submit(id, req.user.id);
|
|
}
|
|
|
|
@Post(':id/approve')
|
|
@Roles(UserRole.SUPERINTENDENTE, UserRole.JEFE_COMPRAS, UserRole.DIRECTOR)
|
|
@ApiOperation({ summary: 'Aprobar requisición' })
|
|
@ApiResponse({ status: 200, description: 'Requisición aprobada' })
|
|
approve(
|
|
@Param('id') id: string,
|
|
@Body() approveDto: ApproveRequisitionDto,
|
|
@Request() req,
|
|
) {
|
|
return this.requisitionsService.approve(id, req.user.id, approveDto.comments);
|
|
}
|
|
|
|
@Post(':id/reject')
|
|
@Roles(UserRole.SUPERINTENDENTE, UserRole.JEFE_COMPRAS, UserRole.DIRECTOR)
|
|
@ApiOperation({ summary: 'Rechazar requisición' })
|
|
@ApiResponse({ status: 200, description: 'Requisición rechazada' })
|
|
reject(
|
|
@Param('id') id: string,
|
|
@Body() rejectDto: RejectRequisitionDto,
|
|
@Request() req,
|
|
) {
|
|
return this.requisitionsService.reject(id, req.user.id, rejectDto.reason);
|
|
}
|
|
|
|
@Get()
|
|
@ApiOperation({ summary: 'Listar requisiciones' })
|
|
@ApiResponse({ status: 200, description: 'Lista de requisiciones' })
|
|
findAll(@Query() filters, @Request() req) {
|
|
return this.requisitionsService.findAll(req.user.constructoraId, filters);
|
|
}
|
|
|
|
@Get(':id')
|
|
@ApiOperation({ summary: 'Obtener requisición por ID' })
|
|
@ApiResponse({ status: 200, description: 'Detalles de la requisición' })
|
|
findOne(@Param('id') id: string) {
|
|
return this.requisitionsService.findOne(id);
|
|
}
|
|
|
|
@Patch(':id')
|
|
@Roles(UserRole.RESIDENTE, UserRole.SUPERINTENDENTE, UserRole.JEFE_COMPRAS)
|
|
@ApiOperation({ summary: 'Actualizar requisición' })
|
|
@ApiResponse({ status: 200, description: 'Requisición actualizada' })
|
|
update(
|
|
@Param('id') id: string,
|
|
@Body() updateRequisitionDto: UpdateRequisitionDto,
|
|
) {
|
|
return this.requisitionsService.update(id, updateRequisitionDto);
|
|
}
|
|
|
|
@Delete(':id')
|
|
@Roles(UserRole.RESIDENTE, UserRole.JEFE_COMPRAS)
|
|
@ApiOperation({ summary: 'Eliminar requisición' })
|
|
@ApiResponse({ status: 200, description: 'Requisición eliminada' })
|
|
remove(@Param('id') id: string) {
|
|
return this.requisitionsService.remove(id);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.2 InventoryController
|
|
|
|
```typescript
|
|
// src/modules/inventory/controllers/inventory.controller.ts
|
|
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
UseGuards,
|
|
Request,
|
|
} from '@nestjs/common';
|
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { InventoryService } from '../services/inventory.service';
|
|
import { CreateInventoryMovementDto } from '../dto/create-inventory-movement.dto';
|
|
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
|
import { RolesGuard } from '../../../common/guards/roles.guard';
|
|
import { Roles } from '../../../common/decorators/roles.decorator';
|
|
import { UserRole } from '../../auth/enums/user-role.enum';
|
|
|
|
@ApiTags('Inventario')
|
|
@ApiBearerAuth()
|
|
@Controller('api/v1/inventory')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
export class InventoryController {
|
|
constructor(private readonly inventoryService: InventoryService) {}
|
|
|
|
@Get('warehouses/:warehouseId/stock')
|
|
@ApiOperation({ summary: 'Obtener stock de un almacén' })
|
|
@ApiResponse({ status: 200, description: 'Stock del almacén' })
|
|
getWarehouseStock(@Param('warehouseId') warehouseId: string) {
|
|
return this.inventoryService.getWarehouseStock(warehouseId);
|
|
}
|
|
|
|
@Get('warehouses/:warehouseId/stock/:materialId')
|
|
@ApiOperation({ summary: 'Obtener stock de un material en almacén' })
|
|
@ApiResponse({ status: 200, description: 'Stock del material' })
|
|
getStock(
|
|
@Param('warehouseId') warehouseId: string,
|
|
@Param('materialId') materialId: string,
|
|
) {
|
|
return this.inventoryService.getStock(warehouseId, materialId);
|
|
}
|
|
|
|
@Get('warehouses/:warehouseId/low-stock')
|
|
@ApiOperation({ summary: 'Obtener materiales con stock bajo' })
|
|
@ApiResponse({ status: 200, description: 'Materiales con stock bajo' })
|
|
getLowStockItems(@Param('warehouseId') warehouseId: string) {
|
|
return this.inventoryService.getLowStockItems(warehouseId);
|
|
}
|
|
|
|
@Post('movements')
|
|
@Roles(UserRole.ALMACENISTA, UserRole.JEFE_ALMACEN)
|
|
@ApiOperation({ summary: 'Crear movimiento de inventario' })
|
|
@ApiResponse({ status: 201, description: 'Movimiento creado exitosamente' })
|
|
createMovement(
|
|
@Body() createMovementDto: CreateInventoryMovementDto,
|
|
@Request() req,
|
|
) {
|
|
return this.inventoryService.createMovement(createMovementDto, req.user.id);
|
|
}
|
|
|
|
@Get('warehouses/:warehouseId/movements')
|
|
@ApiOperation({ summary: 'Obtener movimientos de un almacén' })
|
|
@ApiResponse({ status: 200, description: 'Movimientos del almacén' })
|
|
getMovementsByWarehouse(
|
|
@Param('warehouseId') warehouseId: string,
|
|
@Query('startDate') startDate?: Date,
|
|
@Query('endDate') endDate?: Date,
|
|
) {
|
|
return this.inventoryService.getMovementsByWarehouse(
|
|
warehouseId,
|
|
startDate,
|
|
endDate,
|
|
);
|
|
}
|
|
|
|
@Get('movements/:id')
|
|
@ApiOperation({ summary: 'Obtener movimiento por ID' })
|
|
@ApiResponse({ status: 200, description: 'Detalles del movimiento' })
|
|
findMovement(@Param('id') id: string) {
|
|
return this.inventoryService.findMovement(id);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Endpoints para App Móvil MOB-002
|
|
|
|
### 6.1 Mobile Inventory Controller
|
|
|
|
```typescript
|
|
// src/modules/inventory/controllers/mobile-inventory.controller.ts
|
|
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Body,
|
|
Param,
|
|
UseGuards,
|
|
Request,
|
|
UploadedFiles,
|
|
UseInterceptors,
|
|
} from '@nestjs/common';
|
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
|
import { InventoryService } from '../services/inventory.service';
|
|
import { CreateMobileReceiptDto } from '../dto/create-mobile-receipt.dto';
|
|
import { CreateMobileExitDto } from '../dto/create-mobile-exit.dto';
|
|
import { CreateStockCountDto } from '../dto/create-stock-count.dto';
|
|
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
|
import { FileUploadService } from '../../shared/file-upload/file-upload.service';
|
|
|
|
@ApiTags('Mobile - Inventario')
|
|
@ApiBearerAuth()
|
|
@Controller('api/v1/mobile/inventory')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class MobileInventoryController {
|
|
constructor(
|
|
private readonly inventoryService: InventoryService,
|
|
private readonly fileUploadService: FileUploadService,
|
|
) {}
|
|
|
|
@Post('receipts')
|
|
@UseInterceptors(FilesInterceptor('photos', 5))
|
|
@ApiConsumes('multipart/form-data')
|
|
@ApiOperation({ summary: '[MÓVIL] Registrar recepción de material' })
|
|
@ApiResponse({ status: 201, description: 'Recepción registrada exitosamente' })
|
|
async createReceipt(
|
|
@Body() createReceiptDto: CreateMobileReceiptDto,
|
|
@UploadedFiles() files: Express.Multer.File[],
|
|
@Request() req,
|
|
) {
|
|
// Subir fotos a S3/MinIO
|
|
const photoUrls = await Promise.all(
|
|
files.map((file) => this.fileUploadService.uploadImage(file, 'receipts')),
|
|
);
|
|
|
|
return this.inventoryService.createMobileReceipt({
|
|
...createReceiptDto,
|
|
photoUrls,
|
|
userId: req.user.id,
|
|
});
|
|
}
|
|
|
|
@Post('exits')
|
|
@UseInterceptors(FilesInterceptor('photos', 3))
|
|
@ApiConsumes('multipart/form-data')
|
|
@ApiOperation({ summary: '[MÓVIL] Registrar salida de material' })
|
|
@ApiResponse({ status: 201, description: 'Salida registrada exitosamente' })
|
|
async createExit(
|
|
@Body() createExitDto: CreateMobileExitDto,
|
|
@UploadedFiles() files: Express.Multer.File[],
|
|
@Request() req,
|
|
) {
|
|
// Subir fotos a S3/MinIO
|
|
const photoUrls = await Promise.all(
|
|
files.map((file) => this.fileUploadService.uploadImage(file, 'exits')),
|
|
);
|
|
|
|
return this.inventoryService.createMobileExit({
|
|
...createExitDto,
|
|
photoUrls,
|
|
userId: req.user.id,
|
|
});
|
|
}
|
|
|
|
@Post('stock-counts')
|
|
@ApiOperation({ summary: '[MÓVIL] Registrar conteo físico' })
|
|
@ApiResponse({ status: 201, description: 'Conteo registrado exitosamente' })
|
|
createStockCount(
|
|
@Body() createCountDto: CreateStockCountDto,
|
|
@Request() req,
|
|
) {
|
|
return this.inventoryService.createStockCount({
|
|
...createCountDto,
|
|
userId: req.user.id,
|
|
});
|
|
}
|
|
|
|
@Get('warehouses/:warehouseId/pending-receipts')
|
|
@ApiOperation({ summary: '[MÓVIL] Obtener recepciones pendientes' })
|
|
@ApiResponse({ status: 200, description: 'Recepciones pendientes' })
|
|
getPendingReceipts(@Param('warehouseId') warehouseId: string) {
|
|
return this.inventoryService.getPendingReceipts(warehouseId);
|
|
}
|
|
|
|
@Get('warehouses/:warehouseId/pending-exits')
|
|
@ApiOperation({ summary: '[MÓVIL] Obtener salidas pendientes' })
|
|
@ApiResponse({ status: 200, description: 'Salidas pendientes' })
|
|
getPendingExits(@Param('warehouseId') warehouseId: string) {
|
|
return this.inventoryService.getPendingExits(warehouseId);
|
|
}
|
|
|
|
@Get('materials/:materialId/search')
|
|
@ApiOperation({ summary: '[MÓVIL] Buscar material por código/nombre' })
|
|
@ApiResponse({ status: 200, description: 'Material encontrado' })
|
|
searchMaterial(@Param('materialId') materialId: string) {
|
|
return this.inventoryService.searchMaterial(materialId);
|
|
}
|
|
|
|
@Get('warehouses/:warehouseId/sync-data')
|
|
@ApiOperation({ summary: '[MÓVIL] Obtener datos para sincronización offline' })
|
|
@ApiResponse({ status: 200, description: 'Datos para sincronización' })
|
|
getSyncData(
|
|
@Param('warehouseId') warehouseId: string,
|
|
@Request() req,
|
|
) {
|
|
return this.inventoryService.getSyncData(warehouseId, req.user.id);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 6.2 DTOs para App Móvil
|
|
|
|
```typescript
|
|
// src/modules/inventory/dto/create-mobile-receipt.dto.ts
|
|
import { IsString, IsUUID, IsNumber, IsOptional, IsArray, IsDateString } from 'class-validator';
|
|
import { ApiProperty } from '@nestjs/swagger';
|
|
|
|
export class CreateMobileReceiptDto {
|
|
@ApiProperty({ description: 'ID de la orden de compra' })
|
|
@IsUUID()
|
|
purchaseOrderId: string;
|
|
|
|
@ApiProperty({ description: 'ID del almacén' })
|
|
@IsUUID()
|
|
warehouseId: string;
|
|
|
|
@ApiProperty({ description: 'ID del material' })
|
|
@IsUUID()
|
|
materialId: string;
|
|
|
|
@ApiProperty({ description: 'Cantidad recibida' })
|
|
@IsNumber()
|
|
receivedQuantity: number;
|
|
|
|
@ApiProperty({ description: 'Cantidad aceptada' })
|
|
@IsNumber()
|
|
acceptedQuantity: number;
|
|
|
|
@ApiProperty({ description: 'Cantidad rechazada', required: false })
|
|
@IsOptional()
|
|
@IsNumber()
|
|
rejectedQuantity?: number;
|
|
|
|
@ApiProperty({ description: 'Motivo de rechazo', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
rejectionReason?: string;
|
|
|
|
@ApiProperty({ description: 'Número de lote', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
lot?: string;
|
|
|
|
@ApiProperty({ description: 'Ubicación en almacén', required: false })
|
|
@IsOptional()
|
|
@IsUUID()
|
|
locationId?: string;
|
|
|
|
@ApiProperty({ description: 'Nota de entrega del proveedor', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
deliveryNote?: string;
|
|
|
|
@ApiProperty({ description: 'Empresa de transporte', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
transportCompany?: string;
|
|
|
|
@ApiProperty({ description: 'Notas adicionales', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
notes?: string;
|
|
|
|
@ApiProperty({ description: 'URL de firma digital', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
signatureUrl?: string;
|
|
|
|
@ApiProperty({ description: 'Coordenadas GPS', required: false })
|
|
@IsOptional()
|
|
gpsCoordinates?: { latitude: number; longitude: number };
|
|
|
|
@ApiProperty({ description: 'Timestamp local del dispositivo' })
|
|
@IsDateString()
|
|
localTimestamp: string;
|
|
}
|
|
|
|
// src/modules/inventory/dto/create-mobile-exit.dto.ts
|
|
export class CreateMobileExitDto {
|
|
@ApiProperty({ description: 'ID del almacén' })
|
|
@IsUUID()
|
|
warehouseId: string;
|
|
|
|
@ApiProperty({ description: 'ID del material' })
|
|
@IsUUID()
|
|
materialId: string;
|
|
|
|
@ApiProperty({ description: 'Cantidad entregada' })
|
|
@IsNumber()
|
|
quantity: number;
|
|
|
|
@ApiProperty({ description: 'ID de partida presupuestal', required: false })
|
|
@IsOptional()
|
|
@IsUUID()
|
|
budgetItemId?: string;
|
|
|
|
@ApiProperty({ description: 'Frente de obra', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
workFront?: string;
|
|
|
|
@ApiProperty({ description: 'ID de quien recibe' })
|
|
@IsUUID()
|
|
receivedById: string;
|
|
|
|
@ApiProperty({ description: 'Nombre de quien recibe' })
|
|
@IsString()
|
|
receivedByName: string;
|
|
|
|
@ApiProperty({ description: 'URL de firma digital' })
|
|
@IsString()
|
|
signatureUrl: string;
|
|
|
|
@ApiProperty({ description: 'Notas adicionales', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
notes?: string;
|
|
|
|
@ApiProperty({ description: 'Coordenadas GPS', required: false })
|
|
@IsOptional()
|
|
gpsCoordinates?: { latitude: number; longitude: number };
|
|
|
|
@ApiProperty({ description: 'Timestamp local del dispositivo' })
|
|
@IsDateString()
|
|
localTimestamp: string;
|
|
}
|
|
|
|
// src/modules/inventory/dto/create-stock-count.dto.ts
|
|
export class StockCountItemDto {
|
|
@ApiProperty({ description: 'ID del material' })
|
|
@IsUUID()
|
|
materialId: string;
|
|
|
|
@ApiProperty({ description: 'Cantidad física contada' })
|
|
@IsNumber()
|
|
physicalQuantity: number;
|
|
|
|
@ApiProperty({ description: 'Cantidad en sistema' })
|
|
@IsNumber()
|
|
systemQuantity: number;
|
|
|
|
@ApiProperty({ description: 'Diferencia' })
|
|
@IsNumber()
|
|
difference: number;
|
|
}
|
|
|
|
export class CreateStockCountDto {
|
|
@ApiProperty({ description: 'ID del almacén' })
|
|
@IsUUID()
|
|
warehouseId: string;
|
|
|
|
@ApiProperty({ description: 'Tipo de conteo: completo o cíclico' })
|
|
@IsString()
|
|
countType: 'full' | 'cycle';
|
|
|
|
@ApiProperty({ description: 'Items contados', type: [StockCountItemDto] })
|
|
@IsArray()
|
|
items: StockCountItemDto[];
|
|
|
|
@ApiProperty({ description: 'Notas del conteo', required: false })
|
|
@IsOptional()
|
|
@IsString()
|
|
notes?: string;
|
|
|
|
@ApiProperty({ description: 'Timestamp local del dispositivo' })
|
|
@IsDateString()
|
|
localTimestamp: string;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. WebSocket para Sincronización en Tiempo Real
|
|
|
|
### 7.1 WebSocket Gateway
|
|
|
|
```typescript
|
|
// src/modules/shared/websocket/websocket.gateway.ts
|
|
import {
|
|
WebSocketGateway,
|
|
WebSocketServer,
|
|
SubscribeMessage,
|
|
OnGatewayConnection,
|
|
OnGatewayDisconnect,
|
|
ConnectedSocket,
|
|
MessageBody,
|
|
} from '@nestjs/websockets';
|
|
import { Server, Socket } from 'socket.io';
|
|
import { Logger, UseGuards } from '@nestjs/common';
|
|
import { WsJwtGuard } from '../../../common/guards/ws-jwt.guard';
|
|
|
|
@WebSocketGateway({
|
|
cors: {
|
|
origin: '*',
|
|
},
|
|
namespace: 'inventory',
|
|
})
|
|
@UseGuards(WsJwtGuard)
|
|
export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|
@WebSocketServer()
|
|
server: Server;
|
|
|
|
private logger = new Logger('WebSocketGateway');
|
|
private userSockets = new Map<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
|
|
|
|
```typescript
|
|
// src/modules/shared/websocket/websocket.module.ts
|
|
import { Module } from '@nestjs/common';
|
|
import { WebSocketGateway } from './websocket.gateway';
|
|
|
|
@Module({
|
|
providers: [WebSocketGateway],
|
|
exports: [WebSocketGateway],
|
|
})
|
|
export class WebSocketModule {}
|
|
```
|
|
|
|
### 7.3 WebSocket Events
|
|
|
|
```typescript
|
|
// Eventos emitidos por el servidor:
|
|
|
|
// 1. stock-updated
|
|
{
|
|
warehouseId: string;
|
|
materialId: string;
|
|
stock: {
|
|
quantity: number;
|
|
unitCost: number;
|
|
totalValue: number;
|
|
lastMovementAt: string;
|
|
};
|
|
timestamp: string;
|
|
}
|
|
|
|
// 2. new-movement
|
|
{
|
|
warehouseId: string;
|
|
movement: {
|
|
id: string;
|
|
code: string;
|
|
type: string;
|
|
materialId: string;
|
|
materialName: string;
|
|
quantity: number;
|
|
createdBy: string;
|
|
};
|
|
timestamp: string;
|
|
}
|
|
|
|
// 3. low-stock-alert
|
|
{
|
|
warehouseId: string;
|
|
material: {
|
|
id: string;
|
|
name: string;
|
|
currentQuantity: number;
|
|
minLevel: number;
|
|
reorderPoint: number;
|
|
};
|
|
timestamp: string;
|
|
}
|
|
|
|
// 4. new-requisition
|
|
{
|
|
projectId: string;
|
|
requisition: {
|
|
id: string;
|
|
code: string;
|
|
requester: string;
|
|
itemsCount: number;
|
|
estimatedTotal: number;
|
|
};
|
|
timestamp: string;
|
|
}
|
|
|
|
// 5. requisition-approved
|
|
{
|
|
requisition: {
|
|
id: string;
|
|
code: string;
|
|
status: string;
|
|
approvedBy: string;
|
|
};
|
|
timestamp: string;
|
|
}
|
|
|
|
// 6. requisition-rejected
|
|
{
|
|
requisition: {
|
|
id: string;
|
|
code: string;
|
|
status: string;
|
|
};
|
|
reason: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
// 7. new-purchase-order
|
|
{
|
|
purchaseOrder: {
|
|
id: string;
|
|
code: string;
|
|
supplier: string;
|
|
deliveryDate: string;
|
|
itemsCount: number;
|
|
};
|
|
timestamp: string;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Flujos de Aprobación
|
|
|
|
### 8.1 Approval Workflow Service
|
|
|
|
```typescript
|
|
// src/modules/shared/approval-workflow/approval-workflow.service.ts
|
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { RequisitionApproval, ApprovalStatus } from '../../requisitions/entities/requisition-approval.entity';
|
|
import { User } from '../../auth/entities/user.entity';
|
|
import { UserRole } from '../../auth/enums/user-role.enum';
|
|
|
|
interface ApprovalLevel {
|
|
level: number;
|
|
role: UserRole;
|
|
minAmount?: number;
|
|
maxAmount?: number;
|
|
}
|
|
|
|
@Injectable()
|
|
export class ApprovalWorkflowService {
|
|
constructor(
|
|
@InjectRepository(RequisitionApproval)
|
|
private approvalsRepository: Repository<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
|
|
|
|
```typescript
|
|
// src/modules/shared/budget-validation/budget-validation.service.ts
|
|
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { BudgetItem } from '../../budgets/entities/budget-item.entity';
|
|
|
|
@Injectable()
|
|
export class BudgetValidationService {
|
|
constructor(
|
|
@InjectRepository(BudgetItem)
|
|
private budgetItemsRepository: Repository<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
|
|
|
|
```typescript
|
|
// src/common/strategies/jwt.strategy.ts
|
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import { PassportStrategy } from '@nestjs/passport';
|
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { User } from '../../modules/auth/entities/user.entity';
|
|
|
|
@Injectable()
|
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
|
constructor(
|
|
private configService: ConfigService,
|
|
@InjectRepository(User)
|
|
private usersRepository: Repository<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
|
|
|
|
```typescript
|
|
// src/common/guards/roles.guard.ts
|
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import { UserRole } from '../../modules/auth/enums/user-role.enum';
|
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
|
|
|
@Injectable()
|
|
export class RolesGuard implements CanActivate {
|
|
constructor(private reflector: Reflector) {}
|
|
|
|
canActivate(context: ExecutionContext): boolean {
|
|
const requiredRoles = this.reflector.getAllAndOverride<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
|
|
|
|
```typescript
|
|
// src/common/interceptors/tenant-isolation.interceptor.ts
|
|
import {
|
|
Injectable,
|
|
NestInterceptor,
|
|
ExecutionContext,
|
|
CallHandler,
|
|
} from '@nestjs/common';
|
|
import { Observable } from 'rxjs';
|
|
|
|
@Injectable()
|
|
export class TenantIsolationInterceptor implements NestInterceptor {
|
|
intercept(context: ExecutionContext, next: CallHandler): Observable<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
|
|
|
|
```typescript
|
|
// src/modules/requisitions/services/requisitions.service.spec.ts
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { RequisitionsService } from './requisitions.service';
|
|
import { Requisition, RequisitionStatus } from '../entities/requisition.entity';
|
|
import { RequisitionItem } from '../entities/requisition-item.entity';
|
|
import { BudgetValidationService } from '../../shared/budget-validation/budget-validation.service';
|
|
import { NotificationService } from '../../shared/notification/notification.service';
|
|
import { RequisitionApprovalService } from './requisition-approval.service';
|
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
|
|
describe('RequisitionsService', () => {
|
|
let service: RequisitionsService;
|
|
let requisitionsRepository: Repository<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
|
|
|
|
```typescript
|
|
// src/modules/inventory/services/inventory.service.integration.spec.ts
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
import { ConfigModule } from '@nestjs/config';
|
|
import { InventoryService } from './inventory.service';
|
|
import { InventoryStock } from '../entities/inventory-stock.entity';
|
|
import { InventoryMovement, MovementType } from '../entities/inventory-movement.entity';
|
|
|
|
describe('InventoryService Integration Tests', () => {
|
|
let service: InventoryService;
|
|
let module: TestingModule;
|
|
|
|
beforeAll(async () => {
|
|
module = await Test.createTestingModule({
|
|
imports: [
|
|
ConfigModule.forRoot({
|
|
isGlobal: true,
|
|
envFilePath: '.env.test',
|
|
}),
|
|
TypeOrmModule.forRoot({
|
|
type: 'postgres',
|
|
host: 'localhost',
|
|
port: 5432,
|
|
username: 'test',
|
|
password: 'test',
|
|
database: 'test_db',
|
|
entities: [InventoryStock, InventoryMovement],
|
|
synchronize: true,
|
|
}),
|
|
TypeOrmModule.forFeature([InventoryStock, InventoryMovement]),
|
|
],
|
|
providers: [InventoryService],
|
|
}).compile();
|
|
|
|
service = module.get<InventoryService>(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
|