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:
Adrian Flores Cortes 2026-01-27 07:00:18 -06:00
parent c8a01c5f14
commit 598c3215e1
287 changed files with 26773 additions and 3408 deletions

690
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -33,10 +33,10 @@
"license": "UNLICENSED",
"dependencies": {
"express": "^4.18.2",
"typeorm": "^0.3.17",
"typeorm": "^0.3.28",
"pg": "^8.11.3",
"reflect-metadata": "^0.1.13",
"class-validator": "^0.14.0",
"reflect-metadata": "^0.2.2",
"class-validator": "^0.14.3",
"class-transformer": "^0.5.1",
"dotenv": "^16.3.1",
"cors": "^2.8.5",
@ -46,6 +46,12 @@
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"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"
},
"devDependencies": {
@ -56,6 +62,10 @@
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.5",
"@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",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
@ -67,7 +77,7 @@
"typescript": "^5.3.3"
},
"engines": {
"node": ">=18.0.0",
"node": ">=20.0.0",
"npm": ">=9.0.0"
}
}

41
src/config/index.ts Normal file
View 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',
},
};

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -6,3 +6,8 @@ export { RefreshToken } from './refresh-token.entity';
export { Role } from './role.entity';
export { Permission } from './permission.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';

View 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;
}

View File

@ -1,6 +1,7 @@
/**
* Role Entity
* Roles del sistema para RBAC
* Compatible con erp-core role.entity
*
* @module Auth
*/
@ -13,16 +14,26 @@ import {
UpdateDateColumn,
OneToMany,
ManyToMany,
ManyToOne,
JoinColumn,
JoinTable,
Index,
} from 'typeorm';
import { Permission } from './permission.entity';
import { UserRole } from './user-role.entity';
import { Tenant } from '../../core/entities/tenant.entity';
@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 {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: true, name: 'tenant_id' })
tenantId: string | null;
@Column({ type: 'varchar', length: 50, unique: true })
code: string;
@ -30,7 +41,7 @@ export class Role {
name: string;
@Column({ type: 'text', nullable: true })
description: string;
description: string | null;
@Column({ name: 'is_system', type: 'boolean', default: false })
isSystem: boolean;
@ -38,16 +49,18 @@ export class Role {
@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;
@Column({ type: 'varchar', length: 20, nullable: true })
color: string | null;
// Relations
@ManyToOne(() => Tenant, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant | null;
@ManyToMany(() => Permission)
@JoinTable({
name: 'role_permissions',
schema: 'auth',
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
})
@ -55,4 +68,23 @@ export class Role {
@OneToMany(() => UserRole, (userRole) => userRole.role)
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;
}

View 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;
}

View File

@ -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
*/
@ -11,9 +11,9 @@ import { DataSource } from 'typeorm';
import { BidAnalyticsService } from '../services/bid-analytics.service';
import { AuthMiddleware } from '../../auth/middleware/auth.middleware';
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 { BidCompetitor } from '../entities/bid-competitor.entity';
import { Proposal } from '../entities/proposal.entity';
import { User } from '../../core/entities/user.entity';
import { Tenant } from '../../core/entities/tenant.entity';
import { RefreshToken } from '../../auth/entities/refresh-token.entity';
@ -23,15 +23,15 @@ export function createBidAnalyticsController(dataSource: DataSource): Router {
const router = Router();
// Repositorios
const bidRepository = dataSource.getRepository(Bid);
const tenderRepository = dataSource.getRepository(Tender);
const opportunityRepository = dataSource.getRepository(Opportunity);
const competitorRepository = dataSource.getRepository(BidCompetitor);
const proposalRepository = dataSource.getRepository(Proposal);
const userRepository = dataSource.getRepository(User);
const tenantRepository = dataSource.getRepository(Tenant);
const refreshTokenRepository = dataSource.getRepository(RefreshToken);
// 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 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;
}

View File

@ -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;

View File

@ -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;

View File

@ -1,9 +1,16 @@
/**
* Bidding Controllers Index
*
* Exports all controllers for the MAI-018 Bidding/Preconstruction module.
*
* @module Bidding
*/
// Core bidding controllers
export { createOpportunityController } from './opportunity.controller';
export { createBidController } from './bid.controller';
export { createBidBudgetController } from './bid-budget.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';

View File

@ -1,14 +1,14 @@
/**
* OpportunityController - Controller de Oportunidades
*
* Endpoints REST para gestión del pipeline de oportunidades.
* Endpoints REST para gestion del pipeline de oportunidades.
*
* @module Bidding
*/
import { Router, Request, Response, NextFunction } from 'express';
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 { AuthService } from '../../auth/services/auth.service';
import { Opportunity, OpportunityStatus } from '../entities/opportunity.entity';
@ -52,27 +52,26 @@ export function createOpportunityController(dataSource: DataSource): Router {
return;
}
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const filters: OpportunityFilters = {
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) {
const statuses = (req.query.status as string).split(',') as OpportunityStatus[];
filters.status = statuses.length === 1 ? statuses[0] : statuses;
}
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.assignedToId) filters.assignedToId = req.query.assignedToId 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.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.maxValue) filters.maxValue = parseFloat(req.query.maxValue 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({
success: true,
@ -121,6 +120,7 @@ export function createOpportunityController(dataSource: DataSource): Router {
/**
* GET /opportunities/stats
* Get statistics
*/
router.get('/stats', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
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 opportunity by ID
*/
router.get('/:id', authMiddleware.authenticate, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
@ -170,10 +194,10 @@ export function createOpportunityController(dataSource: DataSource): Router {
}
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({
error: 'Bad Request',
message: 'code, name, source, projectType, clientName, and identificationDate are required',
message: 'title, source, projectType, clientName, and deadlineDate are required',
});
return;
}
@ -187,6 +211,7 @@ export function createOpportunityController(dataSource: DataSource): Router {
/**
* 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> => {
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
* Change opportunity status
*/
router.post('/:id/status', authMiddleware.authenticate, authMiddleware.authorize('admin', 'director', 'gerente', 'comercial'), async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {

View 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;

View 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;

View 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;

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -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;
}

View File

@ -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.
*
* @module Bidding
* @module Bidding (MAI-018)
* @table bidding.bid_calendar
* @ddl schemas/XX-bidding-schema-ddl.sql
*/
import {
@ -16,173 +17,94 @@ import {
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.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 =
| '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'
| 'technical_opening'
| 'economic_opening'
| 'award_date'
| 'other';
export type EventPriority = 'low' | 'medium' | 'high' | 'critical';
export type EventStatus = 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'postponed';
@Entity('bid_calendar', { schema: 'bidding' })
@Index(['tenantId', 'bidId'])
@Index(['tenantId', 'eventDate'])
@Entity({ schema: 'bidding', name: 'bid_calendar' })
@Index(['tenantId'])
@Index(['tenantId', 'tenderId'])
@Index(['tenantId', 'eventType'])
@Index(['eventDate'])
@Index(['alertSent'])
export class BidCalendar {
@PrimaryGeneratedColumn('uuid')
id!: string;
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
tenantId: string;
// Referencia a licitación
@Column({ name: 'bid_id', type: 'uuid' })
bidId!: 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;
/** Reference to the tender */
@Column({ name: 'tender_id', type: 'uuid' })
tenderId: string;
@Column({
name: 'event_type',
type: 'enum',
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'],
enumName: 'calendar_event_type',
type: 'varchar',
length: 50,
})
eventType!: CalendarEventType;
eventType: CalendarEventType;
@Column({
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
/** Date and time of the event */
@Column({ name: 'event_date', type: 'timestamptz' })
eventDate!: Date;
eventDate: Date;
@Column({ name: 'end_date', type: 'timestamptz', nullable: true })
endDate?: Date;
/** Description of the event */
@Column({ type: 'varchar', length: 255 })
description: string;
@Column({ name: 'is_all_day', type: 'boolean', default: false })
isAllDay!: boolean;
/** Days before the event to send alert */
@Column({ name: 'alert_days_before', type: 'int', default: 3 })
alertDaysBefore: number;
@Column({ name: 'timezone', length: 50, default: 'America/Mexico_City' })
timezone!: string;
/** Whether alert has been sent */
@Column({ name: 'alert_sent', type: 'boolean', default: false })
alertSent: boolean;
// Ubicación
@Column({ length: 255, nullable: true })
location?: string;
/** Additional notes */
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'is_virtual', type: 'boolean', default: false })
isVirtual!: boolean;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@Column({ name: 'meeting_link', length: 500, nullable: true })
meetingLink?: string;
@ManyToOne(() => Tender, (tender) => tender.calendarEvents)
@JoinColumn({ name: 'tender_id' })
tender: Tender;
// Recordatorios
@Column({ name: 'reminder_minutes', type: 'int', array: true, nullable: true })
reminderMinutes?: number[];
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'reminder_sent', type: 'boolean', default: false })
reminderSent!: boolean;
@Column({ name: 'last_reminder_at', type: 'timestamptz', nullable: true })
lastReminderAt?: Date;
// Asignación
@Column({ name: 'assigned_to_id', type: 'uuid', nullable: true })
assignedToId?: string;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'assigned_to_id' })
assignedTo?: 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;
@JoinColumn({ name: 'created_by' })
createdBy: User;
@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 })
deletedAt?: Date;
deletedAt: Date;
}

View File

@ -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;
}

View File

@ -1,9 +1,10 @@
/**
* BidDocument Entity - Documentos de 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 {
@ -16,155 +17,110 @@ import {
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { Bid } from './bid.entity';
import { Tender } from './tender.entity';
export type DocumentCategory =
| 'tender_bases'
| 'clarifications'
| 'annexes'
| 'technical_proposal'
| 'economic_proposal'
| 'legal_documents'
| 'experience_certificates'
| 'financial_statements'
| 'bonds'
| 'contracts'
| 'correspondence'
| 'meeting_minutes'
/** Type/category of document */
export type BidDocumentType =
| 'bases'
| 'technical_annex'
| 'economic_annex'
| 'clarification'
| 'proposal_tech'
| 'proposal_econ'
| 'contract'
| 'other';
export type DocumentStatus = 'draft' | 'pending_review' | 'approved' | 'rejected' | 'submitted' | 'archived';
@Entity('bid_documents', { schema: 'bidding' })
@Index(['tenantId', 'bidId'])
@Index(['tenantId', 'category'])
@Entity({ schema: 'bidding', name: 'bid_documents' })
@Index(['tenantId'])
@Index(['tenantId', 'tenderId'])
@Index(['tenantId', 'documentType'])
@Index(['uploadedAt'])
export class BidDocument {
@PrimaryGeneratedColumn('uuid')
id!: string;
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
tenantId: string;
// Referencia a licitación
@Column({ name: 'bid_id', type: 'uuid' })
bidId!: string;
/** Reference to the tender */
@Column({ name: 'tender_id', type: 'uuid' })
tenderId: string;
@ManyToOne(() => Bid, (bid) => bid.documents)
@JoinColumn({ name: 'bid_id' })
bid?: Bid;
@Column({
name: 'document_type',
type: 'varchar',
length: 50,
})
documentType: BidDocumentType;
// Información del documento
@Column({ length: 255 })
name!: string;
/** Document name */
@Column({ type: 'varchar', length: 255 })
name: string;
/** Document description */
@Column({ type: 'text', nullable: true })
description?: string;
description: string;
@Column({
type: 'enum',
enum: ['tender_bases', 'clarifications', 'annexes', 'technical_proposal', 'economic_proposal', 'legal_documents', 'experience_certificates', 'financial_statements', 'bonds', 'contracts', 'correspondence', 'meeting_minutes', 'other'],
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;
/** URL/path to the file */
@Column({ name: 'file_url', type: 'varchar', length: 500 })
fileUrl: string;
/** File size in bytes */
@Column({ name: 'file_size', type: 'bigint' })
fileSize!: number;
fileSize: string;
@Column({ name: 'mime_type', length: 100, nullable: true })
mimeType?: string;
/** MIME type of the file */
@Column({ name: 'mime_type', type: 'varchar', length: 100 })
mimeType: string;
// Versión
/** Version number */
@Column({ type: 'int', default: 1 })
version!: number;
version: number;
@Column({ name: 'is_current_version', type: 'boolean', default: true })
isCurrentVersion!: boolean;
/** User who uploaded the document */
@Column({ name: 'uploaded_by', type: 'uuid' })
uploadedById: string;
@Column({ name: 'previous_version_id', type: 'uuid', nullable: true })
previousVersionId?: string;
/** Timestamp when document was uploaded */
@Column({ name: 'uploaded_at', type: 'timestamptz' })
uploadedAt: Date;
// Metadatos de revisión
@Column({ name: 'reviewed_by_id', type: 'uuid', nullable: true })
reviewedById?: string;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'reviewed_by_id' })
reviewedBy?: User;
@ManyToOne(() => Tender, (tender) => tender.documents)
@JoinColumn({ name: 'tender_id' })
tender: Tender;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt?: Date;
@ManyToOne(() => User)
@JoinColumn({ name: 'uploaded_by' })
uploadedBy: User;
@Column({ name: 'review_comments', type: 'text', nullable: true })
reviewComments?: string;
// 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;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
createdById: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'created_by' })
createdBy: User;
@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 })
deletedAt?: Date;
deletedAt: Date;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,12 +1,24 @@
/**
* 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';
export { Bid, BidType, BidStatus, BidStage } from './bid.entity';
export { BidDocument, DocumentCategory, DocumentStatus } from './bid-document.entity';
export { BidCalendar, CalendarEventType, EventPriority, EventStatus } from './bid-calendar.entity';
export { BidBudget, BudgetItemType, BudgetStatus } from './bid-budget.entity';
export { BidCompetitor, CompetitorStatus, ThreatLevel } from './bid-competitor.entity';
export { BidTeam, TeamRole, MemberStatus } from './bid-team.entity';
// Opportunity
export { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority } from './opportunity.entity';
// Tender
export { Tender, TenderType, TenderStatus } from './tender.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';

View File

@ -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.
*
* @module Bidding
* @module Bidding (MAI-018)
* @table bidding.opportunities
* @ddl schemas/XX-bidding-schema-ddl.sql
*/
import {
@ -17,264 +18,124 @@ import {
JoinColumn,
Index,
} from 'typeorm';
import { Tenant } from '../../core/entities/tenant.entity';
import { User } from '../../core/entities/user.entity';
import { Bid } from './bid.entity';
import { Tender } from './tender.entity';
export type OpportunitySource =
| 'portal_compranet'
| 'portal_state'
| 'direct_invitation'
| 'referral'
| 'public_notice'
| 'networking'
| 'repeat_client'
| 'cold_call'
| 'website'
| 'other';
/** Source of the opportunity */
export type OpportunitySource = 'government_portal' | 'private_client' | 'referral' | 'other';
export type OpportunityStatus =
| 'identified'
| 'qualified'
| 'pursuing'
| 'bid_submitted'
| 'won'
| 'lost'
| 'cancelled'
| 'on_hold';
/** Status of the opportunity in the pipeline */
export type OpportunityStatus = 'registered' | 'evaluating' | 'go' | 'no_go' | 'preparing' | 'converted';
export type OpportunityPriority = 'low' | 'medium' | 'high' | 'critical';
/** Priority level */
export type OpportunityPriority = 'high' | 'medium' | 'low';
export type ProjectType =
| 'residential'
| 'commercial'
| 'industrial'
| 'infrastructure'
| 'institutional'
| 'mixed_use'
| 'renovation'
| 'maintenance';
@Entity('opportunities', { schema: 'bidding' })
@Entity({ schema: 'bidding', name: 'opportunities' })
@Index(['tenantId'])
@Index(['tenantId', 'code'], { unique: true })
@Index(['tenantId', 'status'])
@Index(['tenantId', 'source'])
@Index(['tenantId', 'assignedToId'])
@Index(['tenantId', 'priority'])
@Index(['deadlineDate'])
export class Opportunity {
@PrimaryGeneratedColumn('uuid')
id!: string;
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
@Index()
tenantId!: string;
tenantId: string;
// Información básica
@Column({ length: 100 })
code!: string;
/** Unique code within tenant, format: OPP-2026-001 */
@Column({ type: 'varchar', length: 50 })
code: string;
@Column({ length: 500 })
name!: string;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'text', nullable: true })
description?: string;
description: string;
@Column({
type: 'enum',
enum: ['portal_compranet', 'portal_state', 'direct_invitation', 'referral', 'public_notice', 'networking', 'repeat_client', 'cold_call', 'website', 'other'],
enumName: 'opportunity_source',
type: 'varchar',
length: 50,
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({
type: 'enum',
enum: ['identified', 'qualified', 'pursuing', 'bid_submitted', 'won', 'lost', 'cancelled', 'on_hold'],
enumName: 'opportunity_status',
default: 'identified',
type: 'varchar',
length: 50,
default: 'registered',
})
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({
type: 'enum',
enum: ['low', 'medium', 'high', 'critical'],
enumName: 'opportunity_priority',
type: 'varchar',
length: 20,
default: 'medium',
})
priority!: OpportunityPriority;
priority: OpportunityPriority;
@Column({
name: 'project_type',
type: 'enum',
enum: ['residential', 'commercial', 'industrial', 'infrastructure', 'institutional', 'mixed_use', 'renovation', 'maintenance'],
enumName: 'project_type',
})
projectType!: ProjectType;
/** Deadline for opportunity (proposal submission, etc.) */
@Column({ name: 'deadline_date', type: 'date' })
deadlineDate: Date;
// Cliente/Convocante
@Column({ name: 'client_name', length: 255 })
clientName!: string;
// Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@Column({ name: 'client_contact', length: 255, nullable: true })
clientContact?: string;
@OneToMany(() => Tender, (tender) => tender.opportunity)
tenders: Tender[];
@Column({ name: 'client_email', length: 255, nullable: true })
clientEmail?: string;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'client_phone', length: 50, nullable: true })
clientPhone?: 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;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdById: string;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'assigned_to_id' })
assignedTo?: 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;
@JoinColumn({ name: 'created_by' })
createdBy: User;
@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 })
deletedAt?: Date;
deletedAt: Date;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -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 { ServiceContext } from '../../../shared/services/base.service';
import { Bid, BidStatus, BidType } from '../entities/bid.entity';
import { Opportunity, OpportunitySource, OpportunityStatus } from '../entities/opportunity.entity';
import { BidCompetitor } from '../entities/bid-competitor.entity';
import { Tender, TenderStatus, TenderType } from '../entities/tender.entity';
import { Opportunity, OpportunitySource, OpportunityStatus, OpportunityPriority } from '../entities/opportunity.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 {
constructor(
private readonly bidRepository: Repository<Bid>,
private readonly tenderRepository: Repository<Tender>,
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> {
const now = new Date();
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Oportunidades activas
const activeOpportunities = await this.opportunityRepository.count({
where: {
tenantId: ctx.tenantId,
deletedAt: undefined,
status: 'pursuing' as OpportunityStatus,
status: 'go' as OpportunityStatus,
},
});
// Licitaciones activas
const activeBids = await this.bidRepository.count({
const activeTenders = await this.tenderRepository.count({
where: {
tenantId: ctx.tenantId,
deletedAt: undefined,
status: 'preparation' as BidStatus,
status: 'published' as TenderStatus,
},
});
// Valor del pipeline
const pipelineValue = await this.opportunityRepository
.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 })
.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();
// Próximas fechas límite
const upcomingDeadlines = await this.bidRepository
.createQueryBuilder('b')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...activeStatuses)', { activeStatuses: ['draft', 'preparation', 'review', 'approved'] })
.andWhere('b.submission_deadline >= :now', { now })
.orderBy('b.submission_deadline', 'ASC')
// Proximas fechas limite
const upcomingDeadlines = await this.tenderRepository
.createQueryBuilder('t')
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('t.deleted_at IS NULL')
.andWhere('t.status IN (:...activeStatuses)', { activeStatuses: ['draft', 'published', 'receiving', 'evaluating'] })
.andWhere('t.proposal_deadline >= :now', { now })
.orderBy('t.proposal_deadline', 'ASC')
.take(5)
.getMany();
// Win rate últimos 12 meses
// Win rate ultimos 12 meses
const yearAgo = new Date();
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
const winRateStats = await this.bidRepository
.createQueryBuilder('b')
.select('b.status', 'status')
const winRateStats = await this.tenderRepository
.createQueryBuilder('t')
.select('t.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...closedStatuses)', { closedStatuses: ['awarded', 'rejected'] })
.andWhere('b.award_date >= :yearAgo', { yearAgo })
.groupBy('b.status')
.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 >= :yearAgo', { yearAgo })
.groupBy('t.status')
.getRawMany();
const awarded = winRateStats.find((s) => s.status === 'awarded')?.count || 0;
const rejected = winRateStats.find((s) => s.status === 'rejected')?.count || 0;
const totalClosed = parseInt(awarded) + parseInt(rejected);
const cancelled = winRateStats.find((s) => s.status === 'cancelled')?.count || 0;
const totalClosed = parseInt(awarded) + parseInt(cancelled);
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 wonValue = await this.bidRepository
.createQueryBuilder('b')
.select('SUM(b.winning_amount)', 'value')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status = :status', { status: 'awarded' })
.andWhere('b.award_date >= :startOfYear', { startOfYear })
const wonValue = await this.tenderRepository
.createQueryBuilder('t')
.select('SUM(CAST(t.reference_amount AS DECIMAL))', 'value')
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('t.deleted_at IS NULL')
.andWhere('t.status = :status', { status: 'awarded' })
.andWhere('t.award_date >= :startOfYear', { startOfYear })
.getRawOne();
return {
activeOpportunities,
activeBids,
activeBids: activeTenders,
pipelineValue: parseFloat(pipelineValue?.value) || 0,
upcomingDeadlines: upcomingDeadlines.map((b) => ({
id: b.id,
name: b.name,
deadline: b.submissionDeadline,
status: b.status,
upcomingDeadlines: upcomingDeadlines.map((t) => ({
id: t.id,
name: t.title,
deadline: t.proposalDeadline,
status: t.status,
})),
winRate,
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[]> {
const result = await this.opportunityRepository
.createQueryBuilder('o')
.select('o.source', 'source')
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(o.estimated_value)', 'totalValue')
.addSelect('SUM(o.weighted_value)', 'weightedValue')
.addSelect('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 NOT IN (:...closedStatuses)', { closedStatuses: ['won', 'lost', 'cancelled'] })
.andWhere('o.status NOT IN (:...closedStatuses)', { closedStatuses: ['converted', 'no_go'] })
.groupBy('o.source')
.orderBy('SUM(o.weighted_value)', 'DESC')
.orderBy('SUM(CAST(o.estimated_amount AS DECIMAL))', 'DESC')
.getRawMany();
return result.map((r) => ({
source: r.source as OpportunitySource,
count: parseInt(r.count),
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[]> {
const fromDate = new Date();
fromDate.setMonth(fromDate.getMonth() - months);
const result = await this.bidRepository
.createQueryBuilder('b')
.select('b.bid_type', 'bidType')
.addSelect('COUNT(*) FILTER (WHERE b.status = \'awarded\')', 'won')
.addSelect('COUNT(*) FILTER (WHERE b.status IN (\'awarded\', \'rejected\'))', 'total')
.addSelect('SUM(CASE WHEN b.status = \'awarded\' THEN b.winning_amount ELSE 0 END)', 'wonValue')
.where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.award_date >= :fromDate', { fromDate })
.groupBy('b.bid_type')
const result = await this.tenderRepository
.createQueryBuilder('t')
.select('t.type', 'type')
.addSelect('COUNT(*) FILTER (WHERE t.status = \'awarded\')', 'won')
.addSelect('COUNT(*) FILTER (WHERE t.status IN (\'awarded\', \'cancelled\'))', 'total')
.addSelect('SUM(CASE WHEN t.status = \'awarded\' THEN CAST(t.reference_amount AS DECIMAL) ELSE 0 END)', 'wonValue')
.where('t.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('t.deleted_at IS NULL')
.andWhere('t.award_date >= :fromDate', { fromDate })
.groupBy('t.type')
.getRawMany();
return result.map((r) => ({
bidType: r.bidType as BidType,
bidType: r.type as TenderType,
won: parseInt(r.won) || 0,
total: parseInt(r.total) || 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
.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(*) FILTER (WHERE o.status = \'won\')', 'won')
.addSelect('COUNT(*) FILTER (WHERE o.status = \'lost\')', 'lost')
.addSelect('SUM(CASE WHEN o.status = \'won\' THEN o.estimated_value ELSE 0 END)', 'wonValue')
.addSelect('COUNT(*) FILTER (WHERE o.status = \'converted\')', 'won')
.addSelect('COUNT(*) FILTER (WHERE o.status = \'no_go\')', 'lost')
.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 })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.identification_date >= :fromDate', { fromDate })
.groupBy("TO_CHAR(o.identification_date, 'YYYY-MM')")
.orderBy("TO_CHAR(o.identification_date, 'YYYY-MM')", 'ASC')
.andWhere('o.created_at >= :fromDate', { fromDate })
.groupBy("TO_CHAR(o.created_at, 'YYYY-MM')")
.orderBy("TO_CHAR(o.created_at, 'YYYY-MM')", 'ASC')
.getRawMany();
return result.map((r) => ({
@ -195,19 +225,20 @@ export class BidAnalyticsService {
}
/**
* Análisis de competidores
* Analisis de competidores
*/
async getCompetitorAnalysis(ctx: ServiceContext): Promise<CompetitorAnalysis[]> {
const result = await this.competitorRepository
.createQueryBuilder('c')
.select('c.company_name', 'companyName')
const result = await this.proposalRepository
.createQueryBuilder('p')
.leftJoin('p.vendor', 'v')
.select('v.business_name', 'companyName')
.addSelect('COUNT(*)', 'encounters')
.addSelect('SUM(CASE WHEN c.status = \'winner\' THEN 1 ELSE 0 END)', 'theirWins')
.addSelect('SUM(CASE WHEN c.status = \'loser\' THEN 1 ELSE 0 END)', 'ourWins')
.addSelect('AVG(c.proposed_amount)', 'avgProposedAmount')
.where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('c.deleted_at IS NULL')
.groupBy('c.company_name')
.addSelect('SUM(CASE WHEN p.status = \'winner\' THEN 1 ELSE 0 END)', 'theirWins')
.addSelect('SUM(CASE WHEN p.status != \'winner\' AND p.status = \'qualified\' THEN 1 ELSE 0 END)', 'ourWins')
.addSelect('AVG(CAST(p.proposed_amount AS DECIMAL))', 'avgProposedAmount')
.where('p.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('p.deleted_at IS NULL')
.groupBy('v.business_name')
.having('COUNT(*) >= 2')
.orderBy('COUNT(*)', 'DESC')
.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> {
const fromDate = new Date();
@ -236,44 +267,44 @@ export class BidAnalyticsService {
.createQueryBuilder('o')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.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 qualified = await baseQuery.clone()
.andWhere('o.status NOT IN (:...earlyStatuses)', { earlyStatuses: ['identified'] })
const evaluating = await baseQuery.clone()
.andWhere('o.status NOT IN (:...earlyStatuses)', { earlyStatuses: ['registered'] })
.getCount();
const pursuing = await baseQuery.clone()
.andWhere('o.status IN (:...pursuitStatuses)', { pursuitStatuses: ['pursuing', 'bid_submitted', 'won', 'lost'] })
const go = await baseQuery.clone()
.andWhere('o.status IN (:...goStatuses)', { goStatuses: ['go', 'preparing', 'converted'] })
.getCount();
const bidSubmitted = await baseQuery.clone()
.andWhere('o.status IN (:...submittedStatuses)', { submittedStatuses: ['bid_submitted', 'won', 'lost'] })
const preparing = await baseQuery.clone()
.andWhere('o.status IN (:...prepStatuses)', { prepStatuses: ['preparing', 'converted'] })
.getCount();
const won = await baseQuery.clone()
.andWhere('o.status = :status', { status: 'won' })
const converted = await baseQuery.clone()
.andWhere('o.status = :status', { status: 'converted' })
.getCount();
return {
identified,
qualified,
pursuing,
bidSubmitted,
won,
qualified: evaluating,
pursuing: go,
bidSubmitted: preparing,
won: converted,
conversionRates: {
identifiedToQualified: identified > 0 ? (qualified / identified) * 100 : 0,
qualifiedToPursuing: qualified > 0 ? (pursuing / qualified) * 100 : 0,
pursuingToSubmitted: pursuing > 0 ? (bidSubmitted / pursuing) * 100 : 0,
submittedToWon: bidSubmitted > 0 ? (won / bidSubmitted) * 100 : 0,
overallConversion: identified > 0 ? (won / identified) * 100 : 0,
identifiedToQualified: identified > 0 ? (evaluating / identified) * 100 : 0,
qualifiedToPursuing: evaluating > 0 ? (go / evaluating) * 100 : 0,
pursuingToSubmitted: go > 0 ? (preparing / go) * 100 : 0,
submittedToWon: preparing > 0 ? (converted / preparing) * 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> {
const fromDate = new Date();
@ -281,23 +312,23 @@ export class BidAnalyticsService {
const result = await this.opportunityRepository
.createQueryBuilder('o')
.select('AVG(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'avgDays')
.addSelect('MIN(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'minDays')
.addSelect('MAX(EXTRACT(DAY FROM (o.updated_at - o.identification_date)))', 'maxDays')
.select('AVG(EXTRACT(DAY FROM (o.updated_at - o.created_at)))', 'avgDays')
.addSelect('MIN(EXTRACT(DAY FROM (o.updated_at - o.created_at)))', 'minDays')
.addSelect('MAX(EXTRACT(DAY FROM (o.updated_at - o.created_at)))', 'maxDays')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] })
.andWhere('o.identification_date >= :fromDate', { fromDate })
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['converted', 'no_go'] })
.andWhere('o.created_at >= :fromDate', { fromDate })
.getRawOne();
const byOutcome = await this.opportunityRepository
.createQueryBuilder('o')
.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 })
.andWhere('o.deleted_at IS NULL')
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['won', 'lost'] })
.andWhere('o.identification_date >= :fromDate', { fromDate })
.andWhere('o.status IN (:...closedStatuses)', { closedStatuses: ['converted', 'no_go'] })
.andWhere('o.created_at >= :fromDate', { fromDate })
.groupBy('o.status')
.getRawMany();
@ -308,11 +339,222 @@ export class BidAnalyticsService {
maxDays: Math.round(parseFloat(result?.maxDays) || 0),
},
byOutcome: byOutcome.map((r) => ({
outcome: r.outcome as 'won' | 'lost',
outcome: r.outcome === 'converted' ? 'won' : 'lost',
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
@ -320,7 +562,7 @@ export interface BidDashboard {
activeOpportunities: number;
activeBids: number;
pipelineValue: number;
upcomingDeadlines: { id: string; name: string; deadline: Date; status: BidStatus }[];
upcomingDeadlines: { id: string; name: string; deadline: Date; status: TenderStatus }[];
winRate: number;
wonValueYTD: number;
}
@ -329,11 +571,10 @@ export interface PipelineBySource {
source: OpportunitySource;
count: number;
totalValue: number;
weightedValue: number;
}
export interface WinRateByType {
bidType: BidType;
bidType: TenderType;
won: number;
total: number;
winRate: number;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,9 +1,64 @@
/**
* Bidding Services Index
* @module Bidding
* Bidding Services Index - MAI-018 Preconstrucción/Licitaciones
*
* Exporta todos los servicios del módulo de licitaciones.
*
* @module Bidding (MAI-018)
*/
export { OpportunityService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters, PipelineData, OpportunityStats } from './opportunity.service';
export { BidService, CreateBidDto, UpdateBidDto, BidFilters, BidStats } from './bid.service';
export { BidBudgetService, CreateBudgetItemDto, UpdateBudgetItemDto, BudgetFilters, BudgetSummary } from './bid-budget.service';
export { BidAnalyticsService, BidDashboard, PipelineBySource, WinRateByType, MonthlyTrend, CompetitorAnalysis, FunnelAnalysis, CycleTimeAnalysis } from './bid-analytics.service';
// Opportunity Service
export {
OpportunityService,
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';

View File

@ -2,123 +2,102 @@
* OpportunityService - Gestión de Oportunidades de Negocio
*
* 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 { 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 {
code: string;
name: string;
title: string;
description?: string;
source: OpportunitySource;
projectType: ProjectType;
projectType: string;
clientName: string;
clientContact?: string;
clientEmail?: string;
clientPhone?: string;
clientType?: string;
location?: string;
state?: string;
city?: string;
estimatedValue?: number;
currency?: string;
constructionAreaM2?: number;
landAreaM2?: number;
identificationDate: Date;
deadlineDate?: Date;
expectedAwardDate?: Date;
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>;
estimatedAmount?: number;
estimatedUnits?: number;
priority?: OpportunityPriority;
deadlineDate: Date;
}
export interface GoNoGoDecisionDto {
decision: 'go' | 'no_go';
reason: string;
}
export interface UpdateOpportunityDto extends Partial<CreateOpportunityDto> {
status?: OpportunityStatus;
priority?: OpportunityPriority;
lossReason?: string;
winFactors?: string;
}
export interface OpportunityFilters {
status?: OpportunityStatus | OpportunityStatus[];
source?: OpportunitySource;
projectType?: ProjectType;
projectType?: string;
priority?: OpportunityPriority;
assignedToId?: string;
clientName?: string;
state?: string;
dateFrom?: Date;
dateTo?: Date;
minValue?: number;
maxValue?: number;
search?: string;
page?: number;
limit?: number;
}
export class OpportunityService {
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> {
const weightedValue = data.estimatedValue && data.winProbability
? data.estimatedValue * (data.winProbability / 100)
: undefined;
const code = await this.generateCode(ctx);
const opportunity = this.repository.create({
tenantId: ctx.tenantId,
code: data.code,
name: data.name,
code,
title: data.title,
description: data.description,
source: data.source,
projectType: data.projectType,
clientName: data.clientName,
clientContact: data.clientContact,
clientEmail: data.clientEmail,
clientPhone: data.clientPhone,
clientType: data.clientType,
location: data.location,
state: data.state,
city: data.city,
estimatedValue: data.estimatedValue,
currency: data.currency || 'MXN',
constructionAreaM2: data.constructionAreaM2,
landAreaM2: data.landAreaM2,
identificationDate: data.identificationDate,
estimatedAmount: data.estimatedAmount?.toString(),
estimatedUnits: data.estimatedUnits,
deadlineDate: data.deadlineDate,
expectedAwardDate: data.expectedAwardDate,
expectedStartDate: data.expectedStartDate,
expectedDurationMonths: data.expectedDurationMonths,
winProbability: data.winProbability || 0,
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,
status: 'registered' as OpportunityStatus,
priority: data.priority || 'medium',
createdById: ctx.userId,
updatedById: ctx.userId,
});
return this.repository.save(opportunity);
@ -130,19 +109,29 @@ export class OpportunityService {
async findById(ctx: ServiceContext, id: string): Promise<Opportunity | null> {
return this.repository.findOne({
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,
filters: OpportunityFilters,
page = 1,
limit = 20
filters: OpportunityFilters = {}
): Promise<PaginatedResult<Opportunity>> {
const page = filters.page || 1;
const limit = filters.limit || 20;
const qb = this.repository
.createQueryBuilder('o')
.leftJoinAndSelect('o.assignedTo', 'u')
@ -165,15 +154,9 @@ export class OpportunityService {
if (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) {
qb.andWhere('o.client_name ILIKE :clientName', { clientName: `%${filters.clientName}%` });
}
if (filters.state) {
qb.andWhere('o.state = :state', { state: filters.state });
}
if (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);
if (!opportunity) return null;
// Recalcular weighted value si cambian los factores
let weightedValue = opportunity.weightedValue;
const estimatedValue = data.estimatedValue ?? opportunity.estimatedValue;
const winProbability = data.winProbability ?? opportunity.winProbability;
if (estimatedValue && winProbability) {
weightedValue = estimatedValue * (winProbability / 100);
if (data.title !== undefined) opportunity.title = data.title;
if (data.description !== undefined) opportunity.description = data.description;
if (data.source !== undefined) opportunity.source = data.source;
if (data.projectType !== undefined) opportunity.projectType = data.projectType;
if (data.clientName !== undefined) opportunity.clientName = data.clientName;
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);
}
@ -244,12 +228,49 @@ export class OpportunityService {
if (!opportunity) return null;
opportunity.status = status;
if (status === 'lost' && reason) {
opportunity.lossReason = reason;
} else if (status === 'won' && reason) {
opportunity.winFactors = reason;
if (reason) {
opportunity.goDecisionReason = 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);
}
@ -262,8 +283,7 @@ export class OpportunityService {
.createQueryBuilder('o')
.select('o.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(o.estimated_value)', 'totalValue')
.addSelect('SUM(o.weighted_value)', 'weightedValue')
.addSelect('SUM(CAST(o.estimated_amount AS DECIMAL))', 'totalValue')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('o.deleted_at IS NULL')
.groupBy('o.status')
@ -273,7 +293,6 @@ export class OpportunityService {
status: r.status as OpportunityStatus,
count: parseInt(r.count),
totalValue: parseFloat(r.totalValue) || 0,
weightedValue: parseFloat(r.weightedValue) || 0,
}));
}
@ -289,10 +308,9 @@ export class OpportunityService {
where: {
tenantId: ctx.tenantId,
deletedAt: undefined,
status: In(['identified', 'qualified', 'pursuing']),
status: In(['registered', 'evaluating', 'go', 'preparing']),
deadlineDate: Between(now, future),
},
relations: ['assignedTo'],
order: { deadlineDate: 'ASC' },
});
}
@ -309,7 +327,7 @@ export class OpportunityService {
.createQueryBuilder('o')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.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();
@ -319,7 +337,7 @@ export class OpportunityService {
.addSelect('COUNT(*)', 'count')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.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')
.getRawMany();
@ -329,23 +347,22 @@ export class OpportunityService {
.addSelect('COUNT(*)', 'count')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.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')
.getRawMany();
const valueStats = await this.repository
.createQueryBuilder('o')
.select('SUM(o.estimated_value)', 'totalValue')
.addSelect('SUM(o.weighted_value)', 'weightedValue')
.addSelect('AVG(o.estimated_value)', 'avgValue')
.select('SUM(CAST(o.estimated_amount AS DECIMAL))', 'totalValue')
.addSelect('AVG(CAST(o.estimated_amount AS DECIMAL))', 'avgValue')
.where('o.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.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();
const wonCount = byStatus.find((s) => s.status === 'won')?.count || 0;
const lostCount = byStatus.find((s) => s.status === 'lost')?.count || 0;
const closedCount = parseInt(wonCount) + parseInt(lostCount);
const convertedCount = byStatus.find((s) => s.status === 'converted')?.count || 0;
const noGoCount = byStatus.find((s) => s.status === 'no_go')?.count || 0;
const closedCount = parseInt(convertedCount) + parseInt(noGoCount);
return {
year: currentYear,
@ -353,9 +370,8 @@ export class OpportunityService {
byStatus: byStatus.map((r) => ({ status: r.status, count: parseInt(r.count) })),
bySource: bySource.map((r) => ({ source: r.source, count: parseInt(r.count) })),
totalValue: parseFloat(valueStats?.totalValue) || 0,
weightedValue: parseFloat(valueStats?.weightedValue) || 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> {
const result = await this.repository.update(
{ id, tenantId: ctx.tenantId },
{ deletedAt: new Date(), updatedBy: ctx.userId }
{ deletedAt: new Date(), updatedById: ctx.userId }
);
return (result.affected || 0) > 0;
}
@ -375,16 +391,14 @@ export interface PipelineData {
status: OpportunityStatus;
count: number;
totalValue: number;
weightedValue: number;
}
export interface OpportunityStats {
year: number;
total: number;
byStatus: { status: OpportunityStatus; count: number }[];
bySource: { source: OpportunitySource; count: number }[];
byStatus: { status: string; count: number }[];
bySource: { source: string; count: number }[];
totalValue: number;
weightedValue: number;
avgValue: number;
winRate: number;
}

View 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;
}
}

View 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,
};
}
}

View 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,
},
};
}
}

View 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;
}

View File

@ -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;
}

View 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[];
}

View 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';

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -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;
}

View File

@ -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;
}

View 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;
}

View 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[];
}

View 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';

View File

@ -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;
}

View File

@ -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;
}

View 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;
}

View 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[];
}

View 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';

View File

@ -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;
}

View File

@ -70,7 +70,7 @@ export function createPresupuestoController(dataSource: DataSource): Router {
if (fraccionamientoId) {
result = await presupuestoService.findByFraccionamiento(getContext(req), fraccionamientoId, page, limit);
} else {
result = await presupuestoService.findAll(getContext(req), { page, limit });
result = await presupuestoService.findAll(getContext(req), page, limit);
}
res.status(200).json({

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@ -2,5 +2,18 @@
* Core Entities Index
*/
// Existing entities
export { Tenant } from './tenant.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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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