[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 00:45:57 -06:00
parent 2fc091c656
commit 8275f03053
7 changed files with 685 additions and 0 deletions

View File

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

View File

@ -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<string, any>;
// 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;
}

View File

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

View File

@ -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<string, any>;
// 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;
}

View File

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

View File

@ -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<string, any>;
// 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<string, any>;
// 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;
}

View File

@ -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<string, any>;
// 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;
}