feat(FASE-4A): Complete vertical modules for construction
- MAI-018 Bidding module: entities, services, controllers, DTOs - Opportunity, Tender, Proposal, Vendor management - Bid calendar, documents, analytics - Earned Value Management: Curva S, SPI/CPI reports - earned-value.service.ts with EV, PV, AC calculations - earned-value.controller.ts with 9 endpoints - DTOs for modules: assets, contracts, documents, purchase, quality - 28 new DTO files with class-validator decorators - Storage module: service and controller implementation - Multi-provider support (local, S3, GCS, Azure) - File management, upload/download URLs - Multiple entity and service fixes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c8a01c5f14
commit
598c3215e1
690
package-lock.json
generated
690
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@ -33,10 +33,10 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"typeorm": "^0.3.17",
|
"typeorm": "^0.3.28",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.2.2",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@ -46,6 +46,12 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"zod": "^3.22.4",
|
||||||
|
"uuid": "^9.0.0",
|
||||||
|
"compression": "^1.7.4",
|
||||||
"yamljs": "^0.3.0"
|
"yamljs": "^0.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -56,6 +62,10 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/jsonwebtoken": "^9.0.5",
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
"@types/swagger-ui-express": "^4.1.6",
|
"@types/swagger-ui-express": "^4.1.6",
|
||||||
|
"@types/compression": "^1.7.5",
|
||||||
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"@types/yamljs": "^0.2.34",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||||
"@typescript-eslint/parser": "^6.15.0",
|
"@typescript-eslint/parser": "^6.15.0",
|
||||||
@ -67,7 +77,7 @@
|
|||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0",
|
"node": ">=20.0.0",
|
||||||
"npm": ">=9.0.0"
|
"npm": ">=9.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/config/index.ts
Normal file
41
src/config/index.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Configuración centralizada del proyecto
|
||||||
|
* Bridge desde variables de entorno a objeto tipado
|
||||||
|
* Compatible con erp-core config interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
env: process.env.NODE_ENV || 'development',
|
||||||
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
|
||||||
|
jwt: {
|
||||||
|
secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production-minimum-32-chars',
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '1d',
|
||||||
|
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||||
|
},
|
||||||
|
|
||||||
|
database: {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
name: process.env.DB_NAME || 'erp_construccion_db',
|
||||||
|
user: process.env.DB_USER || 'erp_admin',
|
||||||
|
password: process.env.DB_PASSWORD || 'erp_dev_2026',
|
||||||
|
},
|
||||||
|
|
||||||
|
redis: {
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||||
|
},
|
||||||
|
|
||||||
|
logging: {
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
},
|
||||||
|
|
||||||
|
cors: {
|
||||||
|
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||||
|
},
|
||||||
|
};
|
||||||
530
src/modules/assets/dto/asset.dto.ts
Normal file
530
src/modules/assets/dto/asset.dto.ts
Normal file
@ -0,0 +1,530 @@
|
|||||||
|
/**
|
||||||
|
* Asset DTOs - Data Transfer Objects para Activos
|
||||||
|
*
|
||||||
|
* Maquinaria, equipo, vehiculos y herramientas de construccion.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
IsPositive,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
IsArray,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de activo
|
||||||
|
*/
|
||||||
|
export enum AssetType {
|
||||||
|
HEAVY_MACHINERY = 'heavy_machinery',
|
||||||
|
LIGHT_EQUIPMENT = 'light_equipment',
|
||||||
|
VEHICLE = 'vehicle',
|
||||||
|
TOOL = 'tool',
|
||||||
|
COMPUTER = 'computer',
|
||||||
|
FURNITURE = 'furniture',
|
||||||
|
OTHER = 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado del activo
|
||||||
|
*/
|
||||||
|
export enum AssetStatus {
|
||||||
|
AVAILABLE = 'available',
|
||||||
|
ASSIGNED = 'assigned',
|
||||||
|
IN_MAINTENANCE = 'in_maintenance',
|
||||||
|
IN_TRANSIT = 'in_transit',
|
||||||
|
INACTIVE = 'inactive',
|
||||||
|
RETIRED = 'retired',
|
||||||
|
SOLD = 'sold',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de propiedad
|
||||||
|
*/
|
||||||
|
export enum OwnershipType {
|
||||||
|
OWNED = 'owned',
|
||||||
|
LEASED = 'leased',
|
||||||
|
RENTED = 'rented',
|
||||||
|
BORROWED = 'borrowed',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metodo de depreciacion
|
||||||
|
*/
|
||||||
|
export enum DepreciationMethod {
|
||||||
|
STRAIGHT_LINE = 'straight_line',
|
||||||
|
DECLINING_BALANCE = 'declining_balance',
|
||||||
|
UNITS_OF_PRODUCTION = 'units_of_production',
|
||||||
|
SUM_OF_YEARS = 'sum_of_years',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear un nuevo activo
|
||||||
|
*/
|
||||||
|
export class CreateAssetDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(50)
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(255)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetType)
|
||||||
|
type?: AssetType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(OwnershipType)
|
||||||
|
ownershipType?: OwnershipType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
model?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
brand?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1900)
|
||||||
|
@Max(2100)
|
||||||
|
yearManufactured?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
acquisitionDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
acquisitionCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
purchaseCurrency?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DepreciationMethod)
|
||||||
|
depreciationMethod?: DepreciationMethod;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
@Max(600)
|
||||||
|
usefulLifeMonths?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
salvageValue?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
currentValue?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetStatus)
|
||||||
|
status?: AssetStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
capacity?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
powerRating?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
fuelType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
fuelCapacity?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
fuelConsumptionRate?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
supplierId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
invoiceNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
photoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
manualUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar un activo existente
|
||||||
|
*/
|
||||||
|
export class UpdateAssetDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(50)
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(255)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetType)
|
||||||
|
type?: AssetType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(OwnershipType)
|
||||||
|
ownershipType?: OwnershipType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
model?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
brand?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1900)
|
||||||
|
@Max(2100)
|
||||||
|
yearManufactured?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
acquisitionDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
acquisitionCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
purchaseCurrency?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DepreciationMethod)
|
||||||
|
depreciationMethod?: DepreciationMethod;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
@Max(600)
|
||||||
|
usefulLifeMonths?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
salvageValue?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
currentValue?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetStatus)
|
||||||
|
status?: AssetStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
capacity?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
powerRating?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
fuelType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
fuelCapacity?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
fuelConsumptionRate?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
supplierId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
invoiceNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
photoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
manualUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
currentProjectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
currentHours?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
currentKilometers?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
assignedOperatorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar activos en listados
|
||||||
|
*/
|
||||||
|
export class AssetFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetType)
|
||||||
|
type?: AssetType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetStatus)
|
||||||
|
status?: AssetStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(OwnershipType)
|
||||||
|
ownershipType?: OwnershipType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
currentProjectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
assignedOperatorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para asignar un activo a un proyecto
|
||||||
|
*/
|
||||||
|
export class AssignAssetDto {
|
||||||
|
@IsUUID()
|
||||||
|
projectId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
assignmentDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expectedReturnDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
operatorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar metricas de uso
|
||||||
|
*/
|
||||||
|
export class UpdateUsageMetricsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
currentHours?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
currentKilometers?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
currentLatitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
currentLongitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
currentLocationName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para un activo
|
||||||
|
*/
|
||||||
|
export class AssetResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
assetCode: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
category?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
assetType: AssetType;
|
||||||
|
status: AssetStatus;
|
||||||
|
ownershipType: OwnershipType;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
yearManufactured?: number;
|
||||||
|
purchaseDate?: Date;
|
||||||
|
purchasePrice?: number;
|
||||||
|
purchaseCurrency: string;
|
||||||
|
currentBookValue?: number;
|
||||||
|
currentHours: number;
|
||||||
|
currentKilometers: number;
|
||||||
|
currentProjectId?: string;
|
||||||
|
currentLocationName?: string;
|
||||||
|
assignedOperatorId?: string;
|
||||||
|
nextMaintenanceDate?: Date;
|
||||||
|
photoUrl?: string;
|
||||||
|
tags?: string[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
313
src/modules/assets/dto/fuel-log.dto.ts
Normal file
313
src/modules/assets/dto/fuel-log.dto.ts
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* FuelLog DTOs - Data Transfer Objects para Registro de Combustible
|
||||||
|
*
|
||||||
|
* Cargas de combustible y calculo de rendimiento.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
IsPositive,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear un nuevo registro de combustible
|
||||||
|
*/
|
||||||
|
export class CreateFuelLogDto {
|
||||||
|
@IsUUID()
|
||||||
|
assetId: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
date: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(8)
|
||||||
|
time?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
liters: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
unitPrice: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
cost: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
odometer?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
hoursReading?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
fuelType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
supplier?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
invoiceNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
operatorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
operatorName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar un registro de combustible
|
||||||
|
*/
|
||||||
|
export class UpdateFuelLogDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(8)
|
||||||
|
time?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
liters?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
unitPrice?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
cost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
odometer?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
hoursReading?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
fuelType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
supplier?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
invoiceNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
operatorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
operatorName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar registros de combustible
|
||||||
|
*/
|
||||||
|
export class FuelLogFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
assetId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
fuelType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
minLiters?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxLiters?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
minCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
operatorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
supplier?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para reporte de consumo de combustible
|
||||||
|
*/
|
||||||
|
export class FuelConsumptionReportDto {
|
||||||
|
@IsUUID()
|
||||||
|
assetId: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
dateFrom: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
dateTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para un registro de combustible
|
||||||
|
*/
|
||||||
|
export class FuelLogResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
assetId: string;
|
||||||
|
asset?: {
|
||||||
|
id: string;
|
||||||
|
assetCode: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
logDate: Date;
|
||||||
|
logTime?: string;
|
||||||
|
projectId?: string;
|
||||||
|
location?: string;
|
||||||
|
fuelType: string;
|
||||||
|
quantityLiters: number;
|
||||||
|
unitPrice: number;
|
||||||
|
totalCost: number;
|
||||||
|
odometerReading?: number;
|
||||||
|
hoursReading?: number;
|
||||||
|
kilometersSinceLast?: number;
|
||||||
|
hoursSinceLast?: number;
|
||||||
|
litersPer100km?: number;
|
||||||
|
litersPerHour?: number;
|
||||||
|
vendorName?: string;
|
||||||
|
invoiceNumber?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
operatorName?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para resumen de consumo
|
||||||
|
*/
|
||||||
|
export class FuelConsumptionSummaryDto {
|
||||||
|
assetId: string;
|
||||||
|
assetCode: string;
|
||||||
|
assetName: string;
|
||||||
|
totalLiters: number;
|
||||||
|
totalCost: number;
|
||||||
|
totalKilometers?: number;
|
||||||
|
totalHours?: number;
|
||||||
|
averageLitersPer100km?: number;
|
||||||
|
averageLitersPerHour?: number;
|
||||||
|
logsCount: number;
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
}
|
||||||
47
src/modules/assets/dto/index.ts
Normal file
47
src/modules/assets/dto/index.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Assets Module DTOs - Barrel Export
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Asset DTOs
|
||||||
|
export {
|
||||||
|
AssetType,
|
||||||
|
AssetStatus,
|
||||||
|
OwnershipType,
|
||||||
|
DepreciationMethod,
|
||||||
|
CreateAssetDto,
|
||||||
|
UpdateAssetDto,
|
||||||
|
AssetFiltersDto,
|
||||||
|
AssignAssetDto,
|
||||||
|
UpdateUsageMetricsDto,
|
||||||
|
AssetResponseDto,
|
||||||
|
} from './asset.dto';
|
||||||
|
|
||||||
|
// Fuel Log DTOs
|
||||||
|
export {
|
||||||
|
CreateFuelLogDto,
|
||||||
|
UpdateFuelLogDto,
|
||||||
|
FuelLogFiltersDto,
|
||||||
|
FuelConsumptionReportDto,
|
||||||
|
FuelLogResponseDto,
|
||||||
|
FuelConsumptionSummaryDto,
|
||||||
|
} from './fuel-log.dto';
|
||||||
|
|
||||||
|
// Work Order DTOs
|
||||||
|
export {
|
||||||
|
MaintenanceType,
|
||||||
|
WorkOrderStatus,
|
||||||
|
WorkOrderPriority,
|
||||||
|
CreateWorkOrderDto,
|
||||||
|
UpdateWorkOrderDto,
|
||||||
|
WorkOrderFiltersDto,
|
||||||
|
CreateWorkOrderPartDto,
|
||||||
|
UpdateWorkOrderPartDto,
|
||||||
|
CompleteWorkOrderDto,
|
||||||
|
CancelWorkOrderDto,
|
||||||
|
WorkOrderResponseDto,
|
||||||
|
WorkOrderPartResponseDto,
|
||||||
|
WorkOrderReportFiltersDto,
|
||||||
|
WorkOrderSummaryDto,
|
||||||
|
} from './work-order.dto';
|
||||||
658
src/modules/assets/dto/work-order.dto.ts
Normal file
658
src/modules/assets/dto/work-order.dto.ts
Normal file
@ -0,0 +1,658 @@
|
|||||||
|
/**
|
||||||
|
* WorkOrder DTOs - Data Transfer Objects para Ordenes de Trabajo de Mantenimiento
|
||||||
|
*
|
||||||
|
* Registro de mantenimientos preventivos y correctivos.
|
||||||
|
*
|
||||||
|
* @module Assets (MAE-015)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
IsPositive,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de mantenimiento
|
||||||
|
*/
|
||||||
|
export enum MaintenanceType {
|
||||||
|
PREVENTIVE = 'preventive',
|
||||||
|
CORRECTIVE = 'corrective',
|
||||||
|
PREDICTIVE = 'predictive',
|
||||||
|
EMERGENCY = 'emergency',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de la orden de trabajo
|
||||||
|
*/
|
||||||
|
export enum WorkOrderStatus {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
SCHEDULED = 'scheduled',
|
||||||
|
IN_PROGRESS = 'in_progress',
|
||||||
|
ON_HOLD = 'on_hold',
|
||||||
|
COMPLETED = 'completed',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prioridad de la orden de trabajo
|
||||||
|
*/
|
||||||
|
export enum WorkOrderPriority {
|
||||||
|
LOW = 'low',
|
||||||
|
MEDIUM = 'medium',
|
||||||
|
HIGH = 'high',
|
||||||
|
CRITICAL = 'critical',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear una nueva orden de trabajo
|
||||||
|
*/
|
||||||
|
export class CreateWorkOrderDto {
|
||||||
|
@IsUUID()
|
||||||
|
assetId: string;
|
||||||
|
|
||||||
|
@IsEnum(MaintenanceType)
|
||||||
|
type: MaintenanceType;
|
||||||
|
|
||||||
|
@IsEnum(WorkOrderPriority)
|
||||||
|
priority: WorkOrderPriority;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(255)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
problemReported?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
scheduledDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
scheduledEndDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
assignedToId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
assignedToName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
teamIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
projectName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
locationDescription?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
estimatedHours?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
hoursAtWorkOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
kilometersAtWorkOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
planId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
scheduleId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isScheduled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar una orden de trabajo
|
||||||
|
*/
|
||||||
|
export class UpdateWorkOrderDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(MaintenanceType)
|
||||||
|
type?: MaintenanceType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(WorkOrderPriority)
|
||||||
|
priority?: WorkOrderPriority;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(WorkOrderStatus)
|
||||||
|
status?: WorkOrderStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(255)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
problemReported?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
diagnosis?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
scheduledStartDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
scheduledEndDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
actualStartDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
actualEndDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
assignedToId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
assignedToName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
teamIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
projectName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
locationDescription?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
estimatedHours?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
actualHours?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
workPerformed?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
findings?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
recommendations?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
laborCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
partsCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
externalServiceCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
otherCosts?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
photosBefore?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
photosAfter?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
documents?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresFollowup?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
followupNotes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
completionNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar ordenes de trabajo
|
||||||
|
*/
|
||||||
|
export class WorkOrderFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
assetId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(MaintenanceType)
|
||||||
|
type?: MaintenanceType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(WorkOrderStatus)
|
||||||
|
status?: WorkOrderStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(WorkOrderPriority)
|
||||||
|
priority?: WorkOrderPriority;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
assignedToId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isScheduled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresFollowup?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
scheduledDateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
scheduledDateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear una parte/refaccion en orden de trabajo
|
||||||
|
*/
|
||||||
|
export class CreateWorkOrderPartDto {
|
||||||
|
@IsUUID()
|
||||||
|
workOrderId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
partId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
partCode?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
partName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
partDescription?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
unitCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
fromInventory?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
purchaseOrderId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar una parte/refaccion en orden de trabajo
|
||||||
|
*/
|
||||||
|
export class UpdateWorkOrderPartDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
partCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
partName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
partDescription?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
quantityRequired?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
quantityUsed?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
unitCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
fromInventory?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
purchaseOrderId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para completar una orden de trabajo
|
||||||
|
*/
|
||||||
|
export class CompleteWorkOrderDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
workPerformed: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
findings?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
recommendations?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
actualHours?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
laborCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
partsCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
externalServiceCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
otherCosts?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
completionSignatureUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
completionNotes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresFollowup?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
followupNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para cancelar una orden de trabajo
|
||||||
|
*/
|
||||||
|
export class CancelWorkOrderDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para una orden de trabajo
|
||||||
|
*/
|
||||||
|
export class WorkOrderResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
workOrderNumber: string;
|
||||||
|
assetId: string;
|
||||||
|
asset?: {
|
||||||
|
id: string;
|
||||||
|
assetCode: string;
|
||||||
|
name: string;
|
||||||
|
assetType: string;
|
||||||
|
};
|
||||||
|
maintenanceType: MaintenanceType;
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
priority: WorkOrderPriority;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
problemReported?: string;
|
||||||
|
diagnosis?: string;
|
||||||
|
projectId?: string;
|
||||||
|
projectName?: string;
|
||||||
|
locationDescription?: string;
|
||||||
|
requestedDate: Date;
|
||||||
|
scheduledStartDate?: Date;
|
||||||
|
scheduledEndDate?: Date;
|
||||||
|
actualStartDate?: Date;
|
||||||
|
actualEndDate?: Date;
|
||||||
|
assignedToId?: string;
|
||||||
|
assignedToName?: string;
|
||||||
|
teamIds?: string[];
|
||||||
|
estimatedHours?: number;
|
||||||
|
actualHours?: number;
|
||||||
|
laborCost: number;
|
||||||
|
partsCost: number;
|
||||||
|
externalServiceCost: number;
|
||||||
|
otherCosts: number;
|
||||||
|
totalCost: number;
|
||||||
|
partsUsedCount: number;
|
||||||
|
partsUsed?: WorkOrderPartResponseDto[];
|
||||||
|
workPerformed?: string;
|
||||||
|
findings?: string;
|
||||||
|
recommendations?: string;
|
||||||
|
requiresFollowup: boolean;
|
||||||
|
followupNotes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
createdBy?: string;
|
||||||
|
completedById?: string;
|
||||||
|
completedByName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para una parte/refaccion
|
||||||
|
*/
|
||||||
|
export class WorkOrderPartResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
workOrderId: string;
|
||||||
|
partId?: string;
|
||||||
|
partCode?: string;
|
||||||
|
partName: string;
|
||||||
|
partDescription?: string;
|
||||||
|
quantityRequired: number;
|
||||||
|
quantityUsed?: number;
|
||||||
|
unitCost?: number;
|
||||||
|
totalCost?: number;
|
||||||
|
fromInventory: boolean;
|
||||||
|
purchaseOrderId?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para reporte de ordenes de trabajo
|
||||||
|
*/
|
||||||
|
export class WorkOrderReportFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
assetId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
dateFrom: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
dateTo: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(MaintenanceType)
|
||||||
|
type?: MaintenanceType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
includeDetails?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para resumen de ordenes de trabajo
|
||||||
|
*/
|
||||||
|
export class WorkOrderSummaryDto {
|
||||||
|
totalWorkOrders: number;
|
||||||
|
byStatus: Record<WorkOrderStatus, number>;
|
||||||
|
byType: Record<MaintenanceType, number>;
|
||||||
|
byPriority: Record<WorkOrderPriority, number>;
|
||||||
|
totalLaborCost: number;
|
||||||
|
totalPartsCost: number;
|
||||||
|
totalExternalCost: number;
|
||||||
|
totalCost: number;
|
||||||
|
averageCompletionTime?: number;
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
}
|
||||||
116
src/modules/audit/entities/audit-log.entity.ts
Normal file
116
src/modules/audit/entities/audit-log.entity.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* AuditLog Entity
|
||||||
|
* General activity tracking with full request context
|
||||||
|
* Compatible with erp-core audit-log.entity
|
||||||
|
*
|
||||||
|
* @module Audit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type AuditAction = 'create' | 'read' | 'update' | 'delete' | 'login' | 'logout' | 'export';
|
||||||
|
export type AuditCategory = 'data' | 'auth' | 'system' | 'config' | 'billing';
|
||||||
|
export type AuditStatus = 'success' | 'failure' | 'partial';
|
||||||
|
|
||||||
|
@Entity({ name: 'audit_logs', schema: 'audit' })
|
||||||
|
export class AuditLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
userEmail: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
userName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'session_id', type: 'uuid', nullable: true })
|
||||||
|
sessionId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'impersonator_id', type: 'uuid', nullable: true })
|
||||||
|
impersonatorId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'action', type: 'varchar', length: 50 })
|
||||||
|
action: AuditAction;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'action_category', type: 'varchar', length: 50, nullable: true })
|
||||||
|
actionCategory: AuditCategory;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'resource_type', type: 'varchar', length: 100 })
|
||||||
|
resourceType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'resource_id', type: 'uuid', nullable: true })
|
||||||
|
resourceId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'resource_name', type: 'varchar', length: 255, nullable: true })
|
||||||
|
resourceName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'old_values', type: 'jsonb', nullable: true })
|
||||||
|
oldValues: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'new_values', type: 'jsonb', nullable: true })
|
||||||
|
newValues: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'changed_fields', type: 'text', array: true, nullable: true })
|
||||||
|
changedFields: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
|
userAgent: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_info', type: 'jsonb', default: {} })
|
||||||
|
deviceInfo: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'location', type: 'jsonb', default: {} })
|
||||||
|
location: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'request_id', type: 'varchar', length: 100, nullable: true })
|
||||||
|
requestId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'request_method', type: 'varchar', length: 10, nullable: true })
|
||||||
|
requestMethod: string;
|
||||||
|
|
||||||
|
@Column({ name: 'request_path', type: 'text', nullable: true })
|
||||||
|
requestPath: string;
|
||||||
|
|
||||||
|
@Column({ name: 'request_params', type: 'jsonb', default: {} })
|
||||||
|
requestParams: Record<string, any>;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'success' })
|
||||||
|
status: AuditStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||||
|
errorMessage: string;
|
||||||
|
|
||||||
|
@Column({ name: 'duration_ms', type: 'int', nullable: true })
|
||||||
|
durationMs: number;
|
||||||
|
|
||||||
|
@Column({ name: 'metadata', type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'tags', type: 'text', array: true, default: [] })
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
55
src/modules/audit/entities/config-change.entity.ts
Normal file
55
src/modules/audit/entities/config-change.entity.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* ConfigChange Entity
|
||||||
|
* System configuration change auditing
|
||||||
|
* Compatible with erp-core config-change.entity
|
||||||
|
*
|
||||||
|
* @module Audit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type ConfigType = 'tenant_settings' | 'user_settings' | 'system_settings' | 'feature_flags';
|
||||||
|
|
||||||
|
@Entity({ name: 'config_changes', schema: 'audit' })
|
||||||
|
export class ConfigChange {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'changed_by', type: 'uuid' })
|
||||||
|
changedBy: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'config_type', type: 'varchar', length: 50 })
|
||||||
|
configType: ConfigType;
|
||||||
|
|
||||||
|
@Column({ name: 'config_key', type: 'varchar', length: 100 })
|
||||||
|
configKey: string;
|
||||||
|
|
||||||
|
@Column({ name: 'config_path', type: 'text', nullable: true })
|
||||||
|
configPath: string;
|
||||||
|
|
||||||
|
@Column({ name: 'old_value', type: 'jsonb', nullable: true })
|
||||||
|
oldValue: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'new_value', type: 'jsonb', nullable: true })
|
||||||
|
newValue: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@Column({ name: 'ticket_id', type: 'varchar', length: 50, nullable: true })
|
||||||
|
ticketId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
changedAt: Date;
|
||||||
|
}
|
||||||
88
src/modules/audit/entities/data-export.entity.ts
Normal file
88
src/modules/audit/entities/data-export.entity.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* DataExport Entity
|
||||||
|
* GDPR/reporting data export request management
|
||||||
|
* Compatible with erp-core data-export.entity
|
||||||
|
*
|
||||||
|
* @module Audit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type ExportType = 'report' | 'backup' | 'gdpr_request' | 'bulk_export';
|
||||||
|
export type ExportFormat = 'csv' | 'xlsx' | 'pdf' | 'json';
|
||||||
|
export type ExportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'expired';
|
||||||
|
|
||||||
|
@Entity({ name: 'data_exports', schema: 'audit' })
|
||||||
|
export class DataExport {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'export_type', type: 'varchar', length: 50 })
|
||||||
|
exportType: ExportType;
|
||||||
|
|
||||||
|
@Column({ name: 'export_format', type: 'varchar', length: 20, nullable: true })
|
||||||
|
exportFormat: ExportFormat;
|
||||||
|
|
||||||
|
@Column({ name: 'entity_types', type: 'text', array: true })
|
||||||
|
entityTypes: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'filters', type: 'jsonb', default: {} })
|
||||||
|
filters: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'date_range_start', type: 'timestamptz', nullable: true })
|
||||||
|
dateRangeStart: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'date_range_end', type: 'timestamptz', nullable: true })
|
||||||
|
dateRangeEnd: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'record_count', type: 'int', nullable: true })
|
||||||
|
recordCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'file_size_bytes', type: 'bigint', nullable: true })
|
||||||
|
fileSizeBytes: number;
|
||||||
|
|
||||||
|
@Column({ name: 'file_hash', type: 'varchar', length: 64, nullable: true })
|
||||||
|
fileHash: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' })
|
||||||
|
status: ExportStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'download_url', type: 'text', nullable: true })
|
||||||
|
downloadUrl: string;
|
||||||
|
|
||||||
|
@Column({ name: 'download_expires_at', type: 'timestamptz', nullable: true })
|
||||||
|
downloadExpiresAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'download_count', type: 'int', default: 0 })
|
||||||
|
downloadCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
|
userAgent: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'requested_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
requestedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||||
|
completedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
63
src/modules/audit/entities/entity-change.entity.ts
Normal file
63
src/modules/audit/entities/entity-change.entity.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* EntityChange Entity
|
||||||
|
* Data modification versioning and change history
|
||||||
|
* Compatible with erp-core entity-change.entity
|
||||||
|
*
|
||||||
|
* @module Audit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type ChangeType = 'create' | 'update' | 'delete' | 'restore';
|
||||||
|
|
||||||
|
@Entity({ name: 'entity_changes', schema: 'audit' })
|
||||||
|
export class EntityChange {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'entity_type', type: 'varchar', length: 100 })
|
||||||
|
entityType: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'entity_id', type: 'uuid' })
|
||||||
|
entityId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'entity_name', type: 'varchar', length: 255, nullable: true })
|
||||||
|
entityName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'version', type: 'int', default: 1 })
|
||||||
|
version: number;
|
||||||
|
|
||||||
|
@Column({ name: 'previous_version', type: 'int', nullable: true })
|
||||||
|
previousVersion: number;
|
||||||
|
|
||||||
|
@Column({ name: 'data_snapshot', type: 'jsonb' })
|
||||||
|
dataSnapshot: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'changes', type: 'jsonb', default: [] })
|
||||||
|
changes: Record<string, any>[];
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'changed_by', type: 'uuid', nullable: true })
|
||||||
|
changedBy: string;
|
||||||
|
|
||||||
|
@Column({ name: 'change_reason', type: 'text', nullable: true })
|
||||||
|
changeReason: string;
|
||||||
|
|
||||||
|
@Column({ name: 'change_type', type: 'varchar', length: 20 })
|
||||||
|
changeType: ChangeType;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
changedAt: Date;
|
||||||
|
}
|
||||||
11
src/modules/audit/entities/index.ts
Normal file
11
src/modules/audit/entities/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Audit Entities - Export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { AuditLog, AuditAction, AuditCategory, AuditStatus } from './audit-log.entity';
|
||||||
|
export { EntityChange, ChangeType } from './entity-change.entity';
|
||||||
|
export { LoginHistory, LoginStatus, AuthMethod, MfaMethod } from './login-history.entity';
|
||||||
|
export { SensitiveDataAccess, DataType, AccessType } from './sensitive-data-access.entity';
|
||||||
|
export { DataExport, ExportType, ExportFormat, ExportStatus } from './data-export.entity';
|
||||||
|
export { PermissionChange, PermissionChangeType, PermissionScope } from './permission-change.entity';
|
||||||
|
export { ConfigChange, ConfigType } from './config-change.entity';
|
||||||
114
src/modules/audit/entities/login-history.entity.ts
Normal file
114
src/modules/audit/entities/login-history.entity.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* LoginHistory Entity
|
||||||
|
* Authentication event tracking with device, location and risk scoring
|
||||||
|
* Compatible with erp-core login-history.entity
|
||||||
|
*
|
||||||
|
* @module Audit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type LoginStatus = 'success' | 'failed' | 'blocked' | 'mfa_required' | 'mfa_failed';
|
||||||
|
export type AuthMethod = 'password' | 'sso' | 'oauth' | 'mfa' | 'magic_link' | 'biometric';
|
||||||
|
export type MfaMethod = 'totp' | 'sms' | 'email' | 'push';
|
||||||
|
|
||||||
|
@Entity({ name: 'login_history', schema: 'audit' })
|
||||||
|
export class LoginHistory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ name: 'username', type: 'varchar', length: 100, nullable: true })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20 })
|
||||||
|
status: LoginStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'auth_method', type: 'varchar', length: 30, nullable: true })
|
||||||
|
authMethod: AuthMethod;
|
||||||
|
|
||||||
|
@Column({ name: 'oauth_provider', type: 'varchar', length: 30, nullable: true })
|
||||||
|
oauthProvider: string;
|
||||||
|
|
||||||
|
@Column({ name: 'mfa_method', type: 'varchar', length: 20, nullable: true })
|
||||||
|
mfaMethod: MfaMethod;
|
||||||
|
|
||||||
|
@Column({ name: 'mfa_verified', type: 'boolean', nullable: true })
|
||||||
|
mfaVerified: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'device_id', type: 'uuid', nullable: true })
|
||||||
|
deviceId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_fingerprint', type: 'varchar', length: 255, nullable: true })
|
||||||
|
deviceFingerprint: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_type', type: 'varchar', length: 30, nullable: true })
|
||||||
|
deviceType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_os', type: 'varchar', length: 50, nullable: true })
|
||||||
|
deviceOs: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_browser', type: 'varchar', length: 50, nullable: true })
|
||||||
|
deviceBrowser: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
|
userAgent: string;
|
||||||
|
|
||||||
|
@Column({ name: 'country_code', type: 'varchar', length: 2, nullable: true })
|
||||||
|
countryCode: string;
|
||||||
|
|
||||||
|
@Column({ name: 'city', type: 'varchar', length: 100, nullable: true })
|
||||||
|
city: string;
|
||||||
|
|
||||||
|
@Column({ name: 'latitude', type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@Column({ name: 'longitude', type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
longitude: number;
|
||||||
|
|
||||||
|
@Column({ name: 'risk_score', type: 'int', nullable: true })
|
||||||
|
riskScore: number;
|
||||||
|
|
||||||
|
@Column({ name: 'risk_factors', type: 'jsonb', default: [] })
|
||||||
|
riskFactors: string[];
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_suspicious', type: 'boolean', default: false })
|
||||||
|
isSuspicious: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_new_device', type: 'boolean', default: false })
|
||||||
|
isNewDevice: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_new_location', type: 'boolean', default: false })
|
||||||
|
isNewLocation: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'failure_reason', type: 'varchar', length: 100, nullable: true })
|
||||||
|
failureReason: string;
|
||||||
|
|
||||||
|
@Column({ name: 'failure_count', type: 'int', nullable: true })
|
||||||
|
failureCount: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'attempted_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
attemptedAt: Date;
|
||||||
|
}
|
||||||
71
src/modules/audit/entities/permission-change.entity.ts
Normal file
71
src/modules/audit/entities/permission-change.entity.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* PermissionChange Entity
|
||||||
|
* Access control change auditing
|
||||||
|
* Compatible with erp-core permission-change.entity
|
||||||
|
*
|
||||||
|
* @module Audit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type PermissionChangeType = 'role_assigned' | 'role_revoked' | 'permission_granted' | 'permission_revoked';
|
||||||
|
export type PermissionScope = 'global' | 'tenant' | 'branch';
|
||||||
|
|
||||||
|
@Entity({ name: 'permission_changes', schema: 'audit' })
|
||||||
|
export class PermissionChange {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'changed_by', type: 'uuid' })
|
||||||
|
changedBy: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'target_user_id', type: 'uuid' })
|
||||||
|
targetUserId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'target_user_email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
targetUserEmail: string;
|
||||||
|
|
||||||
|
@Column({ name: 'change_type', type: 'varchar', length: 30 })
|
||||||
|
changeType: PermissionChangeType;
|
||||||
|
|
||||||
|
@Column({ name: 'role_id', type: 'uuid', nullable: true })
|
||||||
|
roleId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'role_code', type: 'varchar', length: 50, nullable: true })
|
||||||
|
roleCode: string;
|
||||||
|
|
||||||
|
@Column({ name: 'permission_id', type: 'uuid', nullable: true })
|
||||||
|
permissionId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'permission_code', type: 'varchar', length: 100, nullable: true })
|
||||||
|
permissionCode: string;
|
||||||
|
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'scope', type: 'varchar', length: 30, nullable: true })
|
||||||
|
scope: PermissionScope;
|
||||||
|
|
||||||
|
@Column({ name: 'previous_roles', type: 'text', array: true, nullable: true })
|
||||||
|
previousRoles: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'previous_permissions', type: 'text', array: true, nullable: true })
|
||||||
|
previousPermissions: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
changedAt: Date;
|
||||||
|
}
|
||||||
70
src/modules/audit/entities/sensitive-data-access.entity.ts
Normal file
70
src/modules/audit/entities/sensitive-data-access.entity.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* SensitiveDataAccess Entity
|
||||||
|
* Security/compliance logging for PII, financial, medical and credential access
|
||||||
|
* Compatible with erp-core sensitive-data-access.entity
|
||||||
|
*
|
||||||
|
* @module Audit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type DataType = 'pii' | 'financial' | 'medical' | 'credentials';
|
||||||
|
export type AccessType = 'view' | 'export' | 'modify' | 'decrypt';
|
||||||
|
|
||||||
|
@Entity({ name: 'sensitive_data_access', schema: 'audit' })
|
||||||
|
export class SensitiveDataAccess {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'session_id', type: 'uuid', nullable: true })
|
||||||
|
sessionId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'data_type', type: 'varchar', length: 100 })
|
||||||
|
dataType: DataType;
|
||||||
|
|
||||||
|
@Column({ name: 'data_category', type: 'varchar', length: 100, nullable: true })
|
||||||
|
dataCategory: string;
|
||||||
|
|
||||||
|
@Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true })
|
||||||
|
entityType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'entity_id', type: 'uuid', nullable: true })
|
||||||
|
entityId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'access_type', type: 'varchar', length: 30 })
|
||||||
|
accessType: AccessType;
|
||||||
|
|
||||||
|
@Column({ name: 'access_reason', type: 'text', nullable: true })
|
||||||
|
accessReason: string;
|
||||||
|
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
|
userAgent: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'was_authorized', type: 'boolean', default: true })
|
||||||
|
wasAuthorized: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'denial_reason', type: 'text', nullable: true })
|
||||||
|
denialReason: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'accessed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
accessedAt: Date;
|
||||||
|
}
|
||||||
84
src/modules/auth/entities/api-key.entity.ts
Normal file
84
src/modules/auth/entities/api-key.entity.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* ApiKey Entity
|
||||||
|
* Gestión de API Keys para integraciones headless/CI/CD
|
||||||
|
* Compatible con erp-core api-key.entity
|
||||||
|
*
|
||||||
|
* @module Auth
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from '../../core/entities/user.entity';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'api_keys' })
|
||||||
|
@Index('idx_api_keys_lookup', ['keyIndex', 'isActive'], {
|
||||||
|
where: 'is_active = TRUE',
|
||||||
|
})
|
||||||
|
@Index('idx_api_keys_expiration', ['expirationDate'], {
|
||||||
|
where: 'expiration_date IS NOT NULL',
|
||||||
|
})
|
||||||
|
@Index('idx_api_keys_user', ['userId'])
|
||||||
|
@Index('idx_api_keys_tenant', ['tenantId'])
|
||||||
|
export class ApiKey {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 16, nullable: false, name: 'key_index' })
|
||||||
|
keyIndex: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false, name: 'key_hash' })
|
||||||
|
keyHash: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
scope: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'inet', array: true, nullable: true, name: 'allowed_ips' })
|
||||||
|
allowedIps: string[] | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'expiration_date' })
|
||||||
|
expirationDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' })
|
||||||
|
lastUsedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'revoked_by' })
|
||||||
|
revokedByUser: User | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' })
|
||||||
|
revokedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'revoked_by' })
|
||||||
|
revokedBy: string | null;
|
||||||
|
}
|
||||||
79
src/modules/auth/entities/company.entity.ts
Normal file
79
src/modules/auth/entities/company.entity.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Company Entity
|
||||||
|
* Soporte multi-empresa dentro de un tenant
|
||||||
|
* Compatible con erp-core company.entity
|
||||||
|
*
|
||||||
|
* @module Auth
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'companies' })
|
||||||
|
@Index('idx_companies_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_companies_parent_company_id', ['parentCompanyId'])
|
||||||
|
@Index('idx_companies_active', ['tenantId'], { where: 'deleted_at IS NULL' })
|
||||||
|
@Index('idx_companies_tax_id', ['taxId'])
|
||||||
|
export class Company {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true, name: 'legal_name' })
|
||||||
|
legalName: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true, name: 'tax_id' })
|
||||||
|
taxId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'currency_id' })
|
||||||
|
currencyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'parent_company_id' })
|
||||||
|
parentCompanyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
|
||||||
|
partnerId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
settings: Record<string, any>;
|
||||||
|
|
||||||
|
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'parent_company_id' })
|
||||||
|
parentCompany: Company | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
75
src/modules/auth/entities/group.entity.ts
Normal file
75
src/modules/auth/entities/group.entity.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Group Entity
|
||||||
|
* Agrupación de usuarios para gestión de permisos
|
||||||
|
* Compatible con erp-core group.entity
|
||||||
|
*
|
||||||
|
* @module Auth
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'groups' })
|
||||||
|
@Index('idx_groups_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_groups_code', ['code'])
|
||||||
|
@Index('idx_groups_category', ['category'])
|
||||||
|
@Index('idx_groups_is_system', ['isSystem'])
|
||||||
|
export class Group {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_system' })
|
||||||
|
isSystem: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
category: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
color: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 30, nullable: true, name: 'api_key_max_duration_days' })
|
||||||
|
apiKeyMaxDurationDays: number | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
@ -6,3 +6,8 @@ export { RefreshToken } from './refresh-token.entity';
|
|||||||
export { Role } from './role.entity';
|
export { Role } from './role.entity';
|
||||||
export { Permission } from './permission.entity';
|
export { Permission } from './permission.entity';
|
||||||
export { UserRole } from './user-role.entity';
|
export { UserRole } from './user-role.entity';
|
||||||
|
export { Session, SessionStatus } from './session.entity';
|
||||||
|
export { ApiKey } from './api-key.entity';
|
||||||
|
export { PasswordReset } from './password-reset.entity';
|
||||||
|
export { Company } from './company.entity';
|
||||||
|
export { Group } from './group.entity';
|
||||||
|
|||||||
49
src/modules/auth/entities/password-reset.entity.ts
Normal file
49
src/modules/auth/entities/password-reset.entity.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* PasswordReset Entity
|
||||||
|
* Flujo seguro de recuperación de contraseña con token temporal
|
||||||
|
* Compatible con erp-core password-reset.entity
|
||||||
|
*
|
||||||
|
* @module Auth
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from '../../core/entities/user.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'password_resets' })
|
||||||
|
@Index('idx_password_resets_user_id', ['userId'])
|
||||||
|
@Index('idx_password_resets_token', ['token'])
|
||||||
|
@Index('idx_password_resets_expires_at', ['expiresAt'])
|
||||||
|
export class PasswordReset {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, unique: true, nullable: false })
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: false, name: 'expires_at' })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'used_at' })
|
||||||
|
usedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
|
||||||
|
ipAddress: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Role Entity
|
* Role Entity
|
||||||
* Roles del sistema para RBAC
|
* Roles del sistema para RBAC
|
||||||
|
* Compatible con erp-core role.entity
|
||||||
*
|
*
|
||||||
* @module Auth
|
* @module Auth
|
||||||
*/
|
*/
|
||||||
@ -13,16 +14,26 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
ManyToMany,
|
ManyToMany,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
JoinTable,
|
JoinTable,
|
||||||
|
Index,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Permission } from './permission.entity';
|
import { Permission } from './permission.entity';
|
||||||
import { UserRole } from './user-role.entity';
|
import { UserRole } from './user-role.entity';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
|
|
||||||
@Entity({ schema: 'auth', name: 'roles' })
|
@Entity({ schema: 'auth', name: 'roles' })
|
||||||
|
@Index('idx_roles_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_roles_code', ['code'])
|
||||||
|
@Index('idx_roles_is_system', ['isSystem'])
|
||||||
export class Role {
|
export class Role {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'tenant_id' })
|
||||||
|
tenantId: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 50, unique: true })
|
@Column({ type: 'varchar', length: 50, unique: true })
|
||||||
code: string;
|
code: string;
|
||||||
|
|
||||||
@ -30,7 +41,7 @@ export class Role {
|
|||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
description: string;
|
description: string | null;
|
||||||
|
|
||||||
@Column({ name: 'is_system', type: 'boolean', default: false })
|
@Column({ name: 'is_system', type: 'boolean', default: false })
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
@ -38,16 +49,18 @@ export class Role {
|
|||||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
createdAt: Date;
|
color: string | null;
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
|
||||||
updatedAt: Date;
|
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
|
@ManyToOne(() => Tenant, { onDelete: 'CASCADE', nullable: true })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant | null;
|
||||||
|
|
||||||
@ManyToMany(() => Permission)
|
@ManyToMany(() => Permission)
|
||||||
@JoinTable({
|
@JoinTable({
|
||||||
name: 'role_permissions',
|
name: 'role_permissions',
|
||||||
|
schema: 'auth',
|
||||||
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
||||||
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
|
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
|
||||||
})
|
})
|
||||||
@ -55,4 +68,23 @@ export class Role {
|
|||||||
|
|
||||||
@OneToMany(() => UserRole, (userRole) => userRole.role)
|
@OneToMany(() => UserRole, (userRole) => userRole.role)
|
||||||
userRoles: UserRole[];
|
userRoles: UserRole[];
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
85
src/modules/auth/entities/session.entity.ts
Normal file
85
src/modules/auth/entities/session.entity.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Session Entity
|
||||||
|
* Gestión de sesiones de usuario (reemplaza refresh tokens simples)
|
||||||
|
* Compatible con erp-core session.entity
|
||||||
|
*
|
||||||
|
* @module Auth
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from '../../core/entities/user.entity';
|
||||||
|
|
||||||
|
export enum SessionStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
EXPIRED = 'expired',
|
||||||
|
REVOKED = 'revoked',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'sessions' })
|
||||||
|
@Index('idx_sessions_user_id', ['userId'])
|
||||||
|
@Index('idx_sessions_token', ['token'])
|
||||||
|
@Index('idx_sessions_status', ['status'])
|
||||||
|
@Index('idx_sessions_expires_at', ['expiresAt'])
|
||||||
|
export class Session {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, unique: true, nullable: false })
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 500,
|
||||||
|
unique: true,
|
||||||
|
nullable: true,
|
||||||
|
name: 'refresh_token',
|
||||||
|
})
|
||||||
|
refreshToken: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: SessionStatus,
|
||||||
|
default: SessionStatus.ACTIVE,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: SessionStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: false, name: 'expires_at' })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'refresh_expires_at' })
|
||||||
|
refreshExpiresAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
|
||||||
|
ipAddress: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'user_agent' })
|
||||||
|
userAgent: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true, name: 'device_info' })
|
||||||
|
deviceInfo: Record<string, any> | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'revoked_at' })
|
||||||
|
revokedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true, name: 'revoked_reason' })
|
||||||
|
revokedReason: string | null;
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* BidAnalyticsController - Controller de Análisis de Licitaciones
|
* BidAnalyticsController - Controller de Analisis de Licitaciones
|
||||||
*
|
*
|
||||||
* Endpoints REST para dashboards y análisis de preconstrucción.
|
* Endpoints REST para dashboards y analisis de preconstruccion.
|
||||||
*
|
*
|
||||||
* @module Bidding
|
* @module Bidding
|
||||||
*/
|
*/
|
||||||
@ -11,9 +11,9 @@ import { DataSource } from 'typeorm';
|
|||||||
import { BidAnalyticsService } from '../services/bid-analytics.service';
|
import { BidAnalyticsService } from '../services/bid-analytics.service';
|
||||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||||
import { AuthService } from '../../auth/services/auth.service';
|
import { AuthService } from '../../auth/services/auth.service';
|
||||||
import { Bid } from '../entities/bid.entity';
|
import { Tender } from '../entities/tender.entity';
|
||||||
import { Opportunity } from '../entities/opportunity.entity';
|
import { Opportunity } from '../entities/opportunity.entity';
|
||||||
import { BidCompetitor } from '../entities/bid-competitor.entity';
|
import { Proposal } from '../entities/proposal.entity';
|
||||||
import { User } from '../../core/entities/user.entity';
|
import { User } from '../../core/entities/user.entity';
|
||||||
import { Tenant } from '../../core/entities/tenant.entity';
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||||
@ -23,15 +23,15 @@ export function createBidAnalyticsController(dataSource: DataSource): Router {
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Repositorios
|
// Repositorios
|
||||||
const bidRepository = dataSource.getRepository(Bid);
|
const tenderRepository = dataSource.getRepository(Tender);
|
||||||
const opportunityRepository = dataSource.getRepository(Opportunity);
|
const opportunityRepository = dataSource.getRepository(Opportunity);
|
||||||
const competitorRepository = dataSource.getRepository(BidCompetitor);
|
const proposalRepository = dataSource.getRepository(Proposal);
|
||||||
const userRepository = dataSource.getRepository(User);
|
const userRepository = dataSource.getRepository(User);
|
||||||
const tenantRepository = dataSource.getRepository(Tenant);
|
const tenantRepository = dataSource.getRepository(Tenant);
|
||||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||||
|
|
||||||
// Servicios
|
// Servicios
|
||||||
const analyticsService = new BidAnalyticsService(bidRepository, opportunityRepository, competitorRepository);
|
const analyticsService = new BidAnalyticsService(tenderRepository, opportunityRepository, proposalRepository);
|
||||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||||
|
|
||||||
@ -169,6 +169,80 @@ export function createBidAnalyticsController(dataSource: DataSource): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /bid-analytics/win-rate
|
||||||
|
*/
|
||||||
|
router.get('/win-rate', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFrom = req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined;
|
||||||
|
const dateTo = req.query.dateTo ? new Date(req.query.dateTo as string) : undefined;
|
||||||
|
|
||||||
|
const data = await analyticsService.getWinRate(getContext(req), { dateFrom, dateTo });
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /bid-analytics/pipeline
|
||||||
|
*/
|
||||||
|
router.get('/pipeline', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await analyticsService.getPipelineValue(getContext(req));
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /bid-analytics/averages
|
||||||
|
*/
|
||||||
|
router.get('/averages', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await analyticsService.getAverages(getContext(req));
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /bid-analytics/sources
|
||||||
|
*/
|
||||||
|
router.get('/sources', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFrom = req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined;
|
||||||
|
const dateTo = req.query.dateTo ? new Date(req.query.dateTo as string) : undefined;
|
||||||
|
|
||||||
|
const data = await analyticsService.getOpportunitiesBySource(getContext(req), dateFrom, dateTo);
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,254 +0,0 @@
|
|||||||
/**
|
|
||||||
* BidBudgetController - Controller de Presupuestos de Licitación
|
|
||||||
*
|
|
||||||
* Endpoints REST para gestión de propuestas económicas.
|
|
||||||
*
|
|
||||||
* @module Bidding
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { BidBudgetService, CreateBudgetItemDto, UpdateBudgetItemDto, BudgetFilters } from '../services/bid-budget.service';
|
|
||||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
|
||||||
import { AuthService } from '../../auth/services/auth.service';
|
|
||||||
import { BidBudget } from '../entities/bid-budget.entity';
|
|
||||||
import { User } from '../../core/entities/user.entity';
|
|
||||||
import { Tenant } from '../../core/entities/tenant.entity';
|
|
||||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
|
||||||
import { ServiceContext } from '../../../shared/services/base.service';
|
|
||||||
|
|
||||||
export function createBidBudgetController(dataSource: DataSource): Router {
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// Repositorios
|
|
||||||
const budgetRepository = dataSource.getRepository(BidBudget);
|
|
||||||
const userRepository = dataSource.getRepository(User);
|
|
||||||
const tenantRepository = dataSource.getRepository(Tenant);
|
|
||||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
|
||||||
|
|
||||||
// Servicios
|
|
||||||
const budgetService = new BidBudgetService(budgetRepository);
|
|
||||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
|
||||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
|
||||||
|
|
||||||
// Helper para crear contexto
|
|
||||||
const getContext = (req: Request): ServiceContext => {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
throw new Error('Tenant ID is required');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tenantId: req.tenantId,
|
|
||||||
userId: req.user?.sub,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /bid-budgets
|
|
||||||
*/
|
|
||||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bidId = req.query.bidId as string;
|
|
||||||
if (!bidId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'bidId is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 500);
|
|
||||||
|
|
||||||
const filters: BudgetFilters = { bidId };
|
|
||||||
if (req.query.itemType) filters.itemType = req.query.itemType as any;
|
|
||||||
if (req.query.status) filters.status = req.query.status as any;
|
|
||||||
if (req.query.parentId !== undefined) {
|
|
||||||
filters.parentId = req.query.parentId === 'null' ? null : req.query.parentId as string;
|
|
||||||
}
|
|
||||||
if (req.query.isSummary !== undefined) filters.isSummary = req.query.isSummary === 'true';
|
|
||||||
|
|
||||||
const result = await budgetService.findWithFilters(getContext(req), filters, page, limit);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: result.data,
|
|
||||||
pagination: { total: result.total, page: result.page, limit: result.limit, totalPages: result.totalPages },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /bid-budgets/tree
|
|
||||||
*/
|
|
||||||
router.get('/tree', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bidId = req.query.bidId as string;
|
|
||||||
if (!bidId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'bidId is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tree = await budgetService.getTree(getContext(req), bidId);
|
|
||||||
res.status(200).json({ success: true, data: tree });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /bid-budgets/summary
|
|
||||||
*/
|
|
||||||
router.get('/summary', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bidId = req.query.bidId as string;
|
|
||||||
if (!bidId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'bidId is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = await budgetService.getSummary(getContext(req), bidId);
|
|
||||||
res.status(200).json({ success: true, data: summary });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /bid-budgets/:id
|
|
||||||
*/
|
|
||||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await budgetService.findById(getContext(req), req.params.id);
|
|
||||||
if (!item) {
|
|
||||||
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: item });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /bid-budgets
|
|
||||||
*/
|
|
||||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'costos'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dto: CreateBudgetItemDto = req.body;
|
|
||||||
if (!dto.bidId || !dto.code || !dto.name || !dto.itemType) {
|
|
||||||
res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'bidId, code, name, and itemType are required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await budgetService.create(getContext(req), dto);
|
|
||||||
res.status(201).json({ success: true, data: item });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /bid-budgets/:id
|
|
||||||
*/
|
|
||||||
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'costos'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dto: UpdateBudgetItemDto = req.body;
|
|
||||||
const item = await budgetService.update(getContext(req), req.params.id, dto);
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: item });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /bid-budgets/status
|
|
||||||
*/
|
|
||||||
router.post('/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { bidId, status } = req.body;
|
|
||||||
if (!bidId || !status) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'bidId and status are required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await budgetService.changeStatus(getContext(req), bidId, status);
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
message: `Updated ${updated} budget items`,
|
|
||||||
data: { updated },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /bid-budgets/:id
|
|
||||||
*/
|
|
||||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleted = await budgetService.softDelete(getContext(req), req.params.id);
|
|
||||||
if (!deleted) {
|
|
||||||
res.status(404).json({ error: 'Not Found', message: 'Budget item not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ success: true, message: 'Budget item deleted' });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createBidBudgetController;
|
|
||||||
@ -1,370 +0,0 @@
|
|||||||
/**
|
|
||||||
* BidController - Controller de Licitaciones
|
|
||||||
*
|
|
||||||
* Endpoints REST para gestión de licitaciones/propuestas.
|
|
||||||
*
|
|
||||||
* @module Bidding
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { BidService, CreateBidDto, UpdateBidDto, BidFilters } from '../services/bid.service';
|
|
||||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
|
||||||
import { AuthService } from '../../auth/services/auth.service';
|
|
||||||
import { Bid, BidStatus } from '../entities/bid.entity';
|
|
||||||
import { User } from '../../core/entities/user.entity';
|
|
||||||
import { Tenant } from '../../core/entities/tenant.entity';
|
|
||||||
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
|
||||||
import { ServiceContext } from '../../../shared/services/base.service';
|
|
||||||
|
|
||||||
export function createBidController(dataSource: DataSource): Router {
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// Repositorios
|
|
||||||
const bidRepository = dataSource.getRepository(Bid);
|
|
||||||
const userRepository = dataSource.getRepository(User);
|
|
||||||
const tenantRepository = dataSource.getRepository(Tenant);
|
|
||||||
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
|
||||||
|
|
||||||
// Servicios
|
|
||||||
const bidService = new BidService(bidRepository);
|
|
||||||
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
|
||||||
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
|
||||||
|
|
||||||
// Helper para crear contexto
|
|
||||||
const getContext = (req: Request): ServiceContext => {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
throw new Error('Tenant ID is required');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tenantId: req.tenantId,
|
|
||||||
userId: req.user?.sub,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /bids
|
|
||||||
*/
|
|
||||||
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
|
||||||
|
|
||||||
const filters: BidFilters = {};
|
|
||||||
if (req.query.status) {
|
|
||||||
const statuses = (req.query.status as string).split(',') as BidStatus[];
|
|
||||||
filters.status = statuses.length === 1 ? statuses[0] : statuses;
|
|
||||||
}
|
|
||||||
if (req.query.bidType) filters.bidType = req.query.bidType as any;
|
|
||||||
if (req.query.stage) filters.stage = req.query.stage as any;
|
|
||||||
if (req.query.opportunityId) filters.opportunityId = req.query.opportunityId as string;
|
|
||||||
if (req.query.bidManagerId) filters.bidManagerId = req.query.bidManagerId as string;
|
|
||||||
if (req.query.contractingEntity) filters.contractingEntity = req.query.contractingEntity as string;
|
|
||||||
if (req.query.deadlineFrom) filters.deadlineFrom = new Date(req.query.deadlineFrom as string);
|
|
||||||
if (req.query.deadlineTo) filters.deadlineTo = new Date(req.query.deadlineTo as string);
|
|
||||||
if (req.query.minBudget) filters.minBudget = parseFloat(req.query.minBudget as string);
|
|
||||||
if (req.query.maxBudget) filters.maxBudget = parseFloat(req.query.maxBudget as string);
|
|
||||||
if (req.query.search) filters.search = req.query.search as string;
|
|
||||||
|
|
||||||
const result = await bidService.findWithFilters(getContext(req), filters, page, limit);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
data: result.data,
|
|
||||||
pagination: { total: result.total, page: result.page, limit: result.limit, totalPages: result.totalPages },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /bids/upcoming-deadlines
|
|
||||||
*/
|
|
||||||
router.get('/upcoming-deadlines', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = parseInt(req.query.days as string) || 7;
|
|
||||||
const bids = await bidService.getUpcomingDeadlines(getContext(req), days);
|
|
||||||
res.status(200).json({ success: true, data: bids });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /bids/stats
|
|
||||||
*/
|
|
||||||
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
|
|
||||||
const stats = await bidService.getStats(getContext(req), year);
|
|
||||||
res.status(200).json({ success: true, data: stats });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /bids/:id
|
|
||||||
*/
|
|
||||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bid = await bidService.findById(getContext(req), req.params.id);
|
|
||||||
if (!bid) {
|
|
||||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: bid });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /bids
|
|
||||||
*/
|
|
||||||
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dto: CreateBidDto = req.body;
|
|
||||||
if (!dto.opportunityId || !dto.code || !dto.name || !dto.bidType || !dto.submissionDeadline) {
|
|
||||||
res.status(400).json({
|
|
||||||
error: 'Bad Request',
|
|
||||||
message: 'opportunityId, code, name, bidType, and submissionDeadline are required',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bid = await bidService.create(getContext(req), dto);
|
|
||||||
res.status(201).json({ success: true, data: bid });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /bids/:id
|
|
||||||
*/
|
|
||||||
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dto: UpdateBidDto = req.body;
|
|
||||||
const bid = await bidService.update(getContext(req), req.params.id, dto);
|
|
||||||
|
|
||||||
if (!bid) {
|
|
||||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: bid });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /bids/:id/status
|
|
||||||
*/
|
|
||||||
router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { status } = req.body;
|
|
||||||
if (!status) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'status is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bid = await bidService.changeStatus(getContext(req), req.params.id, status);
|
|
||||||
|
|
||||||
if (!bid) {
|
|
||||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: bid });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /bids/:id/stage
|
|
||||||
*/
|
|
||||||
router.post('/:id/stage', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stage } = req.body;
|
|
||||||
if (!stage) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'stage is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bid = await bidService.changeStage(getContext(req), req.params.id, stage);
|
|
||||||
|
|
||||||
if (!bid) {
|
|
||||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: bid });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /bids/:id/submit
|
|
||||||
*/
|
|
||||||
router.post('/:id/submit', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { proposalAmount } = req.body;
|
|
||||||
if (!proposalAmount) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'proposalAmount is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bid = await bidService.submit(getContext(req), req.params.id, proposalAmount);
|
|
||||||
|
|
||||||
if (!bid) {
|
|
||||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: bid });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /bids/:id/result
|
|
||||||
*/
|
|
||||||
router.post('/:id/result', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { won, winnerName, winningAmount, rankingPosition, rejectionReason, lessonsLearned } = req.body;
|
|
||||||
if (won === undefined) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'won is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bid = await bidService.recordResult(getContext(req), req.params.id, won, {
|
|
||||||
winnerName,
|
|
||||||
winningAmount,
|
|
||||||
rankingPosition,
|
|
||||||
rejectionReason,
|
|
||||||
lessonsLearned,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!bid) {
|
|
||||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: bid });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /bids/:id/convert
|
|
||||||
*/
|
|
||||||
router.post('/:id/convert', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { projectId } = req.body;
|
|
||||||
if (!projectId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'projectId is required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bid = await bidService.convertToProject(getContext(req), req.params.id, projectId);
|
|
||||||
|
|
||||||
if (!bid) {
|
|
||||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found or not awarded' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ success: true, data: bid });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /bids/:id
|
|
||||||
*/
|
|
||||||
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!req.tenantId) {
|
|
||||||
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleted = await bidService.softDelete(getContext(req), req.params.id);
|
|
||||||
if (!deleted) {
|
|
||||||
res.status(404).json({ error: 'Not Found', message: 'Bid not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({ success: true, message: 'Bid deleted' });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default createBidController;
|
|
||||||
@ -1,9 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Bidding Controllers Index
|
* Bidding Controllers Index
|
||||||
|
*
|
||||||
|
* Exports all controllers for the MAI-018 Bidding/Preconstruction module.
|
||||||
|
*
|
||||||
* @module Bidding
|
* @module Bidding
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Core bidding controllers
|
||||||
export { createOpportunityController } from './opportunity.controller';
|
export { createOpportunityController } from './opportunity.controller';
|
||||||
export { createBidController } from './bid.controller';
|
|
||||||
export { createBidBudgetController } from './bid-budget.controller';
|
|
||||||
export { createBidAnalyticsController } from './bid-analytics.controller';
|
export { createBidAnalyticsController } from './bid-analytics.controller';
|
||||||
|
|
||||||
|
// Tender/Proposal management controllers
|
||||||
|
export { createTenderController } from './tender.controller';
|
||||||
|
export { createProposalController } from './proposal.controller';
|
||||||
|
export { createVendorController } from './vendor.controller';
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* OpportunityController - Controller de Oportunidades
|
* OpportunityController - Controller de Oportunidades
|
||||||
*
|
*
|
||||||
* Endpoints REST para gestión del pipeline de oportunidades.
|
* Endpoints REST para gestion del pipeline de oportunidades.
|
||||||
*
|
*
|
||||||
* @module Bidding
|
* @module Bidding
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from '../services/opportunity.service';
|
import { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters, GoNoGoDecisionDto } from '../services/opportunity.service';
|
||||||
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||||
import { AuthService } from '../../auth/services/auth.service';
|
import { AuthService } from '../../auth/services/auth.service';
|
||||||
import { Opportunity, OpportunityStatus } from '../entities/opportunity.entity';
|
import { Opportunity, OpportunityStatus } from '../entities/opportunity.entity';
|
||||||
@ -52,27 +52,26 @@ export function createOpportunityController(dataSource: DataSource): Router {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const filters: OpportunityFilters = {
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||||
|
};
|
||||||
|
|
||||||
const filters: OpportunityFilters = {};
|
|
||||||
if (req.query.status) {
|
if (req.query.status) {
|
||||||
const statuses = (req.query.status as string).split(',') as OpportunityStatus[];
|
const statuses = (req.query.status as string).split(',') as OpportunityStatus[];
|
||||||
filters.status = statuses.length === 1 ? statuses[0] : statuses;
|
filters.status = statuses.length === 1 ? statuses[0] : statuses;
|
||||||
}
|
}
|
||||||
if (req.query.source) filters.source = req.query.source as any;
|
if (req.query.source) filters.source = req.query.source as any;
|
||||||
if (req.query.projectType) filters.projectType = req.query.projectType as any;
|
if (req.query.projectType) filters.projectType = req.query.projectType as string;
|
||||||
if (req.query.priority) filters.priority = req.query.priority as any;
|
if (req.query.priority) filters.priority = req.query.priority as any;
|
||||||
if (req.query.assignedToId) filters.assignedToId = req.query.assignedToId as string;
|
|
||||||
if (req.query.clientName) filters.clientName = req.query.clientName as string;
|
if (req.query.clientName) filters.clientName = req.query.clientName as string;
|
||||||
if (req.query.state) filters.state = req.query.state as string;
|
|
||||||
if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string);
|
if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string);
|
||||||
if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string);
|
if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string);
|
||||||
if (req.query.minValue) filters.minValue = parseFloat(req.query.minValue as string);
|
if (req.query.minValue) filters.minValue = parseFloat(req.query.minValue as string);
|
||||||
if (req.query.maxValue) filters.maxValue = parseFloat(req.query.maxValue as string);
|
if (req.query.maxValue) filters.maxValue = parseFloat(req.query.maxValue as string);
|
||||||
if (req.query.search) filters.search = req.query.search as string;
|
if (req.query.search) filters.search = req.query.search as string;
|
||||||
|
|
||||||
const result = await opportunityService.findWithFilters(getContext(req), filters, page, limit);
|
const result = await opportunityService.findAll(getContext(req), filters);
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
@ -121,6 +120,7 @@ export function createOpportunityController(dataSource: DataSource): Router {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /opportunities/stats
|
* GET /opportunities/stats
|
||||||
|
* Get statistics
|
||||||
*/
|
*/
|
||||||
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -137,8 +137,32 @@ export function createOpportunityController(dataSource: DataSource): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /opportunities/code/:code
|
||||||
|
* Get opportunity by code
|
||||||
|
*/
|
||||||
|
router.get('/code/:code', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opportunity = await opportunityService.findByCode(getContext(req), req.params.code);
|
||||||
|
if (!opportunity) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: opportunity });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /opportunities/:id
|
* GET /opportunities/:id
|
||||||
|
* Get opportunity by ID
|
||||||
*/
|
*/
|
||||||
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -170,10 +194,10 @@ export function createOpportunityController(dataSource: DataSource): Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dto: CreateOpportunityDto = req.body;
|
const dto: CreateOpportunityDto = req.body;
|
||||||
if (!dto.code || !dto.name || !dto.source || !dto.projectType || !dto.clientName || !dto.identificationDate) {
|
if (!dto.title || !dto.source || !dto.projectType || !dto.clientName || !dto.deadlineDate) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: 'code, name, source, projectType, clientName, and identificationDate are required',
|
message: 'title, source, projectType, clientName, and deadlineDate are required',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -187,6 +211,7 @@ export function createOpportunityController(dataSource: DataSource): Router {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /opportunities/:id
|
* PUT /opportunities/:id
|
||||||
|
* Full update opportunity
|
||||||
*/
|
*/
|
||||||
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -209,8 +234,65 @@ export function createOpportunityController(dataSource: DataSource): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /opportunities/:id
|
||||||
|
* Partial update opportunity
|
||||||
|
*/
|
||||||
|
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateOpportunityDto = req.body;
|
||||||
|
const opportunity = await opportunityService.update(getContext(req), req.params.id, dto);
|
||||||
|
|
||||||
|
if (!opportunity) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Opportunity not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: opportunity });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /opportunities/:id/evaluate
|
||||||
|
* Evaluate Go/No-Go decision
|
||||||
|
*/
|
||||||
|
router.post('/:id/evaluate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { decision, reason } = req.body;
|
||||||
|
if (!decision || !['go', 'no_go'].includes(decision)) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'decision is required and must be go or no_go' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reason) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'reason is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: GoNoGoDecisionDto = { decision, reason };
|
||||||
|
const opportunity = await opportunityService.evaluateGoNoGo(getContext(req), req.params.id, dto);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: opportunity });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /opportunities/:id/status
|
* POST /opportunities/:id/status
|
||||||
|
* Change opportunity status
|
||||||
*/
|
*/
|
||||||
router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
262
src/modules/bidding/controllers/proposal.controller.ts
Normal file
262
src/modules/bidding/controllers/proposal.controller.ts
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* ProposalController - Controller de Propuestas de Proveedores
|
||||||
|
*
|
||||||
|
* Endpoints REST para gestion de propuestas recibidas de proveedores.
|
||||||
|
*
|
||||||
|
* @module Bidding
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { ProposalService, CreateProposalDto, UpdateProposalDto, ProposalFilters, EvaluateProposalDto } from '../services/proposal.service';
|
||||||
|
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||||
|
import { AuthService } from '../../auth/services/auth.service';
|
||||||
|
import { Proposal, ProposalStatus } from '../entities/proposal.entity';
|
||||||
|
import { Tender } from '../entities/tender.entity';
|
||||||
|
import { User } from '../../core/entities/user.entity';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
|
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||||
|
import { ServiceContext } from '../../../shared/services/base.service';
|
||||||
|
|
||||||
|
export function createProposalController(dataSource: DataSource): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Repositorios
|
||||||
|
const proposalRepository = dataSource.getRepository(Proposal);
|
||||||
|
const tenderRepository = dataSource.getRepository(Tender);
|
||||||
|
const userRepository = dataSource.getRepository(User);
|
||||||
|
const tenantRepository = dataSource.getRepository(Tenant);
|
||||||
|
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||||
|
|
||||||
|
// Servicios
|
||||||
|
const proposalService = new ProposalService(proposalRepository, tenderRepository);
|
||||||
|
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||||
|
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||||
|
|
||||||
|
// Helper para crear contexto
|
||||||
|
const getContext = (req: Request): ServiceContext => {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
throw new Error('Tenant ID is required');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
userId: req.user?.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /proposals
|
||||||
|
*/
|
||||||
|
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: ProposalFilters = {
|
||||||
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.query.tenderId) filters.tenderId = req.query.tenderId as string;
|
||||||
|
if (req.query.vendorId) filters.vendorId = req.query.vendorId as string;
|
||||||
|
if (req.query.status) {
|
||||||
|
const statuses = (req.query.status as string).split(',') as ProposalStatus[];
|
||||||
|
filters.status = statuses.length === 1 ? statuses[0] : statuses;
|
||||||
|
}
|
||||||
|
if (req.query.search) filters.search = req.query.search as string;
|
||||||
|
|
||||||
|
const result = await proposalService.findAll(getContext(req), filters);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
pagination: { total: result.total, page: result.page, limit: result.limit, totalPages: result.totalPages },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /proposals/compare/:tenderId
|
||||||
|
*/
|
||||||
|
router.get('/compare/:tenderId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparison = await proposalService.compareProposals(getContext(req), req.params.tenderId);
|
||||||
|
res.status(200).json({ success: true, data: comparison });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /proposals/tender/:tenderId
|
||||||
|
*/
|
||||||
|
router.get('/tender/:tenderId', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposals = await proposalService.findByTender(getContext(req), req.params.tenderId);
|
||||||
|
res.status(200).json({ success: true, data: proposals });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /proposals/:id
|
||||||
|
*/
|
||||||
|
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposal = await proposalService.findById(getContext(req), req.params.id);
|
||||||
|
if (!proposal) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Proposal not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: proposal });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /proposals
|
||||||
|
*/
|
||||||
|
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateProposalDto = req.body;
|
||||||
|
if (!dto.tenderId || !dto.vendorId || !dto.proposedAmount || !dto.proposedScheduleDays) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'tenderId, vendorId, proposedAmount, and proposedScheduleDays are required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposal = await proposalService.create(getContext(req), dto);
|
||||||
|
res.status(201).json({ success: true, data: proposal });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /proposals/:id
|
||||||
|
*/
|
||||||
|
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateProposalDto = req.body;
|
||||||
|
const proposal = await proposalService.update(getContext(req), req.params.id, dto);
|
||||||
|
|
||||||
|
if (!proposal) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Proposal not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: proposal });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /proposals/:id/evaluate
|
||||||
|
*/
|
||||||
|
router.post('/:id/evaluate', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { technicalScore, economicScore } = req.body;
|
||||||
|
if (technicalScore === undefined || economicScore === undefined) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'technicalScore and economicScore are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: EvaluateProposalDto = { technicalScore, economicScore };
|
||||||
|
const proposal = await proposalService.evaluate(getContext(req), req.params.id, dto);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: proposal });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /proposals/:id/disqualify
|
||||||
|
*/
|
||||||
|
router.post('/:id/disqualify', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { reason } = req.body;
|
||||||
|
if (!reason) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'reason is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposal = await proposalService.disqualify(getContext(req), req.params.id, reason);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: proposal });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /proposals/:id
|
||||||
|
*/
|
||||||
|
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await proposalService.softDelete(getContext(req), req.params.id);
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Proposal not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, message: 'Proposal deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createProposalController;
|
||||||
302
src/modules/bidding/controllers/tender.controller.ts
Normal file
302
src/modules/bidding/controllers/tender.controller.ts
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* TenderController - Controller de Licitaciones
|
||||||
|
*
|
||||||
|
* Endpoints REST para gestion de licitaciones.
|
||||||
|
*
|
||||||
|
* @module Bidding
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { TenderService, CreateTenderDto, UpdateTenderDto, TenderFilters } from '../services/tender.service';
|
||||||
|
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||||
|
import { AuthService } from '../../auth/services/auth.service';
|
||||||
|
import { Tender, TenderStatus, TenderType } from '../entities/tender.entity';
|
||||||
|
import { Opportunity } from '../entities/opportunity.entity';
|
||||||
|
import { User } from '../../core/entities/user.entity';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
|
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||||
|
import { ServiceContext } from '../../../shared/services/base.service';
|
||||||
|
|
||||||
|
export function createTenderController(dataSource: DataSource): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Repositorios
|
||||||
|
const tenderRepository = dataSource.getRepository(Tender);
|
||||||
|
const opportunityRepository = dataSource.getRepository(Opportunity);
|
||||||
|
const userRepository = dataSource.getRepository(User);
|
||||||
|
const tenantRepository = dataSource.getRepository(Tenant);
|
||||||
|
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||||
|
|
||||||
|
// Servicios
|
||||||
|
const tenderService = new TenderService(tenderRepository, opportunityRepository);
|
||||||
|
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||||
|
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||||
|
|
||||||
|
// Helper para crear contexto
|
||||||
|
const getContext = (req: Request): ServiceContext => {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
throw new Error('Tenant ID is required');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
userId: req.user?.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /tenders
|
||||||
|
*/
|
||||||
|
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: TenderFilters = {
|
||||||
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.query.opportunityId) filters.opportunityId = req.query.opportunityId as string;
|
||||||
|
if (req.query.type) filters.type = req.query.type as TenderType;
|
||||||
|
if (req.query.status) {
|
||||||
|
const statuses = (req.query.status as string).split(',') as TenderStatus[];
|
||||||
|
filters.status = statuses.length === 1 ? statuses[0] : statuses;
|
||||||
|
}
|
||||||
|
if (req.query.dateFrom) filters.dateFrom = new Date(req.query.dateFrom as string);
|
||||||
|
if (req.query.dateTo) filters.dateTo = new Date(req.query.dateTo as string);
|
||||||
|
if (req.query.search) filters.search = req.query.search as string;
|
||||||
|
|
||||||
|
const result = await tenderService.findAll(getContext(req), filters);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
pagination: { total: result.total, page: result.page, limit: result.limit, totalPages: result.totalPages },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /tenders/stats
|
||||||
|
*/
|
||||||
|
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
|
||||||
|
const stats = await tenderService.getStats(getContext(req), year);
|
||||||
|
res.status(200).json({ success: true, data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /tenders/number/:number
|
||||||
|
*/
|
||||||
|
router.get('/number/:number', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tender = await tenderService.findByNumber(getContext(req), req.params.number);
|
||||||
|
if (!tender) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Tender not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: tender });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /tenders/:id
|
||||||
|
*/
|
||||||
|
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tender = await tenderService.findById(getContext(req), req.params.id);
|
||||||
|
if (!tender) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Tender not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: tender });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tenders
|
||||||
|
*/
|
||||||
|
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateTenderDto = req.body;
|
||||||
|
if (!dto.opportunityId || !dto.title || !dto.proposalDeadline) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'opportunityId, title, and proposalDeadline are required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tender = await tenderService.create(getContext(req), dto);
|
||||||
|
res.status(201).json({ success: true, data: tender });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /tenders/:id
|
||||||
|
*/
|
||||||
|
router.put('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateTenderDto = req.body;
|
||||||
|
const tender = await tenderService.update(getContext(req), req.params.id, dto);
|
||||||
|
|
||||||
|
if (!tender) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Tender not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: tender });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /tenders/:id
|
||||||
|
*/
|
||||||
|
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateTenderDto = req.body;
|
||||||
|
const tender = await tenderService.update(getContext(req), req.params.id, dto);
|
||||||
|
|
||||||
|
if (!tender) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Tender not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: tender });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tenders/:id/publish
|
||||||
|
*/
|
||||||
|
router.post('/:id/publish', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tender = await tenderService.publish(getContext(req), req.params.id);
|
||||||
|
res.status(200).json({ success: true, data: tender });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tenders/:id/award
|
||||||
|
*/
|
||||||
|
router.post('/:id/award', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { proposalId } = req.body;
|
||||||
|
if (!proposalId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'proposalId is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tender = await tenderService.awardWinner(getContext(req), req.params.id, proposalId);
|
||||||
|
res.status(200).json({ success: true, data: tender });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tenders/:id/convert
|
||||||
|
*/
|
||||||
|
router.post('/:id/convert', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tenderService.convertToProject(getContext(req), req.params.id);
|
||||||
|
res.status(200).json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /tenders/:id
|
||||||
|
*/
|
||||||
|
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await tenderService.softDelete(getContext(req), req.params.id);
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Tender not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, message: 'Tender deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createTenderController;
|
||||||
259
src/modules/bidding/controllers/vendor.controller.ts
Normal file
259
src/modules/bidding/controllers/vendor.controller.ts
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* VendorController - Controller de Proveedores
|
||||||
|
*
|
||||||
|
* Endpoints REST para gestion de proveedores/contratistas.
|
||||||
|
*
|
||||||
|
* @module Bidding
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { VendorService, CreateVendorDto, UpdateVendorDto, VendorFilters } from '../services/vendor.service';
|
||||||
|
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
|
||||||
|
import { AuthService } from '../../auth/services/auth.service';
|
||||||
|
import { Vendor } from '../entities/vendor.entity';
|
||||||
|
import { User } from '../../core/entities/user.entity';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
|
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
|
||||||
|
import { ServiceContext } from '../../../shared/services/base.service';
|
||||||
|
|
||||||
|
export function createVendorController(dataSource: DataSource): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Repositorios
|
||||||
|
const vendorRepository = dataSource.getRepository(Vendor);
|
||||||
|
const userRepository = dataSource.getRepository(User);
|
||||||
|
const tenantRepository = dataSource.getRepository(Tenant);
|
||||||
|
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
|
||||||
|
|
||||||
|
// Servicios
|
||||||
|
const vendorService = new VendorService(vendorRepository);
|
||||||
|
const authService = new AuthService(userRepository, tenantRepository, refreshTokenRepository as any);
|
||||||
|
const authMiddleware = new AuthMiddleware(authService, dataSource);
|
||||||
|
|
||||||
|
// Helper para crear contexto
|
||||||
|
const getContext = (req: Request): ServiceContext => {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
throw new Error('Tenant ID is required');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: req.tenantId,
|
||||||
|
userId: req.user?.sub,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /vendors
|
||||||
|
*/
|
||||||
|
router.get('/', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: VendorFilters = {
|
||||||
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.query.search) filters.search = req.query.search as string;
|
||||||
|
if (req.query.specialty) filters.specialty = req.query.specialty as string;
|
||||||
|
if (req.query.isActive !== undefined) filters.isActive = req.query.isActive === 'true';
|
||||||
|
if (req.query.minRating) filters.minRating = parseFloat(req.query.minRating as string);
|
||||||
|
|
||||||
|
const result = await vendorService.findAll(getContext(req), filters);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
pagination: { total: result.total, page: result.page, limit: result.limit, totalPages: result.totalPages },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /vendors/code/:code
|
||||||
|
*/
|
||||||
|
router.get('/code/:code', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendor = await vendorService.findByCode(getContext(req), req.params.code);
|
||||||
|
if (!vendor) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Vendor not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: vendor });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /vendors/rfc/:rfc
|
||||||
|
*/
|
||||||
|
router.get('/rfc/:rfc', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendor = await vendorService.findByRfc(getContext(req), req.params.rfc);
|
||||||
|
if (!vendor) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Vendor not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: vendor });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /vendors/:id/performance
|
||||||
|
*/
|
||||||
|
router.get('/:id/performance', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const performance = await vendorService.getPerformanceHistory(getContext(req), req.params.id);
|
||||||
|
res.status(200).json({ success: true, data: performance });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /vendors/:id
|
||||||
|
*/
|
||||||
|
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendor = await vendorService.findById(getContext(req), req.params.id);
|
||||||
|
if (!vendor) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Vendor not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: vendor });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /vendors
|
||||||
|
*/
|
||||||
|
router.post('/', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateVendorDto = req.body;
|
||||||
|
if (!dto.companyName) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'companyName is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendor = await vendorService.create(getContext(req), dto);
|
||||||
|
res.status(201).json({ success: true, data: vendor });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /vendors/:id
|
||||||
|
*/
|
||||||
|
router.patch('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'compras'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateVendorDto = req.body;
|
||||||
|
const vendor = await vendorService.update(getContext(req), req.params.id, dto);
|
||||||
|
|
||||||
|
if (!vendor) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Vendor not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: vendor });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /vendors/:id
|
||||||
|
*/
|
||||||
|
router.delete('/:id', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await vendorService.softDelete(getContext(req), req.params.id);
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Not Found', message: 'Vendor not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, message: 'Vendor deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /vendors/:id/rating
|
||||||
|
*/
|
||||||
|
router.patch('/:id/rating', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rating } = req.body;
|
||||||
|
if (rating === undefined) {
|
||||||
|
res.status(400).json({ error: 'Bad Request', message: 'rating is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendor = await vendorService.updateRating(getContext(req), req.params.id, rating);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: vendor });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createVendorController;
|
||||||
200
src/modules/bidding/dto/bid-calendar.dto.ts
Normal file
200
src/modules/bidding/dto/bid-calendar.dto.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* BidCalendar DTOs - Data Transfer Objects para Calendario de Licitacion
|
||||||
|
*
|
||||||
|
* Eventos y fechas importantes del proceso de licitacion.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
IsBoolean,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de evento en el calendario
|
||||||
|
*/
|
||||||
|
export enum CalendarEventType {
|
||||||
|
SITE_VISIT = 'site_visit',
|
||||||
|
CLARIFICATION_MEETING = 'clarification_meeting',
|
||||||
|
SUBMISSION_DEADLINE = 'submission_deadline',
|
||||||
|
TECHNICAL_OPENING = 'technical_opening',
|
||||||
|
ECONOMIC_OPENING = 'economic_opening',
|
||||||
|
AWARD_DATE = 'award_date',
|
||||||
|
OTHER = 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear un nuevo evento de calendario
|
||||||
|
*/
|
||||||
|
export class CreateBidCalendarDto {
|
||||||
|
@IsUUID()
|
||||||
|
tenderId: string;
|
||||||
|
|
||||||
|
@IsEnum(CalendarEventType)
|
||||||
|
eventType: CalendarEventType;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
eventDate: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(30)
|
||||||
|
alertDaysBefore?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar un evento de calendario existente
|
||||||
|
*/
|
||||||
|
export class UpdateBidCalendarDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CalendarEventType)
|
||||||
|
eventType?: CalendarEventType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
eventDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(30)
|
||||||
|
alertDaysBefore?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
alertSent?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para marcar alerta como enviada
|
||||||
|
*/
|
||||||
|
export class MarkAlertSentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
alertSent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar eventos de calendario
|
||||||
|
*/
|
||||||
|
export class BidCalendarFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
tenderId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CalendarEventType)
|
||||||
|
eventType?: CalendarEventType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
alertSent?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
pendingAlerts?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para un evento de calendario
|
||||||
|
*/
|
||||||
|
export class BidCalendarResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
tenderId: string;
|
||||||
|
tender?: {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
eventType: CalendarEventType;
|
||||||
|
eventDate: Date;
|
||||||
|
description: string;
|
||||||
|
alertDaysBefore: number;
|
||||||
|
alertSent: boolean;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
createdById?: string;
|
||||||
|
createdBy?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedById?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para obtener eventos proximos que requieren alerta
|
||||||
|
*/
|
||||||
|
export class UpcomingEventsAlertDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(30)
|
||||||
|
daysAhead?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CalendarEventType)
|
||||||
|
eventType?: CalendarEventType;
|
||||||
|
}
|
||||||
208
src/modules/bidding/dto/bid-document.dto.ts
Normal file
208
src/modules/bidding/dto/bid-document.dto.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* BidDocument DTOs - Data Transfer Objects para Documentos de Licitacion
|
||||||
|
*
|
||||||
|
* Almacena documentos asociados a una licitacion.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo/categoria de documento
|
||||||
|
*/
|
||||||
|
export enum BidDocumentType {
|
||||||
|
BASES = 'bases',
|
||||||
|
TECHNICAL_ANNEX = 'technical_annex',
|
||||||
|
ECONOMIC_ANNEX = 'economic_annex',
|
||||||
|
CLARIFICATION = 'clarification',
|
||||||
|
PROPOSAL_TECH = 'proposal_tech',
|
||||||
|
PROPOSAL_ECON = 'proposal_econ',
|
||||||
|
CONTRACT = 'contract',
|
||||||
|
OTHER = 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear un nuevo documento
|
||||||
|
*/
|
||||||
|
export class CreateBidDocumentDto {
|
||||||
|
@IsUUID()
|
||||||
|
tenderId: string;
|
||||||
|
|
||||||
|
@IsEnum(BidDocumentType)
|
||||||
|
documentType: BidDocumentType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(255)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
fileUrl: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
fileSize: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
mimeType: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
version?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
uploadedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar un documento existente
|
||||||
|
*/
|
||||||
|
export class UpdateBidDocumentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(BidDocumentType)
|
||||||
|
documentType?: BidDocumentType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(255)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para subir nueva version de documento
|
||||||
|
*/
|
||||||
|
export class UploadNewVersionDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
fileUrl: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
fileSize: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
mimeType: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar documentos
|
||||||
|
*/
|
||||||
|
export class BidDocumentFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
tenderId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(BidDocumentType)
|
||||||
|
documentType?: BidDocumentType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
uploadedById?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
uploadedFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
uploadedTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para un documento
|
||||||
|
*/
|
||||||
|
export class BidDocumentResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
tenderId: string;
|
||||||
|
tender?: {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
documentType: BidDocumentType;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
fileUrl: string;
|
||||||
|
fileSize: string;
|
||||||
|
mimeType: string;
|
||||||
|
version: number;
|
||||||
|
uploadedById: string;
|
||||||
|
uploadedBy?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
uploadedAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
createdById?: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedById?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para resumen de documentos por tipo
|
||||||
|
*/
|
||||||
|
export class DocumentSummaryByTypeDto {
|
||||||
|
documentType: BidDocumentType;
|
||||||
|
count: number;
|
||||||
|
totalSize: number;
|
||||||
|
latestVersion: number;
|
||||||
|
}
|
||||||
101
src/modules/bidding/dto/index.ts
Normal file
101
src/modules/bidding/dto/index.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Bidding DTOs Index
|
||||||
|
* Barrel file exporting all bidding module DTOs and Enums.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OPPORTUNITY DTOs
|
||||||
|
// ============================================================================
|
||||||
|
export {
|
||||||
|
// Enums
|
||||||
|
OpportunitySource,
|
||||||
|
OpportunityStatus,
|
||||||
|
Priority,
|
||||||
|
// DTOs
|
||||||
|
CreateOpportunityDto,
|
||||||
|
UpdateOpportunityDto,
|
||||||
|
EvaluateOpportunityDto,
|
||||||
|
OpportunityFiltersDto,
|
||||||
|
OpportunityResponseDto,
|
||||||
|
} from './opportunity.dto';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TENDER DTOs
|
||||||
|
// ============================================================================
|
||||||
|
export {
|
||||||
|
// Enums
|
||||||
|
TenderType,
|
||||||
|
TenderStatus,
|
||||||
|
// DTOs
|
||||||
|
CreateTenderDto,
|
||||||
|
UpdateTenderDto,
|
||||||
|
PublishTenderDto,
|
||||||
|
AwardTenderDto,
|
||||||
|
CancelTenderDto,
|
||||||
|
TenderFiltersDto,
|
||||||
|
TenderResponseDto,
|
||||||
|
} from './tender.dto';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PROPOSAL DTOs
|
||||||
|
// ============================================================================
|
||||||
|
export {
|
||||||
|
// Enums
|
||||||
|
ProposalStatus,
|
||||||
|
// DTOs
|
||||||
|
CreateProposalDto,
|
||||||
|
UpdateProposalDto,
|
||||||
|
EvaluateProposalDto,
|
||||||
|
DisqualifyProposalDto,
|
||||||
|
ProposalFiltersDto,
|
||||||
|
ProposalResponseDto,
|
||||||
|
} from './proposal.dto';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VENDOR DTOs
|
||||||
|
// ============================================================================
|
||||||
|
export {
|
||||||
|
// Supporting DTOs
|
||||||
|
VendorCertificationDto,
|
||||||
|
VendorPerformanceEntryDto,
|
||||||
|
// DTOs
|
||||||
|
CreateVendorDto,
|
||||||
|
UpdateVendorDto,
|
||||||
|
UpdateRatingDto,
|
||||||
|
AddPerformanceEntryDto,
|
||||||
|
AddCertificationDto,
|
||||||
|
VendorFiltersDto,
|
||||||
|
VendorResponseDto,
|
||||||
|
} from './vendor.dto';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BID CALENDAR DTOs
|
||||||
|
// ============================================================================
|
||||||
|
export {
|
||||||
|
// Enums
|
||||||
|
CalendarEventType,
|
||||||
|
// DTOs
|
||||||
|
CreateBidCalendarDto,
|
||||||
|
UpdateBidCalendarDto,
|
||||||
|
MarkAlertSentDto,
|
||||||
|
BidCalendarFiltersDto,
|
||||||
|
BidCalendarResponseDto,
|
||||||
|
UpcomingEventsAlertDto,
|
||||||
|
} from './bid-calendar.dto';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BID DOCUMENT DTOs
|
||||||
|
// ============================================================================
|
||||||
|
export {
|
||||||
|
// Enums
|
||||||
|
BidDocumentType,
|
||||||
|
// DTOs
|
||||||
|
CreateBidDocumentDto,
|
||||||
|
UpdateBidDocumentDto,
|
||||||
|
UploadNewVersionDto,
|
||||||
|
BidDocumentFiltersDto,
|
||||||
|
BidDocumentResponseDto,
|
||||||
|
DocumentSummaryByTypeDto,
|
||||||
|
} from './bid-document.dto';
|
||||||
266
src/modules/bidding/dto/opportunity.dto.ts
Normal file
266
src/modules/bidding/dto/opportunity.dto.ts
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* Opportunity DTOs - Data Transfer Objects para Oportunidades de Licitacion
|
||||||
|
*
|
||||||
|
* Representa oportunidades de licitacion/proyecto en el pipeline comercial.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuente de la oportunidad
|
||||||
|
*/
|
||||||
|
export enum OpportunitySource {
|
||||||
|
GOVERNMENT_PORTAL = 'government_portal',
|
||||||
|
PRIVATE_CLIENT = 'private_client',
|
||||||
|
REFERRAL = 'referral',
|
||||||
|
OTHER = 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de la oportunidad en el pipeline
|
||||||
|
*/
|
||||||
|
export enum OpportunityStatus {
|
||||||
|
REGISTERED = 'registered',
|
||||||
|
EVALUATING = 'evaluating',
|
||||||
|
GO = 'go',
|
||||||
|
NO_GO = 'no_go',
|
||||||
|
PREPARING = 'preparing',
|
||||||
|
CONVERTED = 'converted',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prioridad de la oportunidad
|
||||||
|
*/
|
||||||
|
export enum Priority {
|
||||||
|
HIGH = 'high',
|
||||||
|
MEDIUM = 'medium',
|
||||||
|
LOW = 'low',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear una nueva oportunidad
|
||||||
|
*/
|
||||||
|
export class CreateOpportunityDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(50)
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsEnum(OpportunitySource)
|
||||||
|
source: OpportunitySource;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
clientName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
projectType: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
estimatedAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
estimatedUnits?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Priority)
|
||||||
|
priority?: Priority;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
deadlineDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar una oportunidad existente
|
||||||
|
*/
|
||||||
|
export class UpdateOpportunityDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(50)
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(OpportunitySource)
|
||||||
|
source?: OpportunitySource;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
clientName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
projectType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
estimatedAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
estimatedUnits?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Priority)
|
||||||
|
priority?: Priority;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
deadlineDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para evaluar una oportunidad (Go/No-Go decision)
|
||||||
|
*/
|
||||||
|
export class EvaluateOpportunityDto {
|
||||||
|
@IsEnum(['go', 'no_go'])
|
||||||
|
decision: 'go' | 'no_go';
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar oportunidades en listados
|
||||||
|
*/
|
||||||
|
export class OpportunityFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(OpportunitySource)
|
||||||
|
source?: OpportunitySource;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(OpportunityStatus)
|
||||||
|
status?: OpportunityStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Priority)
|
||||||
|
priority?: Priority;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clientName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
projectType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
deadlineFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
deadlineTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para una oportunidad
|
||||||
|
*/
|
||||||
|
export class OpportunityResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
source: OpportunitySource;
|
||||||
|
clientName: string;
|
||||||
|
projectType: string;
|
||||||
|
location?: string;
|
||||||
|
estimatedAmount?: string;
|
||||||
|
estimatedUnits?: number;
|
||||||
|
status: OpportunityStatus;
|
||||||
|
goDecisionDate?: Date;
|
||||||
|
goDecisionReason?: string;
|
||||||
|
priority: Priority;
|
||||||
|
deadlineDate: Date;
|
||||||
|
tendersCount?: number;
|
||||||
|
createdAt: Date;
|
||||||
|
createdById?: string;
|
||||||
|
createdBy?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedById?: string;
|
||||||
|
}
|
||||||
210
src/modules/bidding/dto/proposal.dto.ts
Normal file
210
src/modules/bidding/dto/proposal.dto.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* Proposal DTOs - Data Transfer Objects para Propuestas Enviadas
|
||||||
|
*
|
||||||
|
* Representa una propuesta enviada a una licitacion.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de la propuesta
|
||||||
|
*/
|
||||||
|
export enum ProposalStatus {
|
||||||
|
RECEIVED = 'received',
|
||||||
|
EVALUATING = 'evaluating',
|
||||||
|
QUALIFIED = 'qualified',
|
||||||
|
DISQUALIFIED = 'disqualified',
|
||||||
|
WINNER = 'winner',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear una nueva propuesta
|
||||||
|
*/
|
||||||
|
export class CreateProposalDto {
|
||||||
|
@IsUUID()
|
||||||
|
tenderId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
vendorId: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
proposedAmount: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
proposedScheduleDays: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
technicalProposalUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
economicProposalUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
submittedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar una propuesta existente
|
||||||
|
*/
|
||||||
|
export class UpdateProposalDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
proposedAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
proposedScheduleDays?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
technicalProposalUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
economicProposalUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para evaluar una propuesta (asignar puntajes)
|
||||||
|
*/
|
||||||
|
export class EvaluateProposalDto {
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
technicalScore: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
economicScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para descalificar una propuesta
|
||||||
|
*/
|
||||||
|
export class DisqualifyProposalDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar propuestas en listados
|
||||||
|
*/
|
||||||
|
export class ProposalFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
tenderId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
vendorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ProposalStatus)
|
||||||
|
status?: ProposalStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
submittedFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
submittedTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
minAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para una propuesta
|
||||||
|
*/
|
||||||
|
export class ProposalResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
tenderId: string;
|
||||||
|
tender?: {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
referenceAmount?: string;
|
||||||
|
};
|
||||||
|
vendorId: string;
|
||||||
|
vendor?: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
businessName: string;
|
||||||
|
rating?: number;
|
||||||
|
};
|
||||||
|
proposedAmount: string;
|
||||||
|
proposedScheduleDays: number;
|
||||||
|
technicalProposalUrl?: string;
|
||||||
|
economicProposalUrl?: string;
|
||||||
|
technicalScore?: number;
|
||||||
|
economicScore?: number;
|
||||||
|
totalScore?: number;
|
||||||
|
status: ProposalStatus;
|
||||||
|
submittedAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
createdById?: string;
|
||||||
|
createdBy?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedById?: string;
|
||||||
|
}
|
||||||
279
src/modules/bidding/dto/tender.dto.ts
Normal file
279
src/modules/bidding/dto/tender.dto.ts
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* Tender DTOs - Data Transfer Objects para Licitaciones Formales
|
||||||
|
*
|
||||||
|
* Representa una licitacion formal vinculada a una oportunidad.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de licitacion
|
||||||
|
*/
|
||||||
|
export enum TenderType {
|
||||||
|
PUBLIC = 'public',
|
||||||
|
PRIVATE = 'private',
|
||||||
|
INVITATION_ONLY = 'invitation_only',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de la licitacion
|
||||||
|
*/
|
||||||
|
export enum TenderStatus {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
PUBLISHED = 'published',
|
||||||
|
RECEIVING = 'receiving',
|
||||||
|
EVALUATING = 'evaluating',
|
||||||
|
AWARDED = 'awarded',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
CONVERTING = 'converting',
|
||||||
|
CONVERTED = 'converted',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear una nueva licitacion
|
||||||
|
*/
|
||||||
|
export class CreateTenderDto {
|
||||||
|
@IsUUID()
|
||||||
|
opportunityId: string;
|
||||||
|
|
||||||
|
@IsEnum(TenderType)
|
||||||
|
type: TenderType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
referenceAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
publicationDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
clarificationMeetingDate?: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
proposalDeadline: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
awardDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
contractDurationDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar una licitacion existente
|
||||||
|
*/
|
||||||
|
export class UpdateTenderDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(50)
|
||||||
|
number?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TenderType)
|
||||||
|
type?: TenderType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
referenceAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
publicationDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
clarificationMeetingDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
proposalDeadline?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
awardDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
contractDurationDays?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TenderStatus)
|
||||||
|
status?: TenderStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para publicar una licitacion
|
||||||
|
*/
|
||||||
|
export class PublishTenderDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
publicationDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para adjudicar una licitacion a una propuesta ganadora
|
||||||
|
*/
|
||||||
|
export class AwardTenderDto {
|
||||||
|
@IsUUID()
|
||||||
|
proposalId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
awardDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para cancelar una licitacion
|
||||||
|
*/
|
||||||
|
export class CancelTenderDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar licitaciones en listados
|
||||||
|
*/
|
||||||
|
export class TenderFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
opportunityId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TenderType)
|
||||||
|
type?: TenderType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TenderStatus)
|
||||||
|
status?: TenderStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
proposalDeadlineFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
proposalDeadlineTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para una licitacion
|
||||||
|
*/
|
||||||
|
export class TenderResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
opportunityId: string;
|
||||||
|
opportunity?: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
clientName: string;
|
||||||
|
};
|
||||||
|
number: string;
|
||||||
|
type: TenderType;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
referenceAmount?: string;
|
||||||
|
publicationDate?: Date;
|
||||||
|
clarificationMeetingDate?: Date;
|
||||||
|
proposalDeadline: Date;
|
||||||
|
awardDate?: Date;
|
||||||
|
contractDurationDays?: number;
|
||||||
|
status: TenderStatus;
|
||||||
|
winnerId?: string;
|
||||||
|
winner?: {
|
||||||
|
id: string;
|
||||||
|
vendorId: string;
|
||||||
|
proposedAmount: string;
|
||||||
|
};
|
||||||
|
proposalsCount?: number;
|
||||||
|
documentsCount?: number;
|
||||||
|
calendarEventsCount?: number;
|
||||||
|
createdAt: Date;
|
||||||
|
createdById?: string;
|
||||||
|
createdBy?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedById?: string;
|
||||||
|
}
|
||||||
347
src/modules/bidding/dto/vendor.dto.ts
Normal file
347
src/modules/bidding/dto/vendor.dto.ts
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* Vendor DTOs - Data Transfer Objects para Proveedores/Contratistas
|
||||||
|
*
|
||||||
|
* Representa proveedores y contratistas que participan en licitaciones.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsBoolean,
|
||||||
|
IsArray,
|
||||||
|
IsEmail,
|
||||||
|
ValidateNested,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
Matches,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para certificaciones del proveedor
|
||||||
|
*/
|
||||||
|
export class VendorCertificationDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
issuedBy: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
issuedDate: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
expiryDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
documentUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface para historial de desempeno del proveedor
|
||||||
|
*/
|
||||||
|
export class VendorPerformanceEntryDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
projectName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
clientName: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
contractAmount: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
completedDate: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(5)
|
||||||
|
rating: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear un nuevo proveedor/contratista
|
||||||
|
*/
|
||||||
|
export class CreateVendorDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(20)
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(255)
|
||||||
|
businessName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(13)
|
||||||
|
@Matches(/^[A-Z&]{3,4}[0-9]{6}[A-Z0-9]{3}$/, {
|
||||||
|
message: 'RFC debe tener formato valido (ej: XAXX010101000)',
|
||||||
|
})
|
||||||
|
rfc?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
specialties?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(5)
|
||||||
|
rating?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => VendorCertificationDto)
|
||||||
|
certifications?: VendorCertificationDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
contactName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(255)
|
||||||
|
contactEmail?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
contactPhone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar un proveedor existente
|
||||||
|
*/
|
||||||
|
export class UpdateVendorDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(20)
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(255)
|
||||||
|
businessName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(13)
|
||||||
|
@Matches(/^[A-Z&]{3,4}[0-9]{6}[A-Z0-9]{3}$/, {
|
||||||
|
message: 'RFC debe tener formato valido (ej: XAXX010101000)',
|
||||||
|
})
|
||||||
|
rfc?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
specialties?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(5)
|
||||||
|
rating?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => VendorCertificationDto)
|
||||||
|
certifications?: VendorCertificationDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => VendorPerformanceEntryDto)
|
||||||
|
performanceHistory?: VendorPerformanceEntryDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
documentationValid?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
contactName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(255)
|
||||||
|
contactEmail?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
contactPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar la calificacion de un proveedor
|
||||||
|
*/
|
||||||
|
export class UpdateRatingDto {
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(5)
|
||||||
|
rating: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para agregar entrada de desempeno
|
||||||
|
*/
|
||||||
|
export class AddPerformanceEntryDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
projectName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
clientName: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
contractAmount: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
completedDate: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(5)
|
||||||
|
rating: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para agregar certificacion
|
||||||
|
*/
|
||||||
|
export class AddCertificationDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
issuedBy: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
issuedDate: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
expiryDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
documentUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar proveedores en listados
|
||||||
|
*/
|
||||||
|
export class VendorFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
specialty?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
documentationValid?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(5)
|
||||||
|
minRating?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para un proveedor
|
||||||
|
*/
|
||||||
|
export class VendorResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
code: string;
|
||||||
|
businessName: string;
|
||||||
|
rfc?: string;
|
||||||
|
specialties?: string[];
|
||||||
|
rating?: number;
|
||||||
|
certifications?: VendorCertificationDto[];
|
||||||
|
performanceHistory?: VendorPerformanceEntryDto[];
|
||||||
|
documentationValid: boolean;
|
||||||
|
contactName?: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
proposalsCount?: number;
|
||||||
|
createdAt: Date;
|
||||||
|
createdById?: string;
|
||||||
|
createdBy?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedById?: string;
|
||||||
|
}
|
||||||
@ -1,256 +0,0 @@
|
|||||||
/**
|
|
||||||
* BidBudget Entity - Presupuesto de Licitación
|
|
||||||
*
|
|
||||||
* Desglose del presupuesto para la propuesta económica.
|
|
||||||
*
|
|
||||||
* @module Bidding
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
ManyToOne,
|
|
||||||
JoinColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { Bid } from './bid.entity';
|
|
||||||
|
|
||||||
export type BudgetItemType =
|
|
||||||
| 'direct_cost'
|
|
||||||
| 'indirect_cost'
|
|
||||||
| 'labor'
|
|
||||||
| 'materials'
|
|
||||||
| 'equipment'
|
|
||||||
| 'subcontract'
|
|
||||||
| 'overhead'
|
|
||||||
| 'profit'
|
|
||||||
| 'contingency'
|
|
||||||
| 'financing'
|
|
||||||
| 'taxes'
|
|
||||||
| 'bonds'
|
|
||||||
| 'other';
|
|
||||||
|
|
||||||
export type BudgetStatus = 'draft' | 'calculated' | 'reviewed' | 'approved' | 'locked';
|
|
||||||
|
|
||||||
@Entity('bid_budget', { schema: 'bidding' })
|
|
||||||
@Index(['tenantId', 'bidId'])
|
|
||||||
@Index(['tenantId', 'itemType'])
|
|
||||||
export class BidBudget {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
|
||||||
@Index()
|
|
||||||
tenantId!: string;
|
|
||||||
|
|
||||||
// Referencia a licitación
|
|
||||||
@Column({ name: 'bid_id', type: 'uuid' })
|
|
||||||
bidId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Bid, (bid) => bid.budgetItems)
|
|
||||||
@JoinColumn({ name: 'bid_id' })
|
|
||||||
bid?: Bid;
|
|
||||||
|
|
||||||
// Jerarquía
|
|
||||||
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
|
|
||||||
parentId?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'sort_order', type: 'int', default: 0 })
|
|
||||||
sortOrder!: number;
|
|
||||||
|
|
||||||
@Column({ type: 'int', default: 0 })
|
|
||||||
level!: number;
|
|
||||||
|
|
||||||
@Column({ length: 50 })
|
|
||||||
code!: string;
|
|
||||||
|
|
||||||
// Información del item
|
|
||||||
@Column({ length: 255 })
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'item_type',
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['direct_cost', 'indirect_cost', 'labor', 'materials', 'equipment', 'subcontract', 'overhead', 'profit', 'contingency', 'financing', 'taxes', 'bonds', 'other'],
|
|
||||||
enumName: 'bid_budget_item_type',
|
|
||||||
})
|
|
||||||
itemType!: BudgetItemType;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['draft', 'calculated', 'reviewed', 'approved', 'locked'],
|
|
||||||
enumName: 'bid_budget_status',
|
|
||||||
default: 'draft',
|
|
||||||
})
|
|
||||||
status!: BudgetStatus;
|
|
||||||
|
|
||||||
// Unidad y cantidad
|
|
||||||
@Column({ length: 20, nullable: true })
|
|
||||||
unit?: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 4,
|
|
||||||
default: 0,
|
|
||||||
})
|
|
||||||
quantity!: number;
|
|
||||||
|
|
||||||
// Precios
|
|
||||||
@Column({
|
|
||||||
name: 'unit_price',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 4,
|
|
||||||
default: 0,
|
|
||||||
})
|
|
||||||
unitPrice!: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'total_amount',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
default: 0,
|
|
||||||
})
|
|
||||||
totalAmount!: number;
|
|
||||||
|
|
||||||
// Desglose de costos directos
|
|
||||||
@Column({
|
|
||||||
name: 'materials_cost',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
materialsCost?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'labor_cost',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
laborCost?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'equipment_cost',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
equipmentCost?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'subcontract_cost',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
subcontractCost?: number;
|
|
||||||
|
|
||||||
// Porcentajes
|
|
||||||
@Column({
|
|
||||||
name: 'indirect_percentage',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
indirectPercentage?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'profit_percentage',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
profitPercentage?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'financing_percentage',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
financingPercentage?: number;
|
|
||||||
|
|
||||||
// Comparación con base de licitación
|
|
||||||
@Column({
|
|
||||||
name: 'base_amount',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
baseAmount?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'variance_amount',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
varianceAmount?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'variance_percentage',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 8,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
variancePercentage?: number;
|
|
||||||
|
|
||||||
// Flags
|
|
||||||
@Column({ name: 'is_summary', type: 'boolean', default: false })
|
|
||||||
isSummary!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'is_calculated', type: 'boolean', default: false })
|
|
||||||
isCalculated!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'is_adjusted', type: 'boolean', default: false })
|
|
||||||
isAdjusted!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'adjustment_reason', type: 'text', nullable: true })
|
|
||||||
adjustmentReason?: string;
|
|
||||||
|
|
||||||
// Referencia a concepto de catálogo
|
|
||||||
@Column({ name: 'catalog_concept_id', type: 'uuid', nullable: true })
|
|
||||||
catalogConceptId?: string;
|
|
||||||
|
|
||||||
// Notas y metadatos
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
notes?: string;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
|
|
||||||
// Auditoría
|
|
||||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
|
||||||
createdBy?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
|
||||||
updatedBy?: string;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
|
||||||
updatedAt!: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
|
||||||
deletedAt?: Date;
|
|
||||||
}
|
|
||||||
@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* BidCalendar Entity - Calendario de Licitación
|
* BidCalendar Entity - Fechas Clave de Licitación
|
||||||
*
|
|
||||||
* Eventos y fechas importantes del proceso de licitación.
|
* Eventos y fechas importantes del proceso de licitación.
|
||||||
*
|
*
|
||||||
* @module Bidding
|
* @module Bidding (MAI-018)
|
||||||
|
* @table bidding.bid_calendar
|
||||||
|
* @ddl schemas/XX-bidding-schema-ddl.sql
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -16,173 +17,94 @@ import {
|
|||||||
JoinColumn,
|
JoinColumn,
|
||||||
Index,
|
Index,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
import { User } from '../../core/entities/user.entity';
|
import { User } from '../../core/entities/user.entity';
|
||||||
import { Bid } from './bid.entity';
|
import { Tender } from './tender.entity';
|
||||||
|
|
||||||
|
/** Type of calendar event */
|
||||||
export type CalendarEventType =
|
export type CalendarEventType =
|
||||||
| 'publication'
|
|
||||||
| 'site_visit'
|
| 'site_visit'
|
||||||
| 'clarification_meeting'
|
| 'clarification_meeting'
|
||||||
| 'clarification_deadline'
|
|
||||||
| 'submission_deadline'
|
| 'submission_deadline'
|
||||||
| 'opening'
|
| 'technical_opening'
|
||||||
| 'technical_evaluation'
|
| 'economic_opening'
|
||||||
| 'economic_evaluation'
|
| 'award_date'
|
||||||
| 'award_notification'
|
|
||||||
| 'contract_signing'
|
|
||||||
| 'kick_off'
|
|
||||||
| 'milestone'
|
|
||||||
| 'internal_review'
|
|
||||||
| 'team_meeting'
|
|
||||||
| 'reminder'
|
|
||||||
| 'other';
|
| 'other';
|
||||||
|
|
||||||
export type EventPriority = 'low' | 'medium' | 'high' | 'critical';
|
@Entity({ schema: 'bidding', name: 'bid_calendar' })
|
||||||
|
@Index(['tenantId'])
|
||||||
export type EventStatus = 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'postponed';
|
@Index(['tenantId', 'tenderId'])
|
||||||
|
|
||||||
@Entity('bid_calendar', { schema: 'bidding' })
|
|
||||||
@Index(['tenantId', 'bidId'])
|
|
||||||
@Index(['tenantId', 'eventDate'])
|
|
||||||
@Index(['tenantId', 'eventType'])
|
@Index(['tenantId', 'eventType'])
|
||||||
|
@Index(['eventDate'])
|
||||||
|
@Index(['alertSent'])
|
||||||
export class BidCalendar {
|
export class BidCalendar {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id!: string;
|
id: string;
|
||||||
|
|
||||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
@Index()
|
tenantId: string;
|
||||||
tenantId!: string;
|
|
||||||
|
|
||||||
// Referencia a licitación
|
/** Reference to the tender */
|
||||||
@Column({ name: 'bid_id', type: 'uuid' })
|
@Column({ name: 'tender_id', type: 'uuid' })
|
||||||
bidId!: string;
|
tenderId: string;
|
||||||
|
|
||||||
@ManyToOne(() => Bid, (bid) => bid.calendarEvents)
|
|
||||||
@JoinColumn({ name: 'bid_id' })
|
|
||||||
bid?: Bid;
|
|
||||||
|
|
||||||
// Información del evento
|
|
||||||
@Column({ length: 255 })
|
|
||||||
title!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: 'event_type',
|
name: 'event_type',
|
||||||
type: 'enum',
|
type: 'varchar',
|
||||||
enum: ['publication', 'site_visit', 'clarification_meeting', 'clarification_deadline', 'submission_deadline', 'opening', 'technical_evaluation', 'economic_evaluation', 'award_notification', 'contract_signing', 'kick_off', 'milestone', 'internal_review', 'team_meeting', 'reminder', 'other'],
|
length: 50,
|
||||||
enumName: 'calendar_event_type',
|
|
||||||
})
|
})
|
||||||
eventType!: CalendarEventType;
|
eventType: CalendarEventType;
|
||||||
|
|
||||||
@Column({
|
/** Date and time of the event */
|
||||||
type: 'enum',
|
|
||||||
enum: ['low', 'medium', 'high', 'critical'],
|
|
||||||
enumName: 'event_priority',
|
|
||||||
default: 'medium',
|
|
||||||
})
|
|
||||||
priority!: EventPriority;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['scheduled', 'in_progress', 'completed', 'cancelled', 'postponed'],
|
|
||||||
enumName: 'event_status',
|
|
||||||
default: 'scheduled',
|
|
||||||
})
|
|
||||||
status!: EventStatus;
|
|
||||||
|
|
||||||
// Fechas y hora
|
|
||||||
@Column({ name: 'event_date', type: 'timestamptz' })
|
@Column({ name: 'event_date', type: 'timestamptz' })
|
||||||
eventDate!: Date;
|
eventDate: Date;
|
||||||
|
|
||||||
@Column({ name: 'end_date', type: 'timestamptz', nullable: true })
|
/** Description of the event */
|
||||||
endDate?: Date;
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
description: string;
|
||||||
|
|
||||||
@Column({ name: 'is_all_day', type: 'boolean', default: false })
|
/** Days before the event to send alert */
|
||||||
isAllDay!: boolean;
|
@Column({ name: 'alert_days_before', type: 'int', default: 3 })
|
||||||
|
alertDaysBefore: number;
|
||||||
|
|
||||||
@Column({ name: 'timezone', length: 50, default: 'America/Mexico_City' })
|
/** Whether alert has been sent */
|
||||||
timezone!: string;
|
@Column({ name: 'alert_sent', type: 'boolean', default: false })
|
||||||
|
alertSent: boolean;
|
||||||
|
|
||||||
// Ubicación
|
/** Additional notes */
|
||||||
@Column({ length: 255, nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
location?: string;
|
notes: string;
|
||||||
|
|
||||||
@Column({ name: 'is_virtual', type: 'boolean', default: false })
|
// Relations
|
||||||
isVirtual!: boolean;
|
@ManyToOne(() => Tenant)
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
@Column({ name: 'meeting_link', length: 500, nullable: true })
|
@ManyToOne(() => Tender, (tender) => tender.calendarEvents)
|
||||||
meetingLink?: string;
|
@JoinColumn({ name: 'tender_id' })
|
||||||
|
tender: Tender;
|
||||||
|
|
||||||
// Recordatorios
|
// Audit fields
|
||||||
@Column({ name: 'reminder_minutes', type: 'int', array: true, nullable: true })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
reminderMinutes?: number[];
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ name: 'reminder_sent', type: 'boolean', default: false })
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
reminderSent!: boolean;
|
createdById: string;
|
||||||
|
|
||||||
@Column({ name: 'last_reminder_at', type: 'timestamptz', nullable: true })
|
|
||||||
lastReminderAt?: Date;
|
|
||||||
|
|
||||||
// Asignación
|
|
||||||
@Column({ name: 'assigned_to_id', type: 'uuid', nullable: true })
|
|
||||||
assignedToId?: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => User, { nullable: true })
|
@ManyToOne(() => User, { nullable: true })
|
||||||
@JoinColumn({ name: 'assigned_to_id' })
|
@JoinColumn({ name: 'created_by' })
|
||||||
assignedTo?: User;
|
createdBy: User;
|
||||||
|
|
||||||
@Column({ name: 'attendees', type: 'uuid', array: true, nullable: true })
|
|
||||||
attendees?: string[];
|
|
||||||
|
|
||||||
// Resultado del evento
|
|
||||||
@Column({ name: 'outcome', type: 'text', nullable: true })
|
|
||||||
outcome?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'action_items', type: 'jsonb', nullable: true })
|
|
||||||
actionItems?: Record<string, any>[];
|
|
||||||
|
|
||||||
// Recurrencia
|
|
||||||
@Column({ name: 'is_recurring', type: 'boolean', default: false })
|
|
||||||
isRecurring!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'recurrence_rule', length: 255, nullable: true })
|
|
||||||
recurrenceRule?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'parent_event_id', type: 'uuid', nullable: true })
|
|
||||||
parentEventId?: string;
|
|
||||||
|
|
||||||
// Flags
|
|
||||||
@Column({ name: 'is_mandatory', type: 'boolean', default: false })
|
|
||||||
isMandatory!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'is_external', type: 'boolean', default: false })
|
|
||||||
isExternal!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'requires_preparation', type: 'boolean', default: false })
|
|
||||||
requiresPreparation!: boolean;
|
|
||||||
|
|
||||||
// Notas y metadatos
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
notes?: string;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
|
|
||||||
// Auditoría
|
|
||||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
|
||||||
createdBy?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
|
||||||
updatedBy?: string;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
updatedAt!: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedById: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'updated_by' })
|
||||||
|
updatedBy: User;
|
||||||
|
|
||||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
deletedAt?: Date;
|
deletedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* BidCompetitor Entity - Competidores en Licitación
|
|
||||||
*
|
|
||||||
* Información de competidores en el proceso de licitación.
|
|
||||||
*
|
|
||||||
* @module Bidding
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
ManyToOne,
|
|
||||||
JoinColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { Bid } from './bid.entity';
|
|
||||||
|
|
||||||
export type CompetitorStatus =
|
|
||||||
| 'identified'
|
|
||||||
| 'registered'
|
|
||||||
| 'qualified'
|
|
||||||
| 'disqualified'
|
|
||||||
| 'withdrew'
|
|
||||||
| 'submitted'
|
|
||||||
| 'winner'
|
|
||||||
| 'loser';
|
|
||||||
|
|
||||||
export type ThreatLevel = 'low' | 'medium' | 'high' | 'critical';
|
|
||||||
|
|
||||||
@Entity('bid_competitors', { schema: 'bidding' })
|
|
||||||
@Index(['tenantId', 'bidId'])
|
|
||||||
export class BidCompetitor {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
|
||||||
@Index()
|
|
||||||
tenantId!: string;
|
|
||||||
|
|
||||||
// Referencia a licitación
|
|
||||||
@Column({ name: 'bid_id', type: 'uuid' })
|
|
||||||
bidId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Bid, (bid) => bid.competitors)
|
|
||||||
@JoinColumn({ name: 'bid_id' })
|
|
||||||
bid?: Bid;
|
|
||||||
|
|
||||||
// Información del competidor
|
|
||||||
@Column({ name: 'company_name', length: 255 })
|
|
||||||
companyName!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'trade_name', length: 255, nullable: true })
|
|
||||||
tradeName?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'rfc', length: 13, nullable: true })
|
|
||||||
rfc?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'contact_name', length: 255, nullable: true })
|
|
||||||
contactName?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'contact_email', length: 255, nullable: true })
|
|
||||||
contactEmail?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'contact_phone', length: 50, nullable: true })
|
|
||||||
contactPhone?: string;
|
|
||||||
|
|
||||||
@Column({ length: 255, nullable: true })
|
|
||||||
website?: string;
|
|
||||||
|
|
||||||
// Estado y análisis
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['identified', 'registered', 'qualified', 'disqualified', 'withdrew', 'submitted', 'winner', 'loser'],
|
|
||||||
enumName: 'competitor_status',
|
|
||||||
default: 'identified',
|
|
||||||
})
|
|
||||||
status!: CompetitorStatus;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'threat_level',
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['low', 'medium', 'high', 'critical'],
|
|
||||||
enumName: 'competitor_threat_level',
|
|
||||||
default: 'medium',
|
|
||||||
})
|
|
||||||
threatLevel!: ThreatLevel;
|
|
||||||
|
|
||||||
// Capacidades conocidas
|
|
||||||
@Column({
|
|
||||||
name: 'estimated_annual_revenue',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
estimatedAnnualRevenue?: number;
|
|
||||||
|
|
||||||
@Column({ name: 'employee_count', type: 'int', nullable: true })
|
|
||||||
employeeCount?: number;
|
|
||||||
|
|
||||||
@Column({ name: 'years_in_business', type: 'int', nullable: true })
|
|
||||||
yearsInBusiness?: number;
|
|
||||||
|
|
||||||
@Column({ name: 'certifications', type: 'text', array: true, nullable: true })
|
|
||||||
certifications?: string[];
|
|
||||||
|
|
||||||
@Column({ name: 'specializations', type: 'text', array: true, nullable: true })
|
|
||||||
specializations?: string[];
|
|
||||||
|
|
||||||
// Histórico de competencia
|
|
||||||
@Column({ name: 'previous_encounters', type: 'int', default: 0 })
|
|
||||||
previousEncounters!: number;
|
|
||||||
|
|
||||||
@Column({ name: 'wins_against', type: 'int', default: 0 })
|
|
||||||
winsAgainst!: number;
|
|
||||||
|
|
||||||
@Column({ name: 'losses_against', type: 'int', default: 0 })
|
|
||||||
lossesAgainst!: number;
|
|
||||||
|
|
||||||
// Información de propuesta (si es pública)
|
|
||||||
@Column({
|
|
||||||
name: 'proposed_amount',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
proposedAmount?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'technical_score',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
technicalScore?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'economic_score',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
economicScore?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'final_score',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
finalScore?: number;
|
|
||||||
|
|
||||||
@Column({ name: 'ranking_position', type: 'int', nullable: true })
|
|
||||||
rankingPosition?: number;
|
|
||||||
|
|
||||||
// Fortalezas y debilidades
|
|
||||||
@Column({ type: 'text', array: true, nullable: true })
|
|
||||||
strengths?: string[];
|
|
||||||
|
|
||||||
@Column({ type: 'text', array: true, nullable: true })
|
|
||||||
weaknesses?: string[];
|
|
||||||
|
|
||||||
// Análisis FODA resumido
|
|
||||||
@Column({ name: 'competitive_advantage', type: 'text', nullable: true })
|
|
||||||
competitiveAdvantage?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'vulnerability', type: 'text', nullable: true })
|
|
||||||
vulnerability?: string;
|
|
||||||
|
|
||||||
// Razón de descalificación/retiro
|
|
||||||
@Column({ name: 'disqualification_reason', type: 'text', nullable: true })
|
|
||||||
disqualificationReason?: string;
|
|
||||||
|
|
||||||
// Notas y metadatos
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
notes?: string;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
|
|
||||||
// Auditoría
|
|
||||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
|
||||||
createdBy?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
|
||||||
updatedBy?: string;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
|
||||||
updatedAt!: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
|
||||||
deletedAt?: Date;
|
|
||||||
}
|
|
||||||
@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* BidDocument Entity - Documentos de Licitación
|
* BidDocument Entity - Documentos de Licitación
|
||||||
*
|
|
||||||
* Almacena documentos asociados a una licitación.
|
* Almacena documentos asociados a una licitación.
|
||||||
*
|
*
|
||||||
* @module Bidding
|
* @module Bidding (MAI-018)
|
||||||
|
* @table bidding.bid_documents
|
||||||
|
* @ddl schemas/XX-bidding-schema-ddl.sql
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -16,155 +17,110 @@ import {
|
|||||||
JoinColumn,
|
JoinColumn,
|
||||||
Index,
|
Index,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
import { User } from '../../core/entities/user.entity';
|
import { User } from '../../core/entities/user.entity';
|
||||||
import { Bid } from './bid.entity';
|
import { Tender } from './tender.entity';
|
||||||
|
|
||||||
export type DocumentCategory =
|
/** Type/category of document */
|
||||||
| 'tender_bases'
|
export type BidDocumentType =
|
||||||
| 'clarifications'
|
| 'bases'
|
||||||
| 'annexes'
|
| 'technical_annex'
|
||||||
| 'technical_proposal'
|
| 'economic_annex'
|
||||||
| 'economic_proposal'
|
| 'clarification'
|
||||||
| 'legal_documents'
|
| 'proposal_tech'
|
||||||
| 'experience_certificates'
|
| 'proposal_econ'
|
||||||
| 'financial_statements'
|
| 'contract'
|
||||||
| 'bonds'
|
|
||||||
| 'contracts'
|
|
||||||
| 'correspondence'
|
|
||||||
| 'meeting_minutes'
|
|
||||||
| 'other';
|
| 'other';
|
||||||
|
|
||||||
export type DocumentStatus = 'draft' | 'pending_review' | 'approved' | 'rejected' | 'submitted' | 'archived';
|
@Entity({ schema: 'bidding', name: 'bid_documents' })
|
||||||
|
@Index(['tenantId'])
|
||||||
@Entity('bid_documents', { schema: 'bidding' })
|
@Index(['tenantId', 'tenderId'])
|
||||||
@Index(['tenantId', 'bidId'])
|
@Index(['tenantId', 'documentType'])
|
||||||
@Index(['tenantId', 'category'])
|
@Index(['uploadedAt'])
|
||||||
export class BidDocument {
|
export class BidDocument {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id!: string;
|
id: string;
|
||||||
|
|
||||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
@Index()
|
tenantId: string;
|
||||||
tenantId!: string;
|
|
||||||
|
|
||||||
// Referencia a licitación
|
/** Reference to the tender */
|
||||||
@Column({ name: 'bid_id', type: 'uuid' })
|
@Column({ name: 'tender_id', type: 'uuid' })
|
||||||
bidId!: string;
|
tenderId: string;
|
||||||
|
|
||||||
@ManyToOne(() => Bid, (bid) => bid.documents)
|
@Column({
|
||||||
@JoinColumn({ name: 'bid_id' })
|
name: 'document_type',
|
||||||
bid?: Bid;
|
type: 'varchar',
|
||||||
|
length: 50,
|
||||||
|
})
|
||||||
|
documentType: BidDocumentType;
|
||||||
|
|
||||||
// Información del documento
|
/** Document name */
|
||||||
@Column({ length: 255 })
|
@Column({ type: 'varchar', length: 255 })
|
||||||
name!: string;
|
name: string;
|
||||||
|
|
||||||
|
/** Document description */
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
description?: string;
|
description: string;
|
||||||
|
|
||||||
@Column({
|
/** URL/path to the file */
|
||||||
type: 'enum',
|
@Column({ name: 'file_url', type: 'varchar', length: 500 })
|
||||||
enum: ['tender_bases', 'clarifications', 'annexes', 'technical_proposal', 'economic_proposal', 'legal_documents', 'experience_certificates', 'financial_statements', 'bonds', 'contracts', 'correspondence', 'meeting_minutes', 'other'],
|
fileUrl: string;
|
||||||
enumName: 'bid_document_category',
|
|
||||||
})
|
|
||||||
category!: DocumentCategory;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['draft', 'pending_review', 'approved', 'rejected', 'submitted', 'archived'],
|
|
||||||
enumName: 'bid_document_status',
|
|
||||||
default: 'draft',
|
|
||||||
})
|
|
||||||
status!: DocumentStatus;
|
|
||||||
|
|
||||||
// Archivo
|
|
||||||
@Column({ name: 'file_path', length: 500 })
|
|
||||||
filePath!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'file_name', length: 255 })
|
|
||||||
fileName!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'file_type', length: 100 })
|
|
||||||
fileType!: string;
|
|
||||||
|
|
||||||
|
/** File size in bytes */
|
||||||
@Column({ name: 'file_size', type: 'bigint' })
|
@Column({ name: 'file_size', type: 'bigint' })
|
||||||
fileSize!: number;
|
fileSize: string;
|
||||||
|
|
||||||
@Column({ name: 'mime_type', length: 100, nullable: true })
|
/** MIME type of the file */
|
||||||
mimeType?: string;
|
@Column({ name: 'mime_type', type: 'varchar', length: 100 })
|
||||||
|
mimeType: string;
|
||||||
|
|
||||||
// Versión
|
/** Version number */
|
||||||
@Column({ type: 'int', default: 1 })
|
@Column({ type: 'int', default: 1 })
|
||||||
version!: number;
|
version: number;
|
||||||
|
|
||||||
@Column({ name: 'is_current_version', type: 'boolean', default: true })
|
/** User who uploaded the document */
|
||||||
isCurrentVersion!: boolean;
|
@Column({ name: 'uploaded_by', type: 'uuid' })
|
||||||
|
uploadedById: string;
|
||||||
|
|
||||||
@Column({ name: 'previous_version_id', type: 'uuid', nullable: true })
|
/** Timestamp when document was uploaded */
|
||||||
previousVersionId?: string;
|
@Column({ name: 'uploaded_at', type: 'timestamptz' })
|
||||||
|
uploadedAt: Date;
|
||||||
|
|
||||||
// Metadatos de revisión
|
// Relations
|
||||||
@Column({ name: 'reviewed_by_id', type: 'uuid', nullable: true })
|
@ManyToOne(() => Tenant)
|
||||||
reviewedById?: string;
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
@ManyToOne(() => User, { nullable: true })
|
@ManyToOne(() => Tender, (tender) => tender.documents)
|
||||||
@JoinColumn({ name: 'reviewed_by_id' })
|
@JoinColumn({ name: 'tender_id' })
|
||||||
reviewedBy?: User;
|
tender: Tender;
|
||||||
|
|
||||||
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
@ManyToOne(() => User)
|
||||||
reviewedAt?: Date;
|
@JoinColumn({ name: 'uploaded_by' })
|
||||||
|
uploadedBy: User;
|
||||||
|
|
||||||
@Column({ name: 'review_comments', type: 'text', nullable: true })
|
// Audit fields
|
||||||
reviewComments?: string;
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
// Flags
|
|
||||||
@Column({ name: 'is_required', type: 'boolean', default: false })
|
|
||||||
isRequired!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'is_confidential', type: 'boolean', default: false })
|
|
||||||
isConfidential!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'is_submitted', type: 'boolean', default: false })
|
|
||||||
isSubmitted!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
|
|
||||||
submittedAt?: Date;
|
|
||||||
|
|
||||||
// Fecha de vencimiento (para documentos con vigencia)
|
|
||||||
@Column({ name: 'expiry_date', type: 'date', nullable: true })
|
|
||||||
expiryDate?: Date;
|
|
||||||
|
|
||||||
// Hash para verificación de integridad
|
|
||||||
@Column({ name: 'file_hash', length: 128, nullable: true })
|
|
||||||
fileHash?: string;
|
|
||||||
|
|
||||||
// Notas y metadatos
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
notes?: string;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
|
|
||||||
// Auditoría
|
|
||||||
@Column({ name: 'uploaded_by_id', type: 'uuid', nullable: true })
|
|
||||||
uploadedById?: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => User, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'uploaded_by_id' })
|
|
||||||
uploadedBy?: User;
|
|
||||||
|
|
||||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
createdBy?: string;
|
createdById: string;
|
||||||
|
|
||||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
@ManyToOne(() => User, { nullable: true })
|
||||||
updatedBy?: string;
|
@JoinColumn({ name: 'created_by' })
|
||||||
|
createdBy: User;
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
updatedAt!: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedById: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'updated_by' })
|
||||||
|
updatedBy: User;
|
||||||
|
|
||||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
deletedAt?: Date;
|
deletedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* BidTeam Entity - Equipo de Licitación
|
|
||||||
*
|
|
||||||
* Miembros del equipo asignados a una licitación.
|
|
||||||
*
|
|
||||||
* @module Bidding
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
ManyToOne,
|
|
||||||
JoinColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { User } from '../../core/entities/user.entity';
|
|
||||||
import { Bid } from './bid.entity';
|
|
||||||
|
|
||||||
export type TeamRole =
|
|
||||||
| 'bid_manager'
|
|
||||||
| 'technical_lead'
|
|
||||||
| 'cost_engineer'
|
|
||||||
| 'legal_advisor'
|
|
||||||
| 'commercial_manager'
|
|
||||||
| 'project_manager'
|
|
||||||
| 'quality_manager'
|
|
||||||
| 'hse_manager'
|
|
||||||
| 'procurement_lead'
|
|
||||||
| 'design_lead'
|
|
||||||
| 'reviewer'
|
|
||||||
| 'contributor'
|
|
||||||
| 'support';
|
|
||||||
|
|
||||||
export type MemberStatus = 'active' | 'inactive' | 'pending' | 'removed';
|
|
||||||
|
|
||||||
@Entity('bid_team', { schema: 'bidding' })
|
|
||||||
@Index(['tenantId', 'bidId'])
|
|
||||||
@Index(['tenantId', 'userId'])
|
|
||||||
export class BidTeam {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
|
||||||
@Index()
|
|
||||||
tenantId!: string;
|
|
||||||
|
|
||||||
// Referencia a licitación
|
|
||||||
@Column({ name: 'bid_id', type: 'uuid' })
|
|
||||||
bidId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Bid, (bid) => bid.teamMembers)
|
|
||||||
@JoinColumn({ name: 'bid_id' })
|
|
||||||
bid?: Bid;
|
|
||||||
|
|
||||||
// Referencia a usuario
|
|
||||||
@Column({ name: 'user_id', type: 'uuid' })
|
|
||||||
userId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => User)
|
|
||||||
@JoinColumn({ name: 'user_id' })
|
|
||||||
user?: User;
|
|
||||||
|
|
||||||
// Rol y responsabilidades
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['bid_manager', 'technical_lead', 'cost_engineer', 'legal_advisor', 'commercial_manager', 'project_manager', 'quality_manager', 'hse_manager', 'procurement_lead', 'design_lead', 'reviewer', 'contributor', 'support'],
|
|
||||||
enumName: 'bid_team_role',
|
|
||||||
})
|
|
||||||
role!: TeamRole;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['active', 'inactive', 'pending', 'removed'],
|
|
||||||
enumName: 'bid_team_status',
|
|
||||||
default: 'active',
|
|
||||||
})
|
|
||||||
status!: MemberStatus;
|
|
||||||
|
|
||||||
@Column({ type: 'text', array: true, nullable: true })
|
|
||||||
responsibilities?: string[];
|
|
||||||
|
|
||||||
// Dedicación
|
|
||||||
@Column({
|
|
||||||
name: 'allocation_percentage',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
default: 100,
|
|
||||||
})
|
|
||||||
allocationPercentage!: number;
|
|
||||||
|
|
||||||
@Column({ name: 'estimated_hours', type: 'decimal', precision: 8, scale: 2, nullable: true })
|
|
||||||
estimatedHours?: number;
|
|
||||||
|
|
||||||
@Column({ name: 'actual_hours', type: 'decimal', precision: 8, scale: 2, default: 0 })
|
|
||||||
actualHours!: number;
|
|
||||||
|
|
||||||
// Fechas de participación
|
|
||||||
@Column({ name: 'start_date', type: 'date' })
|
|
||||||
startDate!: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'end_date', type: 'date', nullable: true })
|
|
||||||
endDate?: Date;
|
|
||||||
|
|
||||||
// Permisos específicos
|
|
||||||
@Column({ name: 'can_edit_technical', type: 'boolean', default: false })
|
|
||||||
canEditTechnical!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'can_edit_economic', type: 'boolean', default: false })
|
|
||||||
canEditEconomic!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'can_approve', type: 'boolean', default: false })
|
|
||||||
canApprove!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'can_submit', type: 'boolean', default: false })
|
|
||||||
canSubmit!: boolean;
|
|
||||||
|
|
||||||
// Notificaciones
|
|
||||||
@Column({ name: 'receive_notifications', type: 'boolean', default: true })
|
|
||||||
receiveNotifications!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'notification_preferences', type: 'jsonb', nullable: true })
|
|
||||||
notificationPreferences?: Record<string, any>;
|
|
||||||
|
|
||||||
// Evaluación de participación
|
|
||||||
@Column({
|
|
||||||
name: 'performance_rating',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 3,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
performanceRating?: number;
|
|
||||||
|
|
||||||
@Column({ name: 'performance_notes', type: 'text', nullable: true })
|
|
||||||
performanceNotes?: string;
|
|
||||||
|
|
||||||
// Información de contacto externa (si no es empleado)
|
|
||||||
@Column({ name: 'is_external', type: 'boolean', default: false })
|
|
||||||
isExternal!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'external_company', length: 255, nullable: true })
|
|
||||||
externalCompany?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'external_email', length: 255, nullable: true })
|
|
||||||
externalEmail?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'external_phone', length: 50, nullable: true })
|
|
||||||
externalPhone?: string;
|
|
||||||
|
|
||||||
// Notas y metadatos
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
notes?: string;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
|
|
||||||
// Auditoría
|
|
||||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
|
||||||
createdBy?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
|
||||||
updatedBy?: string;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
|
||||||
updatedAt!: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
|
||||||
deletedAt?: Date;
|
|
||||||
}
|
|
||||||
@ -1,311 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bid Entity - Licitaciones/Propuestas
|
|
||||||
*
|
|
||||||
* Representa una licitación o propuesta formal vinculada a una oportunidad.
|
|
||||||
*
|
|
||||||
* @module Bidding
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
ManyToOne,
|
|
||||||
OneToMany,
|
|
||||||
JoinColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { User } from '../../core/entities/user.entity';
|
|
||||||
import { Opportunity } from './opportunity.entity';
|
|
||||||
import { BidDocument } from './bid-document.entity';
|
|
||||||
import { BidCalendar } from './bid-calendar.entity';
|
|
||||||
import { BidBudget } from './bid-budget.entity';
|
|
||||||
import { BidCompetitor } from './bid-competitor.entity';
|
|
||||||
import { BidTeam } from './bid-team.entity';
|
|
||||||
|
|
||||||
export type BidType = 'public' | 'private' | 'invitation' | 'direct_award' | 'framework_agreement';
|
|
||||||
|
|
||||||
export type BidStatus =
|
|
||||||
| 'draft'
|
|
||||||
| 'preparation'
|
|
||||||
| 'review'
|
|
||||||
| 'approved'
|
|
||||||
| 'submitted'
|
|
||||||
| 'clarification'
|
|
||||||
| 'evaluation'
|
|
||||||
| 'awarded'
|
|
||||||
| 'rejected'
|
|
||||||
| 'cancelled'
|
|
||||||
| 'withdrawn';
|
|
||||||
|
|
||||||
export type BidStage =
|
|
||||||
| 'initial'
|
|
||||||
| 'technical_proposal'
|
|
||||||
| 'economic_proposal'
|
|
||||||
| 'final_submission'
|
|
||||||
| 'post_submission';
|
|
||||||
|
|
||||||
@Entity('bids', { schema: 'bidding' })
|
|
||||||
@Index(['tenantId', 'status'])
|
|
||||||
@Index(['tenantId', 'bidType'])
|
|
||||||
@Index(['tenantId', 'opportunityId'])
|
|
||||||
export class Bid {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
|
||||||
@Index()
|
|
||||||
tenantId!: string;
|
|
||||||
|
|
||||||
// Referencia a oportunidad
|
|
||||||
@Column({ name: 'opportunity_id', type: 'uuid' })
|
|
||||||
opportunityId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Opportunity, (opp) => opp.bids)
|
|
||||||
@JoinColumn({ name: 'opportunity_id' })
|
|
||||||
opportunity?: Opportunity;
|
|
||||||
|
|
||||||
// Información básica
|
|
||||||
@Column({ length: 100 })
|
|
||||||
code!: string;
|
|
||||||
|
|
||||||
@Column({ length: 500 })
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'bid_type',
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['public', 'private', 'invitation', 'direct_award', 'framework_agreement'],
|
|
||||||
enumName: 'bid_type',
|
|
||||||
})
|
|
||||||
bidType!: BidType;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['draft', 'preparation', 'review', 'approved', 'submitted', 'clarification', 'evaluation', 'awarded', 'rejected', 'cancelled', 'withdrawn'],
|
|
||||||
enumName: 'bid_status',
|
|
||||||
default: 'draft',
|
|
||||||
})
|
|
||||||
status!: BidStatus;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
type: 'enum',
|
|
||||||
enum: ['initial', 'technical_proposal', 'economic_proposal', 'final_submission', 'post_submission'],
|
|
||||||
enumName: 'bid_stage',
|
|
||||||
default: 'initial',
|
|
||||||
})
|
|
||||||
stage!: BidStage;
|
|
||||||
|
|
||||||
// Referencia de convocatoria
|
|
||||||
@Column({ name: 'tender_number', length: 100, nullable: true })
|
|
||||||
tenderNumber?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'tender_name', length: 500, nullable: true })
|
|
||||||
tenderName?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'contracting_entity', length: 255, nullable: true })
|
|
||||||
contractingEntity?: string;
|
|
||||||
|
|
||||||
// Fechas clave
|
|
||||||
@Column({ name: 'publication_date', type: 'date', nullable: true })
|
|
||||||
publicationDate?: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'site_visit_date', type: 'timestamptz', nullable: true })
|
|
||||||
siteVisitDate?: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'clarification_deadline', type: 'timestamptz', nullable: true })
|
|
||||||
clarificationDeadline?: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'submission_deadline', type: 'timestamptz' })
|
|
||||||
submissionDeadline!: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'opening_date', type: 'timestamptz', nullable: true })
|
|
||||||
openingDate?: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'award_date', type: 'date', nullable: true })
|
|
||||||
awardDate?: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'contract_signing_date', type: 'date', nullable: true })
|
|
||||||
contractSigningDate?: Date;
|
|
||||||
|
|
||||||
// Montos
|
|
||||||
@Column({
|
|
||||||
name: 'base_budget',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
baseBudget?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'our_proposal_amount',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
ourProposalAmount?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'winning_amount',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
winningAmount?: number;
|
|
||||||
|
|
||||||
@Column({ name: 'currency', length: 3, default: 'MXN' })
|
|
||||||
currency!: string;
|
|
||||||
|
|
||||||
// Propuesta técnica
|
|
||||||
@Column({
|
|
||||||
name: 'technical_score',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
technicalScore?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'technical_weight',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
default: 50,
|
|
||||||
})
|
|
||||||
technicalWeight!: number;
|
|
||||||
|
|
||||||
// Propuesta económica
|
|
||||||
@Column({
|
|
||||||
name: 'economic_score',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
economicScore?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'economic_weight',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
default: 50,
|
|
||||||
})
|
|
||||||
economicWeight!: number;
|
|
||||||
|
|
||||||
// Puntuación final
|
|
||||||
@Column({
|
|
||||||
name: 'final_score',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
finalScore?: number;
|
|
||||||
|
|
||||||
@Column({ name: 'ranking_position', type: 'int', nullable: true })
|
|
||||||
rankingPosition?: number;
|
|
||||||
|
|
||||||
// Garantías
|
|
||||||
@Column({
|
|
||||||
name: 'bid_bond_amount',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
bidBondAmount?: number;
|
|
||||||
|
|
||||||
@Column({ name: 'bid_bond_number', length: 100, nullable: true })
|
|
||||||
bidBondNumber?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'bid_bond_expiry', type: 'date', nullable: true })
|
|
||||||
bidBondExpiry?: Date;
|
|
||||||
|
|
||||||
// Asignación
|
|
||||||
@Column({ name: 'bid_manager_id', type: 'uuid', nullable: true })
|
|
||||||
bidManagerId?: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => User, { nullable: true })
|
|
||||||
@JoinColumn({ name: 'bid_manager_id' })
|
|
||||||
bidManager?: User;
|
|
||||||
|
|
||||||
// Resultado
|
|
||||||
@Column({ name: 'winner_name', length: 255, nullable: true })
|
|
||||||
winnerName?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'rejection_reason', type: 'text', nullable: true })
|
|
||||||
rejectionReason?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'lessons_learned', type: 'text', nullable: true })
|
|
||||||
lessonsLearned?: string;
|
|
||||||
|
|
||||||
// Progreso
|
|
||||||
@Column({
|
|
||||||
name: 'completion_percentage',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
default: 0,
|
|
||||||
})
|
|
||||||
completionPercentage!: number;
|
|
||||||
|
|
||||||
// Checklist de documentos
|
|
||||||
@Column({ name: 'checklist', type: 'jsonb', nullable: true })
|
|
||||||
checklist?: Record<string, any>;
|
|
||||||
|
|
||||||
// Notas y metadatos
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
notes?: string;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
|
|
||||||
// Relaciones
|
|
||||||
@OneToMany(() => BidDocument, (doc) => doc.bid)
|
|
||||||
documents?: BidDocument[];
|
|
||||||
|
|
||||||
@OneToMany(() => BidCalendar, (event) => event.bid)
|
|
||||||
calendarEvents?: BidCalendar[];
|
|
||||||
|
|
||||||
@OneToMany(() => BidBudget, (budget) => budget.bid)
|
|
||||||
budgetItems?: BidBudget[];
|
|
||||||
|
|
||||||
@OneToMany(() => BidCompetitor, (comp) => comp.bid)
|
|
||||||
competitors?: BidCompetitor[];
|
|
||||||
|
|
||||||
@OneToMany(() => BidTeam, (team) => team.bid)
|
|
||||||
teamMembers?: BidTeam[];
|
|
||||||
|
|
||||||
// Conversión a proyecto
|
|
||||||
@Column({ name: 'converted_to_project_id', type: 'uuid', nullable: true })
|
|
||||||
convertedToProjectId?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'converted_at', type: 'timestamptz', nullable: true })
|
|
||||||
convertedAt?: Date;
|
|
||||||
|
|
||||||
// Auditoría
|
|
||||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
|
||||||
createdBy?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
|
||||||
updatedBy?: string;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
|
||||||
updatedAt!: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
|
||||||
deletedAt?: Date;
|
|
||||||
}
|
|
||||||
@ -1,12 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Bidding Entities Index
|
* Bidding Entities Index
|
||||||
* @module Bidding
|
* Barrel file exporting all bidding module entities.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority, ProjectType } from './opportunity.entity';
|
// Opportunity
|
||||||
export { Bid, BidType, BidStatus, BidStage } from './bid.entity';
|
export { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority } from './opportunity.entity';
|
||||||
export { BidDocument, DocumentCategory, DocumentStatus } from './bid-document.entity';
|
|
||||||
export { BidCalendar, CalendarEventType, EventPriority, EventStatus } from './bid-calendar.entity';
|
// Tender
|
||||||
export { BidBudget, BudgetItemType, BudgetStatus } from './bid-budget.entity';
|
export { Tender, TenderType, TenderStatus } from './tender.entity';
|
||||||
export { BidCompetitor, CompetitorStatus, ThreatLevel } from './bid-competitor.entity';
|
|
||||||
export { BidTeam, TeamRole, MemberStatus } from './bid-team.entity';
|
// Proposal
|
||||||
|
export { Proposal, ProposalStatus } from './proposal.entity';
|
||||||
|
|
||||||
|
// Vendor
|
||||||
|
export { Vendor, VendorCertification, VendorPerformanceEntry } from './vendor.entity';
|
||||||
|
|
||||||
|
// Bid Calendar
|
||||||
|
export { BidCalendar, CalendarEventType } from './bid-calendar.entity';
|
||||||
|
|
||||||
|
// Bid Document
|
||||||
|
export { BidDocument, BidDocumentType } from './bid-document.entity';
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Opportunity Entity - Oportunidades de Negocio
|
* Opportunity Entity - Oportunidades de Licitación
|
||||||
*
|
|
||||||
* Representa oportunidades de licitación/proyecto en el pipeline comercial.
|
* Representa oportunidades de licitación/proyecto en el pipeline comercial.
|
||||||
*
|
*
|
||||||
* @module Bidding
|
* @module Bidding (MAI-018)
|
||||||
|
* @table bidding.opportunities
|
||||||
|
* @ddl schemas/XX-bidding-schema-ddl.sql
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -17,264 +18,124 @@ import {
|
|||||||
JoinColumn,
|
JoinColumn,
|
||||||
Index,
|
Index,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
import { User } from '../../core/entities/user.entity';
|
import { User } from '../../core/entities/user.entity';
|
||||||
import { Bid } from './bid.entity';
|
import { Tender } from './tender.entity';
|
||||||
|
|
||||||
export type OpportunitySource =
|
/** Source of the opportunity */
|
||||||
| 'portal_compranet'
|
export type OpportunitySource = 'government_portal' | 'private_client' | 'referral' | 'other';
|
||||||
| 'portal_state'
|
|
||||||
| 'direct_invitation'
|
|
||||||
| 'referral'
|
|
||||||
| 'public_notice'
|
|
||||||
| 'networking'
|
|
||||||
| 'repeat_client'
|
|
||||||
| 'cold_call'
|
|
||||||
| 'website'
|
|
||||||
| 'other';
|
|
||||||
|
|
||||||
export type OpportunityStatus =
|
/** Status of the opportunity in the pipeline */
|
||||||
| 'identified'
|
export type OpportunityStatus = 'registered' | 'evaluating' | 'go' | 'no_go' | 'preparing' | 'converted';
|
||||||
| 'qualified'
|
|
||||||
| 'pursuing'
|
|
||||||
| 'bid_submitted'
|
|
||||||
| 'won'
|
|
||||||
| 'lost'
|
|
||||||
| 'cancelled'
|
|
||||||
| 'on_hold';
|
|
||||||
|
|
||||||
export type OpportunityPriority = 'low' | 'medium' | 'high' | 'critical';
|
/** Priority level */
|
||||||
|
export type OpportunityPriority = 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
export type ProjectType =
|
@Entity({ schema: 'bidding', name: 'opportunities' })
|
||||||
| 'residential'
|
@Index(['tenantId'])
|
||||||
| 'commercial'
|
@Index(['tenantId', 'code'], { unique: true })
|
||||||
| 'industrial'
|
|
||||||
| 'infrastructure'
|
|
||||||
| 'institutional'
|
|
||||||
| 'mixed_use'
|
|
||||||
| 'renovation'
|
|
||||||
| 'maintenance';
|
|
||||||
|
|
||||||
@Entity('opportunities', { schema: 'bidding' })
|
|
||||||
@Index(['tenantId', 'status'])
|
@Index(['tenantId', 'status'])
|
||||||
@Index(['tenantId', 'source'])
|
@Index(['tenantId', 'source'])
|
||||||
@Index(['tenantId', 'assignedToId'])
|
@Index(['tenantId', 'priority'])
|
||||||
|
@Index(['deadlineDate'])
|
||||||
export class Opportunity {
|
export class Opportunity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id!: string;
|
id: string;
|
||||||
|
|
||||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
@Index()
|
tenantId: string;
|
||||||
tenantId!: string;
|
|
||||||
|
|
||||||
// Información básica
|
/** Unique code within tenant, format: OPP-2026-001 */
|
||||||
@Column({ length: 100 })
|
@Column({ type: 'varchar', length: 50 })
|
||||||
code!: string;
|
code: string;
|
||||||
|
|
||||||
@Column({ length: 500 })
|
@Column({ type: 'varchar', length: 255 })
|
||||||
name!: string;
|
title: string;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
description?: string;
|
description: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'varchar',
|
||||||
enum: ['portal_compranet', 'portal_state', 'direct_invitation', 'referral', 'public_notice', 'networking', 'repeat_client', 'cold_call', 'website', 'other'],
|
length: 50,
|
||||||
enumName: 'opportunity_source',
|
default: 'other',
|
||||||
})
|
})
|
||||||
source!: OpportunitySource;
|
source: OpportunitySource;
|
||||||
|
|
||||||
|
/** Client/organization name */
|
||||||
|
@Column({ name: 'client_name', type: 'varchar', length: 255 })
|
||||||
|
clientName: string;
|
||||||
|
|
||||||
|
/** Project type: vivienda_vertical, vivienda_horizontal, urbanizacion, etc. */
|
||||||
|
@Column({ name: 'project_type', type: 'varchar', length: 100 })
|
||||||
|
projectType: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
location: string;
|
||||||
|
|
||||||
|
/** Estimated amount in cents (BIGINT) */
|
||||||
|
@Column({ name: 'estimated_amount', type: 'bigint', nullable: true })
|
||||||
|
estimatedAmount: string;
|
||||||
|
|
||||||
|
/** Estimated number of housing units */
|
||||||
|
@Column({ name: 'estimated_units', type: 'int', nullable: true })
|
||||||
|
estimatedUnits: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'varchar',
|
||||||
enum: ['identified', 'qualified', 'pursuing', 'bid_submitted', 'won', 'lost', 'cancelled', 'on_hold'],
|
length: 50,
|
||||||
enumName: 'opportunity_status',
|
default: 'registered',
|
||||||
default: 'identified',
|
|
||||||
})
|
})
|
||||||
status!: OpportunityStatus;
|
status: OpportunityStatus;
|
||||||
|
|
||||||
|
/** Date when go/no-go decision was made */
|
||||||
|
@Column({ name: 'go_decision_date', type: 'date', nullable: true })
|
||||||
|
goDecisionDate: Date;
|
||||||
|
|
||||||
|
/** Reason for go/no-go decision */
|
||||||
|
@Column({ name: 'go_decision_reason', type: 'text', nullable: true })
|
||||||
|
goDecisionReason: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'varchar',
|
||||||
enum: ['low', 'medium', 'high', 'critical'],
|
length: 20,
|
||||||
enumName: 'opportunity_priority',
|
|
||||||
default: 'medium',
|
default: 'medium',
|
||||||
})
|
})
|
||||||
priority!: OpportunityPriority;
|
priority: OpportunityPriority;
|
||||||
|
|
||||||
@Column({
|
/** Deadline for opportunity (proposal submission, etc.) */
|
||||||
name: 'project_type',
|
@Column({ name: 'deadline_date', type: 'date' })
|
||||||
type: 'enum',
|
deadlineDate: Date;
|
||||||
enum: ['residential', 'commercial', 'industrial', 'infrastructure', 'institutional', 'mixed_use', 'renovation', 'maintenance'],
|
|
||||||
enumName: 'project_type',
|
|
||||||
})
|
|
||||||
projectType!: ProjectType;
|
|
||||||
|
|
||||||
// Cliente/Convocante
|
// Relations
|
||||||
@Column({ name: 'client_name', length: 255 })
|
@ManyToOne(() => Tenant)
|
||||||
clientName!: string;
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
@Column({ name: 'client_contact', length: 255, nullable: true })
|
@OneToMany(() => Tender, (tender) => tender.opportunity)
|
||||||
clientContact?: string;
|
tenders: Tender[];
|
||||||
|
|
||||||
@Column({ name: 'client_email', length: 255, nullable: true })
|
// Audit fields
|
||||||
clientEmail?: string;
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
@Column({ name: 'client_phone', length: 50, nullable: true })
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
clientPhone?: string;
|
createdById: string;
|
||||||
|
|
||||||
@Column({ name: 'client_type', length: 50, nullable: true })
|
|
||||||
clientType?: string; // 'gobierno_federal', 'gobierno_estatal', 'privado', etc.
|
|
||||||
|
|
||||||
// Ubicación
|
|
||||||
@Column({ length: 255, nullable: true })
|
|
||||||
location?: string;
|
|
||||||
|
|
||||||
@Column({ length: 100, nullable: true })
|
|
||||||
state?: string;
|
|
||||||
|
|
||||||
@Column({ length: 100, nullable: true })
|
|
||||||
city?: string;
|
|
||||||
|
|
||||||
// Montos estimados
|
|
||||||
@Column({
|
|
||||||
name: 'estimated_value',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
estimatedValue?: number;
|
|
||||||
|
|
||||||
@Column({ name: 'currency', length: 3, default: 'MXN' })
|
|
||||||
currency!: string;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'construction_area_m2',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 12,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
constructionAreaM2?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'land_area_m2',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 12,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
landAreaM2?: number;
|
|
||||||
|
|
||||||
// Fechas clave
|
|
||||||
@Column({ name: 'identification_date', type: 'date' })
|
|
||||||
identificationDate!: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'deadline_date', type: 'timestamptz', nullable: true })
|
|
||||||
deadlineDate?: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'expected_award_date', type: 'date', nullable: true })
|
|
||||||
expectedAwardDate?: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'expected_start_date', type: 'date', nullable: true })
|
|
||||||
expectedStartDate?: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'expected_duration_months', type: 'int', nullable: true })
|
|
||||||
expectedDurationMonths?: number;
|
|
||||||
|
|
||||||
// Probabilidad y análisis
|
|
||||||
@Column({
|
|
||||||
name: 'win_probability',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 5,
|
|
||||||
scale: 2,
|
|
||||||
default: 0,
|
|
||||||
})
|
|
||||||
winProbability!: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'weighted_value',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
weightedValue?: number;
|
|
||||||
|
|
||||||
// Requisitos
|
|
||||||
@Column({ name: 'requires_bond', type: 'boolean', default: false })
|
|
||||||
requiresBond!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'requires_experience', type: 'boolean', default: false })
|
|
||||||
requiresExperience!: boolean;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'minimum_experience_years',
|
|
||||||
type: 'int',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
minimumExperienceYears?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'minimum_capital',
|
|
||||||
type: 'decimal',
|
|
||||||
precision: 18,
|
|
||||||
scale: 2,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
minimumCapital?: number;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: 'required_certifications',
|
|
||||||
type: 'text',
|
|
||||||
array: true,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
requiredCertifications?: string[];
|
|
||||||
|
|
||||||
// Asignación
|
|
||||||
@Column({ name: 'assigned_to_id', type: 'uuid', nullable: true })
|
|
||||||
assignedToId?: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => User, { nullable: true })
|
@ManyToOne(() => User, { nullable: true })
|
||||||
@JoinColumn({ name: 'assigned_to_id' })
|
@JoinColumn({ name: 'created_by' })
|
||||||
assignedTo?: User;
|
createdBy: User;
|
||||||
|
|
||||||
// Razón de resultado
|
|
||||||
@Column({ name: 'loss_reason', type: 'text', nullable: true })
|
|
||||||
lossReason?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'win_factors', type: 'text', nullable: true })
|
|
||||||
winFactors?: string;
|
|
||||||
|
|
||||||
// Notas y metadatos
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
notes?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'source_url', length: 500, nullable: true })
|
|
||||||
sourceUrl?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'source_reference', length: 255, nullable: true })
|
|
||||||
sourceReference?: string;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
|
|
||||||
// Relaciones
|
|
||||||
@OneToMany(() => Bid, (bid) => bid.opportunity)
|
|
||||||
bids?: Bid[];
|
|
||||||
|
|
||||||
// Auditoría
|
|
||||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
|
||||||
createdBy?: string;
|
|
||||||
|
|
||||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
|
||||||
updatedBy?: string;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
|
||||||
createdAt!: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
updatedAt!: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedById: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'updated_by' })
|
||||||
|
updatedBy: User;
|
||||||
|
|
||||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
deletedAt?: Date;
|
deletedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/modules/bidding/entities/proposal.entity.ts
Normal file
142
src/modules/bidding/entities/proposal.entity.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Proposal Entity - Propuestas Enviadas
|
||||||
|
* Representa una propuesta enviada a una licitación.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
* @table bidding.proposals
|
||||||
|
* @ddl schemas/XX-bidding-schema-ddl.sql
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
|
import { User } from '../../core/entities/user.entity';
|
||||||
|
import { Tender } from './tender.entity';
|
||||||
|
import { Vendor } from './vendor.entity';
|
||||||
|
|
||||||
|
/** Status of the proposal */
|
||||||
|
export type ProposalStatus = 'received' | 'evaluating' | 'qualified' | 'disqualified' | 'winner';
|
||||||
|
|
||||||
|
@Entity({ schema: 'bidding', name: 'proposals' })
|
||||||
|
@Index(['tenantId'])
|
||||||
|
@Index(['tenantId', 'tenderId'])
|
||||||
|
@Index(['tenantId', 'vendorId'])
|
||||||
|
@Index(['tenantId', 'status'])
|
||||||
|
@Index(['submittedAt'])
|
||||||
|
export class Proposal {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
/** Reference to the tender */
|
||||||
|
@Column({ name: 'tender_id', type: 'uuid' })
|
||||||
|
tenderId: string;
|
||||||
|
|
||||||
|
/** Reference to the vendor who submitted */
|
||||||
|
@Column({ name: 'vendor_id', type: 'uuid' })
|
||||||
|
vendorId: string;
|
||||||
|
|
||||||
|
/** Proposed amount in cents */
|
||||||
|
@Column({ name: 'proposed_amount', type: 'bigint' })
|
||||||
|
proposedAmount: string;
|
||||||
|
|
||||||
|
/** Proposed schedule in days */
|
||||||
|
@Column({ name: 'proposed_schedule_days', type: 'int' })
|
||||||
|
proposedScheduleDays: number;
|
||||||
|
|
||||||
|
/** URL to technical proposal document */
|
||||||
|
@Column({ name: 'technical_proposal_url', type: 'varchar', length: 500, nullable: true })
|
||||||
|
technicalProposalUrl: string;
|
||||||
|
|
||||||
|
/** URL to economic proposal document */
|
||||||
|
@Column({ name: 'economic_proposal_url', type: 'varchar', length: 500, nullable: true })
|
||||||
|
economicProposalUrl: string;
|
||||||
|
|
||||||
|
/** Technical evaluation score */
|
||||||
|
@Column({
|
||||||
|
name: 'technical_score',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 5,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
technicalScore: number;
|
||||||
|
|
||||||
|
/** Economic evaluation score */
|
||||||
|
@Column({
|
||||||
|
name: 'economic_score',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 5,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
economicScore: number;
|
||||||
|
|
||||||
|
/** Total combined score */
|
||||||
|
@Column({
|
||||||
|
name: 'total_score',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 5,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
totalScore: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 30,
|
||||||
|
default: 'received',
|
||||||
|
})
|
||||||
|
status: ProposalStatus;
|
||||||
|
|
||||||
|
/** Timestamp when the proposal was submitted */
|
||||||
|
@Column({ name: 'submitted_at', type: 'timestamptz' })
|
||||||
|
submittedAt: Date;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Tenant)
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@ManyToOne(() => Tender, (tender) => tender.proposals)
|
||||||
|
@JoinColumn({ name: 'tender_id' })
|
||||||
|
tender: Tender;
|
||||||
|
|
||||||
|
@ManyToOne(() => Vendor, (vendor) => vendor.proposals)
|
||||||
|
@JoinColumn({ name: 'vendor_id' })
|
||||||
|
vendor: Vendor;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdById: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'created_by' })
|
||||||
|
createdBy: User;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedById: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'updated_by' })
|
||||||
|
updatedBy: User;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
157
src/modules/bidding/entities/tender.entity.ts
Normal file
157
src/modules/bidding/entities/tender.entity.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Tender Entity - Licitaciones Formales
|
||||||
|
* Representa una licitación formal vinculada a una oportunidad.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
* @table bidding.tenders
|
||||||
|
* @ddl schemas/XX-bidding-schema-ddl.sql
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
|
import { User } from '../../core/entities/user.entity';
|
||||||
|
import { Opportunity } from './opportunity.entity';
|
||||||
|
import { Proposal } from './proposal.entity';
|
||||||
|
import { BidCalendar } from './bid-calendar.entity';
|
||||||
|
import { BidDocument } from './bid-document.entity';
|
||||||
|
|
||||||
|
/** Type of tender process */
|
||||||
|
export type TenderType = 'public' | 'private' | 'invitation_only';
|
||||||
|
|
||||||
|
/** Status of the tender */
|
||||||
|
export type TenderStatus =
|
||||||
|
| 'draft'
|
||||||
|
| 'published'
|
||||||
|
| 'receiving'
|
||||||
|
| 'evaluating'
|
||||||
|
| 'awarded'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'converting'
|
||||||
|
| 'converted';
|
||||||
|
|
||||||
|
@Entity({ schema: 'bidding', name: 'tenders' })
|
||||||
|
@Index(['tenantId'])
|
||||||
|
@Index(['tenantId', 'number'], { unique: true })
|
||||||
|
@Index(['tenantId', 'opportunityId'])
|
||||||
|
@Index(['tenantId', 'status'])
|
||||||
|
@Index(['tenantId', 'type'])
|
||||||
|
@Index(['proposalDeadline'])
|
||||||
|
export class Tender {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
/** Reference to the opportunity */
|
||||||
|
@Column({ name: 'opportunity_id', type: 'uuid' })
|
||||||
|
opportunityId: string;
|
||||||
|
|
||||||
|
/** Unique tender number within tenant, format: LIC-2026-001 */
|
||||||
|
@Column({ type: 'varchar', length: 50 })
|
||||||
|
number: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 30,
|
||||||
|
default: 'public',
|
||||||
|
})
|
||||||
|
type: TenderType;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
/** Reference amount (budget) in cents */
|
||||||
|
@Column({ name: 'reference_amount', type: 'bigint', nullable: true })
|
||||||
|
referenceAmount: string;
|
||||||
|
|
||||||
|
/** Date when the tender was published */
|
||||||
|
@Column({ name: 'publication_date', type: 'date', nullable: true })
|
||||||
|
publicationDate: Date;
|
||||||
|
|
||||||
|
/** Date for clarification meeting (junta de aclaraciones) */
|
||||||
|
@Column({ name: 'clarification_meeting_date', type: 'date', nullable: true })
|
||||||
|
clarificationMeetingDate: Date;
|
||||||
|
|
||||||
|
/** Deadline for proposal submission */
|
||||||
|
@Column({ name: 'proposal_deadline', type: 'timestamptz' })
|
||||||
|
proposalDeadline: Date;
|
||||||
|
|
||||||
|
/** Expected or actual award date */
|
||||||
|
@Column({ name: 'award_date', type: 'date', nullable: true })
|
||||||
|
awardDate: Date;
|
||||||
|
|
||||||
|
/** Contract duration in days */
|
||||||
|
@Column({ name: 'contract_duration_days', type: 'int', nullable: true })
|
||||||
|
contractDurationDays: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 30,
|
||||||
|
default: 'draft',
|
||||||
|
})
|
||||||
|
status: TenderStatus;
|
||||||
|
|
||||||
|
/** Reference to the winning proposal */
|
||||||
|
@Column({ name: 'winner_id', type: 'uuid', nullable: true })
|
||||||
|
winnerId: string;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Tenant)
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@ManyToOne(() => Opportunity, (opp) => opp.tenders)
|
||||||
|
@JoinColumn({ name: 'opportunity_id' })
|
||||||
|
opportunity: Opportunity;
|
||||||
|
|
||||||
|
@ManyToOne(() => Proposal, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'winner_id' })
|
||||||
|
winner: Proposal;
|
||||||
|
|
||||||
|
@OneToMany(() => Proposal, (proposal) => proposal.tender)
|
||||||
|
proposals: Proposal[];
|
||||||
|
|
||||||
|
@OneToMany(() => BidCalendar, (event) => event.tender)
|
||||||
|
calendarEvents: BidCalendar[];
|
||||||
|
|
||||||
|
@OneToMany(() => BidDocument, (doc) => doc.tender)
|
||||||
|
documents: BidDocument[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdById: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'created_by' })
|
||||||
|
createdBy: User;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedById: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'updated_by' })
|
||||||
|
updatedBy: User;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
143
src/modules/bidding/entities/vendor.entity.ts
Normal file
143
src/modules/bidding/entities/vendor.entity.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Vendor Entity - Proveedores/Contratistas
|
||||||
|
* Representa proveedores y contratistas que participan en licitaciones.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
* @table bidding.vendors
|
||||||
|
* @ddl schemas/XX-bidding-schema-ddl.sql
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
Check,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from '../../core/entities/tenant.entity';
|
||||||
|
import { User } from '../../core/entities/user.entity';
|
||||||
|
import { Proposal } from './proposal.entity';
|
||||||
|
|
||||||
|
/** Vendor certification structure */
|
||||||
|
export interface VendorCertification {
|
||||||
|
name: string;
|
||||||
|
issuedBy: string;
|
||||||
|
issuedDate: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
documentUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vendor performance history entry */
|
||||||
|
export interface VendorPerformanceEntry {
|
||||||
|
projectName: string;
|
||||||
|
clientName: string;
|
||||||
|
contractAmount: number;
|
||||||
|
completedDate: string;
|
||||||
|
rating: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'bidding', name: 'vendors' })
|
||||||
|
@Index(['tenantId'])
|
||||||
|
@Index(['tenantId', 'code'], { unique: true })
|
||||||
|
@Index(['tenantId', 'rfc'])
|
||||||
|
@Index(['tenantId', 'isActive'])
|
||||||
|
@Index(['rating'])
|
||||||
|
@Check('rating >= 1 AND rating <= 5')
|
||||||
|
export class Vendor {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
/** Unique code within tenant */
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
/** Business name (razon social) */
|
||||||
|
@Column({ name: 'business_name', type: 'varchar', length: 255 })
|
||||||
|
businessName: string;
|
||||||
|
|
||||||
|
/** RFC (tax ID) */
|
||||||
|
@Column({ type: 'varchar', length: 13, nullable: true })
|
||||||
|
rfc: string;
|
||||||
|
|
||||||
|
/** List of specialties */
|
||||||
|
@Column({ type: 'text', array: true, nullable: true })
|
||||||
|
specialties: string[];
|
||||||
|
|
||||||
|
/** Rating from 1 to 5 */
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 2,
|
||||||
|
scale: 1,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
rating: number;
|
||||||
|
|
||||||
|
/** Certifications in JSON format */
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
certifications: VendorCertification[];
|
||||||
|
|
||||||
|
/** Performance history in JSON format */
|
||||||
|
@Column({ name: 'performance_history', type: 'jsonb', nullable: true })
|
||||||
|
performanceHistory: VendorPerformanceEntry[];
|
||||||
|
|
||||||
|
/** Whether all documentation is valid/current */
|
||||||
|
@Column({ name: 'documentation_valid', type: 'boolean', default: false })
|
||||||
|
documentationValid: boolean;
|
||||||
|
|
||||||
|
/** Contact person name */
|
||||||
|
@Column({ name: 'contact_name', type: 'varchar', length: 255, nullable: true })
|
||||||
|
contactName: string;
|
||||||
|
|
||||||
|
/** Contact email */
|
||||||
|
@Column({ name: 'contact_email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
contactEmail: string;
|
||||||
|
|
||||||
|
/** Contact phone */
|
||||||
|
@Column({ name: 'contact_phone', type: 'varchar', length: 50, nullable: true })
|
||||||
|
contactPhone: string;
|
||||||
|
|
||||||
|
/** Whether the vendor is active */
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Tenant)
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@OneToMany(() => Proposal, (proposal) => proposal.vendor)
|
||||||
|
proposals: Proposal[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdById: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'created_by' })
|
||||||
|
createdBy: User;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedById: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'updated_by' })
|
||||||
|
updatedBy: User;
|
||||||
|
|
||||||
|
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
@ -1,22 +1,56 @@
|
|||||||
/**
|
/**
|
||||||
* BidAnalyticsService - Análisis y Reportes de Licitaciones
|
* BidAnalyticsService - Analisis y Reportes de Licitaciones
|
||||||
*
|
*
|
||||||
* Estadísticas, tendencias y análisis de competitividad.
|
* Estadisticas, tendencias y analisis de competitividad.
|
||||||
|
* Proporciona dashboards consolidados, win rates, analisis de competidores y pipeline.
|
||||||
*
|
*
|
||||||
* @module Bidding
|
* @module Bidding (MAI-018)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { ServiceContext } from '../../../shared/services/base.service';
|
import { ServiceContext } from '../../../shared/services/base.service';
|
||||||
import { Bid, BidStatus, BidType } from '../entities/bid.entity';
|
import { Tender, TenderStatus, TenderType } from '../entities/tender.entity';
|
||||||
import { Opportunity, OpportunitySource, OpportunityStatus } from '../entities/opportunity.entity';
|
import { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority } from '../entities/opportunity.entity';
|
||||||
import { BidCompetitor } from '../entities/bid-competitor.entity';
|
import { Proposal } from '../entities/proposal.entity';
|
||||||
|
|
||||||
|
// Interfaces for method results
|
||||||
|
export interface WinRateResult {
|
||||||
|
won: number;
|
||||||
|
lost: number;
|
||||||
|
rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineValueResult {
|
||||||
|
totalValue: number;
|
||||||
|
byStatus: { status: OpportunityStatus; value: number; count: number }[];
|
||||||
|
byPriority: { priority: OpportunityPriority; value: number; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompetitorFrequency {
|
||||||
|
companyName: string;
|
||||||
|
rfc?: string;
|
||||||
|
appearances: number;
|
||||||
|
winsAgainstUs: number;
|
||||||
|
lossesAgainstUs: number;
|
||||||
|
avgProposalDifference: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AveragesResult {
|
||||||
|
avgBidSize: number;
|
||||||
|
avgTimeToAward: number;
|
||||||
|
avgProposalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export class BidAnalyticsService {
|
export class BidAnalyticsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bidRepository: Repository<Bid>,
|
private readonly tenderRepository: Repository<Tender>,
|
||||||
private readonly opportunityRepository: Repository<Opportunity>,
|
private readonly opportunityRepository: Repository<Opportunity>,
|
||||||
private readonly competitorRepository: Repository<BidCompetitor>
|
private readonly proposalRepository: Repository<Proposal>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,87 +58,85 @@ export class BidAnalyticsService {
|
|||||||
*/
|
*/
|
||||||
async getDashboard(ctx: ServiceContext): Promise<BidDashboard> {
|
async getDashboard(ctx: ServiceContext): Promise<BidDashboard> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const thirtyDaysAgo = new Date();
|
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
||||||
|
|
||||||
// Oportunidades activas
|
// Oportunidades activas
|
||||||
const activeOpportunities = await this.opportunityRepository.count({
|
const activeOpportunities = await this.opportunityRepository.count({
|
||||||
where: {
|
where: {
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
deletedAt: undefined,
|
deletedAt: undefined,
|
||||||
status: 'pursuing' as OpportunityStatus,
|
status: 'go' as OpportunityStatus,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Licitaciones activas
|
// Licitaciones activas
|
||||||
const activeBids = await this.bidRepository.count({
|
const activeTenders = await this.tenderRepository.count({
|
||||||
where: {
|
where: {
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
deletedAt: undefined,
|
deletedAt: undefined,
|
||||||
status: 'preparation' as BidStatus,
|
status: 'published' as TenderStatus,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Valor del pipeline
|
// Valor del pipeline
|
||||||
const pipelineValue = await this.opportunityRepository
|
const pipelineValue = await this.opportunityRepository
|
||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.select('SUM(o.weighted_value)', 'value')
|
.select('SUM(CAST(o.estimated_amount AS DECIMAL))', 'value')
|
||||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('o.deleted_at IS NULL')
|
.andWhere('o.deleted_at IS NULL')
|
||||||
.andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['won', 'lost', 'cancelled'] })
|
.andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['converted', 'no_go'] })
|
||||||
.getRawOne();
|
.getRawOne();
|
||||||
|
|
||||||
// Próximas fechas límite
|
// Proximas fechas limite
|
||||||
const upcomingDeadlines = await this.bidRepository
|
const upcomingDeadlines = await this.tenderRepository
|
||||||
.createQueryBuilder('b')
|
.createQueryBuilder('t')
|
||||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('b.deleted_at IS NULL')
|
.andWhere('t.deleted_at IS NULL')
|
||||||
.andWhere('b.status IN (:...activeStatuses)', { activeStatuses: ['draft', 'preparation', 'review', 'approved'] })
|
.andWhere('t.status IN (:...activeStatuses)', { activeStatuses: ['draft', 'published', 'receiving', 'evaluating'] })
|
||||||
.andWhere('b.submission_deadline >= :now', { now })
|
.andWhere('t.proposal_deadline >= :now', { now })
|
||||||
.orderBy('b.submission_deadline', 'ASC')
|
.orderBy('t.proposal_deadline', 'ASC')
|
||||||
.take(5)
|
.take(5)
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
// Win rate últimos 12 meses
|
// Win rate ultimos 12 meses
|
||||||
const yearAgo = new Date();
|
const yearAgo = new Date();
|
||||||
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
|
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
|
||||||
|
|
||||||
const winRateStats = await this.bidRepository
|
const winRateStats = await this.tenderRepository
|
||||||
.createQueryBuilder('b')
|
.createQueryBuilder('t')
|
||||||
.select('b.status', 'status')
|
.select('t.status', 'status')
|
||||||
.addSelect('COUNT(*)', 'count')
|
.addSelect('COUNT(*)', 'count')
|
||||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('b.deleted_at IS NULL')
|
.andWhere('t.deleted_at IS NULL')
|
||||||
.andWhere('b.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'rejected'] })
|
.andWhere('t.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'cancelled'] })
|
||||||
.andWhere('b.award_date >= :yearAgo', { yearAgo })
|
.andWhere('t.award_date >= :yearAgo', { yearAgo })
|
||||||
.groupBy('b.status')
|
.groupBy('t.status')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
const awarded = winRateStats.find((s) => s.status === 'awarded')?.count || 0;
|
const awarded = winRateStats.find((s) => s.status === 'awarded')?.count || 0;
|
||||||
const rejected = winRateStats.find((s) => s.status === 'rejected')?.count || 0;
|
const cancelled = winRateStats.find((s) => s.status === 'cancelled')?.count || 0;
|
||||||
const totalClosed = parseInt(awarded) + parseInt(rejected);
|
const totalClosed = parseInt(awarded) + parseInt(cancelled);
|
||||||
const winRate = totalClosed > 0 ? (parseInt(awarded) / totalClosed) * 100 : 0;
|
const winRate = totalClosed > 0 ? (parseInt(awarded) / totalClosed) * 100 : 0;
|
||||||
|
|
||||||
// Valor ganado este año
|
// Valor ganado este ano
|
||||||
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||||
const wonValue = await this.bidRepository
|
const wonValue = await this.tenderRepository
|
||||||
.createQueryBuilder('b')
|
.createQueryBuilder('t')
|
||||||
.select('SUM(b.winning_amount)', 'value')
|
.select('SUM(CAST(t.reference_amount AS DECIMAL))', 'value')
|
||||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('b.deleted_at IS NULL')
|
.andWhere('t.deleted_at IS NULL')
|
||||||
.andWhere('b.status = :status', { status: 'awarded' })
|
.andWhere('t.status = :status', { status: 'awarded' })
|
||||||
.andWhere('b.award_date >= :startOfYear', { startOfYear })
|
.andWhere('t.award_date >= :startOfYear', { startOfYear })
|
||||||
.getRawOne();
|
.getRawOne();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeOpportunities,
|
activeOpportunities,
|
||||||
activeBids,
|
activeBids: activeTenders,
|
||||||
pipelineValue: parseFloat(pipelineValue?.value) || 0,
|
pipelineValue: parseFloat(pipelineValue?.value) || 0,
|
||||||
upcomingDeadlines: upcomingDeadlines.map((b) => ({
|
upcomingDeadlines: upcomingDeadlines.map((t) => ({
|
||||||
id: b.id,
|
id: t.id,
|
||||||
name: b.name,
|
name: t.title,
|
||||||
deadline: b.submissionDeadline,
|
deadline: t.proposalDeadline,
|
||||||
status: b.status,
|
status: t.status,
|
||||||
})),
|
})),
|
||||||
winRate,
|
winRate,
|
||||||
wonValueYTD: parseFloat(wonValue?.value) || 0,
|
wonValueYTD: parseFloat(wonValue?.value) || 0,
|
||||||
@ -112,51 +144,49 @@ export class BidAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Análisis de pipeline por fuente
|
* Analisis de pipeline por fuente
|
||||||
*/
|
*/
|
||||||
async getPipelineBySource(ctx: ServiceContext): Promise<PipelineBySource[]> {
|
async getPipelineBySource(ctx: ServiceContext): Promise<PipelineBySource[]> {
|
||||||
const result = await this.opportunityRepository
|
const result = await this.opportunityRepository
|
||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.select('o.source', 'source')
|
.select('o.source', 'source')
|
||||||
.addSelect('COUNT(*)', 'count')
|
.addSelect('COUNT(*)', 'count')
|
||||||
.addSelect('SUM(o.estimated_value)', 'totalValue')
|
.addSelect('SUM(CAST(o.estimated_amount AS DECIMAL))', 'totalValue')
|
||||||
.addSelect('SUM(o.weighted_value)', 'weightedValue')
|
|
||||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('o.deleted_at IS NULL')
|
.andWhere('o.deleted_at IS NULL')
|
||||||
.andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['won', 'lost', 'cancelled'] })
|
.andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['converted', 'no_go'] })
|
||||||
.groupBy('o.source')
|
.groupBy('o.source')
|
||||||
.orderBy('SUM(o.weighted_value)', 'DESC')
|
.orderBy('SUM(CAST(o.estimated_amount AS DECIMAL))', 'DESC')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
return result.map((r) => ({
|
return result.map((r) => ({
|
||||||
source: r.source as OpportunitySource,
|
source: r.source as OpportunitySource,
|
||||||
count: parseInt(r.count),
|
count: parseInt(r.count),
|
||||||
totalValue: parseFloat(r.totalValue) || 0,
|
totalValue: parseFloat(r.totalValue) || 0,
|
||||||
weightedValue: parseFloat(r.weightedValue) || 0,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Análisis de win rate por tipo de licitación
|
* Analisis de win rate por tipo de licitacion
|
||||||
*/
|
*/
|
||||||
async getWinRateByType(ctx: ServiceContext, months = 12): Promise<WinRateByType[]> {
|
async getWinRateByType(ctx: ServiceContext, months = 12): Promise<WinRateByType[]> {
|
||||||
const fromDate = new Date();
|
const fromDate = new Date();
|
||||||
fromDate.setMonth(fromDate.getMonth() - months);
|
fromDate.setMonth(fromDate.getMonth() - months);
|
||||||
|
|
||||||
const result = await this.bidRepository
|
const result = await this.tenderRepository
|
||||||
.createQueryBuilder('b')
|
.createQueryBuilder('t')
|
||||||
.select('b.bid_type', 'bidType')
|
.select('t.type', 'type')
|
||||||
.addSelect('COUNT(*) FILTER (WHERE b.status = \'awarded\')', 'won')
|
.addSelect('COUNT(*) FILTER (WHERE t.status = \'awarded\')', 'won')
|
||||||
.addSelect('COUNT(*) FILTER (WHERE b.status IN (\'awarded\', \'rejected\'))', 'total')
|
.addSelect('COUNT(*) FILTER (WHERE t.status IN (\'awarded\', \'cancelled\'))', 'total')
|
||||||
.addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'wonValue')
|
.addSelect('SUM(CASE WHEN t.status = \'awarded\' THEN CAST(t.reference_amount AS DECIMAL) ELSE 0 END)', 'wonValue')
|
||||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('b.deleted_at IS NULL')
|
.andWhere('t.deleted_at IS NULL')
|
||||||
.andWhere('b.award_date >= :fromDate', { fromDate })
|
.andWhere('t.award_date >= :fromDate', { fromDate })
|
||||||
.groupBy('b.bid_type')
|
.groupBy('t.type')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
return result.map((r) => ({
|
return result.map((r) => ({
|
||||||
bidType: r.bidType as BidType,
|
bidType: r.type as TenderType,
|
||||||
won: parseInt(r.won) || 0,
|
won: parseInt(r.won) || 0,
|
||||||
total: parseInt(r.total) || 0,
|
total: parseInt(r.total) || 0,
|
||||||
winRate: parseInt(r.total) > 0 ? (parseInt(r.won) / parseInt(r.total)) * 100 : 0,
|
winRate: parseInt(r.total) > 0 ? (parseInt(r.won) / parseInt(r.total)) * 100 : 0,
|
||||||
@ -173,16 +203,16 @@ export class BidAnalyticsService {
|
|||||||
|
|
||||||
const result = await this.opportunityRepository
|
const result = await this.opportunityRepository
|
||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.select("TO_CHAR(o.identification_date, 'YYYY-MM')", 'month')
|
.select("TO_CHAR(o.created_at, 'YYYY-MM')", 'month')
|
||||||
.addSelect('COUNT(*)', 'identified')
|
.addSelect('COUNT(*)', 'identified')
|
||||||
.addSelect('COUNT(*) FILTER (WHERE o.status = \'won\')', 'won')
|
.addSelect('COUNT(*) FILTER (WHERE o.status = \'converted\')', 'won')
|
||||||
.addSelect('COUNT(*) FILTER (WHERE o.status = \'lost\')', 'lost')
|
.addSelect('COUNT(*) FILTER (WHERE o.status = \'no_go\')', 'lost')
|
||||||
.addSelect('SUM(CASE WHEN o.status = \'won\' THEN o.estimated_value ELSE 0 END)', 'wonValue')
|
.addSelect('SUM(CASE WHEN o.status = \'converted\' THEN CAST(o.estimated_amount AS DECIMAL) ELSE 0 END)', 'wonValue')
|
||||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('o.deleted_at IS NULL')
|
.andWhere('o.deleted_at IS NULL')
|
||||||
.andWhere('o.identification_date >= :fromDate', { fromDate })
|
.andWhere('o.created_at >= :fromDate', { fromDate })
|
||||||
.groupBy("TO_CHAR(o.identification_date, 'YYYY-MM')")
|
.groupBy("TO_CHAR(o.created_at, 'YYYY-MM')")
|
||||||
.orderBy("TO_CHAR(o.identification_date, 'YYYY-MM')", 'ASC')
|
.orderBy("TO_CHAR(o.created_at, 'YYYY-MM')", 'ASC')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
return result.map((r) => ({
|
return result.map((r) => ({
|
||||||
@ -195,19 +225,20 @@ export class BidAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Análisis de competidores
|
* Analisis de competidores
|
||||||
*/
|
*/
|
||||||
async getCompetitorAnalysis(ctx: ServiceContext): Promise<CompetitorAnalysis[]> {
|
async getCompetitorAnalysis(ctx: ServiceContext): Promise<CompetitorAnalysis[]> {
|
||||||
const result = await this.competitorRepository
|
const result = await this.proposalRepository
|
||||||
.createQueryBuilder('c')
|
.createQueryBuilder('p')
|
||||||
.select('c.company_name', 'companyName')
|
.leftJoin('p.vendor', 'v')
|
||||||
|
.select('v.business_name', 'companyName')
|
||||||
.addSelect('COUNT(*)', 'encounters')
|
.addSelect('COUNT(*)', 'encounters')
|
||||||
.addSelect('SUM(CASE WHEN c.status = \'winner\' THEN 1 ELSE 0 END)', 'theirWins')
|
.addSelect('SUM(CASE WHEN p.status = \'winner\' THEN 1 ELSE 0 END)', 'theirWins')
|
||||||
.addSelect('SUM(CASE WHEN c.status = \'loser\' THEN 1 ELSE 0 END)', 'ourWins')
|
.addSelect('SUM(CASE WHEN p.status != \'winner\' AND p.status = \'qualified\' THEN 1 ELSE 0 END)', 'ourWins')
|
||||||
.addSelect('AVG(c.proposed_amount)', 'avgProposedAmount')
|
.addSelect('AVG(CAST(p.proposed_amount AS DECIMAL))', 'avgProposedAmount')
|
||||||
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('c.deleted_at IS NULL')
|
.andWhere('p.deleted_at IS NULL')
|
||||||
.groupBy('c.company_name')
|
.groupBy('v.business_name')
|
||||||
.having('COUNT(*) >= 2')
|
.having('COUNT(*) >= 2')
|
||||||
.orderBy('COUNT(*)', 'DESC')
|
.orderBy('COUNT(*)', 'DESC')
|
||||||
.take(20)
|
.take(20)
|
||||||
@ -226,7 +257,7 @@ export class BidAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Análisis de conversión del funnel
|
* Analisis de conversion del funnel
|
||||||
*/
|
*/
|
||||||
async getFunnelAnalysis(ctx: ServiceContext, months = 12): Promise<FunnelAnalysis> {
|
async getFunnelAnalysis(ctx: ServiceContext, months = 12): Promise<FunnelAnalysis> {
|
||||||
const fromDate = new Date();
|
const fromDate = new Date();
|
||||||
@ -236,44 +267,44 @@ export class BidAnalyticsService {
|
|||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('o.deleted_at IS NULL')
|
.andWhere('o.deleted_at IS NULL')
|
||||||
.andWhere('o.identification_date >= :fromDate', { fromDate });
|
.andWhere('o.created_at >= :fromDate', { fromDate });
|
||||||
|
|
||||||
const identified = await baseQuery.clone().getCount();
|
const identified = await baseQuery.clone().getCount();
|
||||||
|
|
||||||
const qualified = await baseQuery.clone()
|
const evaluating = await baseQuery.clone()
|
||||||
.andWhere('o.status NOT IN (:...earlyStatuses)', { earlyStatuses: ['identified'] })
|
.andWhere('o.status NOT IN (:...earlyStatuses)', { earlyStatuses: ['registered'] })
|
||||||
.getCount();
|
.getCount();
|
||||||
|
|
||||||
const pursuing = await baseQuery.clone()
|
const go = await baseQuery.clone()
|
||||||
.andWhere('o.status IN (:...pursuitStatuses)', { pursuitStatuses: ['pursuing', 'bid_submitted', 'won', 'lost'] })
|
.andWhere('o.status IN (:...goStatuses)', { goStatuses: ['go', 'preparing', 'converted'] })
|
||||||
.getCount();
|
.getCount();
|
||||||
|
|
||||||
const bidSubmitted = await baseQuery.clone()
|
const preparing = await baseQuery.clone()
|
||||||
.andWhere('o.status IN (:...submittedStatuses)', { submittedStatuses: ['bid_submitted', 'won', 'lost'] })
|
.andWhere('o.status IN (:...prepStatuses)', { prepStatuses: ['preparing', 'converted'] })
|
||||||
.getCount();
|
.getCount();
|
||||||
|
|
||||||
const won = await baseQuery.clone()
|
const converted = await baseQuery.clone()
|
||||||
.andWhere('o.status = :status', { status: 'won' })
|
.andWhere('o.status = :status', { status: 'converted' })
|
||||||
.getCount();
|
.getCount();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
identified,
|
identified,
|
||||||
qualified,
|
qualified: evaluating,
|
||||||
pursuing,
|
pursuing: go,
|
||||||
bidSubmitted,
|
bidSubmitted: preparing,
|
||||||
won,
|
won: converted,
|
||||||
conversionRates: {
|
conversionRates: {
|
||||||
identifiedToQualified: identified > 0 ? (qualified / identified) * 100 : 0,
|
identifiedToQualified: identified > 0 ? (evaluating / identified) * 100 : 0,
|
||||||
qualifiedToPursuing: qualified > 0 ? (pursuing / qualified) * 100 : 0,
|
qualifiedToPursuing: evaluating > 0 ? (go / evaluating) * 100 : 0,
|
||||||
pursuingToSubmitted: pursuing > 0 ? (bidSubmitted / pursuing) * 100 : 0,
|
pursuingToSubmitted: go > 0 ? (preparing / go) * 100 : 0,
|
||||||
submittedToWon: bidSubmitted > 0 ? (won / bidSubmitted) * 100 : 0,
|
submittedToWon: preparing > 0 ? (converted / preparing) * 100 : 0,
|
||||||
overallConversion: identified > 0 ? (won / identified) * 100 : 0,
|
overallConversion: identified > 0 ? (converted / identified) * 100 : 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Análisis de tiempos de ciclo
|
* Analisis de tiempos de ciclo
|
||||||
*/
|
*/
|
||||||
async getCycleTimeAnalysis(ctx: ServiceContext, months = 12): Promise<CycleTimeAnalysis> {
|
async getCycleTimeAnalysis(ctx: ServiceContext, months = 12): Promise<CycleTimeAnalysis> {
|
||||||
const fromDate = new Date();
|
const fromDate = new Date();
|
||||||
@ -281,23 +312,23 @@ export class BidAnalyticsService {
|
|||||||
|
|
||||||
const result = await this.opportunityRepository
|
const result = await this.opportunityRepository
|
||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.select('AVG(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'avgDays')
|
.select('AVG(EXTRACT(DAY FROM (o.updated_at - o.created_at)))', 'avgDays')
|
||||||
.addSelect('MIN(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'minDays')
|
.addSelect('MIN(EXTRACT(DAY FROM (o.updated_at - o.created_at)))', 'minDays')
|
||||||
.addSelect('MAX(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'maxDays')
|
.addSelect('MAX(EXTRACT(DAY FROM (o.updated_at - o.created_at)))', 'maxDays')
|
||||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('o.deleted_at IS NULL')
|
.andWhere('o.deleted_at IS NULL')
|
||||||
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] })
|
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['converted', 'no_go'] })
|
||||||
.andWhere('o.identification_date >= :fromDate', { fromDate })
|
.andWhere('o.created_at >= :fromDate', { fromDate })
|
||||||
.getRawOne();
|
.getRawOne();
|
||||||
|
|
||||||
const byOutcome = await this.opportunityRepository
|
const byOutcome = await this.opportunityRepository
|
||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.select('o.status', 'outcome')
|
.select('o.status', 'outcome')
|
||||||
.addSelect('AVG(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'avgDays')
|
.addSelect('AVG(EXTRACT(DAY FROM (o.updated_at - o.created_at)))', 'avgDays')
|
||||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('o.deleted_at IS NULL')
|
.andWhere('o.deleted_at IS NULL')
|
||||||
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] })
|
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['converted', 'no_go'] })
|
||||||
.andWhere('o.identification_date >= :fromDate', { fromDate })
|
.andWhere('o.created_at >= :fromDate', { fromDate })
|
||||||
.groupBy('o.status')
|
.groupBy('o.status')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
@ -308,11 +339,222 @@ export class BidAnalyticsService {
|
|||||||
maxDays: Math.round(parseFloat(result?.maxDays) || 0),
|
maxDays: Math.round(parseFloat(result?.maxDays) || 0),
|
||||||
},
|
},
|
||||||
byOutcome: byOutcome.map((r) => ({
|
byOutcome: byOutcome.map((r) => ({
|
||||||
outcome: r.outcome as 'won' | 'lost',
|
outcome: r.outcome === 'converted' ? 'won' : 'lost',
|
||||||
avgDays: Math.round(parseFloat(r.avgDays) || 0),
|
avgDays: Math.round(parseFloat(r.avgDays) || 0),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener win rate con filtros de fecha opcionales
|
||||||
|
*/
|
||||||
|
async getWinRate(ctx: ServiceContext, filters?: DateRange): Promise<WinRateResult> {
|
||||||
|
const qb = this.tenderRepository
|
||||||
|
.createQueryBuilder('t')
|
||||||
|
.select('t.status', 'status')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('t.deleted_at IS NULL')
|
||||||
|
.andWhere('t.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'cancelled'] });
|
||||||
|
|
||||||
|
if (filters?.dateFrom) {
|
||||||
|
qb.andWhere('t.award_date >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||||
|
}
|
||||||
|
if (filters?.dateTo) {
|
||||||
|
qb.andWhere('t.award_date <= :dateTo', { dateTo: filters.dateTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await qb.groupBy('t.status').getRawMany();
|
||||||
|
|
||||||
|
const won = parseInt(results.find(r => r.status === 'awarded')?.count || '0', 10);
|
||||||
|
const lost = parseInt(results.find(r => r.status === 'cancelled')?.count || '0', 10);
|
||||||
|
const total = won + lost;
|
||||||
|
|
||||||
|
return {
|
||||||
|
won,
|
||||||
|
lost,
|
||||||
|
rate: total > 0 ? (won / total) * 100 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener valor del pipeline por status y prioridad
|
||||||
|
*/
|
||||||
|
async getPipelineValue(ctx: ServiceContext): Promise<PipelineValueResult> {
|
||||||
|
const activeStatuses = ['registered', 'evaluating', 'go', 'preparing'];
|
||||||
|
|
||||||
|
// Total value
|
||||||
|
const totalResult = await this.opportunityRepository
|
||||||
|
.createQueryBuilder('o')
|
||||||
|
.select('SUM(CAST(o.estimated_amount AS DECIMAL))', 'totalValue')
|
||||||
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('o.deleted_at IS NULL')
|
||||||
|
.andWhere('o.status IN (:...statuses)', { statuses: activeStatuses })
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
// By status
|
||||||
|
const byStatusResults = await this.opportunityRepository
|
||||||
|
.createQueryBuilder('o')
|
||||||
|
.select('o.status', 'status')
|
||||||
|
.addSelect('SUM(CAST(o.estimated_amount AS DECIMAL))', 'value')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('o.deleted_at IS NULL')
|
||||||
|
.andWhere('o.status IN (:...statuses)', { statuses: activeStatuses })
|
||||||
|
.groupBy('o.status')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
// By priority
|
||||||
|
const byPriorityResults = await this.opportunityRepository
|
||||||
|
.createQueryBuilder('o')
|
||||||
|
.select('o.priority', 'priority')
|
||||||
|
.addSelect('SUM(CAST(o.estimated_amount AS DECIMAL))', 'value')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('o.deleted_at IS NULL')
|
||||||
|
.andWhere('o.status IN (:...statuses)', { statuses: activeStatuses })
|
||||||
|
.groupBy('o.priority')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalValue: parseFloat(totalResult?.totalValue) || 0,
|
||||||
|
byStatus: byStatusResults.map(r => ({
|
||||||
|
status: r.status as OpportunityStatus,
|
||||||
|
value: parseFloat(r.value) || 0,
|
||||||
|
count: parseInt(r.count, 10),
|
||||||
|
})),
|
||||||
|
byPriority: byPriorityResults.map(r => ({
|
||||||
|
priority: r.priority as OpportunityPriority,
|
||||||
|
value: parseFloat(r.value) || 0,
|
||||||
|
count: parseInt(r.count, 10),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analisis de competidores mas frecuentes
|
||||||
|
*/
|
||||||
|
async getCompetitorFrequency(ctx: ServiceContext, filters?: DateRange): Promise<CompetitorFrequency[]> {
|
||||||
|
const qb = this.proposalRepository
|
||||||
|
.createQueryBuilder('p')
|
||||||
|
.leftJoin('p.vendor', 'v')
|
||||||
|
.select('v.business_name', 'companyName')
|
||||||
|
.addSelect('v.rfc', 'rfc')
|
||||||
|
.addSelect('COUNT(*)', 'appearances')
|
||||||
|
.addSelect("SUM(CASE WHEN p.status = 'winner' THEN 1 ELSE 0 END)", 'winsAgainstUs')
|
||||||
|
.addSelect("SUM(CASE WHEN p.status != 'winner' AND p.status = 'qualified' THEN 1 ELSE 0 END)", 'lossesAgainstUs')
|
||||||
|
.addSelect('AVG(CAST(p.proposed_amount AS DECIMAL))', 'avgProposedAmount')
|
||||||
|
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('p.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (filters?.dateFrom || filters?.dateTo) {
|
||||||
|
qb.innerJoin('p.tender', 't');
|
||||||
|
if (filters.dateFrom) {
|
||||||
|
qb.andWhere('t.created_at >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||||
|
}
|
||||||
|
if (filters.dateTo) {
|
||||||
|
qb.andWhere('t.created_at <= :dateTo', { dateTo: filters.dateTo });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await qb
|
||||||
|
.groupBy('v.business_name')
|
||||||
|
.addGroupBy('v.rfc')
|
||||||
|
.orderBy('COUNT(*)', 'DESC')
|
||||||
|
.limit(20)
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
return results.map(r => ({
|
||||||
|
companyName: r.companyName,
|
||||||
|
rfc: r.rfc,
|
||||||
|
appearances: parseInt(r.appearances, 10),
|
||||||
|
winsAgainstUs: parseInt(r.winsAgainstUs, 10),
|
||||||
|
lossesAgainstUs: parseInt(r.lossesAgainstUs, 10),
|
||||||
|
avgProposalDifference: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener promedios de licitaciones
|
||||||
|
*/
|
||||||
|
async getAverages(ctx: ServiceContext): Promise<AveragesResult> {
|
||||||
|
// Average bid size
|
||||||
|
const bidSizeResult = await this.tenderRepository
|
||||||
|
.createQueryBuilder('t')
|
||||||
|
.select('AVG(CAST(t.reference_amount AS DECIMAL))', 'avgBidSize')
|
||||||
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('t.deleted_at IS NULL')
|
||||||
|
.andWhere('t.reference_amount IS NOT NULL')
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
// Average time to award (from proposal deadline to award)
|
||||||
|
const timeToAwardResult = await this.tenderRepository
|
||||||
|
.createQueryBuilder('t')
|
||||||
|
.select('AVG(EXTRACT(DAY FROM (t.award_date - t.proposal_deadline)))', 'avgDays')
|
||||||
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('t.deleted_at IS NULL')
|
||||||
|
.andWhere('t.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'cancelled'] })
|
||||||
|
.andWhere('t.award_date IS NOT NULL')
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
// Average proposal count per tender
|
||||||
|
const proposalCountResult = await this.proposalRepository
|
||||||
|
.createQueryBuilder('p')
|
||||||
|
.select('AVG(cnt)', 'avgCount')
|
||||||
|
.from(subQuery => {
|
||||||
|
return subQuery
|
||||||
|
.select('p2.tender_id', 'tenderId')
|
||||||
|
.addSelect('COUNT(*)', 'cnt')
|
||||||
|
.from('bidding.proposals', 'p2')
|
||||||
|
.where('p2.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('p2.deleted_at IS NULL')
|
||||||
|
.groupBy('p2.tender_id');
|
||||||
|
}, 'counts')
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
return {
|
||||||
|
avgBidSize: parseFloat(bidSizeResult?.avgBidSize) || 0,
|
||||||
|
avgTimeToAward: Math.round(parseFloat(timeToAwardResult?.avgDays) || 0),
|
||||||
|
avgProposalCount: parseFloat(proposalCountResult?.avgCount) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener oportunidades por fuente
|
||||||
|
*/
|
||||||
|
async getOpportunitiesBySource(
|
||||||
|
ctx: ServiceContext,
|
||||||
|
dateFrom?: Date,
|
||||||
|
dateTo?: Date
|
||||||
|
): Promise<{ source: OpportunitySource; count: number; value: number; winRate: number }[]> {
|
||||||
|
const qb = this.opportunityRepository
|
||||||
|
.createQueryBuilder('o')
|
||||||
|
.select('o.source', 'source')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.addSelect('SUM(CAST(o.estimated_amount AS DECIMAL))', 'value')
|
||||||
|
.addSelect("COUNT(*) FILTER (WHERE o.status = 'converted')", 'won')
|
||||||
|
.addSelect("COUNT(*) FILTER (WHERE o.status IN ('converted', 'no_go'))", 'closed')
|
||||||
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('o.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
qb.andWhere('o.created_at >= :dateFrom', { dateFrom });
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
qb.andWhere('o.created_at <= :dateTo', { dateTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await qb.groupBy('o.source').getRawMany();
|
||||||
|
|
||||||
|
return results.map(r => ({
|
||||||
|
source: r.source as OpportunitySource,
|
||||||
|
count: parseInt(r.count, 10),
|
||||||
|
value: parseFloat(r.value) || 0,
|
||||||
|
winRate: parseInt(r.closed, 10) > 0
|
||||||
|
? (parseInt(r.won, 10) / parseInt(r.closed, 10)) * 100
|
||||||
|
: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@ -320,7 +562,7 @@ export interface BidDashboard {
|
|||||||
activeOpportunities: number;
|
activeOpportunities: number;
|
||||||
activeBids: number;
|
activeBids: number;
|
||||||
pipelineValue: number;
|
pipelineValue: number;
|
||||||
upcomingDeadlines: { id: string; name: string; deadline: Date; status: BidStatus }[];
|
upcomingDeadlines: { id: string; name: string; deadline: Date; status: TenderStatus }[];
|
||||||
winRate: number;
|
winRate: number;
|
||||||
wonValueYTD: number;
|
wonValueYTD: number;
|
||||||
}
|
}
|
||||||
@ -329,11 +571,10 @@ export interface PipelineBySource {
|
|||||||
source: OpportunitySource;
|
source: OpportunitySource;
|
||||||
count: number;
|
count: number;
|
||||||
totalValue: number;
|
totalValue: number;
|
||||||
weightedValue: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WinRateByType {
|
export interface WinRateByType {
|
||||||
bidType: BidType;
|
bidType: TenderType;
|
||||||
won: number;
|
won: number;
|
||||||
total: number;
|
total: number;
|
||||||
winRate: number;
|
winRate: number;
|
||||||
|
|||||||
@ -1,386 +0,0 @@
|
|||||||
/**
|
|
||||||
* BidBudgetService - Gestión de Presupuestos de Licitación
|
|
||||||
*
|
|
||||||
* CRUD y cálculos para propuestas económicas.
|
|
||||||
*
|
|
||||||
* @module Bidding
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
|
||||||
import { BidBudget, BudgetItemType, BudgetStatus } from '../entities/bid-budget.entity';
|
|
||||||
|
|
||||||
export interface CreateBudgetItemDto {
|
|
||||||
bidId: string;
|
|
||||||
parentId?: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
itemType: BudgetItemType;
|
|
||||||
unit?: string;
|
|
||||||
quantity?: number;
|
|
||||||
unitPrice?: number;
|
|
||||||
materialsCost?: number;
|
|
||||||
laborCost?: number;
|
|
||||||
equipmentCost?: number;
|
|
||||||
subcontractCost?: number;
|
|
||||||
indirectPercentage?: number;
|
|
||||||
profitPercentage?: number;
|
|
||||||
financingPercentage?: number;
|
|
||||||
baseAmount?: number;
|
|
||||||
catalogConceptId?: string;
|
|
||||||
notes?: string;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateBudgetItemDto extends Partial<CreateBudgetItemDto> {
|
|
||||||
status?: BudgetStatus;
|
|
||||||
adjustmentReason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BudgetFilters {
|
|
||||||
bidId: string;
|
|
||||||
itemType?: BudgetItemType;
|
|
||||||
status?: BudgetStatus;
|
|
||||||
parentId?: string | null;
|
|
||||||
isSummary?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BidBudgetService {
|
|
||||||
constructor(private readonly repository: Repository<BidBudget>) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crear item de presupuesto
|
|
||||||
*/
|
|
||||||
async create(ctx: ServiceContext, data: CreateBudgetItemDto): Promise<BidBudget> {
|
|
||||||
// Calcular nivel jerárquico
|
|
||||||
let level = 0;
|
|
||||||
if (data.parentId) {
|
|
||||||
const parent = await this.repository.findOne({
|
|
||||||
where: { id: data.parentId, tenantId: ctx.tenantId },
|
|
||||||
});
|
|
||||||
if (parent) {
|
|
||||||
level = parent.level + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcular orden
|
|
||||||
const lastItem = await this.repository
|
|
||||||
.createQueryBuilder('bb')
|
|
||||||
.where('bb.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
|
||||||
.andWhere('bb.bid_id = :bidId', { bidId: data.bidId })
|
|
||||||
.andWhere(data.parentId ? 'bb.parent_id = :parentId' : 'bb.parent_id IS NULL', { parentId: data.parentId })
|
|
||||||
.orderBy('bb.sort_order', 'DESC')
|
|
||||||
.getOne();
|
|
||||||
|
|
||||||
const sortOrder = lastItem ? lastItem.sortOrder + 1 : 0;
|
|
||||||
|
|
||||||
// Calcular totales
|
|
||||||
const quantity = data.quantity || 0;
|
|
||||||
const unitPrice = data.unitPrice || 0;
|
|
||||||
const totalAmount = quantity * unitPrice;
|
|
||||||
|
|
||||||
// Calcular varianza si hay base
|
|
||||||
let varianceAmount = null;
|
|
||||||
let variancePercentage = null;
|
|
||||||
if (data.baseAmount !== undefined && data.baseAmount > 0) {
|
|
||||||
varianceAmount = totalAmount - data.baseAmount;
|
|
||||||
variancePercentage = (varianceAmount / data.baseAmount) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = this.repository.create({
|
|
||||||
tenantId: ctx.tenantId,
|
|
||||||
bidId: data.bidId,
|
|
||||||
parentId: data.parentId,
|
|
||||||
code: data.code,
|
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
itemType: data.itemType,
|
|
||||||
unit: data.unit,
|
|
||||||
quantity: data.quantity || 0,
|
|
||||||
unitPrice: data.unitPrice || 0,
|
|
||||||
materialsCost: data.materialsCost,
|
|
||||||
laborCost: data.laborCost,
|
|
||||||
equipmentCost: data.equipmentCost,
|
|
||||||
subcontractCost: data.subcontractCost,
|
|
||||||
indirectPercentage: data.indirectPercentage,
|
|
||||||
profitPercentage: data.profitPercentage,
|
|
||||||
financingPercentage: data.financingPercentage,
|
|
||||||
baseAmount: data.baseAmount,
|
|
||||||
catalogConceptId: data.catalogConceptId,
|
|
||||||
notes: data.notes,
|
|
||||||
metadata: data.metadata,
|
|
||||||
level,
|
|
||||||
sortOrder,
|
|
||||||
totalAmount,
|
|
||||||
varianceAmount: varianceAmount ?? undefined,
|
|
||||||
variancePercentage: variancePercentage ?? undefined,
|
|
||||||
status: 'draft',
|
|
||||||
isSummary: false,
|
|
||||||
isCalculated: true,
|
|
||||||
createdBy: ctx.userId,
|
|
||||||
updatedBy: ctx.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const saved = await this.repository.save(item);
|
|
||||||
|
|
||||||
// Recalcular padres
|
|
||||||
if (data.parentId) {
|
|
||||||
await this.recalculateParent(ctx, data.parentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Buscar por ID
|
|
||||||
*/
|
|
||||||
async findById(ctx: ServiceContext, id: string): Promise<BidBudget | null> {
|
|
||||||
return this.repository.findOne({
|
|
||||||
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Buscar items de un presupuesto
|
|
||||||
*/
|
|
||||||
async findByBid(ctx: ServiceContext, bidId: string): Promise<BidBudget[]> {
|
|
||||||
return this.repository.find({
|
|
||||||
where: { bidId, tenantId: ctx.tenantId, deletedAt: undefined },
|
|
||||||
order: { sortOrder: 'ASC' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Buscar items con filtros
|
|
||||||
*/
|
|
||||||
async findWithFilters(
|
|
||||||
ctx: ServiceContext,
|
|
||||||
filters: BudgetFilters,
|
|
||||||
page = 1,
|
|
||||||
limit = 100
|
|
||||||
): Promise<PaginatedResult<BidBudget>> {
|
|
||||||
const qb = this.repository
|
|
||||||
.createQueryBuilder('bb')
|
|
||||||
.where('bb.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
|
||||||
.andWhere('bb.bid_id = :bidId', { bidId: filters.bidId })
|
|
||||||
.andWhere('bb.deleted_at IS NULL');
|
|
||||||
|
|
||||||
if (filters.itemType) {
|
|
||||||
qb.andWhere('bb.item_type = :itemType', { itemType: filters.itemType });
|
|
||||||
}
|
|
||||||
if (filters.status) {
|
|
||||||
qb.andWhere('bb.status = :status', { status: filters.status });
|
|
||||||
}
|
|
||||||
if (filters.parentId !== undefined) {
|
|
||||||
if (filters.parentId === null) {
|
|
||||||
qb.andWhere('bb.parent_id IS NULL');
|
|
||||||
} else {
|
|
||||||
qb.andWhere('bb.parent_id = :parentId', { parentId: filters.parentId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (filters.isSummary !== undefined) {
|
|
||||||
qb.andWhere('bb.is_summary = :isSummary', { isSummary: filters.isSummary });
|
|
||||||
}
|
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
qb.orderBy('bb.sort_order', 'ASC').skip(skip).take(limit);
|
|
||||||
|
|
||||||
const [data, total] = await qb.getManyAndCount();
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtener árbol jerárquico
|
|
||||||
*/
|
|
||||||
async getTree(ctx: ServiceContext, bidId: string): Promise<BidBudget[]> {
|
|
||||||
const items = await this.findByBid(ctx, bidId);
|
|
||||||
return this.buildTree(items);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildTree(items: BidBudget[], parentId: string | null = null): (BidBudget & { children?: BidBudget[] })[] {
|
|
||||||
return items
|
|
||||||
.filter((item) => item.parentId === parentId)
|
|
||||||
.map((item) => ({
|
|
||||||
...item,
|
|
||||||
children: this.buildTree(items, item.id),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Actualizar item
|
|
||||||
*/
|
|
||||||
async update(ctx: ServiceContext, id: string, data: UpdateBudgetItemDto): Promise<BidBudget | null> {
|
|
||||||
const item = await this.findById(ctx, id);
|
|
||||||
if (!item) return null;
|
|
||||||
|
|
||||||
// Recalcular totales si cambian cantidad o precio
|
|
||||||
const quantity = data.quantity ?? item.quantity;
|
|
||||||
const unitPrice = data.unitPrice ?? item.unitPrice;
|
|
||||||
const totalAmount = quantity * unitPrice;
|
|
||||||
|
|
||||||
// Recalcular varianza
|
|
||||||
const baseAmount = data.baseAmount ?? item.baseAmount;
|
|
||||||
let varianceAmount = item.varianceAmount;
|
|
||||||
let variancePercentage = item.variancePercentage;
|
|
||||||
if (baseAmount !== undefined && baseAmount > 0) {
|
|
||||||
varianceAmount = totalAmount - baseAmount;
|
|
||||||
variancePercentage = (varianceAmount / baseAmount) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marcar como ajustado si hay razón
|
|
||||||
const isAdjusted = data.adjustmentReason ? true : item.isAdjusted;
|
|
||||||
|
|
||||||
Object.assign(item, {
|
|
||||||
...data,
|
|
||||||
totalAmount,
|
|
||||||
varianceAmount,
|
|
||||||
variancePercentage,
|
|
||||||
isAdjusted,
|
|
||||||
isCalculated: true,
|
|
||||||
updatedBy: ctx.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const saved = await this.repository.save(item);
|
|
||||||
|
|
||||||
// Recalcular padres
|
|
||||||
if (item.parentId) {
|
|
||||||
await this.recalculateParent(ctx, item.parentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recalcular item padre
|
|
||||||
*/
|
|
||||||
private async recalculateParent(ctx: ServiceContext, parentId: string): Promise<void> {
|
|
||||||
const parent = await this.findById(ctx, parentId);
|
|
||||||
if (!parent) return;
|
|
||||||
|
|
||||||
const children = await this.repository.find({
|
|
||||||
where: { parentId, tenantId: ctx.tenantId, deletedAt: undefined },
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalAmount = children.reduce((sum, child) => sum + (Number(child.totalAmount) || 0), 0);
|
|
||||||
|
|
||||||
parent.totalAmount = totalAmount;
|
|
||||||
parent.isSummary = children.length > 0;
|
|
||||||
parent.isCalculated = true;
|
|
||||||
parent.updatedBy = ctx.userId;
|
|
||||||
|
|
||||||
// Recalcular varianza
|
|
||||||
if (parent.baseAmount !== undefined && parent.baseAmount > 0) {
|
|
||||||
parent.varianceAmount = totalAmount - Number(parent.baseAmount);
|
|
||||||
parent.variancePercentage = (parent.varianceAmount / Number(parent.baseAmount)) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.repository.save(parent);
|
|
||||||
|
|
||||||
// Recursivamente actualizar ancestros
|
|
||||||
if (parent.parentId) {
|
|
||||||
await this.recalculateParent(ctx, parent.parentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtener resumen de presupuesto
|
|
||||||
*/
|
|
||||||
async getSummary(ctx: ServiceContext, bidId: string): Promise<BudgetSummary> {
|
|
||||||
const items = await this.findByBid(ctx, bidId);
|
|
||||||
|
|
||||||
const directCosts = items
|
|
||||||
.filter((i) => i.itemType === 'direct_cost' || i.itemType === 'labor' || i.itemType === 'materials' || i.itemType === 'equipment' || i.itemType === 'subcontract')
|
|
||||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
|
||||||
|
|
||||||
const indirectCosts = items
|
|
||||||
.filter((i) => i.itemType === 'indirect_cost' || i.itemType === 'overhead')
|
|
||||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
|
||||||
|
|
||||||
const profit = items
|
|
||||||
.filter((i) => i.itemType === 'profit')
|
|
||||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
|
||||||
|
|
||||||
const financing = items
|
|
||||||
.filter((i) => i.itemType === 'financing')
|
|
||||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
|
||||||
|
|
||||||
const taxes = items
|
|
||||||
.filter((i) => i.itemType === 'taxes')
|
|
||||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
|
||||||
|
|
||||||
const bonds = items
|
|
||||||
.filter((i) => i.itemType === 'bonds')
|
|
||||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
|
||||||
|
|
||||||
const contingency = items
|
|
||||||
.filter((i) => i.itemType === 'contingency')
|
|
||||||
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
|
|
||||||
|
|
||||||
const subtotal = directCosts + indirectCosts + profit + financing + contingency + bonds;
|
|
||||||
const total = subtotal + taxes;
|
|
||||||
|
|
||||||
const baseTotal = items.reduce((sum, i) => sum + (Number(i.baseAmount) || 0), 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
directCosts,
|
|
||||||
indirectCosts,
|
|
||||||
profit,
|
|
||||||
financing,
|
|
||||||
taxes,
|
|
||||||
bonds,
|
|
||||||
contingency,
|
|
||||||
subtotal,
|
|
||||||
total,
|
|
||||||
baseTotal,
|
|
||||||
variance: total - baseTotal,
|
|
||||||
variancePercentage: baseTotal > 0 ? ((total - baseTotal) / baseTotal) * 100 : 0,
|
|
||||||
itemCount: items.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cambiar estado del presupuesto
|
|
||||||
*/
|
|
||||||
async changeStatus(ctx: ServiceContext, bidId: string, status: BudgetStatus): Promise<number> {
|
|
||||||
const result = await this.repository.update(
|
|
||||||
{ bidId, tenantId: ctx.tenantId, deletedAt: undefined },
|
|
||||||
{ status, updatedBy: ctx.userId }
|
|
||||||
);
|
|
||||||
return result.affected || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Soft delete
|
|
||||||
*/
|
|
||||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
|
||||||
const result = await this.repository.update(
|
|
||||||
{ id, tenantId: ctx.tenantId },
|
|
||||||
{ deletedAt: new Date(), updatedBy: ctx.userId }
|
|
||||||
);
|
|
||||||
return (result.affected || 0) > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BudgetSummary {
|
|
||||||
directCosts: number;
|
|
||||||
indirectCosts: number;
|
|
||||||
profit: number;
|
|
||||||
financing: number;
|
|
||||||
taxes: number;
|
|
||||||
bonds: number;
|
|
||||||
contingency: number;
|
|
||||||
subtotal: number;
|
|
||||||
total: number;
|
|
||||||
baseTotal: number;
|
|
||||||
variance: number;
|
|
||||||
variancePercentage: number;
|
|
||||||
itemCount: number;
|
|
||||||
}
|
|
||||||
@ -1,382 +0,0 @@
|
|||||||
/**
|
|
||||||
* BidService - Gestión de Licitaciones
|
|
||||||
*
|
|
||||||
* CRUD y lógica de negocio para licitaciones/propuestas.
|
|
||||||
*
|
|
||||||
* @module Bidding
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Repository, In, Between } from 'typeorm';
|
|
||||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
|
||||||
import { Bid, BidType, BidStatus, BidStage } from '../entities/bid.entity';
|
|
||||||
|
|
||||||
export interface CreateBidDto {
|
|
||||||
opportunityId: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
bidType: BidType;
|
|
||||||
tenderNumber?: string;
|
|
||||||
tenderName?: string;
|
|
||||||
contractingEntity?: string;
|
|
||||||
publicationDate?: Date;
|
|
||||||
siteVisitDate?: Date;
|
|
||||||
clarificationDeadline?: Date;
|
|
||||||
submissionDeadline: Date;
|
|
||||||
openingDate?: Date;
|
|
||||||
baseBudget?: number;
|
|
||||||
currency?: string;
|
|
||||||
technicalWeight?: number;
|
|
||||||
economicWeight?: number;
|
|
||||||
bidBondAmount?: number;
|
|
||||||
bidManagerId?: string;
|
|
||||||
notes?: string;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateBidDto extends Partial<CreateBidDto> {
|
|
||||||
status?: BidStatus;
|
|
||||||
stage?: BidStage;
|
|
||||||
ourProposalAmount?: number;
|
|
||||||
technicalScore?: number;
|
|
||||||
economicScore?: number;
|
|
||||||
finalScore?: number;
|
|
||||||
rankingPosition?: number;
|
|
||||||
bidBondNumber?: string;
|
|
||||||
bidBondExpiry?: Date;
|
|
||||||
awardDate?: Date;
|
|
||||||
contractSigningDate?: Date;
|
|
||||||
winnerName?: string;
|
|
||||||
winningAmount?: number;
|
|
||||||
rejectionReason?: string;
|
|
||||||
lessonsLearned?: string;
|
|
||||||
completionPercentage?: number;
|
|
||||||
checklist?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BidFilters {
|
|
||||||
status?: BidStatus | BidStatus[];
|
|
||||||
bidType?: BidType;
|
|
||||||
stage?: BidStage;
|
|
||||||
opportunityId?: string;
|
|
||||||
bidManagerId?: string;
|
|
||||||
contractingEntity?: string;
|
|
||||||
deadlineFrom?: Date;
|
|
||||||
deadlineTo?: Date;
|
|
||||||
minBudget?: number;
|
|
||||||
maxBudget?: number;
|
|
||||||
search?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BidService {
|
|
||||||
constructor(private readonly repository: Repository<Bid>) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crear licitación
|
|
||||||
*/
|
|
||||||
async create(ctx: ServiceContext, data: CreateBidDto): Promise<Bid> {
|
|
||||||
const bid = this.repository.create({
|
|
||||||
tenantId: ctx.tenantId,
|
|
||||||
...data,
|
|
||||||
status: 'draft',
|
|
||||||
stage: 'initial',
|
|
||||||
completionPercentage: 0,
|
|
||||||
createdBy: ctx.userId,
|
|
||||||
updatedBy: ctx.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.repository.save(bid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Buscar por ID
|
|
||||||
*/
|
|
||||||
async findById(ctx: ServiceContext, id: string): Promise<Bid | null> {
|
|
||||||
return this.repository.findOne({
|
|
||||||
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
|
|
||||||
relations: ['opportunity', 'bidManager', 'documents', 'calendarEvents', 'teamMembers'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Buscar con filtros
|
|
||||||
*/
|
|
||||||
async findWithFilters(
|
|
||||||
ctx: ServiceContext,
|
|
||||||
filters: BidFilters,
|
|
||||||
page = 1,
|
|
||||||
limit = 20
|
|
||||||
): Promise<PaginatedResult<Bid>> {
|
|
||||||
const qb = this.repository
|
|
||||||
.createQueryBuilder('b')
|
|
||||||
.leftJoinAndSelect('b.opportunity', 'o')
|
|
||||||
.leftJoinAndSelect('b.bidManager', 'm')
|
|
||||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
|
||||||
.andWhere('b.deleted_at IS NULL');
|
|
||||||
|
|
||||||
if (filters.status) {
|
|
||||||
if (Array.isArray(filters.status)) {
|
|
||||||
qb.andWhere('b.status IN (:...statuses)', { statuses: filters.status });
|
|
||||||
} else {
|
|
||||||
qb.andWhere('b.status = :status', { status: filters.status });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (filters.bidType) {
|
|
||||||
qb.andWhere('b.bid_type = :bidType', { bidType: filters.bidType });
|
|
||||||
}
|
|
||||||
if (filters.stage) {
|
|
||||||
qb.andWhere('b.stage = :stage', { stage: filters.stage });
|
|
||||||
}
|
|
||||||
if (filters.opportunityId) {
|
|
||||||
qb.andWhere('b.opportunity_id = :opportunityId', { opportunityId: filters.opportunityId });
|
|
||||||
}
|
|
||||||
if (filters.bidManagerId) {
|
|
||||||
qb.andWhere('b.bid_manager_id = :bidManagerId', { bidManagerId: filters.bidManagerId });
|
|
||||||
}
|
|
||||||
if (filters.contractingEntity) {
|
|
||||||
qb.andWhere('b.contracting_entity ILIKE :entity', { entity: `%${filters.contractingEntity}%` });
|
|
||||||
}
|
|
||||||
if (filters.deadlineFrom) {
|
|
||||||
qb.andWhere('b.submission_deadline >= :deadlineFrom', { deadlineFrom: filters.deadlineFrom });
|
|
||||||
}
|
|
||||||
if (filters.deadlineTo) {
|
|
||||||
qb.andWhere('b.submission_deadline <= :deadlineTo', { deadlineTo: filters.deadlineTo });
|
|
||||||
}
|
|
||||||
if (filters.minBudget !== undefined) {
|
|
||||||
qb.andWhere('b.base_budget >= :minBudget', { minBudget: filters.minBudget });
|
|
||||||
}
|
|
||||||
if (filters.maxBudget !== undefined) {
|
|
||||||
qb.andWhere('b.base_budget <= :maxBudget', { maxBudget: filters.maxBudget });
|
|
||||||
}
|
|
||||||
if (filters.search) {
|
|
||||||
qb.andWhere(
|
|
||||||
'(b.name ILIKE :search OR b.code ILIKE :search OR b.tender_number ILIKE :search)',
|
|
||||||
{ search: `%${filters.search}%` }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
qb.orderBy('b.submission_deadline', 'ASC').skip(skip).take(limit);
|
|
||||||
|
|
||||||
const [data, total] = await qb.getManyAndCount();
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Actualizar licitación
|
|
||||||
*/
|
|
||||||
async update(ctx: ServiceContext, id: string, data: UpdateBidDto): Promise<Bid | null> {
|
|
||||||
const bid = await this.findById(ctx, id);
|
|
||||||
if (!bid) return null;
|
|
||||||
|
|
||||||
// Calcular puntuación final si hay scores
|
|
||||||
let finalScore = data.finalScore ?? bid.finalScore;
|
|
||||||
const techScore = data.technicalScore ?? bid.technicalScore;
|
|
||||||
const econScore = data.economicScore ?? bid.economicScore;
|
|
||||||
const techWeight = data.technicalWeight ?? bid.technicalWeight;
|
|
||||||
const econWeight = data.economicWeight ?? bid.economicWeight;
|
|
||||||
|
|
||||||
if (techScore !== undefined && econScore !== undefined) {
|
|
||||||
finalScore = (techScore * techWeight / 100) + (econScore * econWeight / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(bid, {
|
|
||||||
...data,
|
|
||||||
finalScore,
|
|
||||||
updatedBy: ctx.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.repository.save(bid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cambiar estado
|
|
||||||
*/
|
|
||||||
async changeStatus(ctx: ServiceContext, id: string, status: BidStatus): Promise<Bid | null> {
|
|
||||||
const bid = await this.findById(ctx, id);
|
|
||||||
if (!bid) return null;
|
|
||||||
|
|
||||||
bid.status = status;
|
|
||||||
bid.updatedBy = ctx.userId;
|
|
||||||
|
|
||||||
return this.repository.save(bid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cambiar etapa
|
|
||||||
*/
|
|
||||||
async changeStage(ctx: ServiceContext, id: string, stage: BidStage): Promise<Bid | null> {
|
|
||||||
const bid = await this.findById(ctx, id);
|
|
||||||
if (!bid) return null;
|
|
||||||
|
|
||||||
bid.stage = stage;
|
|
||||||
bid.updatedBy = ctx.userId;
|
|
||||||
|
|
||||||
return this.repository.save(bid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marcar como presentada
|
|
||||||
*/
|
|
||||||
async submit(ctx: ServiceContext, id: string, proposalAmount: number): Promise<Bid | null> {
|
|
||||||
const bid = await this.findById(ctx, id);
|
|
||||||
if (!bid) return null;
|
|
||||||
|
|
||||||
bid.status = 'submitted';
|
|
||||||
bid.stage = 'post_submission';
|
|
||||||
bid.ourProposalAmount = proposalAmount;
|
|
||||||
bid.updatedBy = ctx.userId;
|
|
||||||
|
|
||||||
return this.repository.save(bid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registrar resultado
|
|
||||||
*/
|
|
||||||
async recordResult(
|
|
||||||
ctx: ServiceContext,
|
|
||||||
id: string,
|
|
||||||
won: boolean,
|
|
||||||
details: {
|
|
||||||
winnerName?: string;
|
|
||||||
winningAmount?: number;
|
|
||||||
rankingPosition?: number;
|
|
||||||
rejectionReason?: string;
|
|
||||||
lessonsLearned?: string;
|
|
||||||
}
|
|
||||||
): Promise<Bid | null> {
|
|
||||||
const bid = await this.findById(ctx, id);
|
|
||||||
if (!bid) return null;
|
|
||||||
|
|
||||||
bid.status = won ? 'awarded' : 'rejected';
|
|
||||||
bid.awardDate = new Date();
|
|
||||||
Object.assign(bid, details);
|
|
||||||
bid.updatedBy = ctx.userId;
|
|
||||||
|
|
||||||
return this.repository.save(bid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convertir a proyecto
|
|
||||||
*/
|
|
||||||
async convertToProject(ctx: ServiceContext, id: string, projectId: string): Promise<Bid | null> {
|
|
||||||
const bid = await this.findById(ctx, id);
|
|
||||||
if (!bid || bid.status !== 'awarded') return null;
|
|
||||||
|
|
||||||
bid.convertedToProjectId = projectId;
|
|
||||||
bid.convertedAt = new Date();
|
|
||||||
bid.updatedBy = ctx.userId;
|
|
||||||
|
|
||||||
return this.repository.save(bid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtener próximas fechas límite
|
|
||||||
*/
|
|
||||||
async getUpcomingDeadlines(ctx: ServiceContext, days = 7): Promise<Bid[]> {
|
|
||||||
const now = new Date();
|
|
||||||
const future = new Date();
|
|
||||||
future.setDate(future.getDate() + days);
|
|
||||||
|
|
||||||
return this.repository.find({
|
|
||||||
where: {
|
|
||||||
tenantId: ctx.tenantId,
|
|
||||||
deletedAt: undefined,
|
|
||||||
status: In(['draft', 'preparation', 'review', 'approved']),
|
|
||||||
submissionDeadline: Between(now, future),
|
|
||||||
},
|
|
||||||
relations: ['opportunity', 'bidManager'],
|
|
||||||
order: { submissionDeadline: 'ASC' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtener estadísticas
|
|
||||||
*/
|
|
||||||
async getStats(ctx: ServiceContext, year?: number): Promise<BidStats> {
|
|
||||||
const currentYear = year || new Date().getFullYear();
|
|
||||||
const startDate = new Date(currentYear, 0, 1);
|
|
||||||
const endDate = new Date(currentYear, 11, 31);
|
|
||||||
|
|
||||||
const total = await this.repository
|
|
||||||
.createQueryBuilder('b')
|
|
||||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
|
||||||
.andWhere('b.deleted_at IS NULL')
|
|
||||||
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
|
||||||
.getCount();
|
|
||||||
|
|
||||||
const byStatus = await this.repository
|
|
||||||
.createQueryBuilder('b')
|
|
||||||
.select('b.status', 'status')
|
|
||||||
.addSelect('COUNT(*)', 'count')
|
|
||||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
|
||||||
.andWhere('b.deleted_at IS NULL')
|
|
||||||
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
|
||||||
.groupBy('b.status')
|
|
||||||
.getRawMany();
|
|
||||||
|
|
||||||
const byType = await this.repository
|
|
||||||
.createQueryBuilder('b')
|
|
||||||
.select('b.bid_type', 'bidType')
|
|
||||||
.addSelect('COUNT(*)', 'count')
|
|
||||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
|
||||||
.andWhere('b.deleted_at IS NULL')
|
|
||||||
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
|
||||||
.groupBy('b.bid_type')
|
|
||||||
.getRawMany();
|
|
||||||
|
|
||||||
const valueStats = await this.repository
|
|
||||||
.createQueryBuilder('b')
|
|
||||||
.select('SUM(b.base_budget)', 'totalBudget')
|
|
||||||
.addSelect('SUM(b.our_proposal_amount)', 'totalProposed')
|
|
||||||
.addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'totalWon')
|
|
||||||
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
|
||||||
.andWhere('b.deleted_at IS NULL')
|
|
||||||
.andWhere('b.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
|
||||||
.getRawOne();
|
|
||||||
|
|
||||||
const awardedCount = byStatus.find((s) => s.status === 'awarded')?.count || 0;
|
|
||||||
const rejectedCount = byStatus.find((s) => s.status === 'rejected')?.count || 0;
|
|
||||||
const closedCount = parseInt(awardedCount) + parseInt(rejectedCount);
|
|
||||||
|
|
||||||
return {
|
|
||||||
year: currentYear,
|
|
||||||
total,
|
|
||||||
byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })),
|
|
||||||
byType: byType.map((r) => ({ bidType: r.bidType, count: parseInt(r.count) })),
|
|
||||||
totalBudget: parseFloat(valueStats?.totalBudget) || 0,
|
|
||||||
totalProposed: parseFloat(valueStats?.totalProposed) || 0,
|
|
||||||
totalWon: parseFloat(valueStats?.totalWon) || 0,
|
|
||||||
winRate: closedCount > 0 ? (parseInt(awardedCount) / closedCount) * 100 : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Soft delete
|
|
||||||
*/
|
|
||||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
|
||||||
const result = await this.repository.update(
|
|
||||||
{ id, tenantId: ctx.tenantId },
|
|
||||||
{ deletedAt: new Date(), updatedBy: ctx.userId }
|
|
||||||
);
|
|
||||||
return (result.affected || 0) > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BidStats {
|
|
||||||
year: number;
|
|
||||||
total: number;
|
|
||||||
byStatus: { status: BidStatus; count: number }[];
|
|
||||||
byType: { bidType: BidType; count: number }[];
|
|
||||||
totalBudget: number;
|
|
||||||
totalProposed: number;
|
|
||||||
totalWon: number;
|
|
||||||
winRate: number;
|
|
||||||
}
|
|
||||||
@ -1,9 +1,64 @@
|
|||||||
/**
|
/**
|
||||||
* Bidding Services Index
|
* Bidding Services Index - MAI-018 Preconstrucción/Licitaciones
|
||||||
* @module Bidding
|
*
|
||||||
|
* Exporta todos los servicios del módulo de licitaciones.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters, PipelineData, OpportunityStats } from './opportunity.service';
|
// Opportunity Service
|
||||||
export { BidService, CreateBidDto, UpdateBidDto, BidFilters, BidStats } from './bid.service';
|
export {
|
||||||
export { BidBudgetService, CreateBudgetItemDto, UpdateBudgetItemDto, BudgetFilters, BudgetSummary } from './bid-budget.service';
|
OpportunityService,
|
||||||
export { BidAnalyticsService, BidDashboard, PipelineBySource, WinRateByType, MonthlyTrend, CompetitorAnalysis, FunnelAnalysis, CycleTimeAnalysis } from './bid-analytics.service';
|
CreateOpportunityDto,
|
||||||
|
UpdateOpportunityDto,
|
||||||
|
OpportunityFilters,
|
||||||
|
GoNoGoDecisionDto,
|
||||||
|
PipelineData,
|
||||||
|
OpportunityStats,
|
||||||
|
} from './opportunity.service';
|
||||||
|
|
||||||
|
// Tender Service (Bid wrapper with tender-specific logic)
|
||||||
|
export {
|
||||||
|
TenderService,
|
||||||
|
CreateTenderDto,
|
||||||
|
UpdateTenderDto,
|
||||||
|
TenderFilters,
|
||||||
|
TenderStats,
|
||||||
|
} from './tender.service';
|
||||||
|
|
||||||
|
// Proposal Service (BidCompetitor wrapper for proposals)
|
||||||
|
export {
|
||||||
|
ProposalService,
|
||||||
|
CreateProposalDto,
|
||||||
|
UpdateProposalDto,
|
||||||
|
EvaluateProposalDto,
|
||||||
|
ProposalFilters,
|
||||||
|
RankedProposal,
|
||||||
|
} from './proposal.service';
|
||||||
|
|
||||||
|
// Vendor Service (Vendor/Supplier registry)
|
||||||
|
export {
|
||||||
|
VendorService,
|
||||||
|
CreateVendorDto,
|
||||||
|
UpdateVendorDto,
|
||||||
|
VendorFilters,
|
||||||
|
VendorRecord,
|
||||||
|
VendorPerformanceHistory,
|
||||||
|
} from './vendor.service';
|
||||||
|
|
||||||
|
// Bid Analytics Service
|
||||||
|
export {
|
||||||
|
BidAnalyticsService,
|
||||||
|
BidDashboard,
|
||||||
|
PipelineBySource,
|
||||||
|
WinRateByType,
|
||||||
|
MonthlyTrend,
|
||||||
|
CompetitorAnalysis,
|
||||||
|
FunnelAnalysis,
|
||||||
|
CycleTimeAnalysis,
|
||||||
|
WinRateResult,
|
||||||
|
PipelineValueResult,
|
||||||
|
CompetitorFrequency,
|
||||||
|
AveragesResult,
|
||||||
|
DateRange,
|
||||||
|
} from './bid-analytics.service';
|
||||||
|
|||||||
@ -2,123 +2,102 @@
|
|||||||
* OpportunityService - Gestión de Oportunidades de Negocio
|
* OpportunityService - Gestión de Oportunidades de Negocio
|
||||||
*
|
*
|
||||||
* CRUD y lógica de negocio para el pipeline de oportunidades.
|
* CRUD y lógica de negocio para el pipeline de oportunidades.
|
||||||
|
* Gestiona el ciclo de vida completo desde identificación hasta decisión go/no-go.
|
||||||
*
|
*
|
||||||
* @module Bidding
|
* @module Bidding (MAI-018)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Repository, In, Between } from 'typeorm';
|
import { Repository, In, Between } from 'typeorm';
|
||||||
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||||
import { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority, ProjectType } from '../entities/opportunity.entity';
|
import { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority } from '../entities/opportunity.entity';
|
||||||
|
|
||||||
export interface CreateOpportunityDto {
|
export interface CreateOpportunityDto {
|
||||||
code: string;
|
title: string;
|
||||||
name: string;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
source: OpportunitySource;
|
source: OpportunitySource;
|
||||||
projectType: ProjectType;
|
projectType: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
clientContact?: string;
|
|
||||||
clientEmail?: string;
|
|
||||||
clientPhone?: string;
|
|
||||||
clientType?: string;
|
|
||||||
location?: string;
|
location?: string;
|
||||||
state?: string;
|
estimatedAmount?: number;
|
||||||
city?: string;
|
estimatedUnits?: number;
|
||||||
estimatedValue?: number;
|
priority?: OpportunityPriority;
|
||||||
currency?: string;
|
deadlineDate: Date;
|
||||||
constructionAreaM2?: number;
|
}
|
||||||
landAreaM2?: number;
|
|
||||||
identificationDate: Date;
|
export interface GoNoGoDecisionDto {
|
||||||
deadlineDate?: Date;
|
decision: 'go' | 'no_go';
|
||||||
expectedAwardDate?: Date;
|
reason: string;
|
||||||
expectedStartDate?: Date;
|
|
||||||
expectedDurationMonths?: number;
|
|
||||||
winProbability?: number;
|
|
||||||
requiresBond?: boolean;
|
|
||||||
requiresExperience?: boolean;
|
|
||||||
minimumExperienceYears?: number;
|
|
||||||
minimumCapital?: number;
|
|
||||||
requiredCertifications?: string[];
|
|
||||||
assignedToId?: string;
|
|
||||||
sourceUrl?: string;
|
|
||||||
sourceReference?: string;
|
|
||||||
notes?: string;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateOpportunityDto extends Partial<CreateOpportunityDto> {
|
export interface UpdateOpportunityDto extends Partial<CreateOpportunityDto> {
|
||||||
status?: OpportunityStatus;
|
status?: OpportunityStatus;
|
||||||
priority?: OpportunityPriority;
|
|
||||||
lossReason?: string;
|
|
||||||
winFactors?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpportunityFilters {
|
export interface OpportunityFilters {
|
||||||
status?: OpportunityStatus | OpportunityStatus[];
|
status?: OpportunityStatus | OpportunityStatus[];
|
||||||
source?: OpportunitySource;
|
source?: OpportunitySource;
|
||||||
projectType?: ProjectType;
|
projectType?: string;
|
||||||
priority?: OpportunityPriority;
|
priority?: OpportunityPriority;
|
||||||
assignedToId?: string;
|
|
||||||
clientName?: string;
|
clientName?: string;
|
||||||
state?: string;
|
|
||||||
dateFrom?: Date;
|
dateFrom?: Date;
|
||||||
dateTo?: Date;
|
dateTo?: Date;
|
||||||
minValue?: number;
|
minValue?: number;
|
||||||
maxValue?: number;
|
maxValue?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpportunityService {
|
export class OpportunityService {
|
||||||
constructor(private readonly repository: Repository<Opportunity>) {}
|
constructor(private readonly repository: Repository<Opportunity>) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crear oportunidad
|
* Genera código automático para oportunidad: OPP-YYYY-NNN
|
||||||
|
*/
|
||||||
|
private async generateCode(ctx: ServiceContext): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const prefix = `OPP-${year}-`;
|
||||||
|
|
||||||
|
const lastOpportunity = await this.repository
|
||||||
|
.createQueryBuilder('o')
|
||||||
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('o.code LIKE :prefix', { prefix: `${prefix}%` })
|
||||||
|
.orderBy('o.code', 'DESC')
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
let sequence = 1;
|
||||||
|
if (lastOpportunity) {
|
||||||
|
const lastSequence = parseInt(lastOpportunity.code.replace(prefix, ''), 10);
|
||||||
|
if (!isNaN(lastSequence)) {
|
||||||
|
sequence = lastSequence + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${sequence.toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear oportunidad con código auto-generado
|
||||||
*/
|
*/
|
||||||
async create(ctx: ServiceContext, data: CreateOpportunityDto): Promise<Opportunity> {
|
async create(ctx: ServiceContext, data: CreateOpportunityDto): Promise<Opportunity> {
|
||||||
const weightedValue = data.estimatedValue && data.winProbability
|
const code = await this.generateCode(ctx);
|
||||||
? data.estimatedValue * (data.winProbability / 100)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const opportunity = this.repository.create({
|
const opportunity = this.repository.create({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
code: data.code,
|
code,
|
||||||
name: data.name,
|
title: data.title,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
source: data.source,
|
source: data.source,
|
||||||
projectType: data.projectType,
|
projectType: data.projectType,
|
||||||
clientName: data.clientName,
|
clientName: data.clientName,
|
||||||
clientContact: data.clientContact,
|
|
||||||
clientEmail: data.clientEmail,
|
|
||||||
clientPhone: data.clientPhone,
|
|
||||||
clientType: data.clientType,
|
|
||||||
location: data.location,
|
location: data.location,
|
||||||
state: data.state,
|
estimatedAmount: data.estimatedAmount?.toString(),
|
||||||
city: data.city,
|
estimatedUnits: data.estimatedUnits,
|
||||||
estimatedValue: data.estimatedValue,
|
|
||||||
currency: data.currency || 'MXN',
|
|
||||||
constructionAreaM2: data.constructionAreaM2,
|
|
||||||
landAreaM2: data.landAreaM2,
|
|
||||||
identificationDate: data.identificationDate,
|
|
||||||
deadlineDate: data.deadlineDate,
|
deadlineDate: data.deadlineDate,
|
||||||
expectedAwardDate: data.expectedAwardDate,
|
status: 'registered' as OpportunityStatus,
|
||||||
expectedStartDate: data.expectedStartDate,
|
priority: data.priority || 'medium',
|
||||||
expectedDurationMonths: data.expectedDurationMonths,
|
createdById: ctx.userId,
|
||||||
winProbability: data.winProbability || 0,
|
updatedById: ctx.userId,
|
||||||
requiresBond: data.requiresBond || false,
|
|
||||||
requiresExperience: data.requiresExperience || false,
|
|
||||||
minimumExperienceYears: data.minimumExperienceYears,
|
|
||||||
minimumCapital: data.minimumCapital,
|
|
||||||
requiredCertifications: data.requiredCertifications,
|
|
||||||
assignedToId: data.assignedToId,
|
|
||||||
sourceUrl: data.sourceUrl,
|
|
||||||
sourceReference: data.sourceReference,
|
|
||||||
notes: data.notes,
|
|
||||||
metadata: data.metadata,
|
|
||||||
status: 'identified',
|
|
||||||
priority: 'medium',
|
|
||||||
weightedValue,
|
|
||||||
createdBy: ctx.userId,
|
|
||||||
updatedBy: ctx.userId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.repository.save(opportunity);
|
return this.repository.save(opportunity);
|
||||||
@ -130,19 +109,29 @@ export class OpportunityService {
|
|||||||
async findById(ctx: ServiceContext, id: string): Promise<Opportunity | null> {
|
async findById(ctx: ServiceContext, id: string): Promise<Opportunity | null> {
|
||||||
return this.repository.findOne({
|
return this.repository.findOne({
|
||||||
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
|
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||||
relations: ['assignedTo', 'bids'],
|
relations: ['tenders'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Buscar con filtros
|
* Buscar por código
|
||||||
*/
|
*/
|
||||||
async findWithFilters(
|
async findByCode(ctx: ServiceContext, code: string): Promise<Opportunity | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { code, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||||
|
relations: ['tenders'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar con filtros y paginación
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
ctx: ServiceContext,
|
ctx: ServiceContext,
|
||||||
filters: OpportunityFilters,
|
filters: OpportunityFilters = {}
|
||||||
page = 1,
|
|
||||||
limit = 20
|
|
||||||
): Promise<PaginatedResult<Opportunity>> {
|
): Promise<PaginatedResult<Opportunity>> {
|
||||||
|
const page = filters.page || 1;
|
||||||
|
const limit = filters.limit || 20;
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.leftJoinAndSelect('o.assignedTo', 'u')
|
.leftJoinAndSelect('o.assignedTo', 'u')
|
||||||
@ -165,15 +154,9 @@ export class OpportunityService {
|
|||||||
if (filters.priority) {
|
if (filters.priority) {
|
||||||
qb.andWhere('o.priority = :priority', { priority: filters.priority });
|
qb.andWhere('o.priority = :priority', { priority: filters.priority });
|
||||||
}
|
}
|
||||||
if (filters.assignedToId) {
|
|
||||||
qb.andWhere('o.assigned_to_id = :assignedToId', { assignedToId: filters.assignedToId });
|
|
||||||
}
|
|
||||||
if (filters.clientName) {
|
if (filters.clientName) {
|
||||||
qb.andWhere('o.client_name ILIKE :clientName', { clientName: `%${filters.clientName}%` });
|
qb.andWhere('o.client_name ILIKE :clientName', { clientName: `%${filters.clientName}%` });
|
||||||
}
|
}
|
||||||
if (filters.state) {
|
|
||||||
qb.andWhere('o.state = :state', { state: filters.state });
|
|
||||||
}
|
|
||||||
if (filters.dateFrom) {
|
if (filters.dateFrom) {
|
||||||
qb.andWhere('o.identification_date >= :dateFrom', { dateFrom: filters.dateFrom });
|
qb.andWhere('o.identification_date >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||||
}
|
}
|
||||||
@ -214,20 +197,21 @@ export class OpportunityService {
|
|||||||
const opportunity = await this.findById(ctx, id);
|
const opportunity = await this.findById(ctx, id);
|
||||||
if (!opportunity) return null;
|
if (!opportunity) return null;
|
||||||
|
|
||||||
// Recalcular weighted value si cambian los factores
|
if (data.title !== undefined) opportunity.title = data.title;
|
||||||
let weightedValue = opportunity.weightedValue;
|
if (data.description !== undefined) opportunity.description = data.description;
|
||||||
const estimatedValue = data.estimatedValue ?? opportunity.estimatedValue;
|
if (data.source !== undefined) opportunity.source = data.source;
|
||||||
const winProbability = data.winProbability ?? opportunity.winProbability;
|
if (data.projectType !== undefined) opportunity.projectType = data.projectType;
|
||||||
if (estimatedValue && winProbability) {
|
if (data.clientName !== undefined) opportunity.clientName = data.clientName;
|
||||||
weightedValue = estimatedValue * (winProbability / 100);
|
if (data.location !== undefined) opportunity.location = data.location;
|
||||||
|
if (data.estimatedAmount !== undefined) opportunity.estimatedAmount = data.estimatedAmount.toString();
|
||||||
|
if (data.estimatedUnits !== undefined) opportunity.estimatedUnits = data.estimatedUnits;
|
||||||
|
if (data.priority !== undefined) opportunity.priority = data.priority;
|
||||||
|
if (data.deadlineDate !== undefined) opportunity.deadlineDate = data.deadlineDate;
|
||||||
|
if (data.status !== undefined) opportunity.status = data.status;
|
||||||
|
if (ctx.userId) {
|
||||||
|
opportunity.updatedById = ctx.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(opportunity, {
|
|
||||||
...data,
|
|
||||||
weightedValue,
|
|
||||||
updatedBy: ctx.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.repository.save(opportunity);
|
return this.repository.save(opportunity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,12 +228,49 @@ export class OpportunityService {
|
|||||||
if (!opportunity) return null;
|
if (!opportunity) return null;
|
||||||
|
|
||||||
opportunity.status = status;
|
opportunity.status = status;
|
||||||
if (status === 'lost' && reason) {
|
if (reason) {
|
||||||
opportunity.lossReason = reason;
|
opportunity.goDecisionReason = reason;
|
||||||
} else if (status === 'won' && reason) {
|
}
|
||||||
opportunity.winFactors = reason;
|
if (ctx.userId) {
|
||||||
|
opportunity.updatedById = ctx.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.save(opportunity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluar decisión Go/No-Go
|
||||||
|
* @throws Error si el estado actual no es 'evaluating'
|
||||||
|
*/
|
||||||
|
async evaluateGoNoGo(
|
||||||
|
ctx: ServiceContext,
|
||||||
|
id: string,
|
||||||
|
dto: GoNoGoDecisionDto
|
||||||
|
): Promise<Opportunity> {
|
||||||
|
const opportunity = await this.findById(ctx, id);
|
||||||
|
if (!opportunity) {
|
||||||
|
throw new Error('Opportunity not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que la oportunidad esté en fase de evaluación
|
||||||
|
if (opportunity.status !== 'evaluating') {
|
||||||
|
throw new Error('Opportunity must be in "evaluating" status to evaluate go/no-go decision');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar la decisión
|
||||||
|
opportunity.goDecisionDate = new Date();
|
||||||
|
opportunity.goDecisionReason = dto.reason;
|
||||||
|
|
||||||
|
// Cambiar estado según decisión
|
||||||
|
if (dto.decision === 'go') {
|
||||||
|
opportunity.status = 'go';
|
||||||
|
} else {
|
||||||
|
opportunity.status = 'no_go';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.userId) {
|
||||||
|
opportunity.updatedById = ctx.userId;
|
||||||
}
|
}
|
||||||
opportunity.updatedBy = ctx.userId;
|
|
||||||
|
|
||||||
return this.repository.save(opportunity);
|
return this.repository.save(opportunity);
|
||||||
}
|
}
|
||||||
@ -262,8 +283,7 @@ export class OpportunityService {
|
|||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.select('o.status', 'status')
|
.select('o.status', 'status')
|
||||||
.addSelect('COUNT(*)', 'count')
|
.addSelect('COUNT(*)', 'count')
|
||||||
.addSelect('SUM(o.estimated_value)', 'totalValue')
|
.addSelect('SUM(CAST(o.estimated_amount AS DECIMAL))', 'totalValue')
|
||||||
.addSelect('SUM(o.weighted_value)', 'weightedValue')
|
|
||||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('o.deleted_at IS NULL')
|
.andWhere('o.deleted_at IS NULL')
|
||||||
.groupBy('o.status')
|
.groupBy('o.status')
|
||||||
@ -273,7 +293,6 @@ export class OpportunityService {
|
|||||||
status: r.status as OpportunityStatus,
|
status: r.status as OpportunityStatus,
|
||||||
count: parseInt(r.count),
|
count: parseInt(r.count),
|
||||||
totalValue: parseFloat(r.totalValue) || 0,
|
totalValue: parseFloat(r.totalValue) || 0,
|
||||||
weightedValue: parseFloat(r.weightedValue) || 0,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,10 +308,9 @@ export class OpportunityService {
|
|||||||
where: {
|
where: {
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
deletedAt: undefined,
|
deletedAt: undefined,
|
||||||
status: In(['identified', 'qualified', 'pursuing']),
|
status: In(['registered', 'evaluating', 'go', 'preparing']),
|
||||||
deadlineDate: Between(now, future),
|
deadlineDate: Between(now, future),
|
||||||
},
|
},
|
||||||
relations: ['assignedTo'],
|
|
||||||
order: { deadlineDate: 'ASC' },
|
order: { deadlineDate: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -309,7 +327,7 @@ export class OpportunityService {
|
|||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('o.deleted_at IS NULL')
|
.andWhere('o.deleted_at IS NULL')
|
||||||
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate });
|
.andWhere('o.created_at BETWEEN :startDate AND :endDate', { startDate, endDate });
|
||||||
|
|
||||||
const total = await baseQuery.getCount();
|
const total = await baseQuery.getCount();
|
||||||
|
|
||||||
@ -319,7 +337,7 @@ export class OpportunityService {
|
|||||||
.addSelect('COUNT(*)', 'count')
|
.addSelect('COUNT(*)', 'count')
|
||||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('o.deleted_at IS NULL')
|
.andWhere('o.deleted_at IS NULL')
|
||||||
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate })
|
.andWhere('o.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||||
.groupBy('o.status')
|
.groupBy('o.status')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
@ -329,23 +347,22 @@ export class OpportunityService {
|
|||||||
.addSelect('COUNT(*)', 'count')
|
.addSelect('COUNT(*)', 'count')
|
||||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('o.deleted_at IS NULL')
|
.andWhere('o.deleted_at IS NULL')
|
||||||
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate })
|
.andWhere('o.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||||
.groupBy('o.source')
|
.groupBy('o.source')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
const valueStats = await this.repository
|
const valueStats = await this.repository
|
||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.select('SUM(o.estimated_value)', 'totalValue')
|
.select('SUM(CAST(o.estimated_amount AS DECIMAL))', 'totalValue')
|
||||||
.addSelect('SUM(o.weighted_value)', 'weightedValue')
|
.addSelect('AVG(CAST(o.estimated_amount AS DECIMAL))', 'avgValue')
|
||||||
.addSelect('AVG(o.estimated_value)', 'avgValue')
|
|
||||||
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
.andWhere('o.deleted_at IS NULL')
|
.andWhere('o.deleted_at IS NULL')
|
||||||
.andWhere('o.identification_date BETWEEN :startDate AND :endDate', { startDate, endDate })
|
.andWhere('o.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||||
.getRawOne();
|
.getRawOne();
|
||||||
|
|
||||||
const wonCount = byStatus.find((s) => s.status === 'won')?.count || 0;
|
const convertedCount = byStatus.find((s) => s.status === 'converted')?.count || 0;
|
||||||
const lostCount = byStatus.find((s) => s.status === 'lost')?.count || 0;
|
const noGoCount = byStatus.find((s) => s.status === 'no_go')?.count || 0;
|
||||||
const closedCount = parseInt(wonCount) + parseInt(lostCount);
|
const closedCount = parseInt(convertedCount) + parseInt(noGoCount);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
year: currentYear,
|
year: currentYear,
|
||||||
@ -353,9 +370,8 @@ export class OpportunityService {
|
|||||||
byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })),
|
byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })),
|
||||||
bySource: bySource.map((r) => ({ source: r.source, count: parseInt(r.count) })),
|
bySource: bySource.map((r) => ({ source: r.source, count: parseInt(r.count) })),
|
||||||
totalValue: parseFloat(valueStats?.totalValue) || 0,
|
totalValue: parseFloat(valueStats?.totalValue) || 0,
|
||||||
weightedValue: parseFloat(valueStats?.weightedValue) || 0,
|
|
||||||
avgValue: parseFloat(valueStats?.avgValue) || 0,
|
avgValue: parseFloat(valueStats?.avgValue) || 0,
|
||||||
winRate: closedCount > 0 ? (parseInt(wonCount) / closedCount) * 100 : 0,
|
winRate: closedCount > 0 ? (parseInt(convertedCount) / closedCount) * 100 : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,7 +381,7 @@ export class OpportunityService {
|
|||||||
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||||
const result = await this.repository.update(
|
const result = await this.repository.update(
|
||||||
{ id, tenantId: ctx.tenantId },
|
{ id, tenantId: ctx.tenantId },
|
||||||
{ deletedAt: new Date(), updatedBy: ctx.userId }
|
{ deletedAt: new Date(), updatedById: ctx.userId }
|
||||||
);
|
);
|
||||||
return (result.affected || 0) > 0;
|
return (result.affected || 0) > 0;
|
||||||
}
|
}
|
||||||
@ -375,16 +391,14 @@ export interface PipelineData {
|
|||||||
status: OpportunityStatus;
|
status: OpportunityStatus;
|
||||||
count: number;
|
count: number;
|
||||||
totalValue: number;
|
totalValue: number;
|
||||||
weightedValue: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpportunityStats {
|
export interface OpportunityStats {
|
||||||
year: number;
|
year: number;
|
||||||
total: number;
|
total: number;
|
||||||
byStatus: { status: OpportunityStatus; count: number }[];
|
byStatus: { status: string; count: number }[];
|
||||||
bySource: { source: OpportunitySource; count: number }[];
|
bySource: { source: string; count: number }[];
|
||||||
totalValue: number;
|
totalValue: number;
|
||||||
weightedValue: number;
|
|
||||||
avgValue: number;
|
avgValue: number;
|
||||||
winRate: number;
|
winRate: number;
|
||||||
}
|
}
|
||||||
|
|||||||
281
src/modules/bidding/services/proposal.service.ts
Normal file
281
src/modules/bidding/services/proposal.service.ts
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* ProposalService - Gestion de Propuestas de Licitacion
|
||||||
|
*
|
||||||
|
* CRUD y logica de negocio para propuestas/ofertas de proveedores.
|
||||||
|
* Gestiona evaluacion tecnica, economica y comparacion de propuestas.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||||
|
import { Proposal, ProposalStatus } from '../entities/proposal.entity';
|
||||||
|
import { Tender } from '../entities/tender.entity';
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
export interface CreateProposalDto {
|
||||||
|
tenderId: string;
|
||||||
|
vendorId: string;
|
||||||
|
proposedAmount: number;
|
||||||
|
proposedScheduleDays: number;
|
||||||
|
technicalProposalUrl?: string;
|
||||||
|
economicProposalUrl?: string;
|
||||||
|
submittedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProposalDto extends Partial<Omit<CreateProposalDto, 'tenderId' | 'vendorId'>> {
|
||||||
|
status?: ProposalStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvaluateProposalDto {
|
||||||
|
technicalScore: number;
|
||||||
|
economicScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProposalFilters {
|
||||||
|
tenderId?: string;
|
||||||
|
vendorId?: string;
|
||||||
|
status?: ProposalStatus | ProposalStatus[];
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankedProposal extends Proposal {
|
||||||
|
id: string;
|
||||||
|
rank: number;
|
||||||
|
totalScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProposalService {
|
||||||
|
constructor(
|
||||||
|
private readonly repository: Repository<Proposal>,
|
||||||
|
private readonly tenderRepository: Repository<Tender>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar propuestas con filtros y paginacion
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
ctx: ServiceContext,
|
||||||
|
filters: ProposalFilters = {}
|
||||||
|
): Promise<PaginatedResult<Proposal>> {
|
||||||
|
const page = filters.page || 1;
|
||||||
|
const limit = filters.limit || 20;
|
||||||
|
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('p')
|
||||||
|
.leftJoinAndSelect('p.tender', 't')
|
||||||
|
.leftJoinAndSelect('p.vendor', 'v')
|
||||||
|
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('p.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (filters.tenderId) {
|
||||||
|
qb.andWhere('p.tender_id = :tenderId', { tenderId: filters.tenderId });
|
||||||
|
}
|
||||||
|
if (filters.vendorId) {
|
||||||
|
qb.andWhere('p.vendor_id = :vendorId', { vendorId: filters.vendorId });
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
if (Array.isArray(filters.status)) {
|
||||||
|
qb.andWhere('p.status IN (:...statuses)', { statuses: filters.status });
|
||||||
|
} else {
|
||||||
|
qb.andWhere('p.status = :status', { status: filters.status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(v.business_name ILIKE :search OR v.rfc ILIKE :search)',
|
||||||
|
{ search: `%${filters.search}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
qb.orderBy('p.created_at', 'DESC').skip(skip).take(limit);
|
||||||
|
|
||||||
|
const [data, total] = await qb.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar por ID
|
||||||
|
*/
|
||||||
|
async findById(ctx: ServiceContext, id: string): Promise<Proposal | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||||
|
relations: ['tender', 'vendor'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar todas las propuestas de una licitacion
|
||||||
|
*/
|
||||||
|
async findByTender(ctx: ServiceContext, tenderId: string): Promise<Proposal[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: {
|
||||||
|
tenderId,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
deletedAt: undefined,
|
||||||
|
},
|
||||||
|
relations: ['vendor'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear propuesta
|
||||||
|
* @throws Error si la licitacion no esta en status de recepcion
|
||||||
|
*/
|
||||||
|
async create(ctx: ServiceContext, dto: CreateProposalDto): Promise<Proposal> {
|
||||||
|
// Validar que la licitacion existe y esta recibiendo propuestas
|
||||||
|
const tender = await this.tenderRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: dto.tenderId,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
deletedAt: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tender) {
|
||||||
|
throw new Error('Tender not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const receivingStatuses = ['published', 'receiving'];
|
||||||
|
if (!receivingStatuses.includes(tender.status)) {
|
||||||
|
throw new Error('Tender is not currently receiving proposals. Status must be "published" or "receiving"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposal = this.repository.create({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
tenderId: dto.tenderId,
|
||||||
|
vendorId: dto.vendorId,
|
||||||
|
proposedAmount: dto.proposedAmount.toString(),
|
||||||
|
proposedScheduleDays: dto.proposedScheduleDays,
|
||||||
|
technicalProposalUrl: dto.technicalProposalUrl,
|
||||||
|
economicProposalUrl: dto.economicProposalUrl,
|
||||||
|
submittedAt: dto.submittedAt || new Date(),
|
||||||
|
status: 'received' as ProposalStatus,
|
||||||
|
createdById: ctx.userId,
|
||||||
|
updatedById: ctx.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.repository.save(proposal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar propuesta
|
||||||
|
*/
|
||||||
|
async update(ctx: ServiceContext, id: string, dto: UpdateProposalDto): Promise<Proposal | null> {
|
||||||
|
const proposal = await this.findById(ctx, id);
|
||||||
|
if (!proposal) return null;
|
||||||
|
|
||||||
|
if (dto.proposedAmount !== undefined) proposal.proposedAmount = dto.proposedAmount.toString();
|
||||||
|
if (dto.proposedScheduleDays !== undefined) proposal.proposedScheduleDays = dto.proposedScheduleDays;
|
||||||
|
if (dto.technicalProposalUrl !== undefined) proposal.technicalProposalUrl = dto.technicalProposalUrl;
|
||||||
|
if (dto.economicProposalUrl !== undefined) proposal.economicProposalUrl = dto.economicProposalUrl;
|
||||||
|
if (dto.status !== undefined) proposal.status = dto.status;
|
||||||
|
if (ctx.userId) {
|
||||||
|
proposal.updatedById = ctx.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.save(proposal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluar propuesta - asigna puntuaciones tecnica y economica
|
||||||
|
*/
|
||||||
|
async evaluate(
|
||||||
|
ctx: ServiceContext,
|
||||||
|
id: string,
|
||||||
|
dto: EvaluateProposalDto
|
||||||
|
): Promise<Proposal> {
|
||||||
|
const proposal = await this.findById(ctx, id);
|
||||||
|
if (!proposal) {
|
||||||
|
throw new Error('Proposal not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular puntuacion total (promedio simple, puede ajustarse con pesos)
|
||||||
|
const totalScore = (dto.technicalScore + dto.economicScore) / 2;
|
||||||
|
|
||||||
|
proposal.technicalScore = dto.technicalScore;
|
||||||
|
proposal.economicScore = dto.economicScore;
|
||||||
|
proposal.totalScore = totalScore;
|
||||||
|
proposal.status = 'qualified';
|
||||||
|
if (ctx.userId) {
|
||||||
|
proposal.updatedById = ctx.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.save(proposal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descalificar propuesta
|
||||||
|
*/
|
||||||
|
async disqualify(ctx: ServiceContext, id: string, _reason: string): Promise<Proposal> {
|
||||||
|
const proposal = await this.findById(ctx, id);
|
||||||
|
if (!proposal) {
|
||||||
|
throw new Error('Proposal not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
proposal.status = 'disqualified';
|
||||||
|
if (ctx.userId) {
|
||||||
|
proposal.updatedById = ctx.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.save(proposal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparar propuestas de una licitacion
|
||||||
|
* Retorna propuestas ordenadas por puntuacion total (ranking)
|
||||||
|
*/
|
||||||
|
async compareProposals(ctx: ServiceContext, tenderId: string): Promise<RankedProposal[]> {
|
||||||
|
const proposals = await this.repository.find({
|
||||||
|
where: {
|
||||||
|
tenderId,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
deletedAt: undefined,
|
||||||
|
},
|
||||||
|
relations: ['vendor'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrar solo las que tienen puntuacion y estan calificadas
|
||||||
|
const scoredProposals = proposals.filter(
|
||||||
|
p => p.totalScore !== undefined && p.totalScore !== null && p.status === 'qualified'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordenar por puntuacion total descendente
|
||||||
|
scoredProposals.sort((a, b) => {
|
||||||
|
const scoreA = Number(a.totalScore) || 0;
|
||||||
|
const scoreB = Number(b.totalScore) || 0;
|
||||||
|
return scoreB - scoreA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Asignar rankings
|
||||||
|
const rankedProposals: RankedProposal[] = scoredProposals.map((proposal, index) => ({
|
||||||
|
...proposal,
|
||||||
|
rank: index + 1,
|
||||||
|
totalScore: Number(proposal.totalScore) || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return rankedProposals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete
|
||||||
|
*/
|
||||||
|
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||||
|
const result = await this.repository.update(
|
||||||
|
{ id, tenantId: ctx.tenantId },
|
||||||
|
{ deletedAt: new Date(), updatedById: ctx.userId }
|
||||||
|
);
|
||||||
|
return (result.affected || 0) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
371
src/modules/bidding/services/tender.service.ts
Normal file
371
src/modules/bidding/services/tender.service.ts
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* TenderService - Gestion de Licitaciones/Convocatorias
|
||||||
|
*
|
||||||
|
* CRUD y logica de negocio para procesos de licitacion.
|
||||||
|
* Gestiona el ciclo desde publicacion hasta adjudicacion y conversion a proyecto.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||||
|
import { Tender, TenderType, TenderStatus } from '../entities/tender.entity';
|
||||||
|
import { Opportunity } from '../entities/opportunity.entity';
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
export interface CreateTenderDto {
|
||||||
|
opportunityId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
type?: TenderType;
|
||||||
|
referenceAmount?: number;
|
||||||
|
publicationDate?: Date;
|
||||||
|
clarificationMeetingDate?: Date;
|
||||||
|
proposalDeadline: Date;
|
||||||
|
awardDate?: Date;
|
||||||
|
contractDurationDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTenderDto extends Partial<Omit<CreateTenderDto, 'opportunityId'>> {
|
||||||
|
status?: TenderStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenderFilters {
|
||||||
|
opportunityId?: string;
|
||||||
|
type?: TenderType;
|
||||||
|
status?: TenderStatus | TenderStatus[];
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenderStats {
|
||||||
|
total: number;
|
||||||
|
byStatus: { status: string; count: number }[];
|
||||||
|
byType: { type: string; count: number }[];
|
||||||
|
totalBudget: number;
|
||||||
|
winRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TenderService {
|
||||||
|
constructor(
|
||||||
|
private readonly repository: Repository<Tender>,
|
||||||
|
private readonly opportunityRepository: Repository<Opportunity>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera numero automatico para licitacion: LIC-YYYY-NNN
|
||||||
|
*/
|
||||||
|
private async generateNumber(ctx: ServiceContext): Promise<string> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const prefix = `LIC-${year}-`;
|
||||||
|
|
||||||
|
const lastTender = await this.repository
|
||||||
|
.createQueryBuilder('t')
|
||||||
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('t.number LIKE :prefix', { prefix: `${prefix}%` })
|
||||||
|
.orderBy('t.number', 'DESC')
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
let sequence = 1;
|
||||||
|
if (lastTender) {
|
||||||
|
const lastSequence = parseInt(lastTender.number.replace(prefix, ''), 10);
|
||||||
|
if (!isNaN(lastSequence)) {
|
||||||
|
sequence = lastSequence + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${sequence.toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear licitacion
|
||||||
|
* @throws Error si la oportunidad no existe o no esta en status 'go'
|
||||||
|
*/
|
||||||
|
async create(ctx: ServiceContext, dto: CreateTenderDto): Promise<Tender> {
|
||||||
|
// Validar que la oportunidad existe y esta en status 'go'
|
||||||
|
const opportunity = await this.opportunityRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: dto.opportunityId,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
deletedAt: undefined
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!opportunity) {
|
||||||
|
throw new Error('Opportunity not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opportunity.status !== 'go') {
|
||||||
|
throw new Error('Opportunity must be in "go" status to create a tender');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenderNumber = await this.generateNumber(ctx);
|
||||||
|
|
||||||
|
const tender = this.repository.create({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
opportunityId: dto.opportunityId,
|
||||||
|
number: tenderNumber,
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description,
|
||||||
|
type: dto.type || 'public',
|
||||||
|
referenceAmount: dto.referenceAmount?.toString(),
|
||||||
|
publicationDate: dto.publicationDate,
|
||||||
|
clarificationMeetingDate: dto.clarificationMeetingDate,
|
||||||
|
proposalDeadline: dto.proposalDeadline,
|
||||||
|
awardDate: dto.awardDate,
|
||||||
|
contractDurationDays: dto.contractDurationDays,
|
||||||
|
status: 'draft' as TenderStatus,
|
||||||
|
createdById: ctx.userId,
|
||||||
|
updatedById: ctx.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.repository.save(tender);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar licitaciones con filtros y paginacion
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
ctx: ServiceContext,
|
||||||
|
filters: TenderFilters = {}
|
||||||
|
): Promise<PaginatedResult<Tender>> {
|
||||||
|
const page = filters.page || 1;
|
||||||
|
const limit = filters.limit || 20;
|
||||||
|
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('t')
|
||||||
|
.leftJoinAndSelect('t.opportunity', 'o')
|
||||||
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('t.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (filters.opportunityId) {
|
||||||
|
qb.andWhere('t.opportunity_id = :opportunityId', { opportunityId: filters.opportunityId });
|
||||||
|
}
|
||||||
|
if (filters.type) {
|
||||||
|
qb.andWhere('t.type = :type', { type: filters.type });
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
if (Array.isArray(filters.status)) {
|
||||||
|
qb.andWhere('t.status IN (:...statuses)', { statuses: filters.status });
|
||||||
|
} else {
|
||||||
|
qb.andWhere('t.status = :status', { status: filters.status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.dateFrom) {
|
||||||
|
qb.andWhere('t.proposal_deadline >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||||
|
}
|
||||||
|
if (filters.dateTo) {
|
||||||
|
qb.andWhere('t.proposal_deadline <= :dateTo', { dateTo: filters.dateTo });
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(t.title ILIKE :search OR t.number ILIKE :search)',
|
||||||
|
{ search: `%${filters.search}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
qb.orderBy('t.proposal_deadline', 'ASC').skip(skip).take(limit);
|
||||||
|
|
||||||
|
const [data, total] = await qb.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar por ID
|
||||||
|
*/
|
||||||
|
async findById(ctx: ServiceContext, id: string): Promise<Tender | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||||
|
relations: ['opportunity', 'proposals', 'calendarEvents', 'documents'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar por numero de licitacion
|
||||||
|
*/
|
||||||
|
async findByNumber(ctx: ServiceContext, number: string): Promise<Tender | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
number,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
deletedAt: undefined
|
||||||
|
},
|
||||||
|
relations: ['opportunity'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar licitacion
|
||||||
|
*/
|
||||||
|
async update(ctx: ServiceContext, id: string, dto: UpdateTenderDto): Promise<Tender | null> {
|
||||||
|
const tender = await this.findById(ctx, id);
|
||||||
|
if (!tender) return null;
|
||||||
|
|
||||||
|
if (dto.title !== undefined) tender.title = dto.title;
|
||||||
|
if (dto.description !== undefined) tender.description = dto.description;
|
||||||
|
if (dto.type !== undefined) tender.type = dto.type;
|
||||||
|
if (dto.referenceAmount !== undefined) tender.referenceAmount = dto.referenceAmount.toString();
|
||||||
|
if (dto.publicationDate !== undefined) tender.publicationDate = dto.publicationDate;
|
||||||
|
if (dto.clarificationMeetingDate !== undefined) tender.clarificationMeetingDate = dto.clarificationMeetingDate;
|
||||||
|
if (dto.proposalDeadline !== undefined) tender.proposalDeadline = dto.proposalDeadline;
|
||||||
|
if (dto.awardDate !== undefined) tender.awardDate = dto.awardDate;
|
||||||
|
if (dto.contractDurationDays !== undefined) tender.contractDurationDays = dto.contractDurationDays;
|
||||||
|
if (dto.status !== undefined) tender.status = dto.status;
|
||||||
|
if (ctx.userId) {
|
||||||
|
tender.updatedById = ctx.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.save(tender);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete
|
||||||
|
*/
|
||||||
|
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||||
|
const result = await this.repository.update(
|
||||||
|
{ id, tenantId: ctx.tenantId },
|
||||||
|
{ deletedAt: new Date(), updatedById: ctx.userId }
|
||||||
|
);
|
||||||
|
return (result.affected || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publicar licitacion - cambia status a 'published'
|
||||||
|
*/
|
||||||
|
async publish(ctx: ServiceContext, id: string): Promise<Tender> {
|
||||||
|
const tender = await this.findById(ctx, id);
|
||||||
|
if (!tender) {
|
||||||
|
throw new Error('Tender not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tender.status !== 'draft') {
|
||||||
|
throw new Error('Only draft tenders can be published');
|
||||||
|
}
|
||||||
|
|
||||||
|
tender.status = 'published';
|
||||||
|
tender.publicationDate = new Date();
|
||||||
|
if (ctx.userId) {
|
||||||
|
tender.updatedById = ctx.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.save(tender);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjudicar ganador
|
||||||
|
* @param tenderId ID de la licitacion
|
||||||
|
* @param proposalId ID de la propuesta ganadora
|
||||||
|
*/
|
||||||
|
async awardWinner(
|
||||||
|
ctx: ServiceContext,
|
||||||
|
tenderId: string,
|
||||||
|
proposalId: string
|
||||||
|
): Promise<Tender> {
|
||||||
|
const tender = await this.findById(ctx, tenderId);
|
||||||
|
if (!tender) {
|
||||||
|
throw new Error('Tender not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
tender.status = 'awarded';
|
||||||
|
tender.awardDate = new Date();
|
||||||
|
tender.winnerId = proposalId;
|
||||||
|
if (ctx.userId) {
|
||||||
|
tender.updatedById = ctx.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.save(tender);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir licitacion ganada a proyecto
|
||||||
|
*/
|
||||||
|
async convertToProject(ctx: ServiceContext, tenderId: string): Promise<{ tender: Tender; projectId: string }> {
|
||||||
|
const tender = await this.findById(ctx, tenderId);
|
||||||
|
if (!tender) {
|
||||||
|
throw new Error('Tender not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tender.status !== 'awarded') {
|
||||||
|
throw new Error('Only awarded tenders can be converted to projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
tender.status = 'converting';
|
||||||
|
if (ctx.userId) {
|
||||||
|
tender.updatedById = ctx.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar ID de proyecto placeholder (la creacion real la hace projects module)
|
||||||
|
const projectId = `PRJ-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
await this.repository.save(tender);
|
||||||
|
|
||||||
|
return { tender, projectId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener estadisticas de licitaciones
|
||||||
|
*/
|
||||||
|
async getStats(ctx: ServiceContext, year?: number): Promise<TenderStats> {
|
||||||
|
const currentYear = year || new Date().getFullYear();
|
||||||
|
const startDate = new Date(currentYear, 0, 1);
|
||||||
|
const endDate = new Date(currentYear, 11, 31);
|
||||||
|
|
||||||
|
const total = await this.repository
|
||||||
|
.createQueryBuilder('t')
|
||||||
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('t.deleted_at IS NULL')
|
||||||
|
.andWhere('t.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||||
|
.getCount();
|
||||||
|
|
||||||
|
const byStatus = await this.repository
|
||||||
|
.createQueryBuilder('t')
|
||||||
|
.select('t.status', 'status')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('t.deleted_at IS NULL')
|
||||||
|
.andWhere('t.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||||
|
.groupBy('t.status')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
const byType = await this.repository
|
||||||
|
.createQueryBuilder('t')
|
||||||
|
.select('t.type', 'type')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('t.deleted_at IS NULL')
|
||||||
|
.andWhere('t.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||||
|
.groupBy('t.type')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
const valueStats = await this.repository
|
||||||
|
.createQueryBuilder('t')
|
||||||
|
.select('SUM(CAST(t.reference_amount AS DECIMAL))', 'totalBudget')
|
||||||
|
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('t.deleted_at IS NULL')
|
||||||
|
.andWhere('t.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
const awardedCount = byStatus.find((s) => s.status === 'awarded')?.count || 0;
|
||||||
|
const cancelledCount = byStatus.find((s) => s.status === 'cancelled')?.count || 0;
|
||||||
|
const closedCount = parseInt(awardedCount) + parseInt(cancelledCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })),
|
||||||
|
byType: byType.map((r) => ({ type: r.type, count: parseInt(r.count) })),
|
||||||
|
totalBudget: parseFloat(valueStats?.totalBudget) || 0,
|
||||||
|
winRate: closedCount > 0 ? (parseInt(awardedCount) / closedCount) * 100 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
378
src/modules/bidding/services/vendor.service.ts
Normal file
378
src/modules/bidding/services/vendor.service.ts
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* VendorService - Gestion de Proveedores/Contratistas
|
||||||
|
*
|
||||||
|
* Registro centralizado de proveedores y contratistas que participan en licitaciones.
|
||||||
|
* Gestiona informacion de proveedores, calificaciones y historico de participacion.
|
||||||
|
*
|
||||||
|
* @module Bidding (MAI-018)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service';
|
||||||
|
import { Vendor, VendorCertification, VendorPerformanceEntry } from '../entities/vendor.entity';
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
export interface CreateVendorDto {
|
||||||
|
companyName: string;
|
||||||
|
rfc?: string;
|
||||||
|
specialties?: string[];
|
||||||
|
rating?: number;
|
||||||
|
certifications?: VendorCertification[];
|
||||||
|
contactName?: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateVendorDto extends Partial<CreateVendorDto> {
|
||||||
|
isActive?: boolean;
|
||||||
|
documentationValid?: boolean;
|
||||||
|
performanceHistory?: VendorPerformanceEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorFilters {
|
||||||
|
search?: string;
|
||||||
|
specialty?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
minRating?: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorRecord {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
businessName: string;
|
||||||
|
rfc?: string;
|
||||||
|
contactName?: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
specialties?: string[];
|
||||||
|
rating?: number;
|
||||||
|
totalParticipations: number;
|
||||||
|
totalWins: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VendorPerformanceHistory {
|
||||||
|
vendor: VendorRecord;
|
||||||
|
proposals: {
|
||||||
|
tenderId: string;
|
||||||
|
tenderTitle: string;
|
||||||
|
submittedAt: Date;
|
||||||
|
status: string;
|
||||||
|
proposedAmount?: string;
|
||||||
|
technicalScore?: number;
|
||||||
|
economicScore?: number;
|
||||||
|
totalScore?: number;
|
||||||
|
isWinner: boolean;
|
||||||
|
}[];
|
||||||
|
statistics: {
|
||||||
|
totalParticipations: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
winRate: number;
|
||||||
|
avgTechnicalScore: number;
|
||||||
|
avgEconomicScore: number;
|
||||||
|
avgTotalScore: number;
|
||||||
|
totalProposedValue: number;
|
||||||
|
totalWonValue: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VendorService {
|
||||||
|
constructor(private readonly repository: Repository<Vendor>) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera codigo automatico para vendor: VND-NNN
|
||||||
|
*/
|
||||||
|
private async generateCode(ctx: ServiceContext): Promise<string> {
|
||||||
|
const prefix = 'VND-';
|
||||||
|
|
||||||
|
const lastVendor = await this.repository
|
||||||
|
.createQueryBuilder('v')
|
||||||
|
.where('v.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('v.code LIKE :prefix', { prefix: `${prefix}%` })
|
||||||
|
.orderBy('v.code', 'DESC')
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
let sequence = 1;
|
||||||
|
if (lastVendor) {
|
||||||
|
const lastSequence = parseInt(lastVendor.code.replace(prefix, ''), 10);
|
||||||
|
if (!isNaN(lastSequence)) {
|
||||||
|
sequence = lastSequence + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${sequence.toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar formato RFC mexicano
|
||||||
|
*/
|
||||||
|
private validateRfc(rfc: string): boolean {
|
||||||
|
const rfcPattern = /^[A-ZÑ&]{3,4}[0-9]{6}[A-Z0-9]{3}$/i;
|
||||||
|
return rfcPattern.test(rfc.replace(/\s/g, '').toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar vendors con filtros y paginacion
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
ctx: ServiceContext,
|
||||||
|
filters: VendorFilters = {}
|
||||||
|
): Promise<PaginatedResult<Vendor>> {
|
||||||
|
const page = filters.page || 1;
|
||||||
|
const limit = filters.limit || 20;
|
||||||
|
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('v')
|
||||||
|
.where('v.tenant_id = :tenantId', { tenantId: ctx.tenantId })
|
||||||
|
.andWhere('v.deleted_at IS NULL');
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(v.business_name ILIKE :search OR v.rfc ILIKE :search OR v.code ILIKE :search)',
|
||||||
|
{ search: `%${filters.search}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filters.specialty) {
|
||||||
|
qb.andWhere(':specialty = ANY(v.specialties)', { specialty: filters.specialty });
|
||||||
|
}
|
||||||
|
if (filters.isActive !== undefined) {
|
||||||
|
qb.andWhere('v.is_active = :isActive', { isActive: filters.isActive });
|
||||||
|
}
|
||||||
|
if (filters.minRating !== undefined) {
|
||||||
|
qb.andWhere('v.rating >= :minRating', { minRating: filters.minRating });
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
qb.orderBy('v.business_name', 'ASC').skip(skip).take(limit);
|
||||||
|
|
||||||
|
const [data, total] = await qb.getManyAndCount();
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar por ID
|
||||||
|
*/
|
||||||
|
async findById(ctx: ServiceContext, id: string): Promise<Vendor | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||||
|
relations: ['proposals'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar por codigo de vendor
|
||||||
|
*/
|
||||||
|
async findByCode(ctx: ServiceContext, code: string): Promise<Vendor | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
code,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
deletedAt: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar por RFC
|
||||||
|
*/
|
||||||
|
async findByRfc(ctx: ServiceContext, rfc: string): Promise<Vendor | null> {
|
||||||
|
const normalizedRfc = rfc.replace(/\s/g, '').toUpperCase();
|
||||||
|
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
rfc: normalizedRfc,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
deletedAt: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear vendor
|
||||||
|
* @throws Error si el RFC ya existe o tiene formato invalido
|
||||||
|
*/
|
||||||
|
async create(ctx: ServiceContext, dto: CreateVendorDto): Promise<Vendor> {
|
||||||
|
// Validar formato RFC si se proporciona
|
||||||
|
if (dto.rfc && !this.validateRfc(dto.rfc)) {
|
||||||
|
throw new Error('Invalid RFC format. Must be 12 or 13 alphanumeric characters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRfc = dto.rfc?.replace(/\s/g, '').toUpperCase();
|
||||||
|
|
||||||
|
// Verificar unicidad de RFC
|
||||||
|
if (normalizedRfc) {
|
||||||
|
const existing = await this.findByRfc(ctx, normalizedRfc);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Vendor with RFC ${normalizedRfc} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendorCode = await this.generateCode(ctx);
|
||||||
|
|
||||||
|
const vendor = this.repository.create({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
code: vendorCode,
|
||||||
|
businessName: dto.companyName,
|
||||||
|
rfc: normalizedRfc,
|
||||||
|
specialties: dto.specialties,
|
||||||
|
rating: dto.rating,
|
||||||
|
certifications: dto.certifications,
|
||||||
|
contactName: dto.contactName,
|
||||||
|
contactEmail: dto.contactEmail,
|
||||||
|
contactPhone: dto.contactPhone,
|
||||||
|
isActive: true,
|
||||||
|
documentationValid: false,
|
||||||
|
createdById: ctx.userId,
|
||||||
|
updatedById: ctx.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.repository.save(vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar vendor
|
||||||
|
*/
|
||||||
|
async update(ctx: ServiceContext, id: string, dto: UpdateVendorDto): Promise<Vendor | null> {
|
||||||
|
const vendor = await this.findById(ctx, id);
|
||||||
|
if (!vendor) return null;
|
||||||
|
|
||||||
|
if (dto.companyName !== undefined) vendor.businessName = dto.companyName;
|
||||||
|
if (dto.rfc !== undefined) vendor.rfc = dto.rfc?.replace(/\s/g, '').toUpperCase();
|
||||||
|
if (dto.specialties !== undefined) vendor.specialties = dto.specialties;
|
||||||
|
if (dto.rating !== undefined) vendor.rating = dto.rating;
|
||||||
|
if (dto.certifications !== undefined) vendor.certifications = dto.certifications;
|
||||||
|
if (dto.performanceHistory !== undefined) vendor.performanceHistory = dto.performanceHistory;
|
||||||
|
if (dto.contactName !== undefined) vendor.contactName = dto.contactName;
|
||||||
|
if (dto.contactEmail !== undefined) vendor.contactEmail = dto.contactEmail;
|
||||||
|
if (dto.contactPhone !== undefined) vendor.contactPhone = dto.contactPhone;
|
||||||
|
if (dto.isActive !== undefined) vendor.isActive = dto.isActive;
|
||||||
|
if (dto.documentationValid !== undefined) vendor.documentationValid = dto.documentationValid;
|
||||||
|
if (ctx.userId) {
|
||||||
|
vendor.updatedById = ctx.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.save(vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete - marca como inactivo
|
||||||
|
*/
|
||||||
|
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
|
||||||
|
const result = await this.repository.update(
|
||||||
|
{ id, tenantId: ctx.tenantId },
|
||||||
|
{ deletedAt: new Date(), isActive: false, updatedById: ctx.userId }
|
||||||
|
);
|
||||||
|
return (result.affected || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar rating de vendor
|
||||||
|
*/
|
||||||
|
async updateRating(ctx: ServiceContext, id: string, newRating: number): Promise<Vendor> {
|
||||||
|
const vendor = await this.findById(ctx, id);
|
||||||
|
if (!vendor) {
|
||||||
|
throw new Error('Vendor not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newRating < 1 || newRating > 5) {
|
||||||
|
throw new Error('Rating must be between 1 and 5');
|
||||||
|
}
|
||||||
|
|
||||||
|
vendor.rating = newRating;
|
||||||
|
if (ctx.userId) {
|
||||||
|
vendor.updatedById = ctx.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repository.save(vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener historial de rendimiento del vendor
|
||||||
|
*/
|
||||||
|
async getPerformanceHistory(ctx: ServiceContext, id: string): Promise<VendorPerformanceHistory> {
|
||||||
|
const vendor = await this.repository.findOne({
|
||||||
|
where: { id, tenantId: ctx.tenantId, deletedAt: undefined },
|
||||||
|
relations: ['proposals', 'proposals.tender'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vendor) {
|
||||||
|
throw new Error('Vendor not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposals = (vendor.proposals || []).map(p => ({
|
||||||
|
tenderId: p.tenderId,
|
||||||
|
tenderTitle: p.tender?.title || 'Unknown',
|
||||||
|
submittedAt: p.submittedAt,
|
||||||
|
status: p.status,
|
||||||
|
proposedAmount: p.proposedAmount,
|
||||||
|
technicalScore: p.technicalScore,
|
||||||
|
economicScore: p.economicScore,
|
||||||
|
totalScore: p.totalScore,
|
||||||
|
isWinner: p.status === 'winner',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const wins = proposals.filter(p => p.isWinner).length;
|
||||||
|
const totalParticipations = proposals.length;
|
||||||
|
|
||||||
|
const scoredProposals = proposals.filter(p => p.totalScore !== undefined && p.totalScore !== null);
|
||||||
|
const avgTechnicalScore = scoredProposals.length > 0
|
||||||
|
? scoredProposals.reduce((sum, p) => sum + (p.technicalScore || 0), 0) / scoredProposals.length
|
||||||
|
: 0;
|
||||||
|
const avgEconomicScore = scoredProposals.length > 0
|
||||||
|
? scoredProposals.reduce((sum, p) => sum + (p.economicScore || 0), 0) / scoredProposals.length
|
||||||
|
: 0;
|
||||||
|
const avgTotalScore = scoredProposals.length > 0
|
||||||
|
? scoredProposals.reduce((sum, p) => sum + (p.totalScore || 0), 0) / scoredProposals.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const totalProposedValue = proposals.reduce((sum, p) => sum + (parseFloat(p.proposedAmount || '0') || 0), 0);
|
||||||
|
const totalWonValue = proposals
|
||||||
|
.filter(p => p.isWinner)
|
||||||
|
.reduce((sum, p) => sum + (parseFloat(p.proposedAmount || '0') || 0), 0);
|
||||||
|
|
||||||
|
const vendorRecord: VendorRecord = {
|
||||||
|
id: vendor.id,
|
||||||
|
code: vendor.code,
|
||||||
|
businessName: vendor.businessName,
|
||||||
|
rfc: vendor.rfc,
|
||||||
|
contactName: vendor.contactName,
|
||||||
|
contactEmail: vendor.contactEmail,
|
||||||
|
contactPhone: vendor.contactPhone,
|
||||||
|
specialties: vendor.specialties,
|
||||||
|
rating: vendor.rating,
|
||||||
|
totalParticipations,
|
||||||
|
totalWins: wins,
|
||||||
|
isActive: vendor.isActive,
|
||||||
|
createdAt: vendor.createdAt,
|
||||||
|
updatedAt: vendor.updatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
vendor: vendorRecord,
|
||||||
|
proposals,
|
||||||
|
statistics: {
|
||||||
|
totalParticipations,
|
||||||
|
wins,
|
||||||
|
losses: totalParticipations - wins,
|
||||||
|
winRate: totalParticipations > 0 ? (wins / totalParticipations) * 100 : 0,
|
||||||
|
avgTechnicalScore,
|
||||||
|
avgEconomicScore,
|
||||||
|
avgTotalScore,
|
||||||
|
totalProposedValue,
|
||||||
|
totalWonValue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/modules/billing-usage/entities/billing-alert.entity.ts
Normal file
72
src/modules/billing-usage/entities/billing-alert.entity.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type BillingAlertType =
|
||||||
|
| 'usage_limit'
|
||||||
|
| 'payment_due'
|
||||||
|
| 'payment_failed'
|
||||||
|
| 'trial_ending'
|
||||||
|
| 'subscription_ending';
|
||||||
|
|
||||||
|
export type AlertSeverity = 'info' | 'warning' | 'critical';
|
||||||
|
export type AlertStatus = 'active' | 'acknowledged' | 'resolved';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entidad para alertas de facturacion y limites de uso.
|
||||||
|
* Mapea a billing.billing_alerts (DDL: 05-billing-usage.sql)
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'billing_alerts', schema: 'billing' })
|
||||||
|
export class BillingAlert {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Tipo de alerta
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'alert_type', type: 'varchar', length: 30 })
|
||||||
|
alertType: BillingAlertType;
|
||||||
|
|
||||||
|
// Detalles
|
||||||
|
@Column({ type: 'varchar', length: 200 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'info' })
|
||||||
|
severity: AlertSeverity;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||||
|
status: AlertStatus;
|
||||||
|
|
||||||
|
// Notificacion
|
||||||
|
@Column({ name: 'notified_at', type: 'timestamptz', nullable: true })
|
||||||
|
notifiedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'acknowledged_at', type: 'timestamptz', nullable: true })
|
||||||
|
acknowledgedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'acknowledged_by', type: 'uuid', nullable: true })
|
||||||
|
acknowledgedBy: string;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Coupon } from './coupon.entity';
|
||||||
|
import { TenantSubscription } from './tenant-subscription.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'coupon_redemptions', schema: 'billing' })
|
||||||
|
@Unique(['couponId', 'tenantId'])
|
||||||
|
export class CouponRedemption {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'coupon_id', type: 'uuid' })
|
||||||
|
couponId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Coupon, (coupon) => coupon.redemptions)
|
||||||
|
@JoinColumn({ name: 'coupon_id' })
|
||||||
|
coupon!: Coupon;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'subscription_id', type: 'uuid', nullable: true })
|
||||||
|
subscriptionId?: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => TenantSubscription, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'subscription_id' })
|
||||||
|
subscription?: TenantSubscription;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_amount', type: 'decimal', precision: 10, scale: 2 })
|
||||||
|
discountAmount!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'redeemed_at', type: 'timestamptz' })
|
||||||
|
redeemedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||||
|
expiresAt?: Date;
|
||||||
|
}
|
||||||
72
src/modules/billing-usage/entities/coupon.entity.ts
Normal file
72
src/modules/billing-usage/entities/coupon.entity.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { CouponRedemption } from './coupon-redemption.entity';
|
||||||
|
|
||||||
|
export type DiscountType = 'percentage' | 'fixed';
|
||||||
|
export type DurationPeriod = 'once' | 'forever' | 'months';
|
||||||
|
|
||||||
|
@Entity({ name: 'coupons', schema: 'billing' })
|
||||||
|
export class Coupon {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, unique: true })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_type', type: 'varchar', length: 20 })
|
||||||
|
discountType!: DiscountType;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_value', type: 'decimal', precision: 10, scale: 2 })
|
||||||
|
discountValue!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: 'MXN' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'applicable_plans', type: 'uuid', array: true, default: [] })
|
||||||
|
applicablePlans!: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'min_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||||
|
minAmount!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'duration_period', type: 'varchar', length: 20, default: 'once' })
|
||||||
|
durationPeriod!: DurationPeriod;
|
||||||
|
|
||||||
|
@Column({ name: 'duration_months', type: 'integer', nullable: true })
|
||||||
|
durationMonths?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'max_redemptions', type: 'integer', nullable: true })
|
||||||
|
maxRedemptions?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'current_redemptions', type: 'integer', default: 0 })
|
||||||
|
currentRedemptions!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'valid_from', type: 'timestamptz', nullable: true })
|
||||||
|
validFrom?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'valid_until', type: 'timestamptz', nullable: true })
|
||||||
|
validUntil?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => CouponRedemption, (redemption) => redemption.coupon)
|
||||||
|
redemptions!: CouponRedemption[];
|
||||||
|
}
|
||||||
13
src/modules/billing-usage/entities/index.ts
Normal file
13
src/modules/billing-usage/entities/index.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export { SubscriptionPlan, PlanType } from './subscription-plan.entity';
|
||||||
|
export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity';
|
||||||
|
export { UsageTracking } from './usage-tracking.entity';
|
||||||
|
export { UsageEvent, EventCategory } from './usage-event.entity';
|
||||||
|
export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType, InvoiceItem } from './invoice.entity';
|
||||||
|
export { InvoiceItemType } from './invoice-item.entity';
|
||||||
|
export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity';
|
||||||
|
export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity';
|
||||||
|
export { PlanFeature } from './plan-feature.entity';
|
||||||
|
export { PlanLimit, LimitType } from './plan-limit.entity';
|
||||||
|
export { Coupon, DiscountType, DurationPeriod } from './coupon.entity';
|
||||||
|
export { CouponRedemption } from './coupon-redemption.entity';
|
||||||
|
export { StripeEvent } from './stripe-event.entity';
|
||||||
65
src/modules/billing-usage/entities/invoice-item.entity.ts
Normal file
65
src/modules/billing-usage/entities/invoice-item.entity.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Invoice } from '../../invoices/entities/invoice.entity';
|
||||||
|
|
||||||
|
export type InvoiceItemType = 'subscription' | 'user' | 'profile' | 'overage' | 'addon';
|
||||||
|
|
||||||
|
@Entity({ name: 'invoice_items', schema: 'billing' })
|
||||||
|
export class InvoiceItem {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'invoice_id', type: 'uuid' })
|
||||||
|
invoiceId: string;
|
||||||
|
|
||||||
|
// Descripcion
|
||||||
|
@Column({ type: 'varchar', length: 500 })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'item_type', type: 'varchar', length: 30 })
|
||||||
|
itemType: InvoiceItemType;
|
||||||
|
|
||||||
|
// Cantidades
|
||||||
|
@Column({ type: 'integer', default: 1 })
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2 })
|
||||||
|
unitPrice: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||||||
|
subtotal: number;
|
||||||
|
|
||||||
|
// Detalles adicionales
|
||||||
|
@Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true })
|
||||||
|
profileCode: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
platform: string;
|
||||||
|
|
||||||
|
@Column({ name: 'period_start', type: 'date', nullable: true })
|
||||||
|
periodStart: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'period_end', type: 'date', nullable: true })
|
||||||
|
periodEnd: Date;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Invoice, (invoice) => invoice.items, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'invoice_id' })
|
||||||
|
invoice: Invoice;
|
||||||
|
}
|
||||||
17
src/modules/billing-usage/entities/invoice.entity.ts
Normal file
17
src/modules/billing-usage/entities/invoice.entity.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* @deprecated Use Invoice from 'modules/invoices/entities' instead.
|
||||||
|
*
|
||||||
|
* This entity has been unified with the commercial Invoice entity.
|
||||||
|
* Both SaaS billing and commercial invoices now use the same table.
|
||||||
|
*
|
||||||
|
* Migration guide:
|
||||||
|
* - Import from: import { Invoice, InvoiceStatus, InvoiceContext } from '../../invoices/entities/invoice.entity';
|
||||||
|
* - Set invoiceContext: 'saas' for SaaS billing invoices
|
||||||
|
* - Use subscriptionId, periodStart, periodEnd for SaaS-specific fields
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export from unified invoice entity
|
||||||
|
export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType } from '../../invoices/entities/invoice.entity';
|
||||||
|
|
||||||
|
// Re-export InvoiceItem as well since it's used together
|
||||||
|
export { InvoiceItem } from './invoice-item.entity';
|
||||||
85
src/modules/billing-usage/entities/payment-method.entity.ts
Normal file
85
src/modules/billing-usage/entities/payment-method.entity.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type PaymentProvider = 'stripe' | 'mercadopago' | 'bank_transfer';
|
||||||
|
export type PaymentMethodType = 'card' | 'bank_account' | 'wallet';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entidad para metodos de pago guardados por tenant.
|
||||||
|
* Almacena informacion tokenizada/encriptada de metodos de pago.
|
||||||
|
* Mapea a billing.payment_methods (DDL: 05-billing-usage.sql)
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'payment_methods', schema: 'billing' })
|
||||||
|
export class BillingPaymentMethod {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Proveedor
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 30 })
|
||||||
|
provider: PaymentProvider;
|
||||||
|
|
||||||
|
// Tipo
|
||||||
|
@Column({ name: 'method_type', type: 'varchar', length: 20 })
|
||||||
|
methodType: PaymentMethodType;
|
||||||
|
|
||||||
|
// Datos tokenizados del proveedor
|
||||||
|
@Column({ name: 'provider_customer_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
providerCustomerId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'provider_method_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
providerMethodId: string;
|
||||||
|
|
||||||
|
// Display info (no sensible)
|
||||||
|
@Column({ name: 'display_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true })
|
||||||
|
cardBrand: string;
|
||||||
|
|
||||||
|
@Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true })
|
||||||
|
cardLastFour: string;
|
||||||
|
|
||||||
|
@Column({ name: 'card_exp_month', type: 'integer', nullable: true })
|
||||||
|
cardExpMonth: number;
|
||||||
|
|
||||||
|
@Column({ name: 'card_exp_year', type: 'integer', nullable: true })
|
||||||
|
cardExpYear: number;
|
||||||
|
|
||||||
|
@Column({ name: 'bank_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
bankName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'bank_last_four', type: 'varchar', length: 4, nullable: true })
|
||||||
|
bankLastFour: string;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_default', type: 'boolean', default: false })
|
||||||
|
isDefault: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_verified', type: 'boolean', default: false })
|
||||||
|
isVerified: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
61
src/modules/billing-usage/entities/plan-feature.entity.ts
Normal file
61
src/modules/billing-usage/entities/plan-feature.entity.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { SubscriptionPlan } from './subscription-plan.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlanFeature Entity
|
||||||
|
* Maps to billing.plan_features DDL table
|
||||||
|
* Features disponibles por plan de suscripcion
|
||||||
|
* Propagated from template-saas HU-REFACT-005
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'billing', name: 'plan_features' })
|
||||||
|
@Index('idx_plan_features_plan', ['planId'])
|
||||||
|
@Index('idx_plan_features_key', ['featureKey'])
|
||||||
|
export class PlanFeature {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'plan_id' })
|
||||||
|
planId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false, name: 'feature_key' })
|
||||||
|
featureKey: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false, name: 'feature_name' })
|
||||||
|
featureName: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
category: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
configuration: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => SubscriptionPlan, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'plan_id' })
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
52
src/modules/billing-usage/entities/plan-limit.entity.ts
Normal file
52
src/modules/billing-usage/entities/plan-limit.entity.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { SubscriptionPlan } from './subscription-plan.entity';
|
||||||
|
|
||||||
|
export type LimitType = 'monthly' | 'daily' | 'total' | 'per_user';
|
||||||
|
|
||||||
|
@Entity({ name: 'plan_limits', schema: 'billing' })
|
||||||
|
export class PlanLimit {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'plan_id', type: 'uuid' })
|
||||||
|
planId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => SubscriptionPlan, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'plan_id' })
|
||||||
|
plan!: SubscriptionPlan;
|
||||||
|
|
||||||
|
@Column({ name: 'limit_key', type: 'varchar', length: 100 })
|
||||||
|
limitKey!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'limit_name', type: 'varchar', length: 255 })
|
||||||
|
limitName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'limit_value', type: 'integer' })
|
||||||
|
limitValue!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'limit_type', type: 'varchar', length: 50, default: 'monthly' })
|
||||||
|
limitType!: LimitType;
|
||||||
|
|
||||||
|
@Column({ name: 'allow_overage', type: 'boolean', default: false })
|
||||||
|
allowOverage!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'overage_unit_price', type: 'decimal', precision: 10, scale: 4, default: 0 })
|
||||||
|
overageUnitPrice!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'overage_currency', type: 'varchar', length: 3, default: 'MXN' })
|
||||||
|
overageCurrency!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
43
src/modules/billing-usage/entities/stripe-event.entity.ts
Normal file
43
src/modules/billing-usage/entities/stripe-event.entity.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'stripe_events', schema: 'billing' })
|
||||||
|
export class StripeEvent {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'stripe_event_id', type: 'varchar', length: 255, unique: true })
|
||||||
|
@Index()
|
||||||
|
stripeEventId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'event_type', type: 'varchar', length: 100 })
|
||||||
|
@Index()
|
||||||
|
eventType!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'api_version', type: 'varchar', length: 20, nullable: true })
|
||||||
|
apiVersion?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb' })
|
||||||
|
data!: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
@Index()
|
||||||
|
processed!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'processed_at', type: 'timestamptz', nullable: true })
|
||||||
|
processedAt?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||||
|
errorMessage?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'retry_count', type: 'integer', default: 0 })
|
||||||
|
retryCount!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type PlanType = 'saas' | 'on_premise' | 'hybrid';
|
||||||
|
|
||||||
|
@Entity({ name: 'subscription_plans', schema: 'billing' })
|
||||||
|
export class SubscriptionPlan {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
// Identificacion
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column({ type: 'varchar', length: 30 })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
// Tipo
|
||||||
|
@Column({ name: 'plan_type', type: 'varchar', length: 20, default: 'saas' })
|
||||||
|
planType: PlanType;
|
||||||
|
|
||||||
|
// Precios base
|
||||||
|
@Column({ name: 'base_monthly_price', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||||
|
baseMonthlyPrice: number;
|
||||||
|
|
||||||
|
@Column({ name: 'base_annual_price', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
baseAnnualPrice: number;
|
||||||
|
|
||||||
|
@Column({ name: 'setup_fee', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||||
|
setupFee: number;
|
||||||
|
|
||||||
|
// Limites base
|
||||||
|
@Column({ name: 'max_users', type: 'integer', default: 5 })
|
||||||
|
maxUsers: number;
|
||||||
|
|
||||||
|
@Column({ name: 'max_branches', type: 'integer', default: 1 })
|
||||||
|
maxBranches: number;
|
||||||
|
|
||||||
|
@Column({ name: 'storage_gb', type: 'integer', default: 10 })
|
||||||
|
storageGb: number;
|
||||||
|
|
||||||
|
@Column({ name: 'api_calls_monthly', type: 'integer', default: 10000 })
|
||||||
|
apiCallsMonthly: number;
|
||||||
|
|
||||||
|
// Modulos incluidos
|
||||||
|
@Column({ name: 'included_modules', type: 'text', array: true, default: [] })
|
||||||
|
includedModules: string[];
|
||||||
|
|
||||||
|
// Plataformas incluidas
|
||||||
|
@Column({ name: 'included_platforms', type: 'text', array: true, default: ['web'] })
|
||||||
|
includedPlatforms: string[];
|
||||||
|
|
||||||
|
// Features
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
features: Record<string, boolean>;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_public', type: 'boolean', default: true })
|
||||||
|
isPublic: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
132
src/modules/billing-usage/entities/tenant-subscription.entity.ts
Normal file
132
src/modules/billing-usage/entities/tenant-subscription.entity.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { SubscriptionPlan } from './subscription-plan.entity';
|
||||||
|
|
||||||
|
export type BillingCycle = 'monthly' | 'annual';
|
||||||
|
export type SubscriptionStatus = 'trial' | 'active' | 'past_due' | 'cancelled' | 'suspended';
|
||||||
|
|
||||||
|
@Entity({ name: 'tenant_subscriptions', schema: 'billing' })
|
||||||
|
@Unique(['tenantId'])
|
||||||
|
export class TenantSubscription {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'plan_id', type: 'uuid' })
|
||||||
|
planId: string;
|
||||||
|
|
||||||
|
// Periodo
|
||||||
|
@Column({ name: 'billing_cycle', type: 'varchar', length: 20, default: 'monthly' })
|
||||||
|
billingCycle: BillingCycle;
|
||||||
|
|
||||||
|
@Column({ name: 'current_period_start', type: 'timestamptz' })
|
||||||
|
currentPeriodStart: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'current_period_end', type: 'timestamptz' })
|
||||||
|
currentPeriodEnd: Date;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
|
||||||
|
// Trial
|
||||||
|
@Column({ name: 'trial_start', type: 'timestamptz', nullable: true })
|
||||||
|
trialStart: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'trial_end', type: 'timestamptz', nullable: true })
|
||||||
|
trialEnd: Date;
|
||||||
|
|
||||||
|
// Configuracion de facturacion
|
||||||
|
@Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
billingEmail: string;
|
||||||
|
|
||||||
|
@Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
billingName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'billing_address', type: 'jsonb', default: {} })
|
||||||
|
billingAddress: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true })
|
||||||
|
taxId: string; // RFC para Mexico
|
||||||
|
|
||||||
|
// Metodo de pago
|
||||||
|
@Column({ name: 'payment_method_id', type: 'uuid', nullable: true })
|
||||||
|
paymentMethodId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true })
|
||||||
|
paymentProvider: string; // stripe, mercadopago, bank_transfer
|
||||||
|
|
||||||
|
// Stripe integration
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'stripe_customer_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
stripeCustomerId?: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'stripe_subscription_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
stripeSubscriptionId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'last_payment_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastPaymentAt?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'last_payment_amount', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
lastPaymentAmount?: number;
|
||||||
|
|
||||||
|
// Precios actuales
|
||||||
|
@Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 })
|
||||||
|
currentPrice: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||||
|
discountPercent: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_reason', type: 'varchar', length: 100, nullable: true })
|
||||||
|
discountReason: string;
|
||||||
|
|
||||||
|
// Uso contratado
|
||||||
|
@Column({ name: 'contracted_users', type: 'integer', nullable: true })
|
||||||
|
contractedUsers: number;
|
||||||
|
|
||||||
|
@Column({ name: 'contracted_branches', type: 'integer', nullable: true })
|
||||||
|
contractedBranches: number;
|
||||||
|
|
||||||
|
// Facturacion automatica
|
||||||
|
@Column({ name: 'auto_renew', type: 'boolean', default: true })
|
||||||
|
autoRenew: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'next_invoice_date', type: 'date', nullable: true })
|
||||||
|
nextInvoiceDate: Date;
|
||||||
|
|
||||||
|
// Cancelacion
|
||||||
|
@Column({ name: 'cancel_at_period_end', type: 'boolean', default: false })
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||||
|
cancelledAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'cancellation_reason', type: 'text', nullable: true })
|
||||||
|
cancellationReason: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => SubscriptionPlan)
|
||||||
|
@JoinColumn({ name: 'plan_id' })
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
}
|
||||||
73
src/modules/billing-usage/entities/usage-event.entity.ts
Normal file
73
src/modules/billing-usage/entities/usage-event.entity.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type EventCategory = 'user' | 'api' | 'storage' | 'transaction' | 'mobile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entidad para eventos de uso en tiempo real.
|
||||||
|
* Utilizada para calculo de billing y tracking granular.
|
||||||
|
* Mapea a billing.usage_events (DDL: 05-billing-usage.sql)
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'usage_events', schema: 'billing' })
|
||||||
|
export class UsageEvent {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_id', type: 'uuid', nullable: true })
|
||||||
|
deviceId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
// Evento
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'event_type', type: 'varchar', length: 50 })
|
||||||
|
eventType: string; // login, api_call, document_upload, sale, invoice, sync
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'event_category', type: 'varchar', length: 30 })
|
||||||
|
eventCategory: EventCategory;
|
||||||
|
|
||||||
|
// Detalles
|
||||||
|
@Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true })
|
||||||
|
profileCode: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
platform: string;
|
||||||
|
|
||||||
|
@Column({ name: 'resource_id', type: 'uuid', nullable: true })
|
||||||
|
resourceId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'resource_type', type: 'varchar', length: 50, nullable: true })
|
||||||
|
resourceType: string;
|
||||||
|
|
||||||
|
// Metricas
|
||||||
|
@Column({ type: 'integer', default: 1 })
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@Column({ name: 'bytes_used', type: 'bigint', default: 0 })
|
||||||
|
bytesUsed: number;
|
||||||
|
|
||||||
|
@Column({ name: 'duration_ms', type: 'integer', nullable: true })
|
||||||
|
durationMs: number;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
91
src/modules/billing-usage/entities/usage-tracking.entity.ts
Normal file
91
src/modules/billing-usage/entities/usage-tracking.entity.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'usage_tracking', schema: 'billing' })
|
||||||
|
@Unique(['tenantId', 'periodStart'])
|
||||||
|
export class UsageTracking {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Periodo
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'period_start', type: 'date' })
|
||||||
|
periodStart: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'period_end', type: 'date' })
|
||||||
|
periodEnd: Date;
|
||||||
|
|
||||||
|
// Usuarios
|
||||||
|
@Column({ name: 'active_users', type: 'integer', default: 0 })
|
||||||
|
activeUsers: number;
|
||||||
|
|
||||||
|
@Column({ name: 'peak_concurrent_users', type: 'integer', default: 0 })
|
||||||
|
peakConcurrentUsers: number;
|
||||||
|
|
||||||
|
// Por perfil
|
||||||
|
@Column({ name: 'users_by_profile', type: 'jsonb', default: {} })
|
||||||
|
usersByProfile: Record<string, number>; // {"ADM": 2, "VNT": 5, "ALM": 3}
|
||||||
|
|
||||||
|
// Por plataforma
|
||||||
|
@Column({ name: 'users_by_platform', type: 'jsonb', default: {} })
|
||||||
|
usersByPlatform: Record<string, number>; // {"web": 8, "mobile": 5, "desktop": 0}
|
||||||
|
|
||||||
|
// Sucursales
|
||||||
|
@Column({ name: 'active_branches', type: 'integer', default: 0 })
|
||||||
|
activeBranches: number;
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
@Column({ name: 'storage_used_gb', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||||
|
storageUsedGb: number;
|
||||||
|
|
||||||
|
@Column({ name: 'documents_count', type: 'integer', default: 0 })
|
||||||
|
documentsCount: number;
|
||||||
|
|
||||||
|
// API
|
||||||
|
@Column({ name: 'api_calls', type: 'integer', default: 0 })
|
||||||
|
apiCalls: number;
|
||||||
|
|
||||||
|
@Column({ name: 'api_errors', type: 'integer', default: 0 })
|
||||||
|
apiErrors: number;
|
||||||
|
|
||||||
|
// Transacciones
|
||||||
|
@Column({ name: 'sales_count', type: 'integer', default: 0 })
|
||||||
|
salesCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'sales_amount', type: 'decimal', precision: 14, scale: 2, default: 0 })
|
||||||
|
salesAmount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'invoices_generated', type: 'integer', default: 0 })
|
||||||
|
invoicesGenerated: number;
|
||||||
|
|
||||||
|
// Mobile
|
||||||
|
@Column({ name: 'mobile_sessions', type: 'integer', default: 0 })
|
||||||
|
mobileSessions: number;
|
||||||
|
|
||||||
|
@Column({ name: 'offline_syncs', type: 'integer', default: 0 })
|
||||||
|
offlineSyncs: number;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_transactions', type: 'integer', default: 0 })
|
||||||
|
paymentTransactions: number;
|
||||||
|
|
||||||
|
// Calculado
|
||||||
|
@Column({ name: 'total_billable_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||||
|
totalBillableAmount: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Device, BiometricType } from './device.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'biometric_credentials', schema: 'auth' })
|
||||||
|
@Unique(['deviceId', 'credentialId'])
|
||||||
|
export class BiometricCredential {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'device_id', type: 'uuid' })
|
||||||
|
deviceId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
// Tipo de biometrico
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'biometric_type', type: 'varchar', length: 50 })
|
||||||
|
biometricType: BiometricType;
|
||||||
|
|
||||||
|
// Credencial (public key para WebAuthn/FIDO2)
|
||||||
|
@Column({ name: 'credential_id', type: 'text' })
|
||||||
|
credentialId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'public_key', type: 'text' })
|
||||||
|
publicKey: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'ES256' })
|
||||||
|
algorithm: string;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ name: 'credential_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
credentialName: string; // "Huella indice derecho", "Face ID iPhone"
|
||||||
|
|
||||||
|
@Column({ name: 'is_primary', type: 'boolean', default: false })
|
||||||
|
isPrimary: boolean;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastUsedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'use_count', type: 'integer', default: 0 })
|
||||||
|
useCount: number;
|
||||||
|
|
||||||
|
// Seguridad
|
||||||
|
@Column({ name: 'failed_attempts', type: 'integer', default: 0 })
|
||||||
|
failedAttempts: number;
|
||||||
|
|
||||||
|
@Column({ name: 'locked_until', type: 'timestamptz', nullable: true })
|
||||||
|
lockedUntil: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Device, (device) => device.biometricCredentials, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'device_id' })
|
||||||
|
device: Device;
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type ActivityType = 'login' | 'logout' | 'biometric_auth' | 'location_update' | 'app_open' | 'app_close';
|
||||||
|
export type ActivityStatus = 'success' | 'failed' | 'blocked';
|
||||||
|
|
||||||
|
@Entity({ name: 'device_activity_log', schema: 'auth' })
|
||||||
|
export class DeviceActivityLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'device_id', type: 'uuid' })
|
||||||
|
deviceId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
// Actividad
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'activity_type', type: 'varchar', length: 50 })
|
||||||
|
activityType: ActivityType;
|
||||||
|
|
||||||
|
@Column({ name: 'activity_status', type: 'varchar', length: 20 })
|
||||||
|
activityStatus: ActivityStatus;
|
||||||
|
|
||||||
|
// Detalles
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
details: Record<string, any>;
|
||||||
|
|
||||||
|
// Ubicacion
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
longitude: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
84
src/modules/biometrics/entities/device-session.entity.ts
Normal file
84
src/modules/biometrics/entities/device-session.entity.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Device } from './device.entity';
|
||||||
|
|
||||||
|
export type AuthMethod = 'password' | 'biometric' | 'oauth' | 'mfa';
|
||||||
|
|
||||||
|
@Entity({ name: 'device_sessions', schema: 'auth' })
|
||||||
|
export class DeviceSession {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'device_id', type: 'uuid' })
|
||||||
|
deviceId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Tokens
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'access_token_hash', type: 'varchar', length: 255 })
|
||||||
|
accessTokenHash: string;
|
||||||
|
|
||||||
|
@Column({ name: 'refresh_token_hash', type: 'varchar', length: 255, nullable: true })
|
||||||
|
refreshTokenHash: string;
|
||||||
|
|
||||||
|
// Metodo de autenticacion
|
||||||
|
@Column({ name: 'auth_method', type: 'varchar', length: 50 })
|
||||||
|
authMethod: AuthMethod;
|
||||||
|
|
||||||
|
// Validez
|
||||||
|
@Column({ name: 'issued_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
issuedAt: Date;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'expires_at', type: 'timestamptz' })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'refresh_expires_at', type: 'timestamptz', nullable: true })
|
||||||
|
refreshExpiresAt: Date;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
|
||||||
|
revokedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'revoked_reason', type: 'varchar', length: 100, nullable: true })
|
||||||
|
revokedReason: string;
|
||||||
|
|
||||||
|
// Ubicacion
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
|
userAgent: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
longitude: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Device, (device) => device.sessions, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'device_id' })
|
||||||
|
device: Device;
|
||||||
|
}
|
||||||
121
src/modules/biometrics/entities/device.entity.ts
Normal file
121
src/modules/biometrics/entities/device.entity.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { BiometricCredential } from './biometric-credential.entity';
|
||||||
|
import { DeviceSession } from './device-session.entity';
|
||||||
|
|
||||||
|
export type DevicePlatform = 'ios' | 'android' | 'web' | 'desktop';
|
||||||
|
export type BiometricType = 'fingerprint' | 'face_id' | 'face_recognition' | 'iris';
|
||||||
|
|
||||||
|
@Entity({ name: 'devices', schema: 'auth' })
|
||||||
|
@Unique(['userId', 'deviceUuid'])
|
||||||
|
export class Device {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Identificacion del dispositivo
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'device_uuid', type: 'varchar', length: 100 })
|
||||||
|
deviceUuid: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
deviceName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_model', type: 'varchar', length: 100, nullable: true })
|
||||||
|
deviceModel: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_brand', type: 'varchar', length: 50, nullable: true })
|
||||||
|
deviceBrand: string;
|
||||||
|
|
||||||
|
// Plataforma
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
platform: DevicePlatform;
|
||||||
|
|
||||||
|
@Column({ name: 'platform_version', type: 'varchar', length: 20, nullable: true })
|
||||||
|
platformVersion: string;
|
||||||
|
|
||||||
|
@Column({ name: 'app_version', type: 'varchar', length: 20, nullable: true })
|
||||||
|
appVersion: string;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_trusted', type: 'boolean', default: false })
|
||||||
|
isTrusted: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'trust_level', type: 'integer', default: 0 })
|
||||||
|
trustLevel: number; // 0=none, 1=low, 2=medium, 3=high
|
||||||
|
|
||||||
|
// Biometricos habilitados
|
||||||
|
@Column({ name: 'biometric_enabled', type: 'boolean', default: false })
|
||||||
|
biometricEnabled: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'biometric_type', type: 'varchar', length: 50, nullable: true })
|
||||||
|
biometricType: BiometricType;
|
||||||
|
|
||||||
|
// Push notifications
|
||||||
|
@Column({ name: 'push_token', type: 'text', nullable: true })
|
||||||
|
pushToken: string;
|
||||||
|
|
||||||
|
@Column({ name: 'push_token_updated_at', type: 'timestamptz', nullable: true })
|
||||||
|
pushTokenUpdatedAt: Date;
|
||||||
|
|
||||||
|
// Ubicacion ultima conocida
|
||||||
|
@Column({ name: 'last_latitude', type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
lastLatitude: number;
|
||||||
|
|
||||||
|
@Column({ name: 'last_longitude', type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
lastLongitude: number;
|
||||||
|
|
||||||
|
@Column({ name: 'last_location_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastLocationAt: Date;
|
||||||
|
|
||||||
|
// Seguridad
|
||||||
|
@Column({ name: 'last_ip_address', type: 'inet', nullable: true })
|
||||||
|
lastIpAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'last_user_agent', type: 'text', nullable: true })
|
||||||
|
lastUserAgent: string;
|
||||||
|
|
||||||
|
// Registro
|
||||||
|
@Column({ name: 'first_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
firstSeenAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'last_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
lastSeenAt: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@OneToMany(() => BiometricCredential, (credential) => credential.device)
|
||||||
|
biometricCredentials: BiometricCredential[];
|
||||||
|
|
||||||
|
@OneToMany(() => DeviceSession, (session) => session.device)
|
||||||
|
sessions: DeviceSession[];
|
||||||
|
}
|
||||||
4
src/modules/biometrics/entities/index.ts
Normal file
4
src/modules/biometrics/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { Device, DevicePlatform, BiometricType } from './device.entity';
|
||||||
|
export { BiometricCredential } from './biometric-credential.entity';
|
||||||
|
export { DeviceSession, AuthMethod } from './device-session.entity';
|
||||||
|
export { DeviceActivityLog, ActivityType, ActivityStatus } from './device-activity-log.entity';
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Branch } from './branch.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuracion de inventario por sucursal.
|
||||||
|
* Mapea a core.branch_inventory_settings (DDL: 03-core-branches.sql)
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'branch_inventory_settings', schema: 'core' })
|
||||||
|
export class BranchInventorySettings {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid' })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
@OneToOne(() => Branch, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'branch_id' })
|
||||||
|
branch: Branch;
|
||||||
|
|
||||||
|
// Almacen asociado (referencia externa a inventory.warehouses)
|
||||||
|
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||||
|
warehouseId: string;
|
||||||
|
|
||||||
|
// Configuracion de stock
|
||||||
|
@Column({ name: 'default_stock_min', type: 'integer', default: 0 })
|
||||||
|
defaultStockMin: number;
|
||||||
|
|
||||||
|
@Column({ name: 'default_stock_max', type: 'integer', default: 1000 })
|
||||||
|
defaultStockMax: number;
|
||||||
|
|
||||||
|
@Column({ name: 'auto_reorder_enabled', type: 'boolean', default: false })
|
||||||
|
autoReorderEnabled: boolean;
|
||||||
|
|
||||||
|
// Configuracion de precios (referencia externa a sales.price_lists)
|
||||||
|
@Column({ name: 'price_list_id', type: 'uuid', nullable: true })
|
||||||
|
priceListId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'allow_price_override', type: 'boolean', default: false })
|
||||||
|
allowPriceOverride: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'max_discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||||
|
maxDiscountPercent: number;
|
||||||
|
|
||||||
|
// Configuracion de impuestos
|
||||||
|
@Column({ name: 'tax_config', type: 'jsonb', default: {} })
|
||||||
|
taxConfig: Record<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* BranchPaymentTerminal Entity Stub
|
||||||
|
* TODO: Implement when branches module is created
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
export type HealthStatus = 'healthy' | 'degraded' | 'offline' | 'unknown';
|
||||||
|
export type TerminalProvider = 'clip' | 'mercadopago' | 'stripe' | 'stripe_terminal';
|
||||||
|
|
||||||
|
@Entity({ name: 'branch_payment_terminals' })
|
||||||
|
export class BranchPaymentTerminal {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid' })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'terminal_id', type: 'varchar', length: 100 })
|
||||||
|
terminalId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'terminal_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
terminalName: string | undefined;
|
||||||
|
|
||||||
|
@Column({ name: 'terminal_provider', type: 'varchar', length: 50 })
|
||||||
|
terminalProvider: TerminalProvider;
|
||||||
|
|
||||||
|
@Column({ name: 'provider', type: 'varchar', length: 50 })
|
||||||
|
provider: string;
|
||||||
|
|
||||||
|
@Column({ name: 'credentials', type: 'jsonb', nullable: true })
|
||||||
|
credentials: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_primary', type: 'boolean', default: false })
|
||||||
|
isPrimary: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'health_status', type: 'varchar', length: 50, default: 'unknown' })
|
||||||
|
healthStatus: HealthStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'last_health_check_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastHealthCheckAt: Date | undefined;
|
||||||
|
|
||||||
|
@Column({ name: 'last_transaction_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastTransactionAt: Date | undefined;
|
||||||
|
|
||||||
|
@Column({ name: 'daily_limit', type: 'decimal', precision: 16, scale: 2, nullable: true })
|
||||||
|
dailyLimit: number | undefined;
|
||||||
|
|
||||||
|
@Column({ name: 'transaction_limit', type: 'decimal', precision: 16, scale: 2, nullable: true })
|
||||||
|
transactionLimit: number | undefined;
|
||||||
|
|
||||||
|
@Column({ name: 'config', type: 'jsonb', nullable: true })
|
||||||
|
config: Record<string, any> | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true })
|
||||||
|
updatedAt: Date | null;
|
||||||
|
}
|
||||||
73
src/modules/branches/entities/branch-schedule.entity.ts
Normal file
73
src/modules/branches/entities/branch-schedule.entity.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Branch } from './branch.entity';
|
||||||
|
|
||||||
|
export type ScheduleType = 'regular' | 'holiday' | 'special';
|
||||||
|
|
||||||
|
@Entity({ name: 'branch_schedules', schema: 'core' })
|
||||||
|
export class BranchSchedule {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid' })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
// Identificacion
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
// Tipo
|
||||||
|
@Column({ name: 'schedule_type', type: 'varchar', length: 30, default: 'regular' })
|
||||||
|
scheduleType: ScheduleType;
|
||||||
|
|
||||||
|
// Dia de la semana (0=domingo, 1=lunes, ..., 6=sabado) o fecha especifica
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'day_of_week', type: 'integer', nullable: true })
|
||||||
|
dayOfWeek: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'specific_date', type: 'date', nullable: true })
|
||||||
|
specificDate: Date;
|
||||||
|
|
||||||
|
// Horarios
|
||||||
|
@Column({ name: 'open_time', type: 'time' })
|
||||||
|
openTime: string;
|
||||||
|
|
||||||
|
@Column({ name: 'close_time', type: 'time' })
|
||||||
|
closeTime: string;
|
||||||
|
|
||||||
|
// Turnos (si aplica)
|
||||||
|
@Column({ type: 'jsonb', default: [] })
|
||||||
|
shifts: Array<{
|
||||||
|
name: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Branch, (branch) => branch.schedules, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'branch_id' })
|
||||||
|
branch: Branch;
|
||||||
|
}
|
||||||
158
src/modules/branches/entities/branch.entity.ts
Normal file
158
src/modules/branches/entities/branch.entity.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { UserBranchAssignment } from './user-branch-assignment.entity';
|
||||||
|
import { BranchSchedule } from './branch-schedule.entity';
|
||||||
|
import { BranchPaymentTerminal } from './branch-payment-terminal.entity';
|
||||||
|
|
||||||
|
export type BranchType = 'headquarters' | 'regional' | 'store' | 'warehouse' | 'office' | 'factory';
|
||||||
|
|
||||||
|
@Entity({ name: 'branches', schema: 'core' })
|
||||||
|
@Unique(['tenantId', 'code'])
|
||||||
|
export class Branch {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
|
||||||
|
parentId: string;
|
||||||
|
|
||||||
|
// Identificacion
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ name: 'short_name', type: 'varchar', length: 50, nullable: true })
|
||||||
|
shortName: string;
|
||||||
|
|
||||||
|
// Tipo
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'branch_type', type: 'varchar', length: 30, default: 'store' })
|
||||||
|
branchType: BranchType;
|
||||||
|
|
||||||
|
// Contacto
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ name: 'manager_id', type: 'uuid', nullable: true })
|
||||||
|
managerId: string;
|
||||||
|
|
||||||
|
// Direccion
|
||||||
|
@Column({ name: 'address_line1', type: 'varchar', length: 200, nullable: true })
|
||||||
|
addressLine1: string;
|
||||||
|
|
||||||
|
@Column({ name: 'address_line2', type: 'varchar', length: 200, nullable: true })
|
||||||
|
addressLine2: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
city: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
state: string;
|
||||||
|
|
||||||
|
@Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true })
|
||||||
|
postalCode: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: 'MEX' })
|
||||||
|
country: string;
|
||||||
|
|
||||||
|
// Geolocalizacion
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
longitude: number;
|
||||||
|
|
||||||
|
@Column({ name: 'geofence_radius', type: 'integer', default: 100 })
|
||||||
|
geofenceRadius: number; // Radio en metros
|
||||||
|
|
||||||
|
@Column({ name: 'geofence_enabled', type: 'boolean', default: true })
|
||||||
|
geofenceEnabled: boolean;
|
||||||
|
|
||||||
|
// Configuracion
|
||||||
|
@Column({ type: 'varchar', length: 50, default: 'America/Mexico_City' })
|
||||||
|
timezone: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: 'MXN' })
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_main', type: 'boolean', default: false })
|
||||||
|
isMain: boolean; // Sucursal principal/matriz
|
||||||
|
|
||||||
|
// Horarios de operacion
|
||||||
|
@Column({ name: 'operating_hours', type: 'jsonb', default: {} })
|
||||||
|
operatingHours: Record<string, { open: string; close: string }>;
|
||||||
|
|
||||||
|
// Configuraciones especificas
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
settings: {
|
||||||
|
allowPos?: boolean;
|
||||||
|
allowWarehouse?: boolean;
|
||||||
|
allowCheckIn?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jerarquia (path materializado)
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'hierarchy_path', type: 'text', nullable: true })
|
||||||
|
hierarchyPath: string;
|
||||||
|
|
||||||
|
@Column({ name: 'hierarchy_level', type: 'integer', default: 0 })
|
||||||
|
hierarchyLevel: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy: string;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Branch, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'parent_id' })
|
||||||
|
parent: Branch;
|
||||||
|
|
||||||
|
@OneToMany(() => Branch, (branch) => branch.parent)
|
||||||
|
children: Branch[];
|
||||||
|
|
||||||
|
@OneToMany(() => UserBranchAssignment, (assignment) => assignment.branch)
|
||||||
|
userAssignments: UserBranchAssignment[];
|
||||||
|
|
||||||
|
@OneToMany(() => BranchSchedule, (schedule) => schedule.branch)
|
||||||
|
schedules: BranchSchedule[];
|
||||||
|
|
||||||
|
@OneToMany(() => BranchPaymentTerminal, (terminal) => terminal.branchId)
|
||||||
|
paymentTerminals: BranchPaymentTerminal[];
|
||||||
|
}
|
||||||
5
src/modules/branches/entities/index.ts
Normal file
5
src/modules/branches/entities/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { Branch, BranchType } from './branch.entity';
|
||||||
|
export { UserBranchAssignment, AssignmentType, BranchRole } from './user-branch-assignment.entity';
|
||||||
|
export { BranchSchedule, ScheduleType } from './branch-schedule.entity';
|
||||||
|
export { BranchPaymentTerminal, TerminalProvider, HealthStatus } from './branch-payment-terminal.entity';
|
||||||
|
export { BranchInventorySettings } from './branch-inventory-settings.entity';
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Branch } from './branch.entity';
|
||||||
|
|
||||||
|
export type AssignmentType = 'primary' | 'secondary' | 'temporary' | 'floating';
|
||||||
|
export type BranchRole = 'manager' | 'supervisor' | 'staff';
|
||||||
|
|
||||||
|
@Entity({ name: 'user_branch_assignments', schema: 'core' })
|
||||||
|
@Unique(['userId', 'branchId', 'assignmentType'])
|
||||||
|
export class UserBranchAssignment {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid' })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Tipo de asignacion
|
||||||
|
@Column({ name: 'assignment_type', type: 'varchar', length: 30, default: 'primary' })
|
||||||
|
assignmentType: AssignmentType;
|
||||||
|
|
||||||
|
// Rol en la sucursal
|
||||||
|
@Column({ name: 'branch_role', type: 'varchar', length: 50, nullable: true })
|
||||||
|
branchRole: BranchRole;
|
||||||
|
|
||||||
|
// Permisos especificos
|
||||||
|
@Column({ type: 'jsonb', default: [] })
|
||||||
|
permissions: string[];
|
||||||
|
|
||||||
|
// Vigencia (para asignaciones temporales)
|
||||||
|
@Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
validFrom: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'valid_until', type: 'timestamptz', nullable: true })
|
||||||
|
validUntil: Date;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Branch, (branch) => branch.userAssignments, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'branch_id' })
|
||||||
|
branch: Branch;
|
||||||
|
}
|
||||||
@ -70,7 +70,7 @@ export function createPresupuestoController(dataSource: DataSource): Router {
|
|||||||
if (fraccionamientoId) {
|
if (fraccionamientoId) {
|
||||||
result = await presupuestoService.findByFraccionamiento(getContext(req), fraccionamientoId, page, limit);
|
result = await presupuestoService.findByFraccionamiento(getContext(req), fraccionamientoId, page, limit);
|
||||||
} else {
|
} else {
|
||||||
result = await presupuestoService.findAll(getContext(req), { page, limit });
|
result = await presupuestoService.findAll(getContext(req), page, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|||||||
270
src/modules/contracts/dto/contract-addendum.dto.ts
Normal file
270
src/modules/contracts/dto/contract-addendum.dto.ts
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* ContractAddendum DTOs - Data Transfer Objects para Addendas de Contratos
|
||||||
|
*
|
||||||
|
* Addendas y modificaciones a contratos.
|
||||||
|
*
|
||||||
|
* @module Contracts (MAI-012)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de addenda
|
||||||
|
*/
|
||||||
|
export enum AddendumTypeEnum {
|
||||||
|
EXTENSION = 'extension',
|
||||||
|
AMOUNT_INCREASE = 'amount_increase',
|
||||||
|
AMOUNT_DECREASE = 'amount_decrease',
|
||||||
|
SCOPE_CHANGE = 'scope_change',
|
||||||
|
TERMINATION = 'termination',
|
||||||
|
OTHER = 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de la addenda
|
||||||
|
*/
|
||||||
|
export enum AddendumStatusEnum {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
REVIEW = 'review',
|
||||||
|
APPROVED = 'approved',
|
||||||
|
REJECTED = 'rejected',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear una nueva addenda
|
||||||
|
*/
|
||||||
|
export class CreateAddendumDto {
|
||||||
|
@IsUUID()
|
||||||
|
contractId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(50)
|
||||||
|
addendumNumber: string;
|
||||||
|
|
||||||
|
@IsEnum(AddendumTypeEnum)
|
||||||
|
addendumType: AddendumTypeEnum;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
effectiveDate: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
adjustmentAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
newEndDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
newContractAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
scopeChanges?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
documentUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar una addenda existente
|
||||||
|
*/
|
||||||
|
export class UpdateAddendumDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(50)
|
||||||
|
addendumNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AddendumTypeEnum)
|
||||||
|
addendumType?: AddendumTypeEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
effectiveDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
adjustmentAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
newEndDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
newContractAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
scopeChanges?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AddendumStatusEnum)
|
||||||
|
status?: AddendumStatusEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
documentUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para aprobar una addenda
|
||||||
|
*/
|
||||||
|
export class ApproveAddendumDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para rechazar una addenda
|
||||||
|
*/
|
||||||
|
export class RejectAddendumDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
rejectionReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar addendas en listados
|
||||||
|
*/
|
||||||
|
export class AddendumFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
contractId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AddendumTypeEnum)
|
||||||
|
addendumType?: AddendumTypeEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AddendumStatusEnum)
|
||||||
|
status?: AddendumStatusEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
effectiveDateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
effectiveDateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para una addenda
|
||||||
|
*/
|
||||||
|
export class AddendumResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
contractId: string;
|
||||||
|
contract?: {
|
||||||
|
id: string;
|
||||||
|
contractNumber: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
addendumNumber: string;
|
||||||
|
addendumType: AddendumTypeEnum;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
effectiveDate: Date;
|
||||||
|
newEndDate?: Date;
|
||||||
|
amountChange: string;
|
||||||
|
newContractAmount?: string;
|
||||||
|
scopeChanges?: string;
|
||||||
|
status: AddendumStatusEnum;
|
||||||
|
approvedAt?: Date;
|
||||||
|
approvedById?: string;
|
||||||
|
approvedBy?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
rejectionReason?: string;
|
||||||
|
documentUrl?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
createdById?: string;
|
||||||
|
createdBy?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
466
src/modules/contracts/dto/contract.dto.ts
Normal file
466
src/modules/contracts/dto/contract.dto.ts
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
/**
|
||||||
|
* Contract DTOs - Data Transfer Objects para Contratos
|
||||||
|
*
|
||||||
|
* Contratos con clientes y subcontratistas.
|
||||||
|
*
|
||||||
|
* @module Contracts (MAI-012)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsDateString,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de contrato
|
||||||
|
*/
|
||||||
|
export enum ContractTypeEnum {
|
||||||
|
CLIENT = 'client',
|
||||||
|
SUBCONTRACTOR = 'subcontractor',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado del contrato
|
||||||
|
*/
|
||||||
|
export enum ContractStatusEnum {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
REVIEW = 'review',
|
||||||
|
APPROVED = 'approved',
|
||||||
|
ACTIVE = 'active',
|
||||||
|
COMPLETED = 'completed',
|
||||||
|
TERMINATED = 'terminated',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de contrato cliente
|
||||||
|
*/
|
||||||
|
export enum ClientContractTypeEnum {
|
||||||
|
DESARROLLO = 'desarrollo',
|
||||||
|
LLAVE_EN_MANO = 'llave_en_mano',
|
||||||
|
ADMINISTRACION = 'administracion',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear un nuevo contrato
|
||||||
|
*/
|
||||||
|
export class CreateContractDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(50)
|
||||||
|
contractNumber: string;
|
||||||
|
|
||||||
|
@IsEnum(ContractTypeEnum)
|
||||||
|
type: ContractTypeEnum;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
partnerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
fraccionamientoId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
subcontractorId?: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
startDate: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
contractAmount: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ClientContractTypeEnum)
|
||||||
|
clientContractType?: ClientContractTypeEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
clientName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(13)
|
||||||
|
clientRfc?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clientAddress?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
specialty?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
paymentTerms?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
retentionPercentage?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
advancePercentage?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar un contrato existente
|
||||||
|
*/
|
||||||
|
export class UpdateContractDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(50)
|
||||||
|
contractNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContractTypeEnum)
|
||||||
|
type?: ContractTypeEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
partnerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
fraccionamientoId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
subcontractorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
contractAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ClientContractTypeEnum)
|
||||||
|
clientContractType?: ClientContractTypeEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
clientName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(13)
|
||||||
|
clientRfc?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clientAddress?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
specialty?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
paymentTerms?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
retentionPercentage?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100)
|
||||||
|
advancePercentage?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContractStatusEnum)
|
||||||
|
status?: ContractStatusEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
documentUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
signedDocumentUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
terminationReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar contratos en listados
|
||||||
|
*/
|
||||||
|
export class ContractFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContractTypeEnum)
|
||||||
|
type?: ContractTypeEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContractStatusEnum)
|
||||||
|
status?: ContractStatusEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
partnerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
fraccionamientoId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
subcontractorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
amountMin?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
amountMax?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para aprobar un contrato
|
||||||
|
*/
|
||||||
|
export class ApproveContractDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
approvalDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para aprobacion legal del contrato
|
||||||
|
*/
|
||||||
|
export class LegalApproveContractDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para terminar un contrato
|
||||||
|
*/
|
||||||
|
export class TerminateContractDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
terminationDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para firmar un contrato
|
||||||
|
*/
|
||||||
|
export class SignContractDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
signedDocumentUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
signedDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para un contrato
|
||||||
|
*/
|
||||||
|
export class ContractResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
projectId?: string;
|
||||||
|
project?: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
fraccionamientoId?: string;
|
||||||
|
fraccionamiento?: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
contractNumber: string;
|
||||||
|
contractType: ContractTypeEnum;
|
||||||
|
clientContractType?: ClientContractTypeEnum;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
clientName?: string;
|
||||||
|
clientRfc?: string;
|
||||||
|
clientAddress?: string;
|
||||||
|
subcontractorId?: string;
|
||||||
|
subcontractor?: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
businessName: string;
|
||||||
|
};
|
||||||
|
specialty?: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
contractAmount: string;
|
||||||
|
currency: string;
|
||||||
|
paymentTerms?: string;
|
||||||
|
retentionPercentage: string;
|
||||||
|
advancePercentage: string;
|
||||||
|
status: ContractStatusEnum;
|
||||||
|
submittedAt?: Date;
|
||||||
|
legalApprovedAt?: Date;
|
||||||
|
approvedAt?: Date;
|
||||||
|
signedAt?: Date;
|
||||||
|
terminatedAt?: Date;
|
||||||
|
terminationReason?: string;
|
||||||
|
documentUrl?: string;
|
||||||
|
signedDocumentUrl?: string;
|
||||||
|
progressPercentage: string;
|
||||||
|
invoicedAmount: string;
|
||||||
|
paidAmount: string;
|
||||||
|
remainingAmount?: string;
|
||||||
|
isExpiring?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
addendumsCount?: number;
|
||||||
|
createdAt: Date;
|
||||||
|
createdById?: string;
|
||||||
|
createdBy?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
58
src/modules/contracts/dto/index.ts
Normal file
58
src/modules/contracts/dto/index.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Contracts DTOs Index
|
||||||
|
* Barrel file exporting all contracts module DTOs and Enums.
|
||||||
|
*
|
||||||
|
* @module Contracts (MAI-012)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTRACT DTOs
|
||||||
|
// ============================================================================
|
||||||
|
export {
|
||||||
|
// Enums
|
||||||
|
ContractTypeEnum,
|
||||||
|
ContractStatusEnum,
|
||||||
|
ClientContractTypeEnum,
|
||||||
|
// DTOs
|
||||||
|
CreateContractDto,
|
||||||
|
UpdateContractDto,
|
||||||
|
ContractFiltersDto,
|
||||||
|
ApproveContractDto,
|
||||||
|
LegalApproveContractDto,
|
||||||
|
TerminateContractDto,
|
||||||
|
SignContractDto,
|
||||||
|
ContractResponseDto,
|
||||||
|
} from './contract.dto';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTRACT ADDENDUM DTOs
|
||||||
|
// ============================================================================
|
||||||
|
export {
|
||||||
|
// Enums
|
||||||
|
AddendumTypeEnum,
|
||||||
|
AddendumStatusEnum,
|
||||||
|
// DTOs
|
||||||
|
CreateAddendumDto,
|
||||||
|
UpdateAddendumDto,
|
||||||
|
ApproveAddendumDto,
|
||||||
|
RejectAddendumDto,
|
||||||
|
AddendumFiltersDto,
|
||||||
|
AddendumResponseDto,
|
||||||
|
} from './contract-addendum.dto';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SUBCONTRACTOR DTOs
|
||||||
|
// ============================================================================
|
||||||
|
export {
|
||||||
|
// Enums
|
||||||
|
SubcontractorSpecialtyEnum,
|
||||||
|
SubcontractorStatusEnum,
|
||||||
|
// DTOs
|
||||||
|
CreateSubcontractorDto,
|
||||||
|
UpdateSubcontractorDto,
|
||||||
|
UpdateSubcontractorRatingDto,
|
||||||
|
RegisterIncidentDto,
|
||||||
|
BlacklistSubcontractorDto,
|
||||||
|
SubcontractorFiltersDto,
|
||||||
|
SubcontractorResponseDto,
|
||||||
|
} from './subcontractor.dto';
|
||||||
329
src/modules/contracts/dto/subcontractor.dto.ts
Normal file
329
src/modules/contracts/dto/subcontractor.dto.ts
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* Subcontractor DTOs - Data Transfer Objects para Subcontratistas
|
||||||
|
*
|
||||||
|
* Catalogo de subcontratistas.
|
||||||
|
*
|
||||||
|
* @module Contracts (MAI-012)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsEmail,
|
||||||
|
IsArray,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Especialidad del subcontratista
|
||||||
|
*/
|
||||||
|
export enum SubcontractorSpecialtyEnum {
|
||||||
|
CIMENTACION = 'cimentacion',
|
||||||
|
ESTRUCTURA = 'estructura',
|
||||||
|
INSTALACIONES_ELECTRICAS = 'instalaciones_electricas',
|
||||||
|
INSTALACIONES_HIDRAULICAS = 'instalaciones_hidraulicas',
|
||||||
|
ACABADOS = 'acabados',
|
||||||
|
URBANIZACION = 'urbanizacion',
|
||||||
|
CARPINTERIA = 'carpinteria',
|
||||||
|
HERRERIA = 'herreria',
|
||||||
|
OTROS = 'otros',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado del subcontratista
|
||||||
|
*/
|
||||||
|
export enum SubcontractorStatusEnum {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
INACTIVE = 'inactive',
|
||||||
|
BLACKLISTED = 'blacklisted',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear un nuevo subcontratista
|
||||||
|
*/
|
||||||
|
export class CreateSubcontractorDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(30)
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
businessName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
tradeName?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(12)
|
||||||
|
@MaxLength(13)
|
||||||
|
rfc: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(255)
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
contactName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
contactPhone?: string;
|
||||||
|
|
||||||
|
@IsEnum(SubcontractorSpecialtyEnum)
|
||||||
|
primarySpecialty: SubcontractorSpecialtyEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
secondarySpecialties?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
bankName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
bankAccount?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(18)
|
||||||
|
clabe?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar un subcontratista existente
|
||||||
|
*/
|
||||||
|
export class UpdateSubcontractorDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(30)
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(255)
|
||||||
|
businessName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
tradeName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(12)
|
||||||
|
@MaxLength(13)
|
||||||
|
rfc?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(255)
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
contactName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
contactPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SubcontractorSpecialtyEnum)
|
||||||
|
primarySpecialty?: SubcontractorSpecialtyEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
secondarySpecialties?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SubcontractorStatusEnum)
|
||||||
|
status?: SubcontractorStatusEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
bankName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
bankAccount?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(18)
|
||||||
|
clabe?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar rating del subcontratista
|
||||||
|
*/
|
||||||
|
export class UpdateSubcontractorRatingDto {
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(5)
|
||||||
|
rating: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
comments?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para registrar incidente del subcontratista
|
||||||
|
*/
|
||||||
|
export class RegisterIncidentDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
severity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para marcar subcontratista en lista negra
|
||||||
|
*/
|
||||||
|
export class BlacklistSubcontractorDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar subcontratistas en listados
|
||||||
|
*/
|
||||||
|
export class SubcontractorFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SubcontractorSpecialtyEnum)
|
||||||
|
primarySpecialty?: SubcontractorSpecialtyEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SubcontractorStatusEnum)
|
||||||
|
status?: SubcontractorStatusEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(5)
|
||||||
|
minRating?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(5)
|
||||||
|
maxRating?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
minContracts?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para un subcontratista
|
||||||
|
*/
|
||||||
|
export class SubcontractorResponseDto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
code: string;
|
||||||
|
businessName: string;
|
||||||
|
tradeName?: string;
|
||||||
|
rfc: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
contactName?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
primarySpecialty: SubcontractorSpecialtyEnum;
|
||||||
|
secondarySpecialties?: string[];
|
||||||
|
status: SubcontractorStatusEnum;
|
||||||
|
totalContracts: number;
|
||||||
|
completedContracts: number;
|
||||||
|
averageRating: string;
|
||||||
|
totalIncidents: number;
|
||||||
|
bankName?: string;
|
||||||
|
bankAccount?: string;
|
||||||
|
clabe?: string;
|
||||||
|
notes?: string;
|
||||||
|
activeContractsCount?: number;
|
||||||
|
createdAt: Date;
|
||||||
|
createdById?: string;
|
||||||
|
createdBy?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
35
src/modules/core/entities/country.entity.ts
Normal file
35
src/modules/core/entities/country.entity.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'countries' })
|
||||||
|
@Index('idx_countries_code', ['code'], { unique: true })
|
||||||
|
export class Country {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 2, nullable: false, unique: true })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, nullable: true, name: 'phone_code' })
|
||||||
|
phoneCode: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 3,
|
||||||
|
nullable: true,
|
||||||
|
name: 'currency_code',
|
||||||
|
})
|
||||||
|
currencyCode: string | null;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
55
src/modules/core/entities/currency-rate.entity.ts
Normal file
55
src/modules/core/entities/currency-rate.entity.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Currency } from './currency.entity';
|
||||||
|
|
||||||
|
export type RateSource = 'manual' | 'banxico' | 'xe' | 'openexchange';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'currency_rates' })
|
||||||
|
@Index('idx_currency_rates_tenant', ['tenantId'])
|
||||||
|
@Index('idx_currency_rates_from', ['fromCurrencyId'])
|
||||||
|
@Index('idx_currency_rates_to', ['toCurrencyId'])
|
||||||
|
@Index('idx_currency_rates_date', ['rateDate'])
|
||||||
|
@Index('idx_currency_rates_lookup', ['fromCurrencyId', 'toCurrencyId', 'rateDate'])
|
||||||
|
export class CurrencyRate {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'tenant_id', nullable: true })
|
||||||
|
tenantId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'from_currency_id', nullable: false })
|
||||||
|
fromCurrencyId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Currency)
|
||||||
|
@JoinColumn({ name: 'from_currency_id' })
|
||||||
|
fromCurrency: Currency;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'to_currency_id', nullable: false })
|
||||||
|
toCurrencyId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Currency)
|
||||||
|
@JoinColumn({ name: 'to_currency_id' })
|
||||||
|
toCurrency: Currency;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 18, scale: 8, nullable: false })
|
||||||
|
rate: number;
|
||||||
|
|
||||||
|
@Column({ type: 'date', name: 'rate_date', nullable: false })
|
||||||
|
rateDate: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, default: 'manual' })
|
||||||
|
source: RateSource;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'created_by', nullable: true })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
43
src/modules/core/entities/currency.entity.ts
Normal file
43
src/modules/core/entities/currency.entity.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'currencies' })
|
||||||
|
@Index('idx_currencies_code', ['code'], { unique: true })
|
||||||
|
@Index('idx_currencies_active', ['active'])
|
||||||
|
export class Currency {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, nullable: false, unique: true })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, nullable: false })
|
||||||
|
symbol: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 2, name: 'decimals' })
|
||||||
|
decimals: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 6,
|
||||||
|
nullable: true,
|
||||||
|
default: 0.01,
|
||||||
|
})
|
||||||
|
rounding: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
163
src/modules/core/entities/discount-rule.entity.ts
Normal file
163
src/modules/core/entities/discount-rule.entity.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de descuento
|
||||||
|
*/
|
||||||
|
export enum DiscountType {
|
||||||
|
PERCENTAGE = 'percentage', // Porcentaje del total
|
||||||
|
FIXED = 'fixed', // Monto fijo
|
||||||
|
PRICE_OVERRIDE = 'price_override', // Precio especial
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplicacion del descuento
|
||||||
|
*/
|
||||||
|
export enum DiscountAppliesTo {
|
||||||
|
ALL = 'all', // Todos los productos
|
||||||
|
CATEGORY = 'category', // Categoria especifica
|
||||||
|
PRODUCT = 'product', // Producto especifico
|
||||||
|
CUSTOMER = 'customer', // Cliente especifico
|
||||||
|
CUSTOMER_GROUP = 'customer_group', // Grupo de clientes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condicion de activacion
|
||||||
|
*/
|
||||||
|
export enum DiscountCondition {
|
||||||
|
NONE = 'none', // Sin condicion
|
||||||
|
MIN_QUANTITY = 'min_quantity', // Cantidad minima
|
||||||
|
MIN_AMOUNT = 'min_amount', // Monto minimo
|
||||||
|
DATE_RANGE = 'date_range', // Rango de fechas
|
||||||
|
FIRST_PURCHASE = 'first_purchase', // Primera compra
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regla de descuento
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'core', name: 'discount_rules' })
|
||||||
|
@Index('idx_discount_rules_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_discount_rules_code_tenant', ['tenantId', 'code'], { unique: true })
|
||||||
|
@Index('idx_discount_rules_active', ['tenantId', 'isActive'])
|
||||||
|
@Index('idx_discount_rules_dates', ['tenantId', 'startDate', 'endDate'])
|
||||||
|
@Index('idx_discount_rules_priority', ['tenantId', 'priority'])
|
||||||
|
export class DiscountRule {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
|
||||||
|
companyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: DiscountType,
|
||||||
|
default: DiscountType.PERCENTAGE,
|
||||||
|
name: 'discount_type',
|
||||||
|
})
|
||||||
|
discountType: DiscountType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 4,
|
||||||
|
nullable: false,
|
||||||
|
name: 'discount_value',
|
||||||
|
})
|
||||||
|
discountValue: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
name: 'max_discount_amount',
|
||||||
|
})
|
||||||
|
maxDiscountAmount: number | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: DiscountAppliesTo,
|
||||||
|
default: DiscountAppliesTo.ALL,
|
||||||
|
name: 'applies_to',
|
||||||
|
})
|
||||||
|
appliesTo: DiscountAppliesTo;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'applies_to_id' })
|
||||||
|
appliesToId: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: DiscountCondition,
|
||||||
|
default: DiscountCondition.NONE,
|
||||||
|
name: 'condition_type',
|
||||||
|
})
|
||||||
|
conditionType: DiscountCondition;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 4,
|
||||||
|
nullable: true,
|
||||||
|
name: 'condition_value',
|
||||||
|
})
|
||||||
|
conditionValue: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'start_date' })
|
||||||
|
startDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'end_date' })
|
||||||
|
endDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 10 })
|
||||||
|
priority: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true, name: 'combinable' })
|
||||||
|
combinable: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: true, name: 'usage_limit' })
|
||||||
|
usageLimit: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 0, name: 'usage_count' })
|
||||||
|
usageCount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
@ -2,5 +2,18 @@
|
|||||||
* Core Entities Index
|
* Core Entities Index
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Existing entities
|
||||||
export { Tenant } from './tenant.entity';
|
export { Tenant } from './tenant.entity';
|
||||||
export { User } from './user.entity';
|
export { User } from './user.entity';
|
||||||
|
|
||||||
|
// Catalog entities (propagated from erp-core)
|
||||||
|
export { Country } from './country.entity';
|
||||||
|
export { Currency } from './currency.entity';
|
||||||
|
export { CurrencyRate, RateSource } from './currency-rate.entity';
|
||||||
|
export { DiscountRule, DiscountType, DiscountAppliesTo, DiscountCondition } from './discount-rule.entity';
|
||||||
|
export { PaymentTerm, PaymentTermLine, PaymentTermLineType } from './payment-term.entity';
|
||||||
|
export { ProductCategory } from './product-category.entity';
|
||||||
|
export { Sequence, ResetPeriod } from './sequence.entity';
|
||||||
|
export { State } from './state.entity';
|
||||||
|
export { Uom, UomType } from './uom.entity';
|
||||||
|
export { UomCategory } from './uom-category.entity';
|
||||||
|
|||||||
144
src/modules/core/entities/payment-term.entity.ts
Normal file
144
src/modules/core/entities/payment-term.entity.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de calculo para la linea del termino de pago
|
||||||
|
*/
|
||||||
|
export enum PaymentTermLineType {
|
||||||
|
BALANCE = 'balance', // Saldo restante
|
||||||
|
PERCENT = 'percent', // Porcentaje del total
|
||||||
|
FIXED = 'fixed', // Monto fijo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linea de termino de pago (para terminos con multiples vencimientos)
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'core', name: 'payment_term_lines' })
|
||||||
|
@Index('idx_payment_term_lines_term', ['paymentTermId'])
|
||||||
|
export class PaymentTermLine {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'payment_term_id' })
|
||||||
|
paymentTermId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 1 })
|
||||||
|
sequence: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: PaymentTermLineType,
|
||||||
|
default: PaymentTermLineType.BALANCE,
|
||||||
|
name: 'line_type',
|
||||||
|
})
|
||||||
|
lineType: PaymentTermLineType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 5,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
name: 'value_percent',
|
||||||
|
})
|
||||||
|
valuePercent: number | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
name: 'value_amount',
|
||||||
|
})
|
||||||
|
valueAmount: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 0 })
|
||||||
|
days: number;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: true, name: 'day_of_month' })
|
||||||
|
dayOfMonth: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: false, name: 'end_of_month' })
|
||||||
|
endOfMonth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Termino de pago (Net 30, 50% advance + 50% on delivery, etc.)
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'core', name: 'payment_terms' })
|
||||||
|
@Index('idx_payment_terms_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_payment_terms_code_tenant', ['tenantId', 'code'], { unique: true })
|
||||||
|
@Index('idx_payment_terms_active', ['tenantId', 'isActive'])
|
||||||
|
export class PaymentTerm {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
|
||||||
|
companyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 0, name: 'due_days' })
|
||||||
|
dueDays: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 5,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
default: 0,
|
||||||
|
name: 'discount_percent',
|
||||||
|
})
|
||||||
|
discountPercent: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: true, default: 0, name: 'discount_days' })
|
||||||
|
discountDays: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: false, name: 'is_immediate' })
|
||||||
|
isImmediate: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 0 })
|
||||||
|
sequence: number;
|
||||||
|
|
||||||
|
@OneToMany(() => PaymentTermLine, (line) => line.paymentTermId, { eager: true })
|
||||||
|
lines: PaymentTermLine[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
79
src/modules/core/entities/product-category.entity.ts
Normal file
79
src/modules/core/entities/product-category.entity.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'product_categories' })
|
||||||
|
@Index('idx_product_categories_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_product_categories_parent_id', ['parentId'])
|
||||||
|
@Index('idx_product_categories_code_tenant', ['tenantId', 'code'], {
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
@Index('idx_product_categories_active', ['tenantId', 'active'], {
|
||||||
|
where: 'deleted_at IS NULL',
|
||||||
|
})
|
||||||
|
export class ProductCategory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
code: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'parent_id' })
|
||||||
|
parentId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'full_path' })
|
||||||
|
fullPath: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => ProductCategory, (category) => category.children, {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'parent_id' })
|
||||||
|
parent: ProductCategory | null;
|
||||||
|
|
||||||
|
@OneToMany(() => ProductCategory, (category) => category.parent)
|
||||||
|
children: ProductCategory[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
83
src/modules/core/entities/sequence.entity.ts
Normal file
83
src/modules/core/entities/sequence.entity.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export enum ResetPeriod {
|
||||||
|
NONE = 'none',
|
||||||
|
YEAR = 'year',
|
||||||
|
MONTH = 'month',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'sequences' })
|
||||||
|
@Index('idx_sequences_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_sequences_code_tenant', ['tenantId', 'code'], { unique: true })
|
||||||
|
@Index('idx_sequences_active', ['tenantId', 'isActive'])
|
||||||
|
export class Sequence {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
|
||||||
|
companyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
prefix: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
suffix: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 1, name: 'next_number' })
|
||||||
|
nextNumber: number;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 4 })
|
||||||
|
padding: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ResetPeriod,
|
||||||
|
nullable: true,
|
||||||
|
default: ResetPeriod.NONE,
|
||||||
|
name: 'reset_period',
|
||||||
|
})
|
||||||
|
resetPeriod: ResetPeriod | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
name: 'last_reset_date',
|
||||||
|
})
|
||||||
|
lastResetDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
45
src/modules/core/entities/state.entity.ts
Normal file
45
src/modules/core/entities/state.entity.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Country } from './country.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'states' })
|
||||||
|
@Index('idx_states_country', ['countryId'])
|
||||||
|
@Index('idx_states_code', ['code'])
|
||||||
|
@Index('idx_states_country_code', ['countryId', 'code'], { unique: true })
|
||||||
|
export class State {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', name: 'country_id', nullable: false })
|
||||||
|
countryId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Country, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'country_id' })
|
||||||
|
country: Country;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
timezone: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', name: 'is_active', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
45
src/modules/core/entities/uom-category.entity.ts
Normal file
45
src/modules/core/entities/uom-category.entity.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Uom } from './uom.entity';
|
||||||
|
import { Tenant } from './tenant.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'uom_categories' })
|
||||||
|
@Index('idx_uom_categories_tenant', ['tenantId'])
|
||||||
|
@Index('idx_uom_categories_tenant_name', ['tenantId', 'name'], { unique: true })
|
||||||
|
export class UomCategory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Tenant)
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@OneToMany(() => Uom, (uom) => uom.category)
|
||||||
|
uoms: Uom[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
89
src/modules/core/entities/uom.entity.ts
Normal file
89
src/modules/core/entities/uom.entity.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { UomCategory } from './uom-category.entity';
|
||||||
|
import { Tenant } from './tenant.entity';
|
||||||
|
|
||||||
|
export enum UomType {
|
||||||
|
REFERENCE = 'reference',
|
||||||
|
BIGGER = 'bigger',
|
||||||
|
SMALLER = 'smaller',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'uom' })
|
||||||
|
@Index('idx_uom_tenant', ['tenantId'])
|
||||||
|
@Index('idx_uom_category_id', ['categoryId'])
|
||||||
|
@Index('idx_uom_code', ['code'])
|
||||||
|
@Index('idx_uom_active', ['active'])
|
||||||
|
@Index('idx_uom_tenant_category_name', ['tenantId', 'categoryId', 'name'], { unique: true })
|
||||||
|
export class Uom {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'category_id' })
|
||||||
|
categoryId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
code: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: UomType,
|
||||||
|
nullable: false,
|
||||||
|
default: UomType.REFERENCE,
|
||||||
|
name: 'uom_type',
|
||||||
|
})
|
||||||
|
uomType: UomType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 6,
|
||||||
|
nullable: false,
|
||||||
|
default: 1.0,
|
||||||
|
})
|
||||||
|
factor: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 6,
|
||||||
|
nullable: true,
|
||||||
|
default: 0.01,
|
||||||
|
})
|
||||||
|
rounding: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Tenant)
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@ManyToOne(() => UomCategory, (category) => category.uoms, {
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'category_id' })
|
||||||
|
category: UomCategory;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
400
src/modules/documents/dto/approval.dto.ts
Normal file
400
src/modules/documents/dto/approval.dto.ts
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
/**
|
||||||
|
* Approval DTOs - Data Transfer Objects para Flujos de Aprobacion
|
||||||
|
*
|
||||||
|
* Gestiona workflows de aprobacion para documentos.
|
||||||
|
*
|
||||||
|
* @module Documents (MAE-016)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
ValidateNested,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de paso de aprobacion
|
||||||
|
*/
|
||||||
|
export enum ApprovalStepTypeEnum {
|
||||||
|
REVIEW = 'review',
|
||||||
|
APPROVAL = 'approval',
|
||||||
|
SIGNATURE = 'signature',
|
||||||
|
COMMENT = 'comment',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado del workflow
|
||||||
|
*/
|
||||||
|
export enum WorkflowStatusEnum {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
PENDING = 'pending',
|
||||||
|
IN_PROGRESS = 'in_progress',
|
||||||
|
APPROVED = 'approved',
|
||||||
|
REJECTED = 'rejected',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accion de aprobacion
|
||||||
|
*/
|
||||||
|
export enum ApprovalActionEnum {
|
||||||
|
APPROVE = 'approve',
|
||||||
|
REJECT = 'reject',
|
||||||
|
REQUEST_CHANGES = 'request_changes',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para definir un paso de aprobacion dentro de un workflow
|
||||||
|
*/
|
||||||
|
export class CreateApprovalStepDto {
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
stepNumber!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsEnum(ApprovalStepTypeEnum)
|
||||||
|
type!: ApprovalStepTypeEnum;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
approvers!: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
requiredCount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear un nuevo workflow de aprobacion
|
||||||
|
*/
|
||||||
|
export class CreateApprovalWorkflowDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(50)
|
||||||
|
workflowCode!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
documentType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateApprovalStepDto)
|
||||||
|
steps?: CreateApprovalStepDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowParallel?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowSkip?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
autoArchiveOnApproval?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar un workflow de aprobacion existente
|
||||||
|
*/
|
||||||
|
export class UpdateApprovalWorkflowDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(255)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
documentType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateApprovalStepDto)
|
||||||
|
steps?: CreateApprovalStepDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowParallel?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowSkip?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
autoArchiveOnApproval?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para enviar un documento a aprobacion
|
||||||
|
*/
|
||||||
|
export class SubmitForApprovalDto {
|
||||||
|
@IsUUID()
|
||||||
|
documentId!: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
workflowId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
versionId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dueDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para aprobar o rechazar un documento
|
||||||
|
*/
|
||||||
|
export class ApproveRejectDto {
|
||||||
|
@IsEnum(ApprovalActionEnum)
|
||||||
|
action!: ApprovalActionEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
comments?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para cancelar una instancia de aprobacion
|
||||||
|
*/
|
||||||
|
export class CancelApprovalDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(2000)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar workflows de aprobacion
|
||||||
|
*/
|
||||||
|
export class WorkflowFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
documentType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar instancias de aprobacion
|
||||||
|
*/
|
||||||
|
export class ApprovalInstanceFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
documentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
workflowId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(WorkflowStatusEnum)
|
||||||
|
status?: WorkflowStatusEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
initiatedById?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startedFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startedTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dueFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dueTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para un workflow de aprobacion
|
||||||
|
*/
|
||||||
|
export class ApprovalWorkflowResponseDto {
|
||||||
|
id!: string;
|
||||||
|
tenantId!: string;
|
||||||
|
workflowCode!: string;
|
||||||
|
name!: string;
|
||||||
|
description?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
category?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
documentType?: string;
|
||||||
|
steps!: Array<{
|
||||||
|
stepNumber: number;
|
||||||
|
name: string;
|
||||||
|
type: ApprovalStepTypeEnum;
|
||||||
|
approvers: string[];
|
||||||
|
requiredCount: number;
|
||||||
|
}>;
|
||||||
|
allowParallel!: boolean;
|
||||||
|
allowSkip!: boolean;
|
||||||
|
autoArchiveOnApproval!: boolean;
|
||||||
|
isActive!: boolean;
|
||||||
|
createdBy?: string;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedBy?: string;
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para una instancia de aprobacion
|
||||||
|
*/
|
||||||
|
export class ApprovalInstanceResponseDto {
|
||||||
|
id!: string;
|
||||||
|
tenantId!: string;
|
||||||
|
workflowId!: string;
|
||||||
|
workflow?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workflowCode: string;
|
||||||
|
};
|
||||||
|
documentId!: string;
|
||||||
|
document?: {
|
||||||
|
id: string;
|
||||||
|
documentCode: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
versionId?: string;
|
||||||
|
status!: WorkflowStatusEnum;
|
||||||
|
currentStep!: number;
|
||||||
|
totalSteps!: number;
|
||||||
|
startedAt?: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
dueDate?: Date;
|
||||||
|
initiatedById?: string;
|
||||||
|
initiatedByName?: string;
|
||||||
|
finalAction?: ApprovalActionEnum;
|
||||||
|
finalComments?: string;
|
||||||
|
finalApproverId?: string;
|
||||||
|
notes?: string;
|
||||||
|
steps?: Array<{
|
||||||
|
id: string;
|
||||||
|
stepNumber: number;
|
||||||
|
stepName: string;
|
||||||
|
stepType: ApprovalStepTypeEnum;
|
||||||
|
status: WorkflowStatusEnum;
|
||||||
|
startedAt?: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
actionTaken?: ApprovalActionEnum;
|
||||||
|
}>;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
233
src/modules/documents/dto/document-version.dto.ts
Normal file
233
src/modules/documents/dto/document-version.dto.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Document Version DTOs - Data Transfer Objects para Versiones de Documentos
|
||||||
|
*
|
||||||
|
* Gestiona la creacion y filtrado de versiones de documentos.
|
||||||
|
*
|
||||||
|
* @module Documents (MAE-016)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsUrl,
|
||||||
|
IsDateString,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de la version
|
||||||
|
*/
|
||||||
|
export enum VersionStatusEnum {
|
||||||
|
CURRENT = 'current',
|
||||||
|
SUPERSEDED = 'superseded',
|
||||||
|
ARCHIVED = 'archived',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear una nueva version de documento
|
||||||
|
*/
|
||||||
|
export class CreateVersionDto {
|
||||||
|
@IsUUID()
|
||||||
|
documentId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(20)
|
||||||
|
versionNumber!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
versionLabel?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(500)
|
||||||
|
fileName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(20)
|
||||||
|
fileExtension!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
fileSizeBytes!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
mimeType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
checksumMd5?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
checksumSha256?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
storageProvider?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
storageBucket?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(1000)
|
||||||
|
storageKey!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
@MaxLength(2000)
|
||||||
|
storageUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
@MaxLength(2000)
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
@MaxLength(2000)
|
||||||
|
previewUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
changeSummary?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
changeType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
pageCount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
uploadSource?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar versiones de documentos
|
||||||
|
*/
|
||||||
|
export class VersionFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
documentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
versionNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VersionStatusEnum)
|
||||||
|
status?: VersionStatusEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
uploadedById?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
mimeType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
createdFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
createdTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para una version de documento
|
||||||
|
*/
|
||||||
|
export class VersionResponseDto {
|
||||||
|
id!: string;
|
||||||
|
tenantId!: string;
|
||||||
|
documentId!: string;
|
||||||
|
versionNumber!: string;
|
||||||
|
versionLabel?: string;
|
||||||
|
status!: VersionStatusEnum;
|
||||||
|
fileName!: string;
|
||||||
|
fileExtension!: string;
|
||||||
|
fileSizeBytes!: number;
|
||||||
|
mimeType?: string;
|
||||||
|
checksumMd5?: string;
|
||||||
|
checksumSha256?: string;
|
||||||
|
storageProvider!: string;
|
||||||
|
storageBucket?: string;
|
||||||
|
storageKey!: string;
|
||||||
|
storageUrl?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
previewUrl?: string;
|
||||||
|
isProcessed!: boolean;
|
||||||
|
ocrText?: string;
|
||||||
|
pageCount?: number;
|
||||||
|
changeSummary?: string;
|
||||||
|
changeType?: string;
|
||||||
|
uploadedById?: string;
|
||||||
|
uploadedByName?: string;
|
||||||
|
uploadSource?: string;
|
||||||
|
createdAt!: Date;
|
||||||
|
supersededAt?: Date;
|
||||||
|
supersededByVersionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para marcar una version como actual
|
||||||
|
*/
|
||||||
|
export class SetCurrentVersionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para archivar una version
|
||||||
|
*/
|
||||||
|
export class ArchiveVersionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
447
src/modules/documents/dto/document.dto.ts
Normal file
447
src/modules/documents/dto/document.dto.ts
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
/**
|
||||||
|
* Document DTOs - Data Transfer Objects para Documentos
|
||||||
|
*
|
||||||
|
* Gestiona la creacion, actualizacion y filtrado de documentos.
|
||||||
|
*
|
||||||
|
* @module Documents (MAE-016)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsArray,
|
||||||
|
IsUrl,
|
||||||
|
IsDateString,
|
||||||
|
IsBoolean,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo de documento
|
||||||
|
*/
|
||||||
|
export enum DocumentTypeEnum {
|
||||||
|
PLAN = 'plan',
|
||||||
|
SPECIFICATION = 'specification',
|
||||||
|
CONTRACT = 'contract',
|
||||||
|
PERMIT = 'permit',
|
||||||
|
REPORT = 'report',
|
||||||
|
PHOTOGRAPH = 'photograph',
|
||||||
|
DRAWING = 'drawing',
|
||||||
|
MANUAL = 'manual',
|
||||||
|
PROCEDURE = 'procedure',
|
||||||
|
FORM = 'form',
|
||||||
|
CORRESPONDENCE = 'correspondence',
|
||||||
|
INVOICE = 'invoice',
|
||||||
|
ESTIMATE = 'estimate',
|
||||||
|
OTHER = 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado del documento
|
||||||
|
*/
|
||||||
|
export enum DocumentStatusEnum {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
PENDING_REVIEW = 'pending_review',
|
||||||
|
IN_REVIEW = 'in_review',
|
||||||
|
APPROVED = 'approved',
|
||||||
|
REJECTED = 'rejected',
|
||||||
|
OBSOLETE = 'obsolete',
|
||||||
|
ARCHIVED = 'archived',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nivel de acceso del documento
|
||||||
|
*/
|
||||||
|
export enum AccessLevelEnum {
|
||||||
|
PUBLIC = 'public',
|
||||||
|
INTERNAL = 'internal',
|
||||||
|
CONFIDENTIAL = 'confidential',
|
||||||
|
RESTRICTED = 'restricted',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para crear un nuevo documento
|
||||||
|
*/
|
||||||
|
export class CreateDocumentDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(500)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(100)
|
||||||
|
documentCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsEnum(DocumentTypeEnum)
|
||||||
|
documentType!: DocumentTypeEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AccessLevelEnum)
|
||||||
|
accessLevel?: AccessLevelEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
fileUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
fileSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
mimeType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
projectCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
projectName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
keywords?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
author?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
documentDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
effectiveDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expiryDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
source?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
externalReference?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
originalFilename?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentDocumentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
relatedDocuments?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresApproval?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isTemplate?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para actualizar un documento existente
|
||||||
|
*/
|
||||||
|
export class UpdateDocumentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(500)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DocumentTypeEnum)
|
||||||
|
documentType?: DocumentTypeEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DocumentStatusEnum)
|
||||||
|
status?: DocumentStatusEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AccessLevelEnum)
|
||||||
|
accessLevel?: AccessLevelEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
projectCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
projectName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
keywords?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
author?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
documentDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
effectiveDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expiryDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
reviewDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
source?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
externalReference?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentDocumentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
relatedDocuments?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresApproval?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isTemplate?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para filtrar documentos en listados
|
||||||
|
*/
|
||||||
|
export class DocumentFiltersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DocumentTypeEnum)
|
||||||
|
documentType?: DocumentTypeEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DocumentStatusEnum)
|
||||||
|
status?: DocumentStatusEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AccessLevelEnum)
|
||||||
|
accessLevel?: AccessLevelEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
author?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
createdFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
createdTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
documentDateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
documentDateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresApproval?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isTemplate?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentDocumentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sortBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO de respuesta para un documento
|
||||||
|
*/
|
||||||
|
export class DocumentResponseDto {
|
||||||
|
id!: string;
|
||||||
|
tenantId!: string;
|
||||||
|
documentCode!: string;
|
||||||
|
title!: string;
|
||||||
|
description?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
category?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
documentType!: DocumentTypeEnum;
|
||||||
|
status!: DocumentStatusEnum;
|
||||||
|
accessLevel!: AccessLevelEnum;
|
||||||
|
currentVersionId?: string;
|
||||||
|
currentVersionNumber!: string;
|
||||||
|
projectId?: string;
|
||||||
|
projectCode?: string;
|
||||||
|
projectName?: string;
|
||||||
|
author?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
documentDate?: Date;
|
||||||
|
effectiveDate?: Date;
|
||||||
|
expiryDate?: Date;
|
||||||
|
reviewDate?: Date;
|
||||||
|
source?: string;
|
||||||
|
externalReference?: string;
|
||||||
|
originalFilename?: string;
|
||||||
|
parentDocumentId?: string;
|
||||||
|
relatedDocuments?: string[];
|
||||||
|
requiresApproval!: boolean;
|
||||||
|
currentWorkflowId?: string;
|
||||||
|
approvedById?: string;
|
||||||
|
approvedAt?: Date;
|
||||||
|
viewCount!: number;
|
||||||
|
downloadCount!: number;
|
||||||
|
lastAccessedAt?: Date;
|
||||||
|
isTemplate!: boolean;
|
||||||
|
isLocked!: boolean;
|
||||||
|
lockedById?: string;
|
||||||
|
lockedAt?: Date;
|
||||||
|
notes?: string;
|
||||||
|
versionsCount?: number;
|
||||||
|
annotationsCount?: number;
|
||||||
|
createdBy?: string;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedBy?: string;
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO para bloquear/desbloquear un documento
|
||||||
|
*/
|
||||||
|
export class LockDocumentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user