diff --git a/src/modules/biometrics/entities/biometric-credential.entity.ts b/src/modules/biometrics/entities/biometric-credential.entity.ts new file mode 100644 index 0000000..c77fbce --- /dev/null +++ b/src/modules/biometrics/entities/biometric-credential.entity.ts @@ -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; +} diff --git a/src/modules/biometrics/entities/device-activity-log.entity.ts b/src/modules/biometrics/entities/device-activity-log.entity.ts new file mode 100644 index 0000000..e245f45 --- /dev/null +++ b/src/modules/biometrics/entities/device-activity-log.entity.ts @@ -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; + + // 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; +} diff --git a/src/modules/biometrics/entities/device-session.entity.ts b/src/modules/biometrics/entities/device-session.entity.ts new file mode 100644 index 0000000..c94ecb4 --- /dev/null +++ b/src/modules/biometrics/entities/device-session.entity.ts @@ -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; +} diff --git a/src/modules/biometrics/entities/device.entity.ts b/src/modules/biometrics/entities/device.entity.ts new file mode 100644 index 0000000..6ee5295 --- /dev/null +++ b/src/modules/biometrics/entities/device.entity.ts @@ -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[]; +} diff --git a/src/modules/biometrics/entities/index.ts b/src/modules/biometrics/entities/index.ts new file mode 100644 index 0000000..17eca5d --- /dev/null +++ b/src/modules/biometrics/entities/index.ts @@ -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'; diff --git a/src/modules/feature-flags/entities/flag-evaluation.entity.ts b/src/modules/feature-flags/entities/flag-evaluation.entity.ts new file mode 100644 index 0000000..3f8e1b8 --- /dev/null +++ b/src/modules/feature-flags/entities/flag-evaluation.entity.ts @@ -0,0 +1,54 @@ +/** + * FlagEvaluation Entity + * Feature flag evaluation history for analytics + * Compatible with erp-core flag-evaluation.entity + * + * @module FeatureFlags + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Flag } from './flag.entity'; + +@Entity({ schema: 'feature_flags', name: 'flag_evaluations' }) +@Index('idx_flag_evaluations_flag', ['flagId']) +@Index('idx_flag_evaluations_tenant', ['tenantId']) +@Index('idx_flag_evaluations_date', ['evaluatedAt']) +export class FlagEvaluation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'flag_id' }) + flagId: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: true, name: 'user_id' }) + userId: string | null; + + @Column({ type: 'boolean', nullable: false }) + result: boolean; + + @Column({ type: 'varchar', length: 100, nullable: true }) + variant: string | null; + + @Column({ type: 'jsonb', default: {}, name: 'evaluation_context' }) + evaluationContext: Record; + + @Column({ type: 'varchar', length: 100, nullable: true, name: 'evaluation_reason' }) + evaluationReason: string | null; + + @Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP', name: 'evaluated_at' }) + evaluatedAt: Date; + + @ManyToOne(() => Flag, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'flag_id' }) + flag: Flag; +} diff --git a/src/modules/feature-flags/entities/flag.entity.ts b/src/modules/feature-flags/entities/flag.entity.ts new file mode 100644 index 0000000..69579de --- /dev/null +++ b/src/modules/feature-flags/entities/flag.entity.ts @@ -0,0 +1,65 @@ +/** + * Flag Entity + * Feature flag definition with rollout control + * Compatible with erp-core flag.entity + * + * @module FeatureFlags + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + OneToMany, +} from 'typeorm'; +import { TenantOverride } from './tenant-override.entity'; + +@Entity({ name: 'flags', schema: 'feature_flags' }) +@Unique(['code']) +export class Flag { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'code', type: 'varchar', length: 50 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'enabled', type: 'boolean', default: false }) + enabled: boolean; + + @Column({ name: 'rollout_percentage', type: 'int', default: 100 }) + rolloutPercentage: number; + + @Column({ name: 'tags', type: 'text', array: true, nullable: true }) + tags: string[]; + + @Index() + @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({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @OneToMany(() => TenantOverride, (override) => override.flag) + overrides: TenantOverride[]; +} diff --git a/src/modules/feature-flags/entities/index.ts b/src/modules/feature-flags/entities/index.ts new file mode 100644 index 0000000..8cf3637 --- /dev/null +++ b/src/modules/feature-flags/entities/index.ts @@ -0,0 +1,7 @@ +/** + * Feature Flags Entities - Export + */ + +export { Flag } from './flag.entity'; +export { TenantOverride } from './tenant-override.entity'; +export { FlagEvaluation } from './flag-evaluation.entity'; diff --git a/src/modules/feature-flags/entities/tenant-override.entity.ts b/src/modules/feature-flags/entities/tenant-override.entity.ts new file mode 100644 index 0000000..d4dee9b --- /dev/null +++ b/src/modules/feature-flags/entities/tenant-override.entity.ts @@ -0,0 +1,58 @@ +/** + * TenantOverride Entity + * Per-tenant feature flag override + * Compatible with erp-core tenant-override.entity + * + * @module FeatureFlags + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Flag } from './flag.entity'; + +@Entity({ name: 'tenant_overrides', schema: 'feature_flags' }) +@Unique(['flagId', 'tenantId']) +export class TenantOverride { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'flag_id', type: 'uuid' }) + flagId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'enabled', type: 'boolean' }) + enabled: boolean; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason: string; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => Flag, (flag) => flag.overrides, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'flag_id' }) + flag: Flag; +} diff --git a/src/modules/hr/entities/contract.entity.ts b/src/modules/hr/entities/contract.entity.ts new file mode 100644 index 0000000..a5a6a21 --- /dev/null +++ b/src/modules/hr/entities/contract.entity.ts @@ -0,0 +1,161 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Department } from './department.entity'; + +/** + * Contract Type Enum + */ +export type ContractType = 'permanent' | 'temporary' | 'contractor' | 'internship' | 'part_time'; + +/** + * Contract Status Enum + */ +export type ContractStatus = 'draft' | 'active' | 'expired' | 'terminated' | 'cancelled'; + +/** + * Wage Type for payment frequency + */ +export type WageType = 'hourly' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'annual'; + +/** + * Contract Entity (schema: hr.contracts) + * + * Employment contracts with details about compensation, duration, + * and terms of employment. Tracks contract lifecycle from draft to termination. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'contracts', schema: 'hr' }) +export class Contract { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Index() + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + // Note: ManyToOne to Employee removed - construction Employee + // has a different structure and does not have contracts back-reference. + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'reference', type: 'varchar', length: 50, nullable: true }) + reference: string | null; + + @Index() + @Column({ + name: 'contract_type', + type: 'enum', + enum: ['permanent', 'temporary', 'contractor', 'internship', 'part_time'], + enumName: 'hr_contract_type', + default: 'permanent', + }) + contractType: ContractType; + + @Index() + @Column({ + name: 'status', + type: 'enum', + enum: ['draft', 'active', 'expired', 'terminated', 'cancelled'], + enumName: 'hr_contract_status', + default: 'draft', + }) + status: ContractStatus; + + @Index() + @Column({ name: 'date_start', type: 'date' }) + dateStart: Date; + + @Column({ name: 'date_end', type: 'date', nullable: true }) + dateEnd: Date | null; + + @Column({ name: 'job_position_id', type: 'uuid', nullable: true }) + jobPositionId: string | null; + + // Note: ManyToOne to JobPosition removed - construction uses Puesto entity instead. + + @Column({ name: 'department_id', type: 'uuid', nullable: true }) + departmentId: string | null; + + @ManyToOne(() => Department, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'department_id' }) + department: Department | null; + + // Compensation + @Column({ name: 'wage', type: 'decimal', precision: 15, scale: 2 }) + wage: number; + + @Column({ + name: 'wage_type', + type: 'varchar', + length: 20, + default: 'monthly', + }) + wageType: WageType; + + @Column({ name: 'currency', type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Schedule + @Column({ + name: 'hours_per_week', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + default: 48, + }) + hoursPerWeek: number | null; + + @Column({ name: 'schedule_type', type: 'varchar', length: 50, nullable: true }) + scheduleType: string | null; + + // Trial period + @Column({ name: 'trial_period_months', type: 'integer', nullable: true, default: 0 }) + trialPeriodMonths: number | null; + + @Column({ name: 'trial_date_end', type: 'date', nullable: true }) + trialDateEnd: Date | null; + + // Metadata + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string | null; + + @Column({ name: 'document_url', type: 'text', nullable: true }) + documentUrl: string | null; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'activated_at', type: 'timestamptz', nullable: true }) + activatedAt: Date | null; + + @Column({ name: 'terminated_at', type: 'timestamptz', nullable: true }) + terminatedAt: Date | null; + + @Column({ name: 'terminated_by', type: 'uuid', nullable: true }) + terminatedBy: string | null; + + @Column({ name: 'termination_reason', type: 'text', nullable: true }) + terminationReason: string | null; +} diff --git a/src/modules/hr/entities/department.entity.ts b/src/modules/hr/entities/department.entity.ts new file mode 100644 index 0000000..22207a4 --- /dev/null +++ b/src/modules/hr/entities/department.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; + +/** + * Department Entity (schema: hr.departments) + * + * Organizational departments with self-referential hierarchy. + * Supports parent/child relationships for department structure. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'departments', schema: 'hr' }) +@Index(['tenantId', 'companyId', 'code'], { unique: true }) +export class Department { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'code', type: 'varchar', length: 50, nullable: true }) + code: string | null; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string | null; + + @ManyToOne(() => Department, (department) => department.children, { + onDelete: 'SET NULL', + }) + @JoinColumn({ name: 'parent_id' }) + parent: Department | null; + + @OneToMany(() => Department, (department) => department.parent) + children: Department[]; + + @Column({ name: 'manager_id', type: 'uuid', nullable: true }) + managerId: string | null; + + // Note: manager ManyToOne to Employee removed - construction Employee + // has a different structure and does not map this back-reference. + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'color', type: 'varchar', length: 7, nullable: true }) + color: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/hr/entities/employee-fraccionamiento.entity.ts b/src/modules/hr/entities/employee-fraccionamiento.entity.ts new file mode 100644 index 0000000..012f74a --- /dev/null +++ b/src/modules/hr/entities/employee-fraccionamiento.entity.ts @@ -0,0 +1,65 @@ +/** + * EmployeeFraccionamiento Entity + * Asignación de empleados a obras/fraccionamientos + * + * @module HR + * @table hr.employee_fraccionamientos + * @ddl schemas/02-hr-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Employee } from './employee.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +@Entity({ schema: 'hr', name: 'employee_fraccionamientos' }) +@Index(['employeeId', 'fraccionamientoId', 'fechaInicio'], { unique: true }) +export class EmployeeFraccionamiento { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'fecha_inicio', type: 'date' }) + fechaInicio: Date; + + @Column({ name: 'fecha_fin', type: 'date', nullable: true }) + fechaFin: Date; + + @Column({ type: 'varchar', length: 50, nullable: true }) + rol: string; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Employee, (e) => e.asignaciones) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; +} diff --git a/src/modules/hr/entities/employee.entity.ts b/src/modules/hr/entities/employee.entity.ts new file mode 100644 index 0000000..b4be02f --- /dev/null +++ b/src/modules/hr/entities/employee.entity.ts @@ -0,0 +1,136 @@ +/** + * Employee Entity + * Empleados de la empresa + * + * @module HR + * @table hr.employees + * @ddl schemas/02-hr-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 { Puesto } from './puesto.entity'; +import { EmployeeFraccionamiento } from './employee-fraccionamiento.entity'; + +export type EstadoEmpleado = 'activo' | 'inactivo' | 'baja'; +export type Genero = 'M' | 'F'; + +@Entity({ schema: 'hr', name: 'employees' }) +@Index(['tenantId', 'codigo'], { unique: true }) +@Index(['tenantId', 'curp'], { unique: true }) +export class Employee { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + codigo: string; + + @Column({ type: 'varchar', length: 100 }) + nombre: string; + + @Column({ name: 'apellido_paterno', type: 'varchar', length: 100 }) + apellidoPaterno: string; + + @Column({ name: 'apellido_materno', type: 'varchar', length: 100, nullable: true }) + apellidoMaterno: string; + + @Column({ type: 'varchar', length: 18, nullable: true }) + curp: string; + + @Column({ type: 'varchar', length: 13, nullable: true }) + rfc: string; + + @Column({ type: 'varchar', length: 11, nullable: true }) + nss: string; + + @Column({ name: 'fecha_nacimiento', type: 'date', nullable: true }) + fechaNacimiento: Date; + + @Column({ type: 'varchar', length: 1, nullable: true }) + genero: Genero; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + telefono: string; + + @Column({ type: 'text', nullable: true }) + direccion: string; + + @Column({ name: 'fecha_ingreso', type: 'date' }) + fechaIngreso: Date; + + @Column({ name: 'fecha_baja', type: 'date', nullable: true }) + fechaBaja: Date; + + @Column({ name: 'puesto_id', type: 'uuid', nullable: true }) + puestoId: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + departamento: string; + + @Column({ name: 'tipo_contrato', type: 'varchar', length: 50, nullable: true }) + tipoContrato: string; + + @Column({ + name: 'salario_diario', + type: 'decimal', + precision: 10, + scale: 2, + nullable: true + }) + salarioDiario: number; + + @Column({ type: 'varchar', length: 20, default: 'activo' }) + estado: EstadoEmpleado; + + @Column({ name: 'foto_url', type: 'varchar', length: 500, nullable: true }) + fotoUrl: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Puesto, (p) => p.empleados) + @JoinColumn({ name: 'puesto_id' }) + puesto: Puesto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => EmployeeFraccionamiento, (ef) => ef.employee) + asignaciones: EmployeeFraccionamiento[]; + + // Computed property + get nombreCompleto(): string { + return [this.nombre, this.apellidoPaterno, this.apellidoMaterno] + .filter(Boolean) + .join(' '); + } +} diff --git a/src/modules/hr/entities/index.ts b/src/modules/hr/entities/index.ts new file mode 100644 index 0000000..aa6b6aa --- /dev/null +++ b/src/modules/hr/entities/index.ts @@ -0,0 +1,16 @@ +/** + * HR Entities Index + * @module HR + */ + +// Existing construction-specific entities +export * from './puesto.entity'; +export * from './employee.entity'; +export * from './employee-fraccionamiento.entity'; + +// Entities propagated from erp-core +export { Department } from './department.entity'; +export { Contract, ContractType, ContractStatus, WageType } from './contract.entity'; +export { LeaveType, LeaveTypeCategory, AllocationType } from './leave-type.entity'; +export { LeaveAllocation } from './leave-allocation.entity'; +export { Leave, LeaveStatus, HalfDayType } from './leave.entity'; diff --git a/src/modules/hr/entities/leave-allocation.entity.ts b/src/modules/hr/entities/leave-allocation.entity.ts new file mode 100644 index 0000000..3aa9a0b --- /dev/null +++ b/src/modules/hr/entities/leave-allocation.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { LeaveType } from './leave-type.entity'; + +/** + * Leave Allocation Entity (schema: hr.leave_allocations) + * + * Tracks allocated leave days per employee and leave type. + * Supports period-based allocations with used/remaining tracking. + * + * Note: days_remaining is a computed column in PostgreSQL + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'leave_allocations', schema: 'hr' }) +export class LeaveAllocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + // Note: ManyToOne to Employee removed - construction Employee + // has a different structure and does not have leaveAllocations back-reference. + + @Index() + @Column({ name: 'leave_type_id', type: 'uuid' }) + leaveTypeId: string; + + @ManyToOne(() => LeaveType, (leaveType) => leaveType.allocations, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'leave_type_id' }) + leaveType: LeaveType; + + @Column({ name: 'days_allocated', type: 'decimal', precision: 5, scale: 2 }) + daysAllocated: number; + + @Column({ name: 'days_used', type: 'decimal', precision: 5, scale: 2, default: 0 }) + daysUsed: number; + + /** + * Generated column in PostgreSQL: days_allocated - days_used + * Mark as insert: false, update: false since it's computed by DB + */ + @Column({ + name: 'days_remaining', + type: 'decimal', + precision: 5, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + daysRemaining: number; + + @Index() + @Column({ name: 'date_from', type: 'date' }) + dateFrom: Date; + + @Column({ name: 'date_to', type: 'date' }) + dateTo: Date; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/hr/entities/leave-type.entity.ts b/src/modules/hr/entities/leave-type.entity.ts new file mode 100644 index 0000000..f182b35 --- /dev/null +++ b/src/modules/hr/entities/leave-type.entity.ts @@ -0,0 +1,131 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { Leave } from './leave.entity'; +import { LeaveAllocation } from './leave-allocation.entity'; + +/** + * Leave Type Category Enum + */ +export type LeaveTypeCategory = + | 'vacation' + | 'sick' + | 'personal' + | 'maternity' + | 'paternity' + | 'bereavement' + | 'unpaid' + | 'other'; + +/** + * Allocation Type Enum + */ +export type AllocationType = 'fixed' | 'accrual' | 'unlimited'; + +/** + * Leave Type Entity (schema: hr.leave_types) + * + * Configurable leave/absence types for the organization. + * Defines rules for approval, allocation, and payment. + * + * Examples: Vacation, Sick Leave, Maternity, etc. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'leave_types', schema: 'hr' }) +@Index(['tenantId', 'companyId', 'code'], { unique: true }) +export class LeaveType { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'code', type: 'varchar', length: 50 }) + code: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'color', type: 'varchar', length: 7, default: '#3B82F6' }) + color: string; + + @Index() + @Column({ + name: 'leave_category', + type: 'enum', + enum: ['vacation', 'sick', 'personal', 'maternity', 'paternity', 'bereavement', 'unpaid', 'other'], + enumName: 'hr_leave_type_category', + default: 'other', + }) + leaveCategory: LeaveTypeCategory; + + @Column({ + name: 'allocation_type', + type: 'enum', + enum: ['fixed', 'accrual', 'unlimited'], + enumName: 'hr_allocation_type', + default: 'fixed', + }) + allocationType: AllocationType; + + @Column({ name: 'requires_approval', type: 'boolean', default: true }) + requiresApproval: boolean; + + @Column({ name: 'requires_document', type: 'boolean', default: false }) + requiresDocument: boolean; + + // Limits + @Column({ name: 'max_days_per_request', type: 'integer', nullable: true }) + maxDaysPerRequest: number | null; + + @Column({ name: 'max_days_per_year', type: 'integer', nullable: true }) + maxDaysPerYear: number | null; + + @Column({ name: 'min_days_notice', type: 'integer', default: 0 }) + minDaysNotice: number; + + // Payment + @Column({ name: 'is_paid', type: 'boolean', default: true }) + isPaid: boolean; + + @Column({ + name: 'pay_percentage', + type: 'decimal', + precision: 5, + scale: 2, + default: 100, + }) + payPercentage: number; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + // Relations + @OneToMany(() => Leave, (leave) => leave.leaveType) + leaves: Leave[]; + + @OneToMany(() => LeaveAllocation, (allocation) => allocation.leaveType) + allocations: LeaveAllocation[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/hr/entities/leave.entity.ts b/src/modules/hr/entities/leave.entity.ts new file mode 100644 index 0000000..d26b784 --- /dev/null +++ b/src/modules/hr/entities/leave.entity.ts @@ -0,0 +1,138 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { LeaveType } from './leave-type.entity'; +import { LeaveAllocation } from './leave-allocation.entity'; + +/** + * Leave Status Enum + */ +export type LeaveStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'cancelled'; + +/** + * Half Day Type + */ +export type HalfDayType = 'morning' | 'afternoon'; + +/** + * Leave Entity (schema: hr.leaves) + * + * Leave/absence requests from employees. + * Tracks the full lifecycle from draft to approval/rejection. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'leaves', schema: 'hr' }) +export class Leave { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Index() + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + // Note: ManyToOne to Employee removed - construction Employee + // has a different structure and does not have leaves back-reference. + + @Index() + @Column({ name: 'leave_type_id', type: 'uuid' }) + leaveTypeId: string; + + @ManyToOne(() => LeaveType, (leaveType) => leaveType.leaves, { + onDelete: 'RESTRICT', + }) + @JoinColumn({ name: 'leave_type_id' }) + leaveType: LeaveType; + + @Column({ name: 'allocation_id', type: 'uuid', nullable: true }) + allocationId: string | null; + + @ManyToOne(() => LeaveAllocation, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'allocation_id' }) + allocation: LeaveAllocation | null; + + // Period + @Index() + @Column({ name: 'date_from', type: 'date' }) + dateFrom: Date; + + @Column({ name: 'date_to', type: 'date' }) + dateTo: Date; + + @Column({ name: 'days_requested', type: 'decimal', precision: 5, scale: 2 }) + daysRequested: number; + + // Half day support + @Column({ name: 'is_half_day', type: 'boolean', default: false }) + isHalfDay: boolean; + + @Column({ + name: 'half_day_type', + type: 'varchar', + length: 20, + nullable: true, + }) + halfDayType: HalfDayType | null; + + @Index() + @Column({ + name: 'status', + type: 'enum', + enum: ['draft', 'submitted', 'approved', 'rejected', 'cancelled'], + enumName: 'hr_leave_status', + default: 'draft', + }) + status: LeaveStatus; + + // Approval + @Index() + @Column({ name: 'approver_id', type: 'uuid', nullable: true }) + approverId: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason: string | null; + + // Metadata + @Column({ name: 'request_reason', type: 'text', nullable: true }) + requestReason: string | null; + + @Column({ name: 'document_url', type: 'text', nullable: true }) + documentUrl: string | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string | null; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt: Date | null; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt: Date | null; + + @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) + cancelledBy: string | null; +} diff --git a/src/modules/hr/entities/puesto.entity.ts b/src/modules/hr/entities/puesto.entity.ts new file mode 100644 index 0000000..26d89f3 --- /dev/null +++ b/src/modules/hr/entities/puesto.entity.ts @@ -0,0 +1,68 @@ +/** + * Puesto Entity + * Catálogo de puestos de trabajo + * + * @module HR + * @table hr.puestos + * @ddl schemas/02-hr-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Employee } from './employee.entity'; + +@Entity({ schema: 'hr', name: 'puestos' }) +@Index(['tenantId', 'codigo'], { unique: true }) +export class Puesto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 20 }) + codigo: string; + + @Column({ type: 'varchar', length: 100 }) + nombre: string; + + @Column({ type: 'text', nullable: true }) + descripcion: string; + + @Column({ name: 'nivel_riesgo', type: 'varchar', length: 20, nullable: true }) + nivelRiesgo: string; + + @Column({ + name: 'requiere_capacitacion_especial', + type: 'boolean', + default: false + }) + requiereCapacitacionEspecial: boolean; + + @Column({ type: 'boolean', default: true }) + activo: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @OneToMany(() => Employee, (e) => e.puesto) + empleados: Employee[]; +} diff --git a/src/modules/invoices/entities/index.ts b/src/modules/invoices/entities/index.ts new file mode 100644 index 0000000..527fe39 --- /dev/null +++ b/src/modules/invoices/entities/index.ts @@ -0,0 +1,8 @@ +/** + * Invoices Entities - Export + */ + +export { Invoice, InvoiceType, InvoiceStatus, InvoiceContext } from './invoice.entity'; +export { InvoiceItem } from './invoice-item.entity'; +export { Payment } from './payment.entity'; +export { PaymentAllocation } from './payment-allocation.entity'; diff --git a/src/modules/invoices/entities/invoice-item.entity.ts b/src/modules/invoices/entities/invoice-item.entity.ts new file mode 100644 index 0000000..3d46a73 --- /dev/null +++ b/src/modules/invoices/entities/invoice-item.entity.ts @@ -0,0 +1,95 @@ +/** + * InvoiceItem Entity + * Line items for unified invoices + * Compatible with erp-core invoice-item.entity + * + * @module Invoices + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Invoice } from './invoice.entity'; + +@Entity({ name: 'invoice_items', schema: 'billing' }) +export class InvoiceItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId: string; + + @ManyToOne(() => Invoice, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'line_number', type: 'int', default: 1 }) + lineNumber: number; + + @Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true }) + productSku?: string; + + @Column({ name: 'product_name', type: 'varchar', length: 200 }) + productName: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + // SAT (Mexico) + @Column({ name: 'sat_product_code', type: 'varchar', length: 20, nullable: true }) + satProductCode?: string; + + @Column({ name: 'sat_unit_code', type: 'varchar', length: 10, nullable: true }) + satUnitCode?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'withholding_rate', type: 'decimal', precision: 5, scale: 2, default: 0 }) + withholdingRate: number; + + @Column({ name: 'withholding_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + withholdingAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts new file mode 100644 index 0000000..36ad901 --- /dev/null +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -0,0 +1,187 @@ +/** + * Unified Invoice Entity + * Combines commercial and SaaS billing invoices + * Compatible with erp-core invoice.entity + * + * @module Invoices + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { InvoiceItem } from './invoice-item.entity'; + +export type InvoiceType = 'sale' | 'purchase' | 'credit_note' | 'debit_note'; +export type InvoiceStatus = 'draft' | 'validated' | 'sent' | 'partial' | 'paid' | 'overdue' | 'void' | 'refunded' | 'cancelled' | 'voided'; +export type InvoiceContext = 'commercial' | 'saas'; + +@Entity({ name: 'invoices', schema: 'billing' }) +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index({ unique: true }) + @Column({ name: 'invoice_number', type: 'varchar', length: 30 }) + invoiceNumber: string; + + @Index() + @Column({ name: 'invoice_type', type: 'varchar', length: 20, default: 'sale' }) + invoiceType: InvoiceType; + + @Index() + @Column({ name: 'invoice_context', type: 'varchar', length: 20, default: 'commercial' }) + invoiceContext: InvoiceContext; + + // Commercial fields + @Column({ name: 'sales_order_id', type: 'uuid', nullable: true }) + salesOrderId: string | null; + + @Column({ name: 'purchase_order_id', type: 'uuid', nullable: true }) + purchaseOrderId: string | null; + + @Index() + @Column({ name: 'partner_id', type: 'uuid', nullable: true }) + partnerId: string | null; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string | null; + + @Column({ name: 'partner_tax_id', type: 'varchar', length: 50, nullable: true }) + partnerTaxId: string | null; + + // SaaS billing fields + @Index() + @Column({ name: 'subscription_id', type: 'uuid', nullable: true }) + subscriptionId: string | null; + + @Column({ name: 'period_start', type: 'date', nullable: true }) + periodStart: Date | null; + + @Column({ name: 'period_end', type: 'date', nullable: true }) + periodEnd: Date | null; + + // Billing information + @Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true }) + billingName: string | null; + + @Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true }) + billingEmail: string | null; + + @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) + billingAddress: Record | null; + + @Column({ name: 'tax_id', type: 'varchar', length: 50, nullable: true }) + taxId: string | null; + + // Dates + @Index() + @Column({ name: 'invoice_date', type: 'date', default: () => 'CURRENT_DATE' }) + invoiceDate: Date; + + @Column({ name: 'due_date', type: 'date', nullable: true }) + dueDate: Date | null; + + @Column({ name: 'payment_date', type: 'date', nullable: true }) + paymentDate: Date | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date | null; + + // Amounts + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'withholding_tax', type: 'decimal', precision: 15, scale: 2, default: 0 }) + withholdingTax: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountPaid: number; + + @Column({ name: 'amount_due', type: 'decimal', precision: 15, scale: 2, insert: false, update: false, nullable: true }) + amountDue: number | null; + + @Column({ name: 'paid_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + paidAmount: number; + + // Payment details + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) + paymentMethod: string | null; + + @Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true }) + paymentReference: string | null; + + // Status + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: InvoiceStatus; + + // CFDI (Mexico) + @Index() + @Column({ name: 'cfdi_uuid', type: 'varchar', length: 40, nullable: true }) + cfdiUuid: string | null; + + @Column({ name: 'cfdi_status', type: 'varchar', length: 20, nullable: true }) + cfdiStatus: string | null; + + @Column({ name: 'cfdi_xml', type: 'text', nullable: true }) + cfdiXml: string | null; + + @Column({ name: 'cfdi_pdf_url', type: 'varchar', length: 500, nullable: true }) + cfdiPdfUrl: string | null; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string | null; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string | null; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + // Relations + @OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: true }) + items: InvoiceItem[]; +} diff --git a/src/modules/invoices/entities/payment-allocation.entity.ts b/src/modules/invoices/entities/payment-allocation.entity.ts new file mode 100644 index 0000000..66e9001 --- /dev/null +++ b/src/modules/invoices/entities/payment-allocation.entity.ts @@ -0,0 +1,53 @@ +/** + * PaymentAllocation Entity + * Links payments to invoices + * Compatible with erp-core payment-allocation.entity + * + * @module Invoices + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Payment } from './payment.entity'; +import { Invoice } from './invoice.entity'; + +@Entity({ name: 'payment_allocations', schema: 'billing' }) +export class PaymentAllocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'payment_id', type: 'uuid' }) + paymentId: string; + + @ManyToOne(() => Payment, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'payment_id' }) + payment: Payment; + + @Index() + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId: string; + + @ManyToOne(() => Invoice, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + amount: number; + + @Column({ name: 'allocation_date', type: 'date', default: () => 'CURRENT_DATE' }) + allocationDate: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/invoices/entities/payment.entity.ts b/src/modules/invoices/entities/payment.entity.ts new file mode 100644 index 0000000..1e6416a --- /dev/null +++ b/src/modules/invoices/entities/payment.entity.ts @@ -0,0 +1,89 @@ +/** + * Payment Entity + * Payment received or made + * Compatible with erp-core payment.entity + * + * @module Invoices + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'payments', schema: 'billing' }) +export class Payment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'payment_number', type: 'varchar', length: 30 }) + paymentNumber: string; + + @Index() + @Column({ name: 'payment_type', type: 'varchar', length: 20, default: 'received' }) + paymentType: 'received' | 'made'; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + amount: number; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + @Column({ name: 'payment_date', type: 'date', default: () => 'CURRENT_DATE' }) + paymentDate: Date; + + @Index() + @Column({ name: 'payment_method', type: 'varchar', length: 50 }) + paymentMethod: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + reference: string; + + @Column({ name: 'bank_account_id', type: 'uuid', nullable: true }) + bankAccountId: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'reconciled' | 'cancelled'; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'cfdi_uuid', type: 'varchar', length: 40, nullable: true }) + cfdiUuid: string; + + @Column({ name: 'cfdi_status', type: 'varchar', length: 20, nullable: true }) + cfdiStatus: string; + + @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; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/products/entities/index.ts b/src/modules/products/entities/index.ts new file mode 100644 index 0000000..55118e7 --- /dev/null +++ b/src/modules/products/entities/index.ts @@ -0,0 +1,7 @@ +export { ProductCategory } from './product-category.entity'; +export { Product } from './product.entity'; +export { ProductPrice } from './product-price.entity'; +export { ProductSupplier } from './product-supplier.entity'; +export { ProductAttribute } from './product-attribute.entity'; +export { ProductAttributeValue } from './product-attribute-value.entity'; +export { ProductVariant } from './product-variant.entity'; diff --git a/src/modules/products/entities/product-attribute-value.entity.ts b/src/modules/products/entities/product-attribute-value.entity.ts new file mode 100644 index 0000000..0a5f63b --- /dev/null +++ b/src/modules/products/entities/product-attribute-value.entity.ts @@ -0,0 +1,55 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ProductAttribute } from './product-attribute.entity'; + +/** + * Product Attribute Value Entity (schema: products.product_attribute_values) + * + * Represents specific values for product attributes. + * Example: For attribute "Color", values could be "Red", "Blue", "Green". + */ +@Entity({ name: 'product_attribute_values', schema: 'products' }) +export class ProductAttributeValue { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'attribute_id', type: 'uuid' }) + attributeId: string; + + @ManyToOne(() => ProductAttribute, (attribute) => attribute.values, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'attribute_id' }) + attribute: ProductAttribute; + + @Column({ type: 'varchar', length: 50, nullable: true }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'html_color', type: 'varchar', length: 20, nullable: true }) + htmlColor: string; + + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/products/entities/product-attribute.entity.ts b/src/modules/products/entities/product-attribute.entity.ts new file mode 100644 index 0000000..2460ef0 --- /dev/null +++ b/src/modules/products/entities/product-attribute.entity.ts @@ -0,0 +1,60 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { ProductAttributeValue } from './product-attribute-value.entity'; + +/** + * Product Attribute Entity (schema: products.product_attributes) + * + * Represents configurable attributes for products like color, size, material. + * Each attribute can have multiple values (e.g., Color: Red, Blue, Green). + */ +@Entity({ name: 'product_attributes', schema: 'products' }) +export class ProductAttribute { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'display_type', type: 'varchar', length: 20, default: 'radio' }) + displayType: 'radio' | 'select' | 'color' | 'pills'; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: 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; + + @OneToMany(() => ProductAttributeValue, (value) => value.attribute) + values: ProductAttributeValue[]; +} diff --git a/src/modules/products/entities/product-category.entity.ts b/src/modules/products/entities/product-category.entity.ts new file mode 100644 index 0000000..4de6df7 --- /dev/null +++ b/src/modules/products/entities/product-category.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; + +@Entity({ name: 'product_categories', schema: 'products' }) +export class ProductCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @ManyToOne(() => ProductCategory, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_id' }) + parent: ProductCategory; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Jerarquia + @Column({ name: 'hierarchy_path', type: 'text', nullable: true }) + hierarchyPath: string; + + @Column({ name: 'hierarchy_level', type: 'int', default: 0 }) + hierarchyLevel: number; + + // Imagen + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + // Orden + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: number; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + // Metadata + @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; +} diff --git a/src/modules/products/entities/product-price.entity.ts b/src/modules/products/entities/product-price.entity.ts new file mode 100644 index 0000000..c768e2b --- /dev/null +++ b/src/modules/products/entities/product-price.entity.ts @@ -0,0 +1,48 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Product } from './product.entity'; + +@Entity({ name: 'product_prices', schema: 'products' }) +export class ProductPrice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @ManyToOne(() => Product, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @Index() + @Column({ name: 'price_type', type: 'varchar', length: 30, default: 'standard' }) + priceType: 'standard' | 'wholesale' | 'retail' | 'promo'; + + @Column({ name: 'price_list_name', type: 'varchar', length: 100, nullable: true }) + priceListName?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4 }) + price: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'min_quantity', type: 'decimal', precision: 15, scale: 4, default: 1 }) + minQuantity: number; + + @Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + validFrom: Date; + + @Column({ name: 'valid_to', type: 'timestamptz', nullable: true }) + validTo?: Date; + + @Index() + @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; +} diff --git a/src/modules/products/entities/product-supplier.entity.ts b/src/modules/products/entities/product-supplier.entity.ts new file mode 100644 index 0000000..0cfbe24 --- /dev/null +++ b/src/modules/products/entities/product-supplier.entity.ts @@ -0,0 +1,51 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Product } from './product.entity'; + +@Entity({ name: 'product_suppliers', schema: 'products' }) +export class ProductSupplier { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @ManyToOne(() => Product, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @Index() + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId: string; + + @Column({ name: 'supplier_sku', type: 'varchar', length: 50, nullable: true }) + supplierSku?: string; + + @Column({ name: 'supplier_name', type: 'varchar', length: 200, nullable: true }) + supplierName?: string; + + @Column({ name: 'purchase_price', type: 'decimal', precision: 15, scale: 4, nullable: true }) + purchasePrice?: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'min_order_qty', type: 'decimal', precision: 15, scale: 4, default: 1 }) + minOrderQty: number; + + @Column({ name: 'lead_time_days', type: 'int', default: 0 }) + leadTimeDays: number; + + @Index() + @Column({ name: 'is_preferred', type: 'boolean', default: false }) + isPreferred: boolean; + + @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; +} diff --git a/src/modules/products/entities/product-variant.entity.ts b/src/modules/products/entities/product-variant.entity.ts new file mode 100644 index 0000000..5c677fe --- /dev/null +++ b/src/modules/products/entities/product-variant.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Product } from './product.entity'; + +/** + * Product Variant Entity (schema: products.product_variants) + * + * Represents product variants generated from attribute combinations. + * Example: "Blue T-Shirt - Size M" is a variant of product "T-Shirt". + */ +@Entity({ name: 'product_variants', schema: 'products' }) +export class ProductVariant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @ManyToOne(() => Product, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 50 }) + sku: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + barcode: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'price_extra', type: 'decimal', precision: 15, scale: 4, default: 0 }) + priceExtra: number; + + @Column({ name: 'cost_extra', type: 'decimal', precision: 15, scale: 4, default: 0 }) + costExtra: number; + + @Column({ name: 'stock_qty', type: 'decimal', precision: 15, scale: 4, default: 0 }) + stockQty: number; + + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + @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; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; +} diff --git a/src/modules/products/entities/product.entity.ts b/src/modules/products/entities/product.entity.ts new file mode 100644 index 0000000..d665b2c --- /dev/null +++ b/src/modules/products/entities/product.entity.ts @@ -0,0 +1,206 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ProductCategory } from './product-category.entity'; + +/** + * Commerce Product Entity (schema: products.products) + * + * NOTE: This is NOT a duplicate of inventory/entities/product.entity.ts + * + * Key differences: + * - This entity: products.products - Commerce/retail focused + * - Has: SAT codes, tax rates, detailed dimensions, min/max stock, reorder points + * - Used by: Sales, purchases, invoicing, POS + * + * - Inventory Product: inventory.products - Warehouse/stock management focused (Odoo-style) + * - Has: valuationMethod, tracking (lot/serial), isStorable, StockQuant/Lot relations + * - Used by: Inventory module for stock tracking, valuation, picking operations + * + * These are intentionally separate by domain. This commerce product entity handles + * pricing, tax compliance (SAT/CFDI), and business rules. For physical stock tracking, + * use the inventory module's product entity. + */ +@Entity({ name: 'products', schema: 'products' }) +export class Product { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'category_id', type: 'uuid', nullable: true }) + categoryId: string; + + @ManyToOne(() => ProductCategory, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'category_id' }) + category: ProductCategory; + + /** + * Optional link to inventory.products for unified stock management. + * This allows the commerce product to be linked to its inventory counterpart + * for stock tracking, valuation (FIFO/AVERAGE), and warehouse operations. + * + * The inventory product handles: stock levels, lot/serial tracking, valuation layers + * This commerce product handles: pricing, taxes, SAT compliance, commercial data + */ + @Index() + @Column({ name: 'inventory_product_id', type: 'uuid', nullable: true }) + inventoryProductId: string | null; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 50 }) + sku: string; + + @Index() + @Column({ type: 'varchar', length: 50, nullable: true }) + barcode: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'short_name', type: 'varchar', length: 50, nullable: true }) + shortName: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Index() + @Column({ name: 'product_type', type: 'varchar', length: 20, default: 'product' }) + productType: 'product' | 'service' | 'consumable' | 'kit'; + + // Precios + @Column({ name: 'sale_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + salePrice: number; + + @Column({ name: 'cost_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + costPrice: number; + + @Column({ name: 'min_sale_price', type: 'decimal', precision: 15, scale: 4, nullable: true }) + minSalePrice: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Impuestos + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16 }) + taxRate: number; + + @Column({ name: 'tax_included', type: 'boolean', default: false }) + taxIncluded: boolean; + + // SAT (Mexico) + @Column({ name: 'sat_product_code', type: 'varchar', length: 20, nullable: true }) + satProductCode: string; + + @Column({ name: 'sat_unit_code', type: 'varchar', length: 10, nullable: true }) + satUnitCode: string; + + // Unidad de medida + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'uom_purchase', type: 'varchar', length: 20, nullable: true }) + uomPurchase: string; + + @Column({ name: 'conversion_factor', type: 'decimal', precision: 10, scale: 4, default: 1 }) + conversionFactor: number; + + // Inventario + @Column({ name: 'track_inventory', type: 'boolean', default: true }) + trackInventory: boolean; + + @Column({ name: 'min_stock', type: 'decimal', precision: 15, scale: 4, default: 0 }) + minStock: number; + + @Column({ name: 'max_stock', type: 'decimal', precision: 15, scale: 4, nullable: true }) + maxStock: number; + + @Column({ name: 'reorder_point', type: 'decimal', precision: 15, scale: 4, nullable: true }) + reorderPoint: number; + + @Column({ name: 'reorder_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) + reorderQuantity: number; + + // Lotes y series + @Column({ name: 'track_lots', type: 'boolean', default: false }) + trackLots: boolean; + + @Column({ name: 'track_serials', type: 'boolean', default: false }) + trackSerials: boolean; + + @Column({ name: 'track_expiry', type: 'boolean', default: false }) + trackExpiry: boolean; + + // Dimensiones + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + weight: number; + + @Column({ name: 'weight_unit', type: 'varchar', length: 10, default: 'kg' }) + weightUnit: string; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + length: number; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + width: number; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + height: number; + + @Column({ name: 'dimension_unit', type: 'varchar', length: 10, default: 'cm' }) + dimensionUnit: string; + + // Imagenes + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + @Column({ type: 'text', array: true, default: '{}' }) + images: string[]; + + // Tags + @Column({ type: 'text', array: true, default: '{}' }) + tags: string[]; + + // Notas + @Column({ type: 'text', nullable: true }) + notes: string; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_sellable', type: 'boolean', default: true }) + isSellable: boolean; + + @Column({ name: 'is_purchasable', type: 'boolean', default: true }) + isPurchasable: boolean; + + // Metadata + @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; +} diff --git a/src/modules/profiles/entities/index.ts b/src/modules/profiles/entities/index.ts new file mode 100644 index 0000000..31b3568 --- /dev/null +++ b/src/modules/profiles/entities/index.ts @@ -0,0 +1,9 @@ +/** + * Profiles Entities - Export + */ + +export { Person } from './person.entity'; +export { UserProfile } from './user-profile.entity'; +export { ProfileTool } from './profile-tool.entity'; +export { ProfileModule } from './profile-module.entity'; +export { UserProfileAssignment } from './user-profile-assignment.entity'; diff --git a/src/modules/profiles/entities/person.entity.ts b/src/modules/profiles/entities/person.entity.ts new file mode 100644 index 0000000..195f63e --- /dev/null +++ b/src/modules/profiles/entities/person.entity.ts @@ -0,0 +1,78 @@ +/** + * Person Entity + * Contact/person information with identity verification + * Compatible with erp-core person.entity + * + * @module Profiles + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'persons', schema: 'auth' }) +export class Person { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'full_name', type: 'varchar', length: 200 }) + fullName: string; + + @Column({ name: 'first_name', type: 'varchar', length: 100, nullable: true }) + firstName: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100, nullable: true }) + lastName: string; + + @Column({ name: 'maternal_name', type: 'varchar', length: 100, nullable: true }) + maternalName: string; + + @Index() + @Column({ type: 'varchar', length: 255 }) + email: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + phone: string; + + @Column({ name: 'mobile_phone', type: 'varchar', length: 20, nullable: true }) + mobilePhone: string; + + @Column({ name: 'identification_type', type: 'varchar', length: 50, nullable: true }) + identificationType: string; + + @Column({ name: 'identification_number', type: 'varchar', length: 50, nullable: true }) + identificationNumber: string; + + @Column({ name: 'identification_expiry', type: 'date', nullable: true }) + identificationExpiry: Date; + + @Column({ type: 'jsonb', default: {} }) + address: Record; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'verified_by', type: 'uuid', nullable: true }) + verifiedBy: string; + + @Column({ name: 'is_responsible_for_tenant', type: 'boolean', default: false }) + isResponsibleForTenant: 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; +} diff --git a/src/modules/profiles/entities/profile-module.entity.ts b/src/modules/profiles/entities/profile-module.entity.ts new file mode 100644 index 0000000..764fbf0 --- /dev/null +++ b/src/modules/profiles/entities/profile-module.entity.ts @@ -0,0 +1,45 @@ +/** + * ProfileModule Entity + * Module-level access control per profile + * Compatible with erp-core profile-module.entity + * + * @module Profiles + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Unique, + Index, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'profile_modules', schema: 'auth' }) +@Unique(['profileId', 'moduleCode']) +export class ProfileModule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Column({ name: 'module_code', type: 'varchar', length: 50 }) + moduleCode: string; + + @Column({ name: 'access_level', type: 'varchar', length: 20, default: 'read' }) + accessLevel: 'read' | 'write' | 'admin'; + + @Column({ name: 'can_export', type: 'boolean', default: false }) + canExport: boolean; + + @Column({ name: 'can_print', type: 'boolean', default: true }) + canPrint: boolean; + + @ManyToOne(() => UserProfile, (profile) => profile.modules, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/profile-tool.entity.ts b/src/modules/profiles/entities/profile-tool.entity.ts new file mode 100644 index 0000000..c0992f2 --- /dev/null +++ b/src/modules/profiles/entities/profile-tool.entity.ts @@ -0,0 +1,68 @@ +/** + * ProfileTool Entity + * Tool/permission assignments per profile + * Compatible with erp-core profile-tool.entity + * + * @module Profiles + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'profile_tools', schema: 'auth' }) +@Unique(['profileId', 'toolCode']) +export class ProfileTool { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Index() + @Column({ name: 'tool_code', type: 'varchar', length: 50 }) + toolCode: string; + + @Column({ name: 'tool_name', type: 'varchar', length: 100 }) + toolName: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'is_mobile_only', type: 'boolean', default: false }) + isMobileOnly: boolean; + + @Column({ name: 'is_web_only', type: 'boolean', default: false }) + isWebOnly: boolean; + + @Column({ type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ type: 'jsonb', default: {} }) + configuration: Record; + + @Column({ name: 'sort_order', type: 'integer', default: 0 }) + sortOrder: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => UserProfile, (profile) => profile.tools, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/user-profile-assignment.entity.ts b/src/modules/profiles/entities/user-profile-assignment.entity.ts new file mode 100644 index 0000000..bcfdeff --- /dev/null +++ b/src/modules/profiles/entities/user-profile-assignment.entity.ts @@ -0,0 +1,50 @@ +/** + * UserProfileAssignment Entity + * Links users to profiles with expiration support + * Compatible with erp-core user-profile-assignment.entity + * + * @module Profiles + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'user_profile_assignments', schema: 'auth' }) +@Unique(['userId', 'profileId']) +export class UserProfileAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + @CreateDateColumn({ name: 'assigned_at', type: 'timestamptz' }) + assignedAt: Date; + + @Column({ name: 'assigned_by', type: 'uuid', nullable: true }) + assignedBy: string; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @ManyToOne(() => UserProfile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/user-profile.entity.ts b/src/modules/profiles/entities/user-profile.entity.ts new file mode 100644 index 0000000..85438bd --- /dev/null +++ b/src/modules/profiles/entities/user-profile.entity.ts @@ -0,0 +1,90 @@ +/** + * UserProfile Entity + * Role-based profile with module access, tools and pricing + * Compatible with erp-core user-profile.entity + * + * @module Profiles + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + OneToMany, + Unique, +} from 'typeorm'; +import { ProfileTool } from './profile-tool.entity'; +import { ProfileModule } from './profile-module.entity'; + +@Entity({ name: 'user_profiles', schema: 'auth' }) +@Unique(['tenantId', 'code']) +export class UserProfile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 10 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ name: 'base_permissions', type: 'jsonb', default: [] }) + basePermissions: string[]; + + @Column({ name: 'available_modules', type: 'text', array: true, default: [] }) + availableModules: string[]; + + @Column({ name: 'monthly_price', type: 'decimal', precision: 10, scale: 2, default: 0 }) + monthlyPrice: number; + + @Column({ name: 'included_platforms', type: 'text', array: true, default: ['web'] }) + includedPlatforms: string[]; + + @Column({ name: 'default_tools', type: 'text', array: true, default: [] }) + defaultTools: string[]; + + @Column({ name: 'feature_flags', type: 'jsonb', default: {} }) + featureFlags: Record; + + @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; + + @OneToMany(() => ProfileTool, (tool) => tool.profile, { cascade: true }) + tools: ProfileTool[]; + + @OneToMany(() => ProfileModule, (module) => module.profile, { cascade: true }) + modules: ProfileModule[]; +} diff --git a/src/modules/projects/entities/index.ts b/src/modules/projects/entities/index.ts new file mode 100644 index 0000000..dbc3634 --- /dev/null +++ b/src/modules/projects/entities/index.ts @@ -0,0 +1 @@ +export * from './timesheet.entity'; diff --git a/src/modules/projects/entities/timesheet.entity.ts b/src/modules/projects/entities/timesheet.entity.ts new file mode 100644 index 0000000..3bdb6b3 --- /dev/null +++ b/src/modules/projects/entities/timesheet.entity.ts @@ -0,0 +1,93 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum TimesheetStatus { + DRAFT = 'draft', + SUBMITTED = 'submitted', + APPROVED = 'approved', + REJECTED = 'rejected', +} + +@Entity({ schema: 'projects', name: 'timesheets' }) +@Index('idx_timesheets_tenant', ['tenantId']) +@Index('idx_timesheets_company', ['companyId']) +@Index('idx_timesheets_project', ['projectId']) +@Index('idx_timesheets_task', ['taskId']) +@Index('idx_timesheets_user', ['userId']) +@Index('idx_timesheets_user_date', ['userId', 'date']) +@Index('idx_timesheets_date', ['date']) +@Index('idx_timesheets_status', ['status']) +export class TimesheetEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'uuid', nullable: false, name: 'project_id' }) + projectId: string; + + @Column({ type: 'uuid', nullable: true, name: 'task_id' }) + taskId: string | null; + + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + @Column({ type: 'date', nullable: false }) + date: Date; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: false }) + hours: number; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'boolean', default: true, nullable: false }) + billable: boolean; + + @Column({ type: 'boolean', default: false, nullable: false }) + invoiced: boolean; + + @Column({ type: 'uuid', nullable: true, name: 'invoice_id' }) + invoiceId: string | null; + + @Column({ + type: 'enum', + enum: TimesheetStatus, + default: TimesheetStatus.DRAFT, + nullable: false, + }) + status: TimesheetStatus; + + @Column({ type: 'uuid', nullable: true, name: 'approved_by' }) + approvedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'approved_at' }) + approvedAt: Date | null; + + // 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; +} diff --git a/src/modules/purchase/entities/comparativo-cotizaciones.entity.ts b/src/modules/purchase/entities/comparativo-cotizaciones.entity.ts new file mode 100644 index 0000000..5e989bf --- /dev/null +++ b/src/modules/purchase/entities/comparativo-cotizaciones.entity.ts @@ -0,0 +1,101 @@ +/** + * ComparativoCotizaciones Entity + * Cuadro comparativo de cotizaciones + * + * @module Purchase + * @table purchase.comparativo_cotizaciones + * @ddl schemas/07-purchase-ext-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 { RequisicionObra } from '../../inventory/entities/requisicion-obra.entity'; +import { ComparativoProveedor } from './comparativo-proveedor.entity'; + +export type ComparativoStatus = 'draft' | 'in_evaluation' | 'approved' | 'cancelled'; + +@Entity({ schema: 'purchase', name: 'comparativo_cotizaciones' }) +@Index(['tenantId', 'code'], { unique: true }) +export class ComparativoCotizaciones { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'requisicion_id', type: 'uuid', nullable: true }) + requisicionId: string; + + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'comparison_date', type: 'date' }) + comparisonDate: Date; + + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: ComparativoStatus; + + @Column({ name: 'winner_supplier_id', type: 'uuid', nullable: true }) + winnerSupplierId: string; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => RequisicionObra) + @JoinColumn({ name: 'requisicion_id' }) + requisicion: RequisicionObra; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => ComparativoProveedor, (cp) => cp.comparativo) + proveedores: ComparativoProveedor[]; +} diff --git a/src/modules/purchase/entities/comparativo-producto.entity.ts b/src/modules/purchase/entities/comparativo-producto.entity.ts new file mode 100644 index 0000000..f6e9640 --- /dev/null +++ b/src/modules/purchase/entities/comparativo-producto.entity.ts @@ -0,0 +1,75 @@ +/** + * ComparativoProducto Entity + * Productos cotizados por proveedor en comparativo + * + * @module Purchase + * @table purchase.comparativo_productos + * @ddl schemas/07-purchase-ext-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { ComparativoProveedor } from './comparativo-proveedor.entity'; + +@Entity({ schema: 'purchase', name: 'comparativo_productos' }) +export class ComparativoProducto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'comparativo_proveedor_id', type: 'uuid' }) + comparativoProveedorId: string; + + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + quantity: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 4 }) + unitPrice: number; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Computed property (in DB is GENERATED ALWAYS AS) + get totalPrice(): number { + return this.quantity * this.unitPrice; + } + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => ComparativoProveedor, (cp) => cp.productos, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'comparativo_proveedor_id' }) + comparativoProveedor: ComparativoProveedor; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; +} diff --git a/src/modules/purchase/entities/comparativo-proveedor.entity.ts b/src/modules/purchase/entities/comparativo-proveedor.entity.ts new file mode 100644 index 0000000..8a00104 --- /dev/null +++ b/src/modules/purchase/entities/comparativo-proveedor.entity.ts @@ -0,0 +1,87 @@ +/** + * ComparativoProveedor Entity + * Proveedores participantes en comparativo + * + * @module Purchase + * @table purchase.comparativo_proveedores + * @ddl schemas/07-purchase-ext-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { ComparativoCotizaciones } from './comparativo-cotizaciones.entity'; +import { ComparativoProducto } from './comparativo-producto.entity'; + +@Entity({ schema: 'purchase', name: 'comparativo_proveedores' }) +export class ComparativoProveedor { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'comparativo_id', type: 'uuid' }) + comparativoId: string; + + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId: string; + + @Column({ name: 'quotation_number', type: 'varchar', length: 50, nullable: true }) + quotationNumber: string; + + @Column({ name: 'quotation_date', type: 'date', nullable: true }) + quotationDate: Date; + + @Column({ name: 'delivery_days', type: 'integer', nullable: true }) + deliveryDays: number; + + @Column({ name: 'payment_conditions', type: 'varchar', length: 100, nullable: true }) + paymentConditions: string; + + @Column({ name: 'total_amount', type: 'decimal', precision: 16, scale: 2, nullable: true }) + totalAmount: number; + + @Column({ name: 'is_selected', type: 'boolean', default: false }) + isSelected: boolean; + + @Column({ name: 'evaluation_notes', type: 'text', nullable: true }) + evaluationNotes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => ComparativoCotizaciones, (c) => c.proveedores, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'comparativo_id' }) + comparativo: ComparativoCotizaciones; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @OneToMany(() => ComparativoProducto, (cp) => cp.comparativoProveedor) + productos: ComparativoProducto[]; +} diff --git a/src/modules/purchase/entities/index.ts b/src/modules/purchase/entities/index.ts new file mode 100644 index 0000000..408c775 --- /dev/null +++ b/src/modules/purchase/entities/index.ts @@ -0,0 +1,20 @@ +/** + * Purchase Entities Index + * @module Purchase + * + * Extensiones de compras para construccion (MAI-004) + */ + +// Construction-specific entities +export * from './purchase-order-construction.entity'; +export * from './supplier-construction.entity'; +export * from './comparativo-cotizaciones.entity'; +export * from './comparativo-proveedor.entity'; +export * from './comparativo-producto.entity'; + +// Core purchase entities (from erp-core) +export * from './purchase-receipt.entity'; +export * from './purchase-receipt-item.entity'; +export * from './purchase-order-matching.entity'; +export * from './purchase-matching-line.entity'; +export * from './matching-exception.entity'; diff --git a/src/modules/purchase/entities/matching-exception.entity.ts b/src/modules/purchase/entities/matching-exception.entity.ts new file mode 100644 index 0000000..a1ffb37 --- /dev/null +++ b/src/modules/purchase/entities/matching-exception.entity.ts @@ -0,0 +1,78 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PurchaseOrderMatching } from './purchase-order-matching.entity'; +import { PurchaseMatchingLine } from './purchase-matching-line.entity'; + +export type ExceptionType = + | 'over_receipt' + | 'short_receipt' + | 'over_invoice' + | 'short_invoice' + | 'price_variance'; + +export type ExceptionStatus = 'pending' | 'approved' | 'rejected'; + +@Entity({ name: 'matching_exceptions', schema: 'purchases' }) +export class MatchingException { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'matching_id', type: 'uuid', nullable: true }) + matchingId?: string; + + @ManyToOne(() => PurchaseOrderMatching, (matching) => matching.exceptions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'matching_id' }) + matching?: PurchaseOrderMatching; + + @Index() + @Column({ name: 'matching_line_id', type: 'uuid', nullable: true }) + matchingLineId?: string; + + @ManyToOne(() => PurchaseMatchingLine, (line) => line.exceptions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'matching_line_id' }) + matchingLine?: PurchaseMatchingLine; + + @Index() + @Column({ name: 'exception_type', type: 'varchar', length: 50 }) + exceptionType: ExceptionType; + + @Column({ name: 'expected_value', type: 'decimal', precision: 15, scale: 4, nullable: true }) + expectedValue?: number; + + @Column({ name: 'actual_value', type: 'decimal', precision: 15, scale: 4, nullable: true }) + actualValue?: number; + + @Column({ name: 'variance_value', type: 'decimal', precision: 15, scale: 4, nullable: true }) + varianceValue?: number; + + @Column({ name: 'variance_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) + variancePercent?: number; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: ExceptionStatus; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt?: Date; + + @Column({ name: 'resolved_by', type: 'uuid', nullable: true }) + resolvedBy?: string; + + @Column({ name: 'resolution_notes', type: 'text', nullable: true }) + resolutionNotes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/purchase/entities/purchase-matching-line.entity.ts b/src/modules/purchase/entities/purchase-matching-line.entity.ts new file mode 100644 index 0000000..7074368 --- /dev/null +++ b/src/modules/purchase/entities/purchase-matching-line.entity.ts @@ -0,0 +1,98 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { PurchaseOrderMatching } from './purchase-order-matching.entity'; +import { MatchingException } from './matching-exception.entity'; + +export type MatchingLineStatus = 'pending' | 'partial' | 'matched' | 'mismatch'; + +@Entity({ name: 'purchase_matching_lines', schema: 'purchases' }) +export class PurchaseMatchingLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'matching_id', type: 'uuid' }) + matchingId: string; + + @ManyToOne(() => PurchaseOrderMatching, (matching) => matching.lines, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'matching_id' }) + matching: PurchaseOrderMatching; + + @Index() + @Column({ name: 'order_item_id', type: 'uuid' }) + orderItemId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Quantities + @Column({ name: 'qty_ordered', type: 'decimal', precision: 15, scale: 4 }) + qtyOrdered: number; + + @Column({ name: 'qty_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) + qtyReceived: number; + + @Column({ name: 'qty_invoiced', type: 'decimal', precision: 15, scale: 4, default: 0 }) + qtyInvoiced: number; + + // Prices + @Column({ name: 'price_ordered', type: 'decimal', precision: 15, scale: 2 }) + priceOrdered: number; + + @Column({ name: 'price_invoiced', type: 'decimal', precision: 15, scale: 2, default: 0 }) + priceInvoiced: number; + + // Generated columns (read-only in TypeORM) + @Column({ + name: 'qty_variance', + type: 'decimal', + precision: 15, + scale: 4, + insert: false, + update: false, + nullable: true, + }) + qtyVariance: number; + + @Column({ + name: 'invoice_qty_variance', + type: 'decimal', + precision: 15, + scale: 4, + insert: false, + update: false, + nullable: true, + }) + invoiceQtyVariance: number; + + @Column({ + name: 'price_variance', + type: 'decimal', + precision: 15, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + priceVariance: number; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: MatchingLineStatus; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @OneToMany(() => MatchingException, (exception) => exception.matchingLine) + exceptions: MatchingException[]; +} diff --git a/src/modules/purchase/entities/purchase-order-construction.entity.ts b/src/modules/purchase/entities/purchase-order-construction.entity.ts new file mode 100644 index 0000000..e2ab173 --- /dev/null +++ b/src/modules/purchase/entities/purchase-order-construction.entity.ts @@ -0,0 +1,114 @@ +/** + * PurchaseOrderConstruction Entity + * Extensión de órdenes de compra para construcción + * + * @module Purchase (MAI-004) + * @table purchase.purchase_order_construction + * @ddl schemas/07-purchase-ext-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 { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { RequisicionObra } from '../../inventory/entities/requisicion-obra.entity'; + +@Entity({ schema: 'purchase', name: 'purchase_order_construction' }) +@Index(['tenantId']) +@Index(['purchaseOrderId'], { unique: true }) +@Index(['fraccionamientoId']) +@Index(['requisicionId']) +export class PurchaseOrderConstruction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // FK a purchase.purchase_orders (ERP Core) + @Column({ name: 'purchase_order_id', type: 'uuid' }) + purchaseOrderId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true }) + fraccionamientoId: string; + + @Column({ name: 'requisicion_id', type: 'uuid', nullable: true }) + requisicionId: string; + + // Delivery information + @Column({ name: 'delivery_location', type: 'varchar', length: 255, nullable: true }) + deliveryLocation: string; + + @Column({ name: 'delivery_contact', type: 'varchar', length: 100, nullable: true }) + deliveryContact: string; + + @Column({ name: 'delivery_phone', type: 'varchar', length: 20, nullable: true }) + deliveryPhone: string; + + // Reception + @Column({ name: 'received_by', type: 'uuid', nullable: true }) + receivedById: string; + + @Column({ name: 'received_at', type: 'timestamptz', nullable: true }) + receivedAt: Date; + + // Quality check + @Column({ name: 'quality_approved', type: 'boolean', nullable: true }) + qualityApproved: boolean; + + @Column({ name: 'quality_notes', type: 'text', nullable: true }) + qualityNotes: string; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento, { nullable: true }) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => RequisicionObra, { nullable: true }) + @JoinColumn({ name: 'requisicion_id' }) + requisicion: RequisicionObra; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'received_by' }) + receivedBy: User; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User; +} diff --git a/src/modules/purchase/entities/purchase-order-matching.entity.ts b/src/modules/purchase/entities/purchase-order-matching.entity.ts new file mode 100644 index 0000000..4b59740 --- /dev/null +++ b/src/modules/purchase/entities/purchase-order-matching.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { PurchaseReceipt } from './purchase-receipt.entity'; +import { PurchaseMatchingLine } from './purchase-matching-line.entity'; +import { MatchingException } from './matching-exception.entity'; + +export type MatchingStatus = + | 'pending' + | 'partial_receipt' + | 'received' + | 'partial_invoice' + | 'matched' + | 'mismatch'; + +@Entity({ name: 'purchase_order_matching', schema: 'purchases' }) +export class PurchaseOrderMatching { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'purchase_order_id', type: 'uuid' }) + purchaseOrderId: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: MatchingStatus; + + @Column({ name: 'total_ordered', type: 'decimal', precision: 15, scale: 2 }) + totalOrdered: number; + + @Column({ name: 'total_received', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalReceived: number; + + @Column({ name: 'total_invoiced', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalInvoiced: number; + + // Generated columns (read-only in TypeORM) + @Column({ + name: 'receipt_variance', + type: 'decimal', + precision: 15, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + receiptVariance: number; + + @Column({ + name: 'invoice_variance', + type: 'decimal', + precision: 15, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + invoiceVariance: number; + + @Index() + @Column({ name: 'last_receipt_id', type: 'uuid', nullable: true }) + lastReceiptId?: string; + + @ManyToOne(() => PurchaseReceipt, { nullable: true }) + @JoinColumn({ name: 'last_receipt_id' }) + lastReceipt?: PurchaseReceipt; + + @Column({ name: 'last_invoice_id', type: 'uuid', nullable: true }) + lastInvoiceId?: string; + + @Column({ name: 'matched_at', type: 'timestamptz', nullable: true }) + matchedAt?: Date; + + @Column({ name: 'matched_by', type: 'uuid', nullable: true }) + matchedBy?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @OneToMany(() => PurchaseMatchingLine, (line) => line.matching) + lines: PurchaseMatchingLine[]; + + @OneToMany(() => MatchingException, (exception) => exception.matching) + exceptions: MatchingException[]; +} diff --git a/src/modules/purchase/entities/purchase-receipt-item.entity.ts b/src/modules/purchase/entities/purchase-receipt-item.entity.ts new file mode 100644 index 0000000..8cd3eeb --- /dev/null +++ b/src/modules/purchase/entities/purchase-receipt-item.entity.ts @@ -0,0 +1,57 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { PurchaseReceipt } from './purchase-receipt.entity'; + +@Entity({ name: 'purchase_receipt_items', schema: 'purchases' }) +export class PurchaseReceiptItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'receipt_id', type: 'uuid' }) + receiptId: string; + + @ManyToOne(() => PurchaseReceipt, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'receipt_id' }) + receipt: PurchaseReceipt; + + @Column({ name: 'order_item_id', type: 'uuid', nullable: true }) + orderItemId?: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'quantity_expected', type: 'decimal', precision: 15, scale: 4, nullable: true }) + quantityExpected?: number; + + @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4 }) + quantityReceived: number; + + @Column({ name: 'quantity_rejected', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityRejected: number; + + @Index() + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber?: string; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate?: Date; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + @Column({ name: 'quality_status', type: 'varchar', length: 20, default: 'pending' }) + qualityStatus: 'pending' | 'approved' | 'rejected' | 'quarantine'; + + @Column({ name: 'quality_notes', type: 'text', nullable: true }) + qualityNotes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/purchase/entities/purchase-receipt.entity.ts b/src/modules/purchase/entities/purchase-receipt.entity.ts new file mode 100644 index 0000000..03da5cc --- /dev/null +++ b/src/modules/purchase/entities/purchase-receipt.entity.ts @@ -0,0 +1,52 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'purchase_receipts', schema: 'purchases' }) +export class PurchaseReceipt { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @Column({ name: 'receipt_number', type: 'varchar', length: 30 }) + receiptNumber: string; + + @Column({ name: 'receipt_date', type: 'date', default: () => 'CURRENT_DATE' }) + receiptDate: Date; + + @Column({ name: 'received_by', type: 'uuid', nullable: true }) + receivedBy?: string; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + @Column({ name: 'supplier_delivery_note', type: 'varchar', length: 100, nullable: true }) + supplierDeliveryNote?: string; + + @Column({ name: 'supplier_invoice_number', type: 'varchar', length: 100, nullable: true }) + supplierInvoiceNumber?: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'cancelled'; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @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; +} diff --git a/src/modules/purchase/entities/supplier-construction.entity.ts b/src/modules/purchase/entities/supplier-construction.entity.ts new file mode 100644 index 0000000..6104a47 --- /dev/null +++ b/src/modules/purchase/entities/supplier-construction.entity.ts @@ -0,0 +1,130 @@ +/** + * SupplierConstruction Entity + * Extensión de proveedores para construcción + * + * @module Purchase (MAI-004) + * @table purchase.supplier_construction + * @ddl schemas/07-purchase-ext-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'; + +@Entity({ schema: 'purchase', name: 'supplier_construction' }) +@Index(['tenantId']) +@Index(['supplierId'], { unique: true }) +@Index(['overallRating']) +export class SupplierConstruction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // FK a purchase.suppliers (ERP Core) + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId: string; + + // Supplier type flags + @Column({ name: 'is_materials_supplier', type: 'boolean', default: false }) + isMaterialsSupplier: boolean; + + @Column({ name: 'is_services_supplier', type: 'boolean', default: false }) + isServicesSupplier: boolean; + + @Column({ name: 'is_equipment_supplier', type: 'boolean', default: false }) + isEquipmentSupplier: boolean; + + @Column({ type: 'text', array: true, nullable: true }) + specialties: string[]; + + // Ratings (1.00 - 5.00) + @Column({ name: 'quality_rating', type: 'decimal', precision: 3, scale: 2, nullable: true }) + qualityRating: number; + + @Column({ name: 'delivery_rating', type: 'decimal', precision: 3, scale: 2, nullable: true }) + deliveryRating: number; + + @Column({ name: 'price_rating', type: 'decimal', precision: 3, scale: 2, nullable: true }) + priceRating: number; + + // Overall rating (computed in DB, but we can calculate in code too) + @Column({ + name: 'overall_rating', + type: 'decimal', + precision: 3, + scale: 2, + nullable: true, + insert: false, + update: false, + }) + overallRating: number; + + @Column({ name: 'last_evaluation_date', type: 'date', nullable: true }) + lastEvaluationDate: Date; + + // Credit terms + @Column({ name: 'credit_limit', type: 'decimal', precision: 14, scale: 2, nullable: true }) + creditLimit: number; + + @Column({ name: 'payment_days', type: 'int', default: 30 }) + paymentDays: number; + + // Documents status + @Column({ name: 'has_valid_documents', type: 'boolean', default: false }) + hasValidDocuments: boolean; + + @Column({ name: 'documents_expiry_date', type: 'date', nullable: true }) + documentsExpiryDate: Date; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + createdBy: User; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User; + + // Computed property for overall rating + calculateOverallRating(): number { + const ratings = [this.qualityRating, this.deliveryRating, this.priceRating].filter( + (r) => r !== null && r !== undefined + ); + if (ratings.length === 0) return 0; + return ratings.reduce((sum, r) => sum + Number(r), 0) / ratings.length; + } +} diff --git a/src/modules/reports/entities/custom-report.entity.ts b/src/modules/reports/entities/custom-report.entity.ts new file mode 100644 index 0000000..9626d81 --- /dev/null +++ b/src/modules/reports/entities/custom-report.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Report } from './report.entity'; + +/** + * Custom Report Entity (schema: reports.custom_reports) + * + * User-personalized reports based on existing definitions. + * Stores custom columns, filters, grouping, and sorting preferences. + */ +@Entity({ name: 'custom_reports', schema: 'reports' }) +export class CustomReport { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'owner_id', type: 'uuid' }) + ownerId: string; + + @Column({ name: 'base_definition_id', type: 'uuid', nullable: true }) + baseDefinitionId: string | null; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'custom_columns', type: 'jsonb', default: '[]' }) + customColumns: Record[]; + + @Column({ name: 'custom_filters', type: 'jsonb', default: '[]' }) + customFilters: Record[]; + + @Column({ name: 'custom_grouping', type: 'jsonb', default: '[]' }) + customGrouping: Record[]; + + @Column({ name: 'custom_sorting', type: 'jsonb', default: '[]' }) + customSorting: Record[]; + + @Index() + @Column({ name: 'is_favorite', type: 'boolean', default: false }) + isFavorite: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Report, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'base_definition_id' }) + baseDefinition: Report | null; +} diff --git a/src/modules/reports/entities/dashboard-widget.entity.ts b/src/modules/reports/entities/dashboard-widget.entity.ts new file mode 100644 index 0000000..0fa4257 --- /dev/null +++ b/src/modules/reports/entities/dashboard-widget.entity.ts @@ -0,0 +1,222 @@ +/** + * DashboardWidget Entity + * Configuración de widgets de dashboard + * + * @module Reports + * @table reports.dashboard_widgets + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { Dashboard } from './dashboard.entity'; + +export type WidgetType = + | 'kpi_card' + | 'line_chart' + | 'bar_chart' + | 'pie_chart' + | 'donut_chart' + | 'area_chart' + | 'gauge' + | 'table' + | 'heatmap' + | 'map' + | 'timeline' + | 'progress' + | 'list' + | 'text' + | 'image' + | 'custom'; + +export type DataSourceType = 'query' | 'api' | 'static' | 'kpi' | 'report'; + +@Entity({ schema: 'reports', name: 'dashboard_widgets' }) +@Index(['tenantId']) +@Index(['dashboardId']) +@Index(['widgetType']) +@Index(['isActive']) +export class DashboardWidget { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'dashboard_id', type: 'uuid' }) + dashboardId: string; + + @Column({ type: 'varchar', length: 200 }) + title: string; + + @Column({ type: 'text', nullable: true }) + subtitle: string | null; + + @Column({ + name: 'widget_type', + type: 'varchar', + length: 30, + }) + widgetType: WidgetType; + + @Column({ + name: 'data_source_type', + type: 'varchar', + length: 20, + default: 'query', + }) + dataSourceType: DataSourceType; + + @Column({ + name: 'data_source', + type: 'jsonb', + nullable: true, + comment: 'Query, API endpoint, or KPI code', + }) + dataSource: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Widget-specific configuration', + }) + config: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Chart options (colors, legend, etc)', + }) + chartOptions: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Threshold/alert configuration', + }) + thresholds: Record | null; + + @Column({ + name: 'grid_x', + type: 'integer', + default: 0, + comment: 'Grid position X', + }) + gridX: number; + + @Column({ + name: 'grid_y', + type: 'integer', + default: 0, + comment: 'Grid position Y', + }) + gridY: number; + + @Column({ + name: 'grid_width', + type: 'integer', + default: 4, + comment: 'Width in grid units', + }) + gridWidth: number; + + @Column({ + name: 'grid_height', + type: 'integer', + default: 2, + comment: 'Height in grid units', + }) + gridHeight: number; + + @Column({ + name: 'min_width', + type: 'integer', + default: 2, + }) + minWidth: number; + + @Column({ + name: 'min_height', + type: 'integer', + default: 1, + }) + minHeight: number; + + @Column({ + name: 'refresh_interval', + type: 'integer', + nullable: true, + comment: 'Override dashboard refresh (seconds)', + }) + refreshInterval: number | null; + + @Column({ + name: 'cache_duration', + type: 'integer', + default: 60, + comment: 'Cache duration in seconds', + }) + cacheDuration: number; + + @Column({ + name: 'drill_down_config', + type: 'jsonb', + nullable: true, + comment: 'Drill-down navigation config', + }) + drillDownConfig: Record | null; + + @Column({ + name: 'click_action', + type: 'jsonb', + nullable: true, + comment: 'Action on click (navigate, filter, etc)', + }) + clickAction: Record | null; + + @Column({ + name: 'is_active', + type: 'boolean', + default: true, + }) + isActive: boolean; + + @Column({ + name: 'sort_order', + type: 'integer', + default: 0, + }) + sortOrder: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Dashboard) + @JoinColumn({ name: 'dashboard_id' }) + dashboard: Dashboard; +} diff --git a/src/modules/reports/entities/dashboard.entity.ts b/src/modules/reports/entities/dashboard.entity.ts new file mode 100644 index 0000000..ca109be --- /dev/null +++ b/src/modules/reports/entities/dashboard.entity.ts @@ -0,0 +1,205 @@ +/** + * Dashboard Entity + * Configuración de dashboards personalizables + * + * @module Reports + * @table reports.dashboards + */ + +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 { DashboardWidget } from './dashboard-widget.entity'; + +export type DashboardType = 'corporate' | 'project' | 'department' | 'personal' | 'custom'; + +export type DashboardVisibility = 'private' | 'team' | 'department' | 'company'; + +@Entity({ schema: 'reports', name: 'dashboards' }) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId']) +@Index(['dashboardType']) +@Index(['ownerId']) +@Index(['isActive']) +export class Dashboard { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + name: 'dashboard_type', + type: 'varchar', + length: 30, + default: 'custom', + }) + dashboardType: DashboardType; + + @Column({ + type: 'varchar', + length: 20, + default: 'private', + }) + visibility: DashboardVisibility; + + @Column({ + name: 'owner_id', + type: 'uuid', + nullable: true, + comment: 'User who owns this dashboard', + }) + ownerId: string | null; + + @Column({ + name: 'fraccionamiento_id', + type: 'uuid', + nullable: true, + comment: 'Project-specific dashboard', + }) + fraccionamientoId: string | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Layout configuration (grid positions)', + }) + layout: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Theme and styling configuration', + }) + theme: Record | null; + + @Column({ + name: 'refresh_interval', + type: 'integer', + default: 300, + comment: 'Auto-refresh interval in seconds', + }) + refreshInterval: number; + + @Column({ + name: 'default_date_range', + type: 'varchar', + length: 30, + default: 'last_30_days', + comment: 'Default date range filter', + }) + defaultDateRange: string; + + @Column({ + name: 'default_filters', + type: 'jsonb', + nullable: true, + }) + defaultFilters: Record | null; + + @Column({ + name: 'allowed_roles', + type: 'varchar', + array: true, + nullable: true, + comment: 'Roles that can view this dashboard', + }) + allowedRoles: string[] | null; + + @Column({ + name: 'is_default', + type: 'boolean', + default: false, + comment: 'Default dashboard for type', + }) + isDefault: boolean; + + @Column({ + name: 'is_active', + type: 'boolean', + default: true, + }) + isActive: boolean; + + @Column({ + name: 'is_system', + type: 'boolean', + default: false, + comment: 'System dashboard cannot be deleted', + }) + isSystem: boolean; + + @Column({ + name: 'sort_order', + type: 'integer', + default: 0, + }) + sortOrder: number; + + @Column({ + name: 'view_count', + type: 'integer', + default: 0, + }) + viewCount: number; + + @Column({ + name: 'last_viewed_at', + type: 'timestamptz', + nullable: true, + }) + lastViewedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'owner_id' }) + owner: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => DashboardWidget, (w) => w.dashboard) + widgets: DashboardWidget[]; +} diff --git a/src/modules/reports/entities/data-model-entity.entity.ts b/src/modules/reports/entities/data-model-entity.entity.ts new file mode 100644 index 0000000..913eb76 --- /dev/null +++ b/src/modules/reports/entities/data-model-entity.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { DataModelField } from './data-model-field.entity'; +import { DataModelRelationship } from './data-model-relationship.entity'; + +/** + * Data Model Entity (schema: reports.data_model_entities) + * + * Represents database tables/entities available for report building. + * Used by the report builder UI to construct dynamic queries. + */ +@Entity({ name: 'data_model_entities', schema: 'reports' }) +@Index(['name'], { unique: true }) +export class DataModelEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'display_name', type: 'varchar', length: 255 }) + displayName: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Index() + @Column({ name: 'schema_name', type: 'varchar', length: 100 }) + schemaName: string; + + @Column({ name: 'table_name', type: 'varchar', length: 100 }) + tableName: string; + + @Column({ name: 'primary_key_column', type: 'varchar', length: 100, default: 'id' }) + primaryKeyColumn: string; + + @Column({ name: 'tenant_column', type: 'varchar', length: 100, nullable: true, default: 'tenant_id' }) + tenantColumn: string | null; + + @Column({ name: 'is_multi_tenant', type: 'boolean', default: true }) + isMultiTenant: boolean; + + @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; + + // Relations + @OneToMany(() => DataModelField, (field) => field.entity) + fields: DataModelField[]; + + @OneToMany(() => DataModelRelationship, (rel) => rel.sourceEntity) + sourceRelationships: DataModelRelationship[]; + + @OneToMany(() => DataModelRelationship, (rel) => rel.targetEntity) + targetRelationships: DataModelRelationship[]; +} diff --git a/src/modules/reports/entities/data-model-field.entity.ts b/src/modules/reports/entities/data-model-field.entity.ts new file mode 100644 index 0000000..f81c988 --- /dev/null +++ b/src/modules/reports/entities/data-model-field.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { DataModelEntity } from './data-model-entity.entity'; + +/** + * Data Model Field Entity (schema: reports.data_model_fields) + * + * Represents columns/fields within a data model entity. + * Includes metadata for filtering, sorting, grouping, and formatting. + */ +@Entity({ name: 'data_model_fields', schema: 'reports' }) +@Index(['entityId', 'name'], { unique: true }) +export class DataModelField { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'entity_id', type: 'uuid' }) + entityId: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'display_name', type: 'varchar', length: 255 }) + displayName: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'data_type', type: 'varchar', length: 50 }) + dataType: string; + + @Column({ name: 'is_nullable', type: 'boolean', default: true }) + isNullable: boolean; + + @Column({ name: 'is_filterable', type: 'boolean', default: true }) + isFilterable: boolean; + + @Column({ name: 'is_sortable', type: 'boolean', default: true }) + isSortable: boolean; + + @Column({ name: 'is_groupable', type: 'boolean', default: false }) + isGroupable: boolean; + + @Column({ name: 'is_aggregatable', type: 'boolean', default: false }) + isAggregatable: boolean; + + @Column({ name: 'aggregation_functions', type: 'text', array: true, default: '{}' }) + aggregationFunctions: string[]; + + @Column({ name: 'format_pattern', type: 'varchar', length: 100, nullable: true }) + formatPattern: string | null; + + @Column({ name: 'display_format', type: 'varchar', length: 50, nullable: true }) + displayFormat: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => DataModelEntity, (entity) => entity.fields, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'entity_id' }) + entity: DataModelEntity; +} diff --git a/src/modules/reports/entities/data-model-relationship.entity.ts b/src/modules/reports/entities/data-model-relationship.entity.ts new file mode 100644 index 0000000..cca04dd --- /dev/null +++ b/src/modules/reports/entities/data-model-relationship.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { DataModelEntity } from './data-model-entity.entity'; + +/** + * Relationship type enum + */ +export enum RelationshipType { + ONE_TO_ONE = 'one_to_one', + ONE_TO_MANY = 'one_to_many', + MANY_TO_ONE = 'many_to_one', + MANY_TO_MANY = 'many_to_many', +} + +/** + * Data Model Relationship Entity (schema: reports.data_model_relationships) + * + * Defines relationships between data model entities for join operations + * in the report builder. + */ +@Entity({ name: 'data_model_relationships', schema: 'reports' }) +@Index(['sourceEntityId', 'targetEntityId', 'name'], { unique: true }) +export class DataModelRelationship { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'source_entity_id', type: 'uuid' }) + sourceEntityId: string; + + @Index() + @Column({ name: 'target_entity_id', type: 'uuid' }) + targetEntityId: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ + name: 'relationship_type', + type: 'varchar', + length: 20, + }) + relationshipType: RelationshipType; + + @Column({ name: 'source_column', type: 'varchar', length: 100 }) + sourceColumn: string; + + @Column({ name: 'target_column', type: 'varchar', length: 100 }) + targetColumn: string; + + @Column({ name: 'join_condition', type: 'text', nullable: true }) + joinCondition: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => DataModelEntity, (entity) => entity.sourceRelationships, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'source_entity_id' }) + sourceEntity: DataModelEntity; + + @ManyToOne(() => DataModelEntity, (entity) => entity.targetRelationships, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'target_entity_id' }) + targetEntity: DataModelEntity; +} diff --git a/src/modules/reports/entities/index.ts b/src/modules/reports/entities/index.ts new file mode 100644 index 0000000..c3accac --- /dev/null +++ b/src/modules/reports/entities/index.ts @@ -0,0 +1,21 @@ +/** + * Reports Module - Entity Exports + * MAI-006: Reportes y Analytics + */ + +// Existing construction entities +export * from './report.entity'; +export * from './report-execution.entity'; +export * from './dashboard.entity'; +export * from './dashboard-widget.entity'; +export * from './kpi-snapshot.entity'; + +// Core report entities (from erp-core) +export * from './report-schedule.entity'; +export * from './report-recipient.entity'; +export * from './schedule-execution.entity'; +export * from './widget-query.entity'; +export * from './custom-report.entity'; +export * from './data-model-entity.entity'; +export * from './data-model-field.entity'; +export * from './data-model-relationship.entity'; diff --git a/src/modules/reports/entities/kpi-snapshot.entity.ts b/src/modules/reports/entities/kpi-snapshot.entity.ts new file mode 100644 index 0000000..82c7e59 --- /dev/null +++ b/src/modules/reports/entities/kpi-snapshot.entity.ts @@ -0,0 +1,220 @@ +/** + * KpiSnapshot Entity + * Snapshots históricos de KPIs para análisis + * + * @module Reports + * @table reports.kpi_snapshots + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; + +export type KpiCategory = + | 'financial' + | 'progress' + | 'quality' + | 'hse' + | 'hr' + | 'inventory' + | 'sales' + | 'operational'; + +export type KpiPeriodType = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'; + +export type TrendDirection = 'up' | 'down' | 'stable'; + +@Entity({ schema: 'reports', name: 'kpi_snapshots' }) +@Index(['tenantId', 'kpiCode', 'snapshotDate']) +@Index(['tenantId']) +@Index(['kpiCode']) +@Index(['category']) +@Index(['snapshotDate']) +@Index(['fraccionamientoId']) +export class KpiSnapshot { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ + name: 'kpi_code', + type: 'varchar', + length: 50, + comment: 'Unique KPI identifier', + }) + kpiCode: string; + + @Column({ + name: 'kpi_name', + type: 'varchar', + length: 200, + }) + kpiName: string; + + @Column({ + type: 'varchar', + length: 30, + }) + category: KpiCategory; + + @Column({ + name: 'snapshot_date', + type: 'date', + }) + snapshotDate: Date; + + @Column({ + name: 'period_type', + type: 'varchar', + length: 20, + default: 'daily', + }) + periodType: KpiPeriodType; + + @Column({ + name: 'period_start', + type: 'date', + nullable: true, + }) + periodStart: Date | null; + + @Column({ + name: 'period_end', + type: 'date', + nullable: true, + }) + periodEnd: Date | null; + + @Column({ + name: 'fraccionamiento_id', + type: 'uuid', + nullable: true, + comment: 'Project-specific KPI, null for global', + }) + fraccionamientoId: string | null; + + @Column({ + type: 'decimal', + precision: 18, + scale: 4, + }) + value: number; + + @Column({ + name: 'previous_value', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + }) + previousValue: number | null; + + @Column({ + name: 'target_value', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + }) + targetValue: number | null; + + @Column({ + name: 'min_value', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + comment: 'Minimum acceptable value', + }) + minValue: number | null; + + @Column({ + name: 'max_value', + type: 'decimal', + precision: 18, + scale: 4, + nullable: true, + comment: 'Maximum acceptable value', + }) + maxValue: number | null; + + @Column({ + type: 'varchar', + length: 20, + nullable: true, + comment: 'Unit of measurement', + }) + unit: string | null; + + @Column({ + name: 'change_percentage', + type: 'decimal', + precision: 8, + scale: 2, + nullable: true, + comment: 'Percentage change from previous', + }) + changePercentage: number | null; + + @Column({ + name: 'trend_direction', + type: 'varchar', + length: 10, + nullable: true, + }) + trendDirection: TrendDirection | null; + + @Column({ + name: 'is_on_target', + type: 'boolean', + nullable: true, + }) + isOnTarget: boolean | null; + + @Column({ + name: 'status_color', + type: 'varchar', + length: 20, + nullable: true, + comment: 'green, yellow, red based on thresholds', + }) + statusColor: string | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Additional breakdown data', + }) + breakdown: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Source data references', + }) + metadata: Record | null; + + @Column({ + name: 'calculated_at', + type: 'timestamptz', + default: () => 'CURRENT_TIMESTAMP', + }) + calculatedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; +} diff --git a/src/modules/reports/entities/report-execution.entity.ts b/src/modules/reports/entities/report-execution.entity.ts new file mode 100644 index 0000000..57fa312 --- /dev/null +++ b/src/modules/reports/entities/report-execution.entity.ts @@ -0,0 +1,191 @@ +/** + * ReportExecution Entity + * Historial de ejecuciones de reportes + * + * @module Reports + * @table reports.report_executions + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Report, ReportFormat } from './report.entity'; + +export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + +@Entity({ schema: 'reports', name: 'report_executions' }) +@Index(['tenantId']) +@Index(['reportId']) +@Index(['status']) +@Index(['executedAt']) +@Index(['executedById']) +export class ReportExecution { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'report_id', type: 'uuid' }) + reportId: string; + + @Column({ + type: 'varchar', + length: 20, + default: 'pending', + }) + status: ExecutionStatus; + + @Column({ + type: 'varchar', + length: 20, + }) + format: ReportFormat; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Parameters used for this execution', + }) + parameters: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Filters applied', + }) + filters: Record | null; + + @Column({ + name: 'started_at', + type: 'timestamptz', + nullable: true, + }) + startedAt: Date | null; + + @Column({ + name: 'completed_at', + type: 'timestamptz', + nullable: true, + }) + completedAt: Date | null; + + @Column({ + name: 'duration_ms', + type: 'integer', + nullable: true, + comment: 'Execution duration in milliseconds', + }) + durationMs: number | null; + + @Column({ + name: 'row_count', + type: 'integer', + nullable: true, + }) + rowCount: number | null; + + @Column({ + name: 'file_path', + type: 'varchar', + length: 500, + nullable: true, + }) + filePath: string | null; + + @Column({ + name: 'file_size', + type: 'integer', + nullable: true, + comment: 'File size in bytes', + }) + fileSize: number | null; + + @Column({ + name: 'file_url', + type: 'varchar', + length: 1000, + nullable: true, + comment: 'Presigned URL or download URL', + }) + fileUrl: string | null; + + @Column({ + name: 'url_expires_at', + type: 'timestamptz', + nullable: true, + }) + urlExpiresAt: Date | null; + + @Column({ + name: 'error_message', + type: 'text', + nullable: true, + }) + errorMessage: string | null; + + @Column({ + name: 'error_stack', + type: 'text', + nullable: true, + }) + errorStack: string | null; + + @Column({ + name: 'is_scheduled', + type: 'boolean', + default: false, + comment: 'Was this a scheduled execution?', + }) + isScheduled: boolean; + + @Column({ + name: 'distributed_to', + type: 'varchar', + array: true, + nullable: true, + comment: 'Email addresses report was sent to', + }) + distributedTo: string[] | null; + + @Column({ + name: 'distributed_at', + type: 'timestamptz', + nullable: true, + }) + distributedAt: Date | null; + + @Column({ + name: 'executed_at', + type: 'timestamptz', + default: () => 'CURRENT_TIMESTAMP', + }) + executedAt: Date; + + @Column({ name: 'executed_by', type: 'uuid', nullable: true }) + executedById: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Report) + @JoinColumn({ name: 'report_id' }) + report: Report; + + @ManyToOne(() => User) + @JoinColumn({ name: 'executed_by' }) + executedBy: User | null; +} diff --git a/src/modules/reports/entities/report-recipient.entity.ts b/src/modules/reports/entities/report-recipient.entity.ts new file mode 100644 index 0000000..ff7d96f --- /dev/null +++ b/src/modules/reports/entities/report-recipient.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ReportSchedule } from './report-schedule.entity'; + +/** + * Report Recipient Entity (schema: reports.report_recipients) + * + * Stores recipients for scheduled reports. Can reference internal users + * or external email addresses. + */ +@Entity({ name: 'report_recipients', schema: 'reports' }) +export class ReportRecipient { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'schedule_id', type: 'uuid' }) + scheduleId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string | null; + + @Column({ name: 'email', type: 'varchar', length: 255, nullable: true }) + email: string | null; + + @Column({ name: 'name', type: 'varchar', length: 255, nullable: true }) + name: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relations + @ManyToOne(() => ReportSchedule, (schedule) => schedule.recipients, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'schedule_id' }) + schedule: ReportSchedule; +} diff --git a/src/modules/reports/entities/report-schedule.entity.ts b/src/modules/reports/entities/report-schedule.entity.ts new file mode 100644 index 0000000..8c20a20 --- /dev/null +++ b/src/modules/reports/entities/report-schedule.entity.ts @@ -0,0 +1,142 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { Report } from './report.entity'; +import { ReportRecipient } from './report-recipient.entity'; +import { ScheduleExecution } from './schedule-execution.entity'; + +/** + * Delivery method enum + */ +export enum DeliveryMethod { + NONE = 'none', + EMAIL = 'email', + STORAGE = 'storage', + WEBHOOK = 'webhook', +} + +/** + * Schedule execution status enum + */ +export enum ScheduleExecutionStatus { + PENDING = 'pending', + RUNNING = 'running', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +/** + * Schedule export format enum + */ +export enum ScheduleExportFormat { + PDF = 'pdf', + EXCEL = 'excel', + CSV = 'csv', + JSON = 'json', + HTML = 'html', +} + +/** + * Report Schedule Entity (schema: reports.report_schedules) + * + * Configures scheduled report execution with cron expressions, + * delivery methods, and default parameters. + */ +@Entity({ name: 'report_schedules', schema: 'reports' }) +export class ReportSchedule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'report_definition_id', type: 'uuid' }) + reportDefinitionId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'cron_expression', type: 'varchar', length: 100 }) + cronExpression: string; + + @Column({ name: 'timezone', type: 'varchar', length: 100, default: 'America/Mexico_City' }) + timezone: string; + + @Column({ name: 'parameters', type: 'jsonb', default: '{}' }) + parameters: Record; + + @Column({ + name: 'delivery_method', + type: 'enum', + enum: DeliveryMethod, + enumName: 'delivery_method', + default: DeliveryMethod.EMAIL, + }) + deliveryMethod: DeliveryMethod; + + @Column({ name: 'delivery_config', type: 'jsonb', default: '{}' }) + deliveryConfig: Record; + + @Column({ + name: 'export_format', + type: 'enum', + enum: ScheduleExportFormat, + enumName: 'schedule_export_format', + default: ScheduleExportFormat.PDF, + }) + exportFormat: ScheduleExportFormat; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'last_run_at', type: 'timestamptz', nullable: true }) + lastRunAt: Date | null; + + @Column({ + name: 'last_run_status', + type: 'enum', + enum: ScheduleExecutionStatus, + enumName: 'schedule_execution_status', + nullable: true, + }) + lastRunStatus: ScheduleExecutionStatus | null; + + @Index() + @Column({ name: 'next_run_at', type: 'timestamptz', nullable: true }) + nextRunAt: Date | null; + + @Column({ name: 'run_count', type: 'int', default: 0 }) + runCount: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string | null; + + // Relations + @ManyToOne(() => Report, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'report_definition_id' }) + reportDefinition: Report; + + @OneToMany(() => ReportRecipient, (recipient) => recipient.schedule) + recipients: ReportRecipient[]; + + @OneToMany(() => ScheduleExecution, (scheduleExec) => scheduleExec.schedule) + scheduleExecutions: ScheduleExecution[]; +} diff --git a/src/modules/reports/entities/report.entity.ts b/src/modules/reports/entities/report.entity.ts new file mode 100644 index 0000000..7117fef --- /dev/null +++ b/src/modules/reports/entities/report.entity.ts @@ -0,0 +1,222 @@ +/** + * Report Entity + * Definición de reportes configurables + * + * @module Reports + * @table reports.report_definitions + */ + +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 { ReportExecution } from './report-execution.entity'; + +export type ReportType = + | 'financial' + | 'progress' + | 'quality' + | 'hse' + | 'hr' + | 'inventory' + | 'contracts' + | 'executive' + | 'custom'; + +export type ReportFormat = 'pdf' | 'excel' | 'csv' | 'html' | 'json'; + +export type ReportFrequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'; + +@Entity({ schema: 'reports', name: 'report_definitions' }) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId']) +@Index(['reportType']) +@Index(['isActive']) +export class Report { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + name: 'report_type', + type: 'varchar', + length: 30, + }) + reportType: ReportType; + + @Column({ + name: 'default_format', + type: 'varchar', + length: 20, + default: 'pdf', + }) + defaultFormat: ReportFormat; + + @Column({ + name: 'available_formats', + type: 'varchar', + array: true, + default: ['pdf', 'excel'], + }) + availableFormats: ReportFormat[]; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'SQL query or data source configuration', + }) + query: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Report parameters definition', + }) + parameters: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Column/field definitions', + }) + columns: Record[] | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Grouping and aggregation config', + }) + grouping: Record | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Sorting configuration', + }) + sorting: Record[] | null; + + @Column({ + type: 'jsonb', + nullable: true, + comment: 'Default filters', + }) + filters: Record | null; + + @Column({ + name: 'template_path', + type: 'varchar', + length: 500, + nullable: true, + }) + templatePath: string | null; + + @Column({ + name: 'is_scheduled', + type: 'boolean', + default: false, + }) + isScheduled: boolean; + + @Column({ + type: 'varchar', + length: 20, + nullable: true, + }) + frequency: ReportFrequency | null; + + @Column({ + name: 'schedule_config', + type: 'jsonb', + nullable: true, + comment: 'Cron or schedule configuration', + }) + scheduleConfig: Record | null; + + @Column({ + name: 'distribution_list', + type: 'varchar', + array: true, + nullable: true, + comment: 'Email addresses for distribution', + }) + distributionList: string[] | null; + + @Column({ + name: 'is_active', + type: 'boolean', + default: true, + }) + isActive: boolean; + + @Column({ + name: 'is_system', + type: 'boolean', + default: false, + comment: 'System report cannot be deleted', + }) + isSystem: boolean; + + @Column({ + name: 'execution_count', + type: 'integer', + default: 0, + }) + executionCount: number; + + @Column({ + name: 'last_executed_at', + type: 'timestamptz', + nullable: true, + }) + lastExecutedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => ReportExecution, (e) => e.report) + executions: ReportExecution[]; +} diff --git a/src/modules/reports/entities/schedule-execution.entity.ts b/src/modules/reports/entities/schedule-execution.entity.ts new file mode 100644 index 0000000..4d37a2f --- /dev/null +++ b/src/modules/reports/entities/schedule-execution.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ReportSchedule, ScheduleExecutionStatus } from './report-schedule.entity'; +import { ReportExecution } from './report-execution.entity'; + +/** + * Schedule Execution Entity (schema: reports.schedule_executions) + * + * Links scheduled reports to their actual executions, + * tracking delivery status and recipient notifications. + */ +@Entity({ name: 'schedule_executions', schema: 'reports' }) +export class ScheduleExecution { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'schedule_id', type: 'uuid' }) + scheduleId: string; + + @Column({ name: 'execution_id', type: 'uuid', nullable: true }) + executionId: string | null; + + @Column({ + name: 'status', + type: 'enum', + enum: ScheduleExecutionStatus, + enumName: 'schedule_execution_status', + }) + status: ScheduleExecutionStatus; + + @Column({ name: 'recipients_notified', type: 'int', default: 0 }) + recipientsNotified: number; + + @Column({ name: 'delivery_status', type: 'jsonb', default: '{}' }) + deliveryStatus: Record; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string | null; + + @Index() + @Column({ name: 'executed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + executedAt: Date; + + // Relations + @ManyToOne(() => ReportSchedule, (schedule) => schedule.scheduleExecutions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'schedule_id' }) + schedule: ReportSchedule; + + @ManyToOne(() => ReportExecution, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'execution_id' }) + execution: ReportExecution | null; +} diff --git a/src/modules/reports/entities/widget-query.entity.ts b/src/modules/reports/entities/widget-query.entity.ts new file mode 100644 index 0000000..2f96d98 --- /dev/null +++ b/src/modules/reports/entities/widget-query.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { DashboardWidget } from './dashboard-widget.entity'; + +/** + * Widget Query Entity (schema: reports.widget_queries) + * + * Data source queries for dashboard widgets. + * Supports both raw SQL and function-based queries with caching. + */ +@Entity({ name: 'widget_queries', schema: 'reports' }) +export class WidgetQuery { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'widget_id', type: 'uuid' }) + widgetId: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'query_text', type: 'text', nullable: true }) + queryText: string | null; + + @Column({ name: 'query_function', type: 'varchar', length: 255, nullable: true }) + queryFunction: string | null; + + @Column({ name: 'parameters', type: 'jsonb', default: '{}' }) + parameters: Record; + + @Column({ name: 'result_mapping', type: 'jsonb', default: '{}' }) + resultMapping: Record; + + @Column({ name: 'cache_ttl_seconds', type: 'int', nullable: true, default: 300 }) + cacheTtlSeconds: number | null; + + @Column({ name: 'last_cached_at', type: 'timestamptz', nullable: true }) + lastCachedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => DashboardWidget, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'widget_id' }) + widget: DashboardWidget; +} diff --git a/src/modules/sales/entities/index.ts b/src/modules/sales/entities/index.ts new file mode 100644 index 0000000..cca5d8f --- /dev/null +++ b/src/modules/sales/entities/index.ts @@ -0,0 +1,4 @@ +export { Quotation } from './quotation.entity'; +export { QuotationItem } from './quotation-item.entity'; +export { SalesOrder } from './sales-order.entity'; +export { SalesOrderItem } from './sales-order-item.entity'; diff --git a/src/modules/sales/entities/quotation-item.entity.ts b/src/modules/sales/entities/quotation-item.entity.ts new file mode 100644 index 0000000..95928bd --- /dev/null +++ b/src/modules/sales/entities/quotation-item.entity.ts @@ -0,0 +1,65 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Quotation } from './quotation.entity'; + +@Entity({ name: 'quotation_items', schema: 'sales' }) +export class QuotationItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'quotation_id', type: 'uuid' }) + quotationId: string; + + @ManyToOne(() => Quotation, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'quotation_id' }) + quotation: Quotation; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'line_number', type: 'int', default: 1 }) + lineNumber: number; + + @Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true }) + productSku?: string; + + @Column({ name: 'product_name', type: 'varchar', length: 200 }) + productName: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/sales/entities/quotation.entity.ts b/src/modules/sales/entities/quotation.entity.ts new file mode 100644 index 0000000..bb2e52b --- /dev/null +++ b/src/modules/sales/entities/quotation.entity.ts @@ -0,0 +1,101 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'quotations', schema: 'sales' }) +export class Quotation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'quotation_number', type: 'varchar', length: 30 }) + quotationNumber: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string; + + @Column({ name: 'partner_email', type: 'varchar', length: 255, nullable: true }) + partnerEmail: string; + + @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) + billingAddress: object; + + @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) + shippingAddress: object; + + @Column({ name: 'quotation_date', type: 'date', default: () => 'CURRENT_DATE' }) + quotationDate: Date; + + @Column({ name: 'valid_until', type: 'date', nullable: true }) + validUntil: Date; + + @Column({ name: 'expected_close_date', type: 'date', nullable: true }) + expectedCloseDate: Date; + + @Column({ name: 'sales_rep_id', type: 'uuid', nullable: true }) + salesRepId: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) + paymentMethod: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'sent' | 'accepted' | 'rejected' | 'expired' | 'converted'; + + @Column({ name: 'converted_to_order', type: 'boolean', default: false }) + convertedToOrder: boolean; + + @Column({ name: 'order_id', type: 'uuid', nullable: true }) + orderId: string; + + @Column({ name: 'converted_at', type: 'timestamptz', nullable: true }) + convertedAt: Date; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + @Column({ name: 'terms_and_conditions', type: 'text', nullable: true }) + termsAndConditions: string; + + @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; +} diff --git a/src/modules/sales/entities/sales-order-item.entity.ts b/src/modules/sales/entities/sales-order-item.entity.ts new file mode 100644 index 0000000..3a38976 --- /dev/null +++ b/src/modules/sales/entities/sales-order-item.entity.ts @@ -0,0 +1,90 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { SalesOrder } from './sales-order.entity'; + +@Entity({ name: 'sales_order_items', schema: 'sales' }) +export class SalesOrderItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @ManyToOne(() => SalesOrder, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'order_id' }) + order: SalesOrder; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'line_number', type: 'int', default: 1 }) + lineNumber: number; + + @Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true }) + productSku?: string; + + @Column({ name: 'product_name', type: 'varchar', length: 200 }) + productName: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ name: 'quantity_reserved', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReserved: number; + + @Column({ name: 'quantity_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityShipped: number; + + @Column({ name: 'quantity_delivered', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityDelivered: number; + + @Column({ name: 'quantity_returned', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReturned: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitPrice: number; + + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitCost: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber?: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: 'pending' | 'reserved' | 'shipped' | 'delivered' | 'cancelled'; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/sales/entities/sales-order.entity.ts b/src/modules/sales/entities/sales-order.entity.ts new file mode 100644 index 0000000..1528295 --- /dev/null +++ b/src/modules/sales/entities/sales-order.entity.ts @@ -0,0 +1,138 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +/** + * Sales Order Entity + * + * Aligned with SQL schema used by orders.service.ts + * Supports full Order-to-Cash flow with: + * - PaymentTerms integration + * - Automatic picking creation + * - Stock reservation + * - Invoice and delivery status tracking + */ +@Entity({ name: 'sales_orders', schema: 'sales' }) +export class SalesOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + // Order identification + @Index() + @Column({ type: 'varchar', length: 30 }) + name: string; // Order number (e.g., SO-000001) + + @Column({ name: 'client_order_ref', type: 'varchar', length: 100, nullable: true }) + clientOrderRef: string | null; // Customer's reference number + + @Column({ name: 'quotation_id', type: 'uuid', nullable: true }) + quotationId: string | null; + + // Partner/Customer + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + // Dates + @Column({ name: 'order_date', type: 'date', default: () => 'CURRENT_DATE' }) + orderDate: Date; + + @Column({ name: 'validity_date', type: 'date', nullable: true }) + validityDate: Date | null; + + @Column({ name: 'commitment_date', type: 'date', nullable: true }) + commitmentDate: Date | null; // Promised delivery date + + // Currency and pricing + @Index() + @Column({ name: 'currency_id', type: 'uuid' }) + currencyId: string; + + @Column({ name: 'pricelist_id', type: 'uuid', nullable: true }) + pricelistId: string | null; + + // Payment terms integration (TASK-003-01) + @Index() + @Column({ name: 'payment_term_id', type: 'uuid', nullable: true }) + paymentTermId: string | null; + + // Sales team + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string | null; // Sales representative + + @Column({ name: 'sales_team_id', type: 'uuid', nullable: true }) + salesTeamId: string | null; + + // Amounts + @Column({ name: 'amount_untaxed', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountUntaxed: number; + + @Column({ name: 'amount_tax', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountTax: number; + + @Column({ name: 'amount_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountTotal: number; + + // Status fields (Order-to-Cash tracking) + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'sent' | 'sale' | 'done' | 'cancelled'; + + @Index() + @Column({ name: 'invoice_status', type: 'varchar', length: 20, default: 'pending' }) + invoiceStatus: 'pending' | 'partial' | 'invoiced'; + + @Index() + @Column({ name: 'delivery_status', type: 'varchar', length: 20, default: 'pending' }) + deliveryStatus: 'pending' | 'partial' | 'delivered'; + + @Column({ name: 'invoice_policy', type: 'varchar', length: 20, default: 'order' }) + invoicePolicy: 'order' | 'delivery'; + + // Delivery/Picking integration (TASK-003-03) + @Column({ name: 'picking_id', type: 'uuid', nullable: true }) + pickingId: string | null; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ name: 'terms_conditions', type: 'text', nullable: true }) + termsConditions: string | null; + + // Confirmation tracking + @Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true }) + confirmedAt: Date | null; + + @Column({ name: 'confirmed_by', type: 'uuid', nullable: true }) + confirmedBy: string | null; + + // Cancellation tracking + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt: Date | null; + + @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) + cancelledBy: string | null; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string | null; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; +} diff --git a/src/modules/settings/entities/index.ts b/src/modules/settings/entities/index.ts new file mode 100644 index 0000000..17c9fe0 --- /dev/null +++ b/src/modules/settings/entities/index.ts @@ -0,0 +1,8 @@ +/** + * Settings Entities - Export + */ + +export { SystemSetting } from './system-setting.entity'; +export { PlanSetting } from './plan-setting.entity'; +export { TenantSetting } from './tenant-setting.entity'; +export { UserPreference } from './user-preference.entity'; diff --git a/src/modules/settings/entities/plan-setting.entity.ts b/src/modules/settings/entities/plan-setting.entity.ts new file mode 100644 index 0000000..61c733c --- /dev/null +++ b/src/modules/settings/entities/plan-setting.entity.ts @@ -0,0 +1,42 @@ +/** + * Plan Setting Entity + * Default configuration per subscription plan + * Hierarchy: system_settings < plan_settings < tenant_settings + * Compatible with erp-core plan-setting.entity + * + * @module Settings + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity({ name: 'plan_settings', schema: 'core_settings' }) +@Unique(['planId', 'key']) +export class PlanSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'plan_id', type: 'uuid' }) + planId: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/settings/entities/system-setting.entity.ts b/src/modules/settings/entities/system-setting.entity.ts new file mode 100644 index 0000000..080b9c4 --- /dev/null +++ b/src/modules/settings/entities/system-setting.entity.ts @@ -0,0 +1,67 @@ +/** + * System Setting Entity + * Global system configuration settings across all tenants + * Compatible with erp-core system-setting.entity + * + * @module Settings + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity({ name: 'system_settings', schema: 'core_settings' }) +@Unique(['key']) +export class SystemSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @Column({ + name: 'data_type', + type: 'varchar', + length: 20, + default: 'string', + }) + dataType: 'string' | 'number' | 'boolean' | 'json' | 'array' | 'secret'; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 50 }) + category: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'is_public', type: 'boolean', default: false }) + isPublic: boolean; + + @Column({ name: 'is_editable', type: 'boolean', default: true }) + isEditable: boolean; + + @Column({ name: 'default_value', type: 'jsonb', nullable: true }) + defaultValue: any; + + @Column({ name: 'validation_rules', type: 'jsonb', default: '{}' }) + validationRules: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string | null; +} diff --git a/src/modules/settings/entities/tenant-setting.entity.ts b/src/modules/settings/entities/tenant-setting.entity.ts new file mode 100644 index 0000000..d99446c --- /dev/null +++ b/src/modules/settings/entities/tenant-setting.entity.ts @@ -0,0 +1,53 @@ +/** + * Tenant Setting Entity + * Custom configuration per tenant, overrides system and plan defaults + * Hierarchy: system_settings < plan_settings < tenant_settings + * Compatible with erp-core tenant-setting.entity + * + * @module Settings + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity({ name: 'tenant_settings', schema: 'core_settings' }) +@Unique(['tenantId', 'key']) +export class TenantSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @Column({ + name: 'inherited_from', + type: 'varchar', + length: 20, + default: 'custom', + }) + inheritedFrom: 'system' | 'plan' | 'custom'; + + @Column({ name: 'is_overridden', type: 'boolean', default: true }) + isOverridden: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/settings/entities/user-preference.entity.ts b/src/modules/settings/entities/user-preference.entity.ts new file mode 100644 index 0000000..1191d32 --- /dev/null +++ b/src/modules/settings/entities/user-preference.entity.ts @@ -0,0 +1,44 @@ +/** + * User Preference Entity + * Personal preferences per user (theme, language, notifications, etc.) + * Compatible with erp-core user-preference.entity + * + * @module Settings + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity({ name: 'user_preferences', schema: 'core_settings' }) +@Unique(['userId', 'key']) +export class UserPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'key', type: 'varchar', length: 100 }) + key: string; + + @Column({ name: 'value', type: 'jsonb' }) + value: any; + + @Column({ name: 'synced_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + syncedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/storage/entities/bucket.entity.ts b/src/modules/storage/entities/bucket.entity.ts new file mode 100644 index 0000000..c33ecbb --- /dev/null +++ b/src/modules/storage/entities/bucket.entity.ts @@ -0,0 +1,74 @@ +/** + * StorageBucket Entity + * Storage bucket configuration with provider and quota settings + * Compatible with erp-core bucket.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type BucketType = 'public' | 'private' | 'protected'; +export type StorageProvider = 'local' | 's3' | 'gcs' | 'azure'; + +@Entity({ name: 'buckets', schema: 'storage' }) +export class StorageBucket { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'bucket_type', type: 'varchar', length: 30, default: 'private' }) + bucketType: BucketType; + + @Column({ name: 'max_file_size_mb', type: 'int', default: 50 }) + maxFileSizeMb: number; + + @Column({ name: 'allowed_mime_types', type: 'text', array: true, default: [] }) + allowedMimeTypes: string[]; + + @Column({ name: 'allowed_extensions', type: 'text', array: true, default: [] }) + allowedExtensions: string[]; + + @Column({ name: 'auto_delete_days', type: 'int', nullable: true }) + autoDeleteDays: number; + + @Column({ name: 'versioning_enabled', type: 'boolean', default: false }) + versioningEnabled: boolean; + + @Column({ name: 'max_versions', type: 'int', default: 5 }) + maxVersions: number; + + @Column({ name: 'storage_provider', type: 'varchar', length: 30, default: 'local' }) + storageProvider: StorageProvider; + + @Column({ name: 'storage_config', type: 'jsonb', default: {} }) + storageConfig: Record; + + @Column({ name: 'quota_per_tenant_gb', type: 'int', nullable: true }) + quotaPerTenantGb: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/storage/entities/file-access-token.entity.ts b/src/modules/storage/entities/file-access-token.entity.ts new file mode 100644 index 0000000..3ac6170 --- /dev/null +++ b/src/modules/storage/entities/file-access-token.entity.ts @@ -0,0 +1,71 @@ +/** + * FileAccessToken Entity + * Temporary access tokens for file downloads + * Compatible with erp-core file-access-token.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageFile } from './file.entity'; + +@Entity({ name: 'file_access_tokens', schema: 'storage' }) +export class FileAccessToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'file_id', type: 'uuid' }) + fileId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'token', type: 'varchar', length: 255, unique: true }) + token: string; + + @Column({ name: 'permissions', type: 'text', array: true, default: ['read'] }) + permissions: string[]; + + @Column({ name: 'allowed_ips', type: 'inet', array: true, nullable: true }) + allowedIps: string[]; + + @Column({ name: 'max_downloads', type: 'int', nullable: true }) + maxDownloads: number; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date; + + @Column({ name: 'created_for', type: 'varchar', length: 255, nullable: true }) + createdFor: string; + + @Column({ name: 'purpose', type: 'text', nullable: true }) + purpose: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageFile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/storage/entities/file-share.entity.ts b/src/modules/storage/entities/file-share.entity.ts new file mode 100644 index 0000000..f9647a0 --- /dev/null +++ b/src/modules/storage/entities/file-share.entity.ts @@ -0,0 +1,96 @@ +/** + * FileShare Entity + * File sharing with granular permissions + * Compatible with erp-core file-share.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageFile } from './file.entity'; + +@Entity({ name: 'file_shares', schema: 'storage' }) +export class FileShare { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'file_id', type: 'uuid' }) + fileId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'shared_with_user_id', type: 'uuid', nullable: true }) + sharedWithUserId: string; + + @Column({ name: 'shared_with_email', type: 'varchar', length: 255, nullable: true }) + sharedWithEmail: string; + + @Column({ name: 'shared_with_role', type: 'varchar', length: 50, nullable: true }) + sharedWithRole: string; + + @Column({ name: 'can_view', type: 'boolean', default: true }) + canView: boolean; + + @Column({ name: 'can_download', type: 'boolean', default: true }) + canDownload: boolean; + + @Column({ name: 'can_edit', type: 'boolean', default: false }) + canEdit: boolean; + + @Column({ name: 'can_delete', type: 'boolean', default: false }) + canDelete: boolean; + + @Column({ name: 'can_share', type: 'boolean', default: false }) + canShare: boolean; + + @Index() + @Column({ name: 'public_link', type: 'varchar', length: 255, unique: true, nullable: true }) + publicLink: string; + + @Column({ name: 'public_link_password', type: 'varchar', length: 255, nullable: true }) + publicLinkPassword: string; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date; + + @Column({ name: 'view_count', type: 'int', default: 0 }) + viewCount: number; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Column({ name: 'last_accessed_at', type: 'timestamptz', nullable: true }) + lastAccessedAt: Date; + + @Column({ name: 'notify_on_access', type: 'boolean', default: false }) + notifyOnAccess: 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; + + @ManyToOne(() => StorageFile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/storage/entities/file.entity.ts b/src/modules/storage/entities/file.entity.ts new file mode 100644 index 0000000..1fb8e4a --- /dev/null +++ b/src/modules/storage/entities/file.entity.ts @@ -0,0 +1,162 @@ +/** + * StorageFile Entity + * File metadata with versioning, checksums, and processing status + * Compatible with erp-core file.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; +import { StorageFolder } from './folder.entity'; + +export type FileCategory = 'image' | 'document' | 'video' | 'audio' | 'archive' | 'other'; +export type FileStatus = 'active' | 'processing' | 'archived' | 'deleted'; +export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +@Entity({ name: 'files', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'path', 'version']) +export class StorageFile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Index() + @Column({ name: 'folder_id', type: 'uuid', nullable: true }) + folderId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'original_name', type: 'varchar', length: 255 }) + originalName: string; + + @Column({ name: 'path', type: 'text' }) + path: string; + + @Index() + @Column({ name: 'mime_type', type: 'varchar', length: 100 }) + mimeType: string; + + @Column({ name: 'extension', type: 'varchar', length: 20, nullable: true }) + extension: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 30, nullable: true }) + category: FileCategory; + + @Column({ name: 'size_bytes', type: 'bigint' }) + sizeBytes: number; + + @Column({ name: 'checksum_md5', type: 'varchar', length: 32, nullable: true }) + checksumMd5: string; + + @Index() + @Column({ name: 'checksum_sha256', type: 'varchar', length: 64, nullable: true }) + checksumSha256: string; + + @Column({ name: 'storage_key', type: 'text' }) + storageKey: string; + + @Column({ name: 'storage_url', type: 'text', nullable: true }) + storageUrl: string; + + @Column({ name: 'cdn_url', type: 'text', nullable: true }) + cdnUrl: string; + + @Column({ name: 'width', type: 'int', nullable: true }) + width: number; + + @Column({ name: 'height', type: 'int', nullable: true }) + height: number; + + @Column({ name: 'thumbnail_url', type: 'text', nullable: true }) + thumbnailUrl: string; + + @Column({ name: 'thumbnails', type: 'jsonb', default: {} }) + thumbnails: Record; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'alt_text', type: 'text', nullable: true }) + altText: string; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @Column({ name: 'parent_version_id', type: 'uuid', nullable: true }) + parentVersionId: string; + + @Column({ name: 'is_latest', type: 'boolean', default: true }) + isLatest: boolean; + + @Index() + @Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true }) + entityType: string; + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ name: 'is_public', type: 'boolean', default: false }) + isPublic: boolean; + + @Column({ name: 'access_count', type: 'int', default: 0 }) + accessCount: number; + + @Column({ name: 'last_accessed_at', type: 'timestamptz', nullable: true }) + lastAccessedAt: Date; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'active' }) + status: FileStatus; + + @Column({ name: 'archived_at', type: 'timestamptz', nullable: true }) + archivedAt: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'processing_status', type: 'varchar', length: 20, nullable: true }) + processingStatus: ProcessingStatus; + + @Column({ name: 'processing_error', type: 'text', nullable: true }) + processingError: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'uploaded_by', type: 'uuid', nullable: true }) + uploadedBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'folder_id' }) + folder: StorageFolder; +} diff --git a/src/modules/storage/entities/folder.entity.ts b/src/modules/storage/entities/folder.entity.ts new file mode 100644 index 0000000..0295474 --- /dev/null +++ b/src/modules/storage/entities/folder.entity.ts @@ -0,0 +1,91 @@ +/** + * StorageFolder Entity + * Hierarchical folder structure within buckets + * Compatible with erp-core folder.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; + +@Entity({ name: 'folders', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'path']) +export class StorageFolder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @Index() + @Column({ name: 'path', type: 'text' }) + path: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'depth', type: 'int', default: 0 }) + depth: number; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'color', type: 'varchar', length: 7, nullable: true }) + color: string; + + @Column({ name: 'icon', type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ name: 'is_private', type: 'boolean', default: false }) + isPrivate: boolean; + + @Column({ name: 'owner_id', type: 'uuid', nullable: true }) + ownerId: string; + + @Column({ name: 'file_count', type: 'int', default: 0 }) + fileCount: number; + + @Column({ name: 'total_size_bytes', type: 'bigint', default: 0 }) + totalSizeBytes: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'parent_id' }) + parent: StorageFolder; + + @OneToMany(() => StorageFolder, (folder) => folder.parent) + children: StorageFolder[]; +} diff --git a/src/modules/storage/entities/index.ts b/src/modules/storage/entities/index.ts new file mode 100644 index 0000000..2e12efd --- /dev/null +++ b/src/modules/storage/entities/index.ts @@ -0,0 +1,11 @@ +/** + * Storage Entities - Export + */ + +export { StorageBucket, BucketType, StorageProvider } from './bucket.entity'; +export { StorageFolder } from './folder.entity'; +export { StorageFile, FileCategory, FileStatus, ProcessingStatus } from './file.entity'; +export { FileAccessToken } from './file-access-token.entity'; +export { StorageUpload, UploadStatus } from './upload.entity'; +export { FileShare } from './file-share.entity'; +export { TenantUsage } from './tenant-usage.entity'; diff --git a/src/modules/storage/entities/tenant-usage.entity.ts b/src/modules/storage/entities/tenant-usage.entity.ts new file mode 100644 index 0000000..5c63781 --- /dev/null +++ b/src/modules/storage/entities/tenant-usage.entity.ts @@ -0,0 +1,65 @@ +/** + * TenantUsage Entity + * Per-tenant storage usage and quota tracking + * Compatible with erp-core tenant-usage.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; + +@Entity({ name: 'tenant_usage', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'monthYear']) +export class TenantUsage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Column({ name: 'file_count', type: 'int', default: 0 }) + fileCount: number; + + @Column({ name: 'total_size_bytes', type: 'bigint', default: 0 }) + totalSizeBytes: number; + + @Column({ name: 'quota_bytes', type: 'bigint', nullable: true }) + quotaBytes: number; + + @Column({ name: 'quota_file_count', type: 'int', nullable: true }) + quotaFileCount: number; + + @Column({ name: 'usage_by_category', type: 'jsonb', default: {} }) + usageByCategory: Record; + + @Column({ name: 'monthly_upload_bytes', type: 'bigint', default: 0 }) + monthlyUploadBytes: number; + + @Column({ name: 'monthly_download_bytes', type: 'bigint', default: 0 }) + monthlyDownloadBytes: number; + + @Column({ name: 'month_year', type: 'varchar', length: 7 }) + monthYear: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; +} diff --git a/src/modules/storage/entities/upload.entity.ts b/src/modules/storage/entities/upload.entity.ts new file mode 100644 index 0000000..bd3113e --- /dev/null +++ b/src/modules/storage/entities/upload.entity.ts @@ -0,0 +1,110 @@ +/** + * StorageUpload Entity + * Chunked upload tracking + * Compatible with erp-core upload.entity + * + * @module Storage + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; +import { StorageFolder } from './folder.entity'; +import { StorageFile } from './file.entity'; + +export type UploadStatus = 'pending' | 'uploading' | 'processing' | 'completed' | 'failed' | 'cancelled'; + +@Entity({ name: 'uploads', schema: 'storage' }) +export class StorageUpload { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Column({ name: 'folder_id', type: 'uuid', nullable: true }) + folderId: string; + + @Column({ name: 'file_name', type: 'varchar', length: 255 }) + fileName: string; + + @Column({ name: 'mime_type', type: 'varchar', length: 100, nullable: true }) + mimeType: string; + + @Column({ name: 'total_size_bytes', type: 'bigint', nullable: true }) + totalSizeBytes: number; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: UploadStatus; + + @Column({ name: 'uploaded_bytes', type: 'bigint', default: 0 }) + uploadedBytes: number; + + @Column({ name: 'upload_progress', type: 'decimal', precision: 5, scale: 2, default: 0 }) + uploadProgress: number; + + @Column({ name: 'total_chunks', type: 'int', nullable: true }) + totalChunks: number; + + @Column({ name: 'completed_chunks', type: 'int', default: 0 }) + completedChunks: number; + + @Column({ name: 'chunk_size_bytes', type: 'int', nullable: true }) + chunkSizeBytes: number; + + @Column({ name: 'chunks_status', type: 'jsonb', default: {} }) + chunksStatus: Record; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'file_id', type: 'uuid', nullable: true }) + fileId: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'started_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + startedAt: Date; + + @Column({ name: 'last_chunk_at', type: 'timestamptz', nullable: true }) + lastChunkAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { nullable: true }) + @JoinColumn({ name: 'folder_id' }) + folder: StorageFolder; + + @ManyToOne(() => StorageFile, { nullable: true }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/warehouses/entities/index.ts b/src/modules/warehouses/entities/index.ts new file mode 100644 index 0000000..fb6b6e3 --- /dev/null +++ b/src/modules/warehouses/entities/index.ts @@ -0,0 +1,3 @@ +export { Warehouse } from './warehouse.entity'; +export { WarehouseLocation } from './warehouse-location.entity'; +export { WarehouseZone } from './warehouse-zone.entity'; diff --git a/src/modules/warehouses/entities/warehouse-location.entity.ts b/src/modules/warehouses/entities/warehouse-location.entity.ts new file mode 100644 index 0000000..030ff0a --- /dev/null +++ b/src/modules/warehouses/entities/warehouse-location.entity.ts @@ -0,0 +1,111 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Warehouse } from './warehouse.entity'; + +@Entity({ name: 'warehouse_locations', schema: 'inventory' }) +export class WarehouseLocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @ManyToOne(() => Warehouse, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'warehouse_id' }) + warehouse: Warehouse; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @ManyToOne(() => WarehouseLocation, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_id' }) + parent: WarehouseLocation; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Index() + @Column({ type: 'varchar', length: 50, nullable: true }) + barcode: string; + + // Tipo de ubicacion + @Index() + @Column({ name: 'location_type', type: 'varchar', length: 20, default: 'shelf' }) + locationType: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin'; + + // Jerarquia + @Column({ name: 'hierarchy_path', type: 'text', nullable: true }) + hierarchyPath: string; + + @Column({ name: 'hierarchy_level', type: 'int', default: 0 }) + hierarchyLevel: number; + + // Coordenadas dentro del almacen + @Column({ type: 'varchar', length: 10, nullable: true }) + aisle: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + rack: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + shelf: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + bin: string; + + // Capacidad + @Column({ name: 'capacity_units', type: 'int', nullable: true }) + capacityUnits: number; + + @Column({ name: 'capacity_volume', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityVolume: number; + + @Column({ name: 'capacity_weight', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityWeight: number; + + // Restricciones + @Column({ name: 'allowed_product_types', type: 'text', array: true, default: '{}' }) + allowedProductTypes: string[]; + + @Column({ name: 'temperature_range', type: 'jsonb', nullable: true }) + temperatureRange: { min?: number; max?: number }; + + @Column({ name: 'humidity_range', type: 'jsonb', nullable: true }) + humidityRange: { min?: number; max?: number }; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_pickable', type: 'boolean', default: true }) + isPickable: boolean; + + @Column({ name: 'is_receivable', type: 'boolean', default: true }) + isReceivable: boolean; + + // Metadata + @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; +} diff --git a/src/modules/warehouses/entities/warehouse-zone.entity.ts b/src/modules/warehouses/entities/warehouse-zone.entity.ts new file mode 100644 index 0000000..d710cc5 --- /dev/null +++ b/src/modules/warehouses/entities/warehouse-zone.entity.ts @@ -0,0 +1,41 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Warehouse } from './warehouse.entity'; + +@Entity({ name: 'warehouse_zones', schema: 'inventory' }) +export class WarehouseZone { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @ManyToOne(() => Warehouse, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'warehouse_id' }) + warehouse: Warehouse; + + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color?: string; + + @Index() + @Column({ name: 'zone_type', type: 'varchar', length: 20, default: 'storage' }) + zoneType: 'storage' | 'picking' | 'packing' | 'shipping' | 'receiving' | 'quarantine'; + + @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; +} diff --git a/src/modules/warehouses/entities/warehouse.entity.ts b/src/modules/warehouses/entities/warehouse.entity.ts new file mode 100644 index 0000000..0343d30 --- /dev/null +++ b/src/modules/warehouses/entities/warehouse.entity.ts @@ -0,0 +1,138 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Company } from '../../auth/entities/company.entity'; + +/** + * Warehouse Entity (schema: inventory.warehouses) + * + * This is the CANONICAL warehouse entity for the ERP system. + * All warehouse-related imports should use this entity. + * + * Note: The deprecated entity at inventory/entities/warehouse.entity.ts + * has been superseded by this one and should not be used for new code. + */ +@Entity({ name: 'warehouses', schema: 'inventory' }) +@Index('idx_warehouses_tenant_id', ['tenantId']) +@Index('idx_warehouses_company_id', ['companyId']) +@Index('idx_warehouses_code_company', ['companyId', 'code'], { unique: true }) +export class Warehouse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId: string | null; + + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + @Index() + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Index() + @Column({ name: 'warehouse_type', type: 'varchar', length: 20, default: 'standard' }) + warehouseType: 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual'; + + // 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; + + // Contacto + @Column({ name: 'manager_name', type: 'varchar', length: 100, nullable: true }) + managerName: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + // Geolocalizacion + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + // Capacidad + @Column({ name: 'capacity_units', type: 'int', nullable: true }) + capacityUnits: number; + + @Column({ name: 'capacity_volume', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityVolume: number; + + @Column({ name: 'capacity_weight', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityWeight: number; + + // Configuracion + @Column({ type: 'jsonb', default: {} }) + settings: { + allowNegative?: boolean; + autoReorder?: boolean; + }; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + // Metadata + @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; +} diff --git a/src/modules/webhooks/entities/delivery.entity.ts b/src/modules/webhooks/entities/delivery.entity.ts new file mode 100644 index 0000000..47d82d0 --- /dev/null +++ b/src/modules/webhooks/entities/delivery.entity.ts @@ -0,0 +1,105 @@ +/** + * WebhookDelivery Entity + * Delivery tracking with retry logic + * Compatible with erp-core delivery.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; + +export type DeliveryStatus = 'pending' | 'sending' | 'delivered' | 'failed' | 'retrying' | 'cancelled'; + +@Entity({ name: 'deliveries', schema: 'webhooks' }) +export class WebhookDelivery { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + eventType: string; + + @Column({ name: 'event_id', type: 'uuid' }) + eventId: string; + + @Column({ name: 'payload', type: 'jsonb' }) + payload: Record; + + @Column({ name: 'payload_hash', type: 'varchar', length: 64, nullable: true }) + payloadHash: string; + + @Column({ name: 'request_url', type: 'text' }) + requestUrl: string; + + @Column({ name: 'request_method', type: 'varchar', length: 10 }) + requestMethod: string; + + @Column({ name: 'request_headers', type: 'jsonb', default: {} }) + requestHeaders: Record; + + @Column({ name: 'response_status', type: 'int', nullable: true }) + responseStatus: number; + + @Column({ name: 'response_headers', type: 'jsonb', default: {} }) + responseHeaders: Record; + + @Column({ name: 'response_body', type: 'text', nullable: true }) + responseBody: string; + + @Column({ name: 'response_time_ms', type: 'int', nullable: true }) + responseTimeMs: number; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: DeliveryStatus; + + @Column({ name: 'attempt_number', type: 'int', default: 1 }) + attemptNumber: number; + + @Column({ name: 'max_attempts', type: 'int', default: 5 }) + maxAttempts: number; + + @Index() + @Column({ name: 'next_retry_at', type: 'timestamptz', nullable: true }) + nextRetryAt: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true }) + errorCode: string; + + @Column({ name: 'scheduled_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + scheduledAt: Date; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; +} diff --git a/src/modules/webhooks/entities/endpoint-log.entity.ts b/src/modules/webhooks/entities/endpoint-log.entity.ts new file mode 100644 index 0000000..cde11c2 --- /dev/null +++ b/src/modules/webhooks/entities/endpoint-log.entity.ts @@ -0,0 +1,54 @@ +/** + * WebhookEndpointLog Entity + * Activity log for webhook endpoints + * Compatible with erp-core endpoint-log.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; + +export type WebhookLogType = 'config_changed' | 'activated' | 'deactivated' | 'verified' | 'error' | 'rate_limited' | 'created'; + +@Entity({ name: 'endpoint_logs', schema: 'webhooks' }) +export class WebhookEndpointLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'log_type', type: 'varchar', length: 30 }) + logType: WebhookLogType; + + @Column({ name: 'message', type: 'text', nullable: true }) + message: string; + + @Column({ name: 'details', type: 'jsonb', default: {} }) + details: Record; + + @Column({ name: 'actor_id', type: 'uuid', nullable: true }) + actorId: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; +} diff --git a/src/modules/webhooks/entities/endpoint.entity.ts b/src/modules/webhooks/entities/endpoint.entity.ts new file mode 100644 index 0000000..a7ab0ad --- /dev/null +++ b/src/modules/webhooks/entities/endpoint.entity.ts @@ -0,0 +1,118 @@ +/** + * WebhookEndpoint Entity + * Outbound webhook endpoint configuration with retry and rate limiting + * Compatible with erp-core endpoint.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type AuthType = 'none' | 'basic' | 'bearer' | 'hmac' | 'oauth2'; + +@Entity({ name: 'endpoints', schema: 'webhooks' }) +@Unique(['tenantId', 'url']) +export class WebhookEndpoint { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'url', type: 'text' }) + url: string; + + @Column({ name: 'http_method', type: 'varchar', length: 10, default: 'POST' }) + httpMethod: string; + + @Column({ name: 'auth_type', type: 'varchar', length: 30, default: 'none' }) + authType: AuthType; + + @Column({ name: 'auth_config', type: 'jsonb', default: {} }) + authConfig: Record; + + @Column({ name: 'custom_headers', type: 'jsonb', default: {} }) + customHeaders: Record; + + @Column({ name: 'subscribed_events', type: 'text', array: true, default: [] }) + subscribedEvents: string[]; + + @Column({ name: 'filters', type: 'jsonb', default: {} }) + filters: Record; + + @Column({ name: 'retry_enabled', type: 'boolean', default: true }) + retryEnabled: boolean; + + @Column({ name: 'max_retries', type: 'int', default: 5 }) + maxRetries: number; + + @Column({ name: 'retry_delay_seconds', type: 'int', default: 60 }) + retryDelaySeconds: number; + + @Column({ name: 'retry_backoff_multiplier', type: 'decimal', precision: 3, scale: 1, default: 2.0 }) + retryBackoffMultiplier: number; + + @Column({ name: 'timeout_seconds', type: 'int', default: 30 }) + timeoutSeconds: number; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'signing_secret', type: 'varchar', length: 255, nullable: true }) + signingSecret: string; + + @Column({ name: 'total_deliveries', type: 'int', default: 0 }) + totalDeliveries: number; + + @Column({ name: 'successful_deliveries', type: 'int', default: 0 }) + successfulDeliveries: number; + + @Column({ name: 'failed_deliveries', type: 'int', default: 0 }) + failedDeliveries: number; + + @Column({ name: 'last_delivery_at', type: 'timestamptz', nullable: true }) + lastDeliveryAt: Date; + + @Column({ name: 'last_success_at', type: 'timestamptz', nullable: true }) + lastSuccessAt: Date; + + @Column({ name: 'last_failure_at', type: 'timestamptz', nullable: true }) + lastFailureAt: Date; + + @Column({ name: 'rate_limit_per_minute', type: 'int', default: 60 }) + rateLimitPerMinute: number; + + @Column({ name: 'rate_limit_per_hour', type: 'int', default: 1000 }) + rateLimitPerHour: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; +} diff --git a/src/modules/webhooks/entities/event-type.entity.ts b/src/modules/webhooks/entities/event-type.entity.ts new file mode 100644 index 0000000..5874773 --- /dev/null +++ b/src/modules/webhooks/entities/event-type.entity.ts @@ -0,0 +1,56 @@ +/** + * WebhookEventType Entity + * Event type definitions for webhook subscriptions + * Compatible with erp-core event-type.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type EventCategory = 'sales' | 'inventory' | 'customers' | 'auth' | 'billing' | 'system'; + +@Entity({ name: 'event_types', schema: 'webhooks' }) +export class WebhookEventType { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: EventCategory; + + @Column({ name: 'payload_schema', type: 'jsonb', default: {} }) + payloadSchema: Record; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_internal', type: 'boolean', default: false }) + isInternal: boolean; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/webhooks/entities/event.entity.ts b/src/modules/webhooks/entities/event.entity.ts new file mode 100644 index 0000000..987fa3e --- /dev/null +++ b/src/modules/webhooks/entities/event.entity.ts @@ -0,0 +1,69 @@ +/** + * WebhookEvent Entity + * Individual webhook events with dispatch tracking + * Compatible with erp-core event.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type WebhookEventStatus = 'pending' | 'processing' | 'dispatched' | 'failed'; + +@Entity({ name: 'events', schema: 'webhooks' }) +export class WebhookEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + eventType: string; + + @Column({ name: 'payload', type: 'jsonb' }) + payload: Record; + + @Column({ name: 'resource_type', type: 'varchar', length: 100, nullable: true }) + resourceType: string; + + @Column({ name: 'resource_id', type: 'uuid', nullable: true }) + resourceId: string; + + @Column({ name: 'triggered_by', type: 'uuid', nullable: true }) + triggeredBy: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: WebhookEventStatus; + + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt: Date; + + @Column({ name: 'dispatched_endpoints', type: 'int', default: 0 }) + dispatchedEndpoints: number; + + @Column({ name: 'failed_endpoints', type: 'int', default: 0 }) + failedEndpoints: number; + + @Index() + @Column({ name: 'idempotency_key', type: 'varchar', length: 255, nullable: true }) + idempotencyKey: string; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; +} diff --git a/src/modules/webhooks/entities/index.ts b/src/modules/webhooks/entities/index.ts new file mode 100644 index 0000000..c247310 --- /dev/null +++ b/src/modules/webhooks/entities/index.ts @@ -0,0 +1,10 @@ +/** + * Webhooks Entities - Export + */ + +export { WebhookEventType, EventCategory } from './event-type.entity'; +export { WebhookEndpoint, AuthType } from './endpoint.entity'; +export { WebhookDelivery, DeliveryStatus } from './delivery.entity'; +export { WebhookEvent, WebhookEventStatus } from './event.entity'; +export { WebhookSubscription } from './subscription.entity'; +export { WebhookEndpointLog, WebhookLogType } from './endpoint-log.entity'; diff --git a/src/modules/webhooks/entities/subscription.entity.ts b/src/modules/webhooks/entities/subscription.entity.ts new file mode 100644 index 0000000..87993e8 --- /dev/null +++ b/src/modules/webhooks/entities/subscription.entity.ts @@ -0,0 +1,63 @@ +/** + * WebhookSubscription Entity + * Links endpoints to specific event types + * Compatible with erp-core subscription.entity + * + * @module Webhooks + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; +import { WebhookEventType } from './event-type.entity'; + +@Entity({ name: 'subscriptions', schema: 'webhooks' }) +@Unique(['endpointId', 'eventTypeId']) +export class WebhookSubscription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'event_type_id', type: 'uuid' }) + eventTypeId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'filters', type: 'jsonb', default: {} }) + filters: Record; + + @Column({ name: 'payload_template', type: 'jsonb', nullable: true }) + payloadTemplate: Record; + + @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; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; + + @ManyToOne(() => WebhookEventType, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'event_type_id' }) + eventType: WebhookEventType; +} diff --git a/src/modules/whatsapp/entities/account.entity.ts b/src/modules/whatsapp/entities/account.entity.ts new file mode 100644 index 0000000..85893f2 --- /dev/null +++ b/src/modules/whatsapp/entities/account.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type AccountStatus = 'pending' | 'active' | 'suspended' | 'disconnected'; + +@Entity({ name: 'accounts', schema: 'whatsapp' }) +@Unique(['tenantId', 'phoneNumber']) +export class WhatsAppAccount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Index() + @Column({ name: 'phone_number', type: 'varchar', length: 20 }) + phoneNumber: string; + + @Column({ name: 'phone_number_id', type: 'varchar', length: 50 }) + phoneNumberId: string; + + @Column({ name: 'business_account_id', type: 'varchar', length: 50 }) + businessAccountId: string; + + @Column({ name: 'access_token', type: 'text', nullable: true }) + accessToken: string; + + @Column({ name: 'webhook_verify_token', type: 'varchar', length: 255, nullable: true }) + webhookVerifyToken: string; + + @Column({ name: 'webhook_secret', type: 'varchar', length: 255, nullable: true }) + webhookSecret: string; + + @Column({ name: 'business_name', type: 'varchar', length: 200, nullable: true }) + businessName: string; + + @Column({ name: 'business_description', type: 'text', nullable: true }) + businessDescription: string; + + @Column({ name: 'business_category', type: 'varchar', length: 100, nullable: true }) + businessCategory: string; + + @Column({ name: 'business_website', type: 'text', nullable: true }) + businessWebsite: string; + + @Column({ name: 'profile_picture_url', type: 'text', nullable: true }) + profilePictureUrl: string; + + @Column({ name: 'default_language', type: 'varchar', length: 10, default: 'es_MX' }) + defaultLanguage: string; + + @Column({ name: 'auto_reply_enabled', type: 'boolean', default: false }) + autoReplyEnabled: boolean; + + @Column({ name: 'auto_reply_message', type: 'text', nullable: true }) + autoReplyMessage: string; + + @Column({ name: 'business_hours', type: 'jsonb', default: {} }) + businessHours: Record; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: AccountStatus; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'daily_message_limit', type: 'int', default: 1000 }) + dailyMessageLimit: number; + + @Column({ name: 'messages_sent_today', type: 'int', default: 0 }) + messagesSentToday: number; + + @Column({ name: 'last_limit_reset', type: 'timestamptz', nullable: true }) + lastLimitReset: Date; + + @Column({ name: 'total_messages_sent', type: 'bigint', default: 0 }) + totalMessagesSent: number; + + @Column({ name: 'total_messages_received', type: 'bigint', default: 0 }) + totalMessagesReceived: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; +} diff --git a/src/modules/whatsapp/entities/automation.entity.ts b/src/modules/whatsapp/entities/automation.entity.ts new file mode 100644 index 0000000..a8aeb81 --- /dev/null +++ b/src/modules/whatsapp/entities/automation.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type AutomationTriggerType = 'keyword' | 'first_message' | 'after_hours' | 'no_response' | 'webhook'; +export type AutomationActionType = 'send_message' | 'send_template' | 'assign_agent' | 'add_tag' | 'create_ticket'; + +@Entity({ name: 'automations', schema: 'whatsapp' }) +export class WhatsAppAutomation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'trigger_type', type: 'varchar', length: 30 }) + triggerType: AutomationTriggerType; + + @Column({ name: 'trigger_config', type: 'jsonb', default: {} }) + triggerConfig: Record; + + @Column({ name: 'action_type', type: 'varchar', length: 30 }) + actionType: AutomationActionType; + + @Column({ name: 'action_config', type: 'jsonb', default: {} }) + actionConfig: Record; + + @Column({ name: 'conditions', type: 'jsonb', default: [] }) + conditions: Record[]; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'priority', type: 'int', default: 0 }) + priority: number; + + @Column({ name: 'trigger_count', type: 'int', default: 0 }) + triggerCount: number; + + @Column({ name: 'last_triggered_at', type: 'timestamptz', nullable: true }) + lastTriggeredAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/broadcast-recipient.entity.ts b/src/modules/whatsapp/entities/broadcast-recipient.entity.ts new file mode 100644 index 0000000..71fb0ad --- /dev/null +++ b/src/modules/whatsapp/entities/broadcast-recipient.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Broadcast } from './broadcast.entity'; +import { WhatsAppContact } from './contact.entity'; +import { WhatsAppMessage } from './message.entity'; + +export type RecipientStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; + +@Entity({ name: 'broadcast_recipients', schema: 'whatsapp' }) +@Unique(['broadcastId', 'contactId']) +export class BroadcastRecipient { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'broadcast_id', type: 'uuid' }) + broadcastId: string; + + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Column({ name: 'template_variables', type: 'jsonb', default: [] }) + templateVariables: any[]; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: RecipientStatus; + + @Column({ name: 'message_id', type: 'uuid', nullable: true }) + messageId: string; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => Broadcast, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'broadcast_id' }) + broadcast: Broadcast; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; + + @ManyToOne(() => WhatsAppMessage, { nullable: true }) + @JoinColumn({ name: 'message_id' }) + message: WhatsAppMessage; +} diff --git a/src/modules/whatsapp/entities/broadcast.entity.ts b/src/modules/whatsapp/entities/broadcast.entity.ts new file mode 100644 index 0000000..94f11cc --- /dev/null +++ b/src/modules/whatsapp/entities/broadcast.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppTemplate } from './template.entity'; + +export type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'completed' | 'cancelled' | 'failed'; +export type AudienceType = 'all' | 'segment' | 'custom' | 'file'; + +@Entity({ name: 'broadcasts', schema: 'whatsapp' }) +export class Broadcast { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'template_id', type: 'uuid' }) + templateId: string; + + @Column({ name: 'audience_type', type: 'varchar', length: 30 }) + audienceType: AudienceType; + + @Column({ name: 'audience_filter', type: 'jsonb', default: {} }) + audienceFilter: Record; + + @Column({ name: 'recipient_count', type: 'int', default: 0 }) + recipientCount: number; + + @Index() + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt: Date; + + @Column({ name: 'timezone', type: 'varchar', length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'draft' }) + status: BroadcastStatus; + + @Column({ name: 'sent_count', type: 'int', default: 0 }) + sentCount: number; + + @Column({ name: 'delivered_count', type: 'int', default: 0 }) + deliveredCount: number; + + @Column({ name: 'read_count', type: 'int', default: 0 }) + readCount: number; + + @Column({ name: 'failed_count', type: 'int', default: 0 }) + failedCount: number; + + @Column({ name: 'reply_count', type: 'int', default: 0 }) + replyCount: number; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'estimated_cost', type: 'decimal', precision: 10, scale: 2, nullable: true }) + estimatedCost: number; + + @Column({ name: 'actual_cost', type: 'decimal', precision: 10, scale: 2, nullable: true }) + actualCost: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppTemplate) + @JoinColumn({ name: 'template_id' }) + template: WhatsAppTemplate; +} diff --git a/src/modules/whatsapp/entities/contact.entity.ts b/src/modules/whatsapp/entities/contact.entity.ts new file mode 100644 index 0000000..b3b6726 --- /dev/null +++ b/src/modules/whatsapp/entities/contact.entity.ts @@ -0,0 +1,99 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type ConversationStatus = 'active' | 'waiting' | 'resolved' | 'blocked'; +export type MessageDirection = 'inbound' | 'outbound'; + +@Entity({ name: 'contacts', schema: 'whatsapp' }) +@Unique(['accountId', 'phoneNumber']) +export class WhatsAppContact { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'phone_number', type: 'varchar', length: 20 }) + phoneNumber: string; + + @Column({ name: 'wa_id', type: 'varchar', length: 50, nullable: true }) + waId: string; + + @Column({ name: 'profile_name', type: 'varchar', length: 200, nullable: true }) + profileName: string; + + @Column({ name: 'profile_picture_url', type: 'text', nullable: true }) + profilePictureUrl: string; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'conversation_status', type: 'varchar', length: 20, default: 'active' }) + conversationStatus: ConversationStatus; + + @Column({ name: 'last_message_at', type: 'timestamptz', nullable: true }) + lastMessageAt: Date; + + @Column({ name: 'last_message_direction', type: 'varchar', length: 10, nullable: true }) + lastMessageDirection: MessageDirection; + + @Column({ name: 'conversation_window_expires_at', type: 'timestamptz', nullable: true }) + conversationWindowExpiresAt: Date; + + @Column({ name: 'can_send_template_only', type: 'boolean', default: true }) + canSendTemplateOnly: boolean; + + @Index() + @Column({ name: 'opted_in', type: 'boolean', default: false }) + optedIn: boolean; + + @Column({ name: 'opted_in_at', type: 'timestamptz', nullable: true }) + optedInAt: Date; + + @Column({ name: 'opted_out', type: 'boolean', default: false }) + optedOut: boolean; + + @Column({ name: 'opted_out_at', type: 'timestamptz', nullable: true }) + optedOutAt: Date; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'total_messages_sent', type: 'int', default: 0 }) + totalMessagesSent: number; + + @Column({ name: 'total_messages_received', type: 'int', default: 0 }) + totalMessagesReceived: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/conversation.entity.ts b/src/modules/whatsapp/entities/conversation.entity.ts new file mode 100644 index 0000000..7eef57b --- /dev/null +++ b/src/modules/whatsapp/entities/conversation.entity.ts @@ -0,0 +1,92 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppContact } from './contact.entity'; + +export type WAConversationStatus = 'open' | 'pending' | 'resolved' | 'closed'; +export type WAConversationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +@Entity({ name: 'conversations', schema: 'whatsapp' }) +export class WhatsAppConversation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'open' }) + status: WAConversationStatus; + + @Column({ name: 'priority', type: 'varchar', length: 20, default: 'normal' }) + priority: WAConversationPriority; + + @Index() + @Column({ name: 'assigned_to', type: 'uuid', nullable: true }) + assignedTo: string; + + @Column({ name: 'assigned_at', type: 'timestamptz', nullable: true }) + assignedAt: Date; + + @Column({ name: 'team_id', type: 'uuid', nullable: true }) + teamId: string; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Column({ name: 'first_response_at', type: 'timestamptz', nullable: true }) + firstResponseAt: Date; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt: Date; + + @Column({ name: 'message_count', type: 'int', default: 0 }) + messageCount: number; + + @Column({ name: 'unread_count', type: 'int', default: 0 }) + unreadCount: number; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; +} diff --git a/src/modules/whatsapp/entities/index.ts b/src/modules/whatsapp/entities/index.ts new file mode 100644 index 0000000..4eafbfe --- /dev/null +++ b/src/modules/whatsapp/entities/index.ts @@ -0,0 +1,10 @@ +export { WhatsAppAccount, AccountStatus } from './account.entity'; +export { WhatsAppContact, ConversationStatus } from './contact.entity'; +export { WhatsAppMessage, MessageType, MessageStatus, MessageDirection, CostCategory } from './message.entity'; +export { WhatsAppTemplate, TemplateCategory, TemplateStatus, HeaderType } from './template.entity'; +export { WhatsAppConversation, WAConversationStatus, WAConversationPriority } from './conversation.entity'; +export { MessageStatusUpdate } from './message-status-update.entity'; +export { QuickReply } from './quick-reply.entity'; +export { WhatsAppAutomation, AutomationTriggerType, AutomationActionType } from './automation.entity'; +export { Broadcast, BroadcastStatus, AudienceType } from './broadcast.entity'; +export { BroadcastRecipient, RecipientStatus } from './broadcast-recipient.entity'; diff --git a/src/modules/whatsapp/entities/message-status-update.entity.ts b/src/modules/whatsapp/entities/message-status-update.entity.ts new file mode 100644 index 0000000..a729d27 --- /dev/null +++ b/src/modules/whatsapp/entities/message-status-update.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppMessage } from './message.entity'; + +@Entity({ name: 'message_status_updates', schema: 'whatsapp' }) +export class MessageStatusUpdate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'message_id', type: 'uuid' }) + messageId: string; + + @Column({ name: 'status', type: 'varchar', length: 20 }) + status: string; + + @Column({ name: 'previous_status', type: 'varchar', length: 20, nullable: true }) + previousStatus: string; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_title', type: 'varchar', length: 200, nullable: true }) + errorTitle: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'meta_timestamp', type: 'timestamptz', nullable: true }) + metaTimestamp: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WhatsAppMessage, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'message_id' }) + message: WhatsAppMessage; +} diff --git a/src/modules/whatsapp/entities/message.entity.ts b/src/modules/whatsapp/entities/message.entity.ts new file mode 100644 index 0000000..d51fc47 --- /dev/null +++ b/src/modules/whatsapp/entities/message.entity.ts @@ -0,0 +1,137 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppContact } from './contact.entity'; + +export type MessageType = 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'location' | 'contacts' | 'interactive' | 'template' | 'reaction'; +export type MessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; +export type MessageDirection = 'inbound' | 'outbound'; +export type CostCategory = 'utility' | 'authentication' | 'marketing'; + +@Entity({ name: 'messages', schema: 'whatsapp' }) +export class WhatsAppMessage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Index() + @Column({ name: 'conversation_id', type: 'uuid', nullable: true }) + conversationId: string; + + @Index() + @Column({ name: 'wa_message_id', type: 'varchar', length: 100, nullable: true }) + waMessageId: string; + + @Column({ name: 'wa_conversation_id', type: 'varchar', length: 100, nullable: true }) + waConversationId: string; + + @Index() + @Column({ name: 'direction', type: 'varchar', length: 10 }) + direction: MessageDirection; + + @Column({ name: 'message_type', type: 'varchar', length: 20 }) + messageType: MessageType; + + @Column({ name: 'content', type: 'text', nullable: true }) + content: string; + + @Column({ name: 'caption', type: 'text', nullable: true }) + caption: string; + + @Column({ name: 'media_id', type: 'varchar', length: 100, nullable: true }) + mediaId: string; + + @Column({ name: 'media_url', type: 'text', nullable: true }) + mediaUrl: string; + + @Column({ name: 'media_mime_type', type: 'varchar', length: 100, nullable: true }) + mediaMimeType: string; + + @Column({ name: 'media_sha256', type: 'varchar', length: 64, nullable: true }) + mediaSha256: string; + + @Column({ name: 'media_size_bytes', type: 'int', nullable: true }) + mediaSizeBytes: number; + + @Column({ name: 'template_id', type: 'uuid', nullable: true }) + templateId: string; + + @Column({ name: 'template_name', type: 'varchar', length: 512, nullable: true }) + templateName: string; + + @Column({ name: 'template_variables', type: 'jsonb', default: [] }) + templateVariables: string[]; + + @Column({ name: 'interactive_type', type: 'varchar', length: 30, nullable: true }) + interactiveType: string; + + @Column({ name: 'interactive_data', type: 'jsonb', default: {} }) + interactiveData: Record; + + @Column({ name: 'context_message_id', type: 'varchar', length: 100, nullable: true }) + contextMessageId: string; + + @Column({ name: 'quoted_message_id', type: 'uuid', nullable: true }) + quotedMessageId: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: MessageStatus; + + @Column({ name: 'status_updated_at', type: 'timestamptz', nullable: true }) + statusUpdatedAt: Date; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'is_billable', type: 'boolean', default: false }) + isBillable: boolean; + + @Column({ name: 'cost_category', type: 'varchar', length: 30, nullable: true }) + costCategory: CostCategory; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; +} diff --git a/src/modules/whatsapp/entities/quick-reply.entity.ts b/src/modules/whatsapp/entities/quick-reply.entity.ts new file mode 100644 index 0000000..ddb14bf --- /dev/null +++ b/src/modules/whatsapp/entities/quick-reply.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +@Entity({ name: 'quick_replies', schema: 'whatsapp' }) +@Unique(['tenantId', 'shortcut']) +export class QuickReply { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'account_id', type: 'uuid', nullable: true }) + accountId: string; + + @Index() + @Column({ name: 'shortcut', type: 'varchar', length: 50 }) + shortcut: string; + + @Column({ name: 'title', type: 'varchar', length: 200 }) + title: string; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'message_type', type: 'varchar', length: 20, default: 'text' }) + messageType: string; + + @Column({ name: 'content', type: 'text' }) + content: string; + + @Column({ name: 'media_url', type: 'text', nullable: true }) + mediaUrl: string; + + @Column({ name: 'usage_count', type: 'int', default: 0 }) + usageCount: number; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: 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; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/template.entity.ts b/src/modules/whatsapp/entities/template.entity.ts new file mode 100644 index 0000000..1100d5d --- /dev/null +++ b/src/modules/whatsapp/entities/template.entity.ts @@ -0,0 +1,106 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type TemplateCategory = 'MARKETING' | 'UTILITY' | 'AUTHENTICATION'; +export type TemplateStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'PAUSED' | 'DISABLED'; +export type HeaderType = 'TEXT' | 'IMAGE' | 'VIDEO' | 'DOCUMENT'; + +@Entity({ name: 'templates', schema: 'whatsapp' }) +@Unique(['accountId', 'name', 'language']) +export class WhatsAppTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 512 }) + name: string; + + @Column({ name: 'display_name', type: 'varchar', length: 200 }) + displayName: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 30 }) + category: TemplateCategory; + + @Column({ name: 'language', type: 'varchar', length: 10, default: 'es_MX' }) + language: string; + + @Column({ name: 'header_type', type: 'varchar', length: 20, nullable: true }) + headerType: HeaderType; + + @Column({ name: 'header_text', type: 'text', nullable: true }) + headerText: string; + + @Column({ name: 'header_media_url', type: 'text', nullable: true }) + headerMediaUrl: string; + + @Column({ name: 'body_text', type: 'text' }) + bodyText: string; + + @Column({ name: 'body_variables', type: 'text', array: true, default: [] }) + bodyVariables: string[]; + + @Column({ name: 'footer_text', type: 'varchar', length: 60, nullable: true }) + footerText: string; + + @Column({ name: 'buttons', type: 'jsonb', default: [] }) + buttons: Record[]; + + @Column({ name: 'meta_template_id', type: 'varchar', length: 50, nullable: true }) + metaTemplateId: string; + + @Index() + @Column({ name: 'meta_status', type: 'varchar', length: 20, default: 'PENDING' }) + metaStatus: TemplateStatus; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'usage_count', type: 'int', default: 0 }) + usageCount: number; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt: Date; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +}