From 8275f03053de710fc00852a7cae28e065207c7dc Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Wed, 4 Feb 2026 00:45:57 -0600 Subject: [PATCH] [GAP-001,002,003] feat(entities): Add KPIs config and assets depreciation/loans entities - Add KpiConfig and KpiValue entities for dynamic KPIs (GAP-001) - Add ToolLoan entity for tool loans tracking (GAP-002) - Add DepreciationSchedule and DepreciationEntry entities (GAP-003) - Update index exports for assets and reports modules Implements 13 SP of critical gaps identified in EPIC-003. Co-Authored-By: Claude Opus 4.5 --- .../entities/depreciation-entry.entity.ts | 106 +++++++++++ .../entities/depreciation-schedule.entity.ts | 136 ++++++++++++++ src/modules/assets/entities/index.ts | 7 + .../assets/entities/tool-loan.entity.ts | 137 ++++++++++++++ src/modules/reports/entities/index.ts | 4 + .../reports/entities/kpi-config.entity.ts | 169 ++++++++++++++++++ .../reports/entities/kpi-value.entity.ts | 126 +++++++++++++ 7 files changed, 685 insertions(+) create mode 100644 src/modules/assets/entities/depreciation-entry.entity.ts create mode 100644 src/modules/assets/entities/depreciation-schedule.entity.ts create mode 100644 src/modules/assets/entities/tool-loan.entity.ts create mode 100644 src/modules/reports/entities/kpi-config.entity.ts create mode 100644 src/modules/reports/entities/kpi-value.entity.ts diff --git a/src/modules/assets/entities/depreciation-entry.entity.ts b/src/modules/assets/entities/depreciation-entry.entity.ts new file mode 100644 index 0000000..9936ce8 --- /dev/null +++ b/src/modules/assets/entities/depreciation-entry.entity.ts @@ -0,0 +1,106 @@ +/** + * DepreciationEntry Entity - Entradas de Depreciacion + * + * Registro mensual de depreciacion aplicada por activo. + * + * @module Assets (MAE-015) + * @table assets.depreciation_entries + * @gap GAP-003 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, + Unique, +} from 'typeorm'; +import { DepreciationSchedule } from './depreciation-schedule.entity'; + +export type DepreciationEntryStatus = 'draft' | 'posted' | 'reversed'; + +@Entity({ schema: 'assets', name: 'depreciation_entries' }) +@Index(['tenantId']) +@Index(['scheduleId']) +@Index(['tenantId', 'periodDate']) +@Index(['tenantId', 'fiscalYear', 'fiscalMonth']) +@Index(['tenantId', 'status']) +@Unique(['scheduleId', 'periodDate']) +export class DepreciationEntry { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Programacion + @Column({ name: 'schedule_id', type: 'uuid' }) + scheduleId!: string; + + @ManyToOne(() => DepreciationSchedule, (schedule) => schedule.entries) + @JoinColumn({ name: 'schedule_id' }) + schedule?: DepreciationSchedule; + + // Periodo + @Column({ name: 'period_date', type: 'date' }) + periodDate!: Date; + + @Column({ name: 'fiscal_year', type: 'int' }) + fiscalYear!: number; + + @Column({ name: 'fiscal_month', type: 'int' }) + fiscalMonth!: number; + + // Valores + @Column({ name: 'depreciation_amount', type: 'decimal', precision: 12, scale: 2 }) + depreciationAmount!: number; + + @Column({ name: 'accumulated_depreciation', type: 'decimal', precision: 18, scale: 2 }) + accumulatedDepreciation!: number; + + @Column({ name: 'book_value', type: 'decimal', precision: 18, scale: 2 }) + bookValue!: number; + + // Para units_of_production + @Column({ name: 'units_used', type: 'int', nullable: true }) + unitsUsed?: number; + + // Contabilidad + @Column({ name: 'journal_entry_id', type: 'uuid', nullable: true }) + journalEntryId?: string; + + @Column({ name: 'is_posted', type: 'boolean', default: false }) + isPosted!: boolean; + + @Column({ name: 'posted_at', type: 'timestamptz', nullable: true }) + postedAt?: Date; + + @Column({ name: 'posted_by', type: 'uuid', nullable: true }) + postedBy?: string; + + // Estado + @Column({ + type: 'varchar', + length: 20, + default: 'draft', + }) + status!: DepreciationEntryStatus; + + // Notas + @Column({ type: 'text', nullable: true }) + notes?: string; + + // Auditoria + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/src/modules/assets/entities/depreciation-schedule.entity.ts b/src/modules/assets/entities/depreciation-schedule.entity.ts new file mode 100644 index 0000000..6bae78f --- /dev/null +++ b/src/modules/assets/entities/depreciation-schedule.entity.ts @@ -0,0 +1,136 @@ +/** + * DepreciationSchedule Entity - Programacion de Depreciacion + * + * Configuracion de depreciacion para cada activo fijo. + * + * @module Assets (MAE-015) + * @table assets.depreciation_schedule + * @gap GAP-003 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Asset } from './asset.entity'; +import { DepreciationEntry } from './depreciation-entry.entity'; + +export type DepreciationMethod = + | 'straight_line' + | 'declining_balance' + | 'double_declining' + | 'sum_of_years' + | 'units_of_production'; + +export type AssetDepreciationType = 'equipment' | 'machinery' | 'vehicle' | 'tool'; + +@Entity({ schema: 'assets', name: 'depreciation_schedule' }) +@Index(['tenantId', 'assetId'], { unique: true }) +@Index(['tenantId']) +@Index(['tenantId', 'isActive']) +@Index(['tenantId', 'isFullyDepreciated']) +export class DepreciationSchedule { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Activo + @Column({ name: 'asset_id', type: 'uuid' }) + assetId!: string; + + @ManyToOne(() => Asset) + @JoinColumn({ name: 'asset_id' }) + asset?: Asset; + + // Tipo de activo + @Column({ name: 'asset_type', length: 20 }) + assetType!: AssetDepreciationType; + + // Metodo de depreciacion + @Column({ + type: 'enum', + enum: ['straight_line', 'declining_balance', 'double_declining', 'sum_of_years', 'units_of_production'], + enumName: 'depreciation_method', + default: 'straight_line', + }) + method!: DepreciationMethod; + + // Valores + @Column({ name: 'original_value', type: 'decimal', precision: 18, scale: 2 }) + originalValue!: number; + + @Column({ name: 'salvage_value', type: 'decimal', precision: 18, scale: 2, default: 0 }) + salvageValue!: number; + + // Campos calculados (GENERATED ALWAYS AS en DDL) + @Column({ name: 'depreciable_amount', type: 'decimal', precision: 18, scale: 2, insert: false, update: false }) + depreciableAmount?: number; + + // Vida util + @Column({ name: 'useful_life_months', type: 'int' }) + usefulLifeMonths!: number; + + @Column({ name: 'useful_life_units', type: 'int', nullable: true }) + usefulLifeUnits?: number; + + // Depreciacion mensual calculada (GENERATED en DDL) + @Column({ name: 'monthly_depreciation', type: 'decimal', precision: 12, scale: 2, insert: false, update: false }) + monthlyDepreciation?: number; + + // Fechas + @Column({ name: 'depreciation_start_date', type: 'date' }) + depreciationStartDate!: Date; + + @Column({ name: 'depreciation_end_date', type: 'date', nullable: true }) + depreciationEndDate?: Date; + + // Estado actual + @Column({ name: 'accumulated_depreciation', type: 'decimal', precision: 18, scale: 2, default: 0 }) + accumulatedDepreciation!: number; + + @Column({ name: 'current_book_value', type: 'decimal', precision: 18, scale: 2, nullable: true }) + currentBookValue?: number; + + @Column({ name: 'last_entry_date', type: 'date', nullable: true }) + lastEntryDate?: Date; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'is_fully_depreciated', type: 'boolean', default: false }) + isFullyDepreciated!: boolean; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Relaciones + @OneToMany(() => DepreciationEntry, (entry) => entry.schedule) + entries?: DepreciationEntry[]; + + // Auditoria + @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; +} diff --git a/src/modules/assets/entities/index.ts b/src/modules/assets/entities/index.ts index 5d50d02..d8cb426 100644 --- a/src/modules/assets/entities/index.ts +++ b/src/modules/assets/entities/index.ts @@ -12,3 +12,10 @@ export * from './work-order.entity'; export * from './work-order-part.entity'; export * from './asset-cost.entity'; export * from './fuel-log.entity'; + +// GAP-002: Prestamos de Herramientas +export * from './tool-loan.entity'; + +// GAP-003: Depreciacion de Activos +export * from './depreciation-schedule.entity'; +export * from './depreciation-entry.entity'; diff --git a/src/modules/assets/entities/tool-loan.entity.ts b/src/modules/assets/entities/tool-loan.entity.ts new file mode 100644 index 0000000..e47970d --- /dev/null +++ b/src/modules/assets/entities/tool-loan.entity.ts @@ -0,0 +1,137 @@ +/** + * ToolLoan Entity - Prestamos de Herramientas + * + * Registro de prestamos de herramientas entre obras o a empleados. + * + * @module Assets (MAE-015) + * @table assets.tool_loans + * @gap GAP-002 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Asset } from './asset.entity'; + +export type LoanStatus = 'active' | 'returned' | 'overdue' | 'lost' | 'damaged'; + +@Entity({ schema: 'assets', name: 'tool_loans' }) +@Index(['tenantId']) +@Index(['tenantId', 'toolId']) +@Index(['tenantId', 'employeeId']) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'fraccionamientoOrigenId']) +@Index(['tenantId', 'fraccionamientoDestinoId']) +export class ToolLoan { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Herramienta prestada + @Column({ name: 'tool_id', type: 'uuid' }) + toolId!: string; + + @ManyToOne(() => Asset) + @JoinColumn({ name: 'tool_id' }) + tool?: Asset; + + // Quien recibe el prestamo + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId!: string; + + @Column({ name: 'employee_name', length: 255, nullable: true }) + employeeName?: string; + + // Origen del prestamo + @Column({ name: 'fraccionamiento_origen_id', type: 'uuid', nullable: true }) + fraccionamientoOrigenId?: string; + + @Column({ name: 'fraccionamiento_origen_name', length: 255, nullable: true }) + fraccionamientoOrigenName?: string; + + // Destino del prestamo + @Column({ name: 'fraccionamiento_destino_id', type: 'uuid', nullable: true }) + fraccionamientoDestinoId?: string; + + @Column({ name: 'fraccionamiento_destino_name', length: 255, nullable: true }) + fraccionamientoDestinoName?: string; + + // Fechas + @Column({ name: 'loan_date', type: 'date' }) + loanDate!: Date; + + @Column({ name: 'expected_return_date', type: 'date', nullable: true }) + expectedReturnDate?: Date; + + @Column({ name: 'actual_return_date', type: 'date', nullable: true }) + actualReturnDate?: Date; + + // Estado + @Column({ + type: 'enum', + enum: ['active', 'returned', 'overdue', 'lost', 'damaged'], + enumName: 'loan_status', + default: 'active', + }) + status!: LoanStatus; + + // Condicion de salida + @Column({ name: 'condition_out', type: 'text', nullable: true }) + conditionOut?: string; + + @Column({ name: 'condition_out_photos', type: 'jsonb', nullable: true }) + conditionOutPhotos?: string[]; + + // Condicion de entrada + @Column({ name: 'condition_in', type: 'text', nullable: true }) + conditionIn?: string; + + @Column({ name: 'condition_in_photos', type: 'jsonb', nullable: true }) + conditionInPhotos?: string[]; + + // Aprobacion + @Column({ name: 'approved_by_id', type: 'uuid', nullable: true }) + approvedById?: string; + + @Column({ name: 'approved_by_name', length: 255, nullable: true }) + approvedByName?: string; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt?: Date; + + // Devolucion + @Column({ name: 'received_by_id', type: 'uuid', nullable: true }) + receivedById?: string; + + @Column({ name: 'received_by_name', length: 255, nullable: true }) + receivedByName?: string; + + // Notas y metadatos + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Auditoria + @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; +} diff --git a/src/modules/reports/entities/index.ts b/src/modules/reports/entities/index.ts index c3accac..416ef33 100644 --- a/src/modules/reports/entities/index.ts +++ b/src/modules/reports/entities/index.ts @@ -10,6 +10,10 @@ export * from './dashboard.entity'; export * from './dashboard-widget.entity'; export * from './kpi-snapshot.entity'; +// KPIs Configurables (GAP-001) +export * from './kpi-config.entity'; +export * from './kpi-value.entity'; + // Core report entities (from erp-core) export * from './report-schedule.entity'; export * from './report-recipient.entity'; diff --git a/src/modules/reports/entities/kpi-config.entity.ts b/src/modules/reports/entities/kpi-config.entity.ts new file mode 100644 index 0000000..89ed86b --- /dev/null +++ b/src/modules/reports/entities/kpi-config.entity.ts @@ -0,0 +1,169 @@ +/** + * KpiConfig Entity - Configuracion de KPIs Dinamicos + * + * Permite definir KPIs con formulas configurables, umbrales + * y semaforizacion. + * + * @module Reports (MAI-006) + * @table reports.kpis_config + * @gap GAP-001 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { KpiValue } from './kpi-value.entity'; + +export type KpiCategory = + | 'financial' + | 'progress' + | 'quality' + | 'hse' + | 'hr' + | 'inventory' + | 'operational'; + +export type FormulaType = 'sql' | 'expression' | 'function'; + +export type CalculationFrequency = + | 'realtime' + | 'hourly' + | 'daily' + | 'weekly' + | 'monthly'; + +@Entity({ schema: 'reports', name: 'kpis_config' }) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId']) +@Index(['tenantId', 'category']) +@Index(['tenantId', 'module']) +@Index(['tenantId', 'isActive']) +export class KpiConfig { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // Identificacion + @Column({ length: 50 }) + code!: string; + + @Column({ length: 200 }) + name!: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + // Clasificacion + @Column({ length: 50 }) + category!: KpiCategory; + + @Column({ length: 50 }) + module!: string; + + // Formula de calculo + @Column({ type: 'text' }) + formula!: string; + + @Column({ + name: 'formula_type', + type: 'varchar', + length: 20, + default: 'sql', + }) + formulaType!: FormulaType; + + @Column({ name: 'query_function', length: 255, nullable: true }) + queryFunction?: string; + + // Parametros de la formula + @Column({ name: 'parameters_schema', type: 'jsonb', default: '{}' }) + parametersSchema!: Record; + + // Unidad y formato + @Column({ length: 20, nullable: true }) + unit?: string; + + @Column({ name: 'decimal_places', type: 'int', default: 2 }) + decimalPlaces!: number; + + @Column({ name: 'format_pattern', length: 50, nullable: true }) + formatPattern?: string; + + // Umbrales de semaforizacion + @Column({ name: 'target_value', type: 'decimal', precision: 18, scale: 4, nullable: true }) + targetValue?: number; + + @Column({ name: 'threshold_green', type: 'decimal', precision: 18, scale: 4, nullable: true }) + thresholdGreen?: number; + + @Column({ name: 'threshold_yellow', type: 'decimal', precision: 18, scale: 4, nullable: true }) + thresholdYellow?: number; + + @Column({ name: 'invert_colors', type: 'boolean', default: false }) + invertColors!: boolean; + + // Frecuencia de calculo + @Column({ + name: 'calculation_frequency', + type: 'varchar', + length: 20, + default: 'daily', + }) + calculationFrequency!: CalculationFrequency; + + // Visualizacion + @Column({ name: 'display_order', type: 'int', default: 0 }) + displayOrder!: number; + + @Column({ length: 50, nullable: true }) + icon?: string; + + @Column({ length: 20, nullable: true }) + color?: string; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem!: boolean; + + // Metadatos + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + // Relaciones + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant?: Tenant; + + @OneToMany(() => KpiValue, (value) => value.kpiConfig) + values?: KpiValue[]; + + // Auditoria + @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; +} diff --git a/src/modules/reports/entities/kpi-value.entity.ts b/src/modules/reports/entities/kpi-value.entity.ts new file mode 100644 index 0000000..d6cf561 --- /dev/null +++ b/src/modules/reports/entities/kpi-value.entity.ts @@ -0,0 +1,126 @@ +/** + * KpiValue Entity - Valores Calculados de KPIs + * + * Almacena los valores calculados periodicamente para cada KPI. + * + * @module Reports (MAI-006) + * @table reports.kpis_values + * @gap GAP-001 + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { KpiConfig } from './kpi-config.entity'; + +export type PeriodType = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'; + +export type KpiStatus = 'green' | 'yellow' | 'red'; + +export type TrendDirection = 'up' | 'down' | 'stable'; + +@Entity({ schema: 'reports', name: 'kpis_values' }) +@Index(['tenantId']) +@Index(['kpiId']) +@Index(['tenantId', 'periodStart', 'periodEnd']) +@Index(['kpiId', 'periodStart']) +@Index(['tenantId', 'projectId']) +@Index(['calculatedAt']) +export class KpiValue { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string; + + // KPI + @Column({ name: 'kpi_id', type: 'uuid' }) + kpiId!: string; + + // Periodo + @Column({ name: 'period_start', type: 'date' }) + periodStart!: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd!: Date; + + @Column({ + name: 'period_type', + type: 'varchar', + length: 20, + default: 'daily', + }) + periodType!: PeriodType; + + // Contexto opcional + @Column({ name: 'project_id', type: 'uuid', nullable: true }) + projectId?: string; + + @Column({ name: 'department_id', type: 'uuid', nullable: true }) + departmentId?: string; + + // Valor calculado + @Column({ type: 'decimal', precision: 18, scale: 4 }) + value!: number; + + @Column({ name: 'previous_value', type: 'decimal', precision: 18, scale: 4, nullable: true }) + previousValue?: number; + + // Comparacion con objetivo + @Column({ name: 'target_value', type: 'decimal', precision: 18, scale: 4, nullable: true }) + targetValue?: number; + + @Column({ name: 'variance_value', type: 'decimal', precision: 18, scale: 4, nullable: true }) + varianceValue?: number; + + @Column({ name: 'variance_percentage', type: 'decimal', precision: 8, scale: 2, nullable: true }) + variancePercentage?: number; + + // Semaforizacion calculada + @Column({ type: 'varchar', length: 10, nullable: true }) + status?: KpiStatus; + + @Column({ name: 'is_on_target', type: 'boolean', nullable: true }) + isOnTarget?: boolean; + + // Tendencia + @Column({ name: 'trend_direction', type: 'varchar', length: 10, nullable: true }) + trendDirection?: TrendDirection; + + @Column({ name: 'change_percentage', type: 'decimal', precision: 8, scale: 2, nullable: true }) + changePercentage?: number; + + // Desglose + @Column({ type: 'jsonb', nullable: true }) + breakdown?: Record; + + // Calculo + @Column({ name: 'calculated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + calculatedAt!: Date; + + @Column({ name: 'calculation_duration_ms', type: 'int', nullable: true }) + calculationDurationMs?: number; + + @Column({ name: 'calculation_error', type: 'text', nullable: true }) + calculationError?: string; + + // Relaciones + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant?: Tenant; + + @ManyToOne(() => KpiConfig, (config) => config.values) + @JoinColumn({ name: 'kpi_id' }) + kpiConfig?: KpiConfig; + + // Auditoria + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +}