Commit pendiente de Etapas 3+4. Son cambios validados (migraciones corriendo, build verde, 521 tests). Hay que commitearlos antes de empezar la siguiente etapa.

This commit is contained in:
2026-06-16 21:52:44 -03:00
parent 33b1d47229
commit 025393cd43
14 changed files with 82 additions and 1208 deletions

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { simulationRepo } from '@/persistence/repositories'
import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo'
import type { Process, Activity, Resource, Simulation } from '@/domain/types'
interface ReportData {
@@ -31,7 +31,7 @@ export function useReportData(processId: string): ReportData {
try {
const [proc, sim, acts, res] = await Promise.all([
supabaseProcessRepo.getById(processId),
simulationRepo.getLatestByProcess(processId),
supabaseSimulationRepo.getLatestByProcess(processId),
supabaseActivityRepo.getByProcess(processId),
supabaseResourceRepo.getAll(),
])

View File

@@ -1,125 +0,0 @@
import Dexie, { type Table } from 'dexie'
import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types'
export class ProcessCostDb extends Dexie {
processes!: Table<Process>
activities!: Table<Activity>
gateways!: Table<GatewayConfig>
resources!: Table<Resource>
simulations!: Table<Simulation>
groups!: Table<ProcessGroup> // 🆕 Sprint 3
settings!: Table<AppSettings> // 🆕 Sprint 3
constructor() {
super('ProcessCostPlatform')
this.version(1).stores({
processes: 'id, name, updatedAt',
activities: 'id, processId, bpmnElementId',
gateways: 'id, processId, bpmnElementId',
resources: 'id, processId, name',
simulations: 'id, processId, executedAt',
})
// Sprint 1: agrega campos de automatización a Activity y volumetría a Process.
// El upgrade popula datos existentes con defaults para garantizar invariante de tipo.
this.version(2)
.stores({
processes: 'id, name, updatedAt',
activities: 'id, processId, bpmnElementId',
gateways: 'id, processId, bpmnElementId',
resources: 'id, processId, name',
simulations: 'id, processId, executedAt',
})
.upgrade(async (tx) => {
await tx.table('activities').toCollection().modify((act) => {
if (act.automatable === undefined) act.automatable = false
if (act.automatedCostFixed === undefined) act.automatedCostFixed = 0
if (act.automatedTimeMinutes === undefined) act.automatedTimeMinutes = 0
})
await tx.table('processes').toCollection().modify((proc) => {
if (proc.annualFrequency === undefined) proc.annualFrequency = 1000
if (proc.analysisHorizonYears === undefined) proc.analysisHorizonYears = 3
if (proc.automationInvestment === undefined) proc.automationInvestment = 0
})
})
// Sprint 2: migra ActivityResourceAssignment.utilizationPercent → utilizationMinutes + units.
// Los stores no cambian — assignments están embebidos en Activity, no son tabla propia.
this.version(3)
.stores({
processes: 'id, name, updatedAt',
activities: 'id, processId, bpmnElementId',
gateways: 'id, processId, bpmnElementId',
resources: 'id, processId, name',
simulations: 'id, processId, executedAt',
})
.upgrade(async (tx) => {
await tx.table('activities').toCollection().modify((act) => {
if (!Array.isArray(act.assignedResources)) {
act.assignedResources = []
return
}
act.assignedResources = act.assignedResources.map((assignment: Record<string, unknown>) => {
if ('utilizationPercent' in assignment) {
// Heurística: minutos = round(executionTimeMinutes × utilizationPercent)
// Si executionTimeMinutes no está disponible o es 0, usar 0 (conservador)
const execMinutes = typeof act.executionTimeMinutes === 'number' ? act.executionTimeMinutes : 0
const utilizationMinutes = Math.round(execMinutes * (assignment.utilizationPercent as number))
return { resourceId: assignment.resourceId, utilizationMinutes, units: 1 }
}
if ('utilizationMinutes' in assignment) {
// Ya está en nuevo formato (idempotencia)
return {
resourceId: assignment.resourceId,
utilizationMinutes: assignment.utilizationMinutes ?? 0,
units: assignment.units ?? 1,
}
}
// Fallback defensivo
return { resourceId: assignment.resourceId, utilizationMinutes: 0, units: 1 }
})
})
})
// Sprint 3: agrega tabla de grupos (Process Library), tabla de settings,
// campos groupId/tags en Process, y automatedAssignedResources en Activity.
this.version(4)
.stores({
processes: 'id, name, updatedAt, groupId', // groupId indexado para queries por grupo
activities: 'id, processId, bpmnElementId',
gateways: 'id, processId, bpmnElementId',
resources: 'id, processId, name',
simulations: 'id, processId, executedAt',
groups: 'id, name, updatedAt', // nueva tabla
settings: 'id', // nueva tabla (singleton)
})
.upgrade(async (tx) => {
// Process: agregar groupId (null) y tags ([])
await tx.table('processes').toCollection().modify((proc) => {
if (proc.groupId === undefined) proc.groupId = null
if (!Array.isArray(proc.tags)) proc.tags = []
})
// Activity: agregar automatedAssignedResources ([])
await tx.table('activities').toCollection().modify((act) => {
if (!Array.isArray(act.automatedAssignedResources)) {
act.automatedAssignedResources = []
}
})
// Settings: crear singleton con defaults (put es idempotente por clave primaria)
await tx.table('settings').put({ id: 'singleton', groupLabel: 'Cliente' })
})
// Belt-and-suspenders: garantizar singleton en fresh installs y entornos de test.
// El upgrade también lo crea; este handler cubre el caso en que la tabla sea nueva
// pero el upgrade no haya persistido el put (conocido con fake-indexeddb).
this.on('ready', async () => {
const existing = await this.settings.get('singleton')
if (!existing) {
await this.settings.put({ id: 'singleton', groupLabel: 'Cliente' })
}
})
}
}
export const db = new ProcessCostDb()

View File

@@ -1,180 +0,0 @@
/**
* @internal
*
* Repositorios de acceso a IndexedDB (Dexie).
*
* REGLA ARQUITECTÓNICA: este módulo solo debe importarse desde `src/store/**`.
* Las features y hooks deben mutar datos a través de los stores (useProcessStore,
* useSimulationStore), que garantizan la invalidación del resultado de simulación
* cuando los datos cambian.
*
* Excepciones permitidas (ver eslint.config.js):
* - src/features/import/** → operación de importación inicial (no hay simulación previa)
* - src/features/report/useReportData.ts → solo lectura, sin mutaciones
* - tests/** → acceso directo controlado en contexto de testing
*
* Cualquier import desde fuera de estos paths falla `npm run lint`.
*/
import { db } from './db'
import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types'
// ─── Process ─────────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/process-store.ts */
export const processRepo = {
async save(process: Process): Promise<void> {
await db.processes.put(process)
},
async getById(id: string): Promise<Process | undefined> {
return db.processes.get(id)
},
async getAll(): Promise<Process[]> {
return db.processes.orderBy('updatedAt').reverse().toArray()
},
async delete(id: string): Promise<void> {
await db.transaction('rw', [db.processes, db.activities, db.gateways, db.resources, db.simulations], async () => {
await db.processes.delete(id)
await db.activities.where('processId').equals(id).delete()
await db.gateways.where('processId').equals(id).delete()
await db.resources.where('processId').equals(id).delete()
await db.simulations.where('processId').equals(id).delete()
})
},
}
// ─── Activity ─────────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/process-store.ts */
export const activityRepo = {
async save(activity: Activity): Promise<void> {
await db.activities.put(activity)
},
async saveMany(activities: Activity[]): Promise<void> {
await db.activities.bulkPut(activities)
},
async getByProcess(processId: string): Promise<Activity[]> {
return db.activities.where('processId').equals(processId).toArray()
},
async getByBpmnElementId(processId: string, bpmnElementId: string): Promise<Activity | undefined> {
return db.activities
.where('[processId+bpmnElementId]')
.equals([processId, bpmnElementId])
.first()
},
async deleteByProcess(processId: string): Promise<void> {
await db.activities.where('processId').equals(processId).delete()
},
}
// ─── GatewayConfig ────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/process-store.ts */
export const gatewayRepo = {
async save(gateway: GatewayConfig): Promise<void> {
await db.gateways.put(gateway)
},
async saveMany(gateways: GatewayConfig[]): Promise<void> {
await db.gateways.bulkPut(gateways)
},
async getByProcess(processId: string): Promise<GatewayConfig[]> {
return db.gateways.where('processId').equals(processId).toArray()
},
async deleteByProcess(processId: string): Promise<void> {
await db.gateways.where('processId').equals(processId).delete()
},
}
// ─── Resource ─────────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/process-store.ts */
export const resourceRepo = {
async save(resource: Resource): Promise<void> {
await db.resources.put(resource)
},
async getByProcess(processId: string): Promise<Resource[]> {
return db.resources.where('processId').equals(processId).toArray()
},
async delete(id: string): Promise<void> {
await db.resources.delete(id)
},
async deleteByProcess(processId: string): Promise<void> {
await db.resources.where('processId').equals(processId).delete()
},
}
// ─── Simulation ───────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/simulation-store.ts */
export const simulationRepo = {
async save(simulation: Simulation): Promise<void> {
await db.simulations.put(simulation)
},
async getLatestByProcess(processId: string): Promise<Simulation | undefined> {
// Ordena por executedAt descendente para retornar la simulación más reciente.
// No se usa .last() porque el primary key es UUID (no ordenado cronológicamente).
const all = await db.simulations
.where('processId')
.equals(processId)
.toArray()
if (!all.length) return undefined
return all.reduce((latest, sim) => sim.executedAt > latest.executedAt ? sim : latest)
},
async getByProcess(processId: string): Promise<Simulation[]> {
return db.simulations.where('processId').equals(processId).toArray()
},
}
// ─── ProcessGroup ─────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/library-store.ts */
export const groupRepo = {
async save(group: ProcessGroup): Promise<void> {
await db.groups.put(group)
},
async getAll(): Promise<ProcessGroup[]> {
return db.groups.orderBy('name').toArray()
},
async getById(id: string): Promise<ProcessGroup | undefined> {
return db.groups.get(id)
},
async delete(id: string): Promise<void> {
// Procesos del grupo pasan a "Sin clasificar" (groupId = null)
await db.transaction('rw', [db.groups, db.processes], async () => {
await db.processes.where('groupId').equals(id).modify({ groupId: null })
await db.groups.delete(id)
})
},
}
// ─── AppSettings ──────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/library-store.ts */
export const settingsRepo = {
async get(): Promise<AppSettings> {
const s = await db.settings.get('singleton')
return s ?? { id: 'singleton', groupLabel: 'Cliente' }
},
async update(updates: Partial<Omit<AppSettings, 'id'>>): Promise<void> {
await db.settings.update('singleton', updates)
},
}

View File

@@ -1,5 +1,4 @@
import { supabase } from '@/lib/supabase'
import { db } from '@/persistence/db'
import type { Process } from '@/domain/types'
function toRow(process: Process, userId: string) {
@@ -66,13 +65,8 @@ export const supabaseProcessRepo = {
},
async delete(id: string): Promise<void> {
// CASCADE en Supabase elimina: activities, gateways, activity_resource_assignments
// CASCADE en Supabase elimina: activities, gateways, activity_resource_assignments, simulations
const { error } = await supabase.from('processes').delete().eq('id', id)
if (error) throw error
// TODO Sprint 4 Etapa 4: eliminar cuando simulaciones migren a Supabase
await db.transaction('rw', [db.simulations], async () => {
await db.simulations.where('processId').equals(id).delete()
})
},
}

View File

@@ -0,0 +1,49 @@
import { supabase } from '@/lib/supabase'
import type { Simulation, SimulationResult } from '@/domain/types'
function fromRow(row: Record<string, unknown>): Simulation {
return {
id: row.id as string,
processId: row.process_id as string,
executedAt: Number(row.executed_at),
result: row.result as SimulationResult,
resultAutomated: row.result_automated != null
? (row.result_automated as SimulationResult)
: undefined,
}
}
export const supabaseSimulationRepo = {
async save(simulation: Simulation): Promise<void> {
const { error } = await supabase.from('simulations').insert({
id: simulation.id,
process_id: simulation.processId,
executed_at: simulation.executedAt,
result: simulation.result,
result_automated: simulation.resultAutomated ?? null,
})
if (error) throw error
},
async getLatestByProcess(processId: string): Promise<Simulation | undefined> {
const { data, error } = await supabase
.from('simulations')
.select('*')
.eq('process_id', processId)
.order('executed_at', { ascending: false })
.limit(1)
.single()
if (error) return undefined
return fromRow(data as Record<string, unknown>)
},
async getByProcess(processId: string): Promise<Simulation[]> {
const { data, error } = await supabase
.from('simulations')
.select('*')
.eq('process_id', processId)
.order('executed_at', { ascending: false })
if (error) throw error
return (data ?? []).map(fromRow)
},
}

View File

@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { supabaseGroupRepo } from '@/persistence/supabase/group-repo'
import { supabaseSettingsRepo } from '@/persistence/supabase/settings-repo'
import { simulationRepo } from '@/persistence/repositories'
import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo'
import type { Process, ProcessGroup, AppSettings, Simulation } from '@/domain/types'
interface LibraryState {
@@ -39,19 +39,10 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
load: async () => {
set({ isLoading: true })
try {
const TIMEOUT_MS = 8_000
const [groups, processes, settings] = await Promise.race([
Promise.all([
supabaseGroupRepo.getAll(),
supabaseProcessRepo.getAll(),
supabaseSettingsRepo.get(),
]),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('Timeout: la biblioteca tardó más de 8s en cargar')),
TIMEOUT_MS,
)
),
const [groups, processes, settings] = await Promise.all([
supabaseGroupRepo.getAll(),
supabaseProcessRepo.getAll(),
supabaseSettingsRepo.get(),
])
set({ groups, processes, settings, isLoading: false })
} catch (err) {
@@ -99,7 +90,7 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
},
getLatestSimulation: (processId) => {
return simulationRepo.getLatestByProcess(processId)
return supabaseSimulationRepo.getLatestByProcess(processId)
},
updateSettings: async (updates) => {

View File

@@ -1,28 +1,25 @@
import { create } from 'zustand'
import { v4 as uuidv4 } from 'uuid'
import type { SimulationResult } from '@/domain/types'
import { simulationRepo } from '@/persistence/repositories'
import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo'
// Excepción arquitectónica documentada: simulation-store importa process-store en una dirección
// para suscribirse a cambios de datos y invalidar resultados. No existe dependencia inversa.
import { useProcessStore } from '@/store/process-store'
interface SimulationState {
resultActual: SimulationResult | null // resultado del escenario actual
resultAutomated: SimulationResult | null // resultado del escenario automatizado
lastSimulatedAt: Date | null // timestamp de la última simulación exitosa
lastPersistedExecutedAt: number | null // executedAt de la última sim guardada en IndexedDB (sobrevive recargas)
resultActual: SimulationResult | null
resultAutomated: SimulationResult | null
lastSimulatedAt: Date | null
lastPersistedExecutedAt: number | null
isRunning: boolean
error: string | null
selectedActivityId: string | null
// Persiste ambos resultados en IndexedDB y actualiza el estado en memoria.
// REGLA: los dos resultados siempre se guardan y actualizan en conjunto.
persistResults: (processId: string, actual: SimulationResult, automated: SimulationResult) => Promise<void>
setRunning: (running: boolean) => void
setError: (error: string | null) => void
selectActivity: (bpmnElementId: string | null) => void
// Invalida AMBOS resultados a null. Se llama cuando cambian datos del proceso.
invalidateResults: () => void
reset: () => void
loadLatestForProcess: (processId: string) => Promise<void>
@@ -39,7 +36,7 @@ export const useSimulationStore = create<SimulationState>((set) => ({
persistResults: async (processId, actual, automated) => {
const executedAt = Date.now()
await simulationRepo.save({
await supabaseSimulationRepo.save({
id: uuidv4(),
processId,
executedAt,
@@ -74,14 +71,13 @@ export const useSimulationStore = create<SimulationState>((set) => ({
}),
loadLatestForProcess: async (processId) => {
const sim = await simulationRepo.getLatestByProcess(processId)
const sim = await supabaseSimulationRepo.getLatestByProcess(processId)
set({ lastPersistedExecutedAt: sim?.executedAt ?? null })
},
}))
// Suscripción reactiva: cualquier cambio en activities o currentProcess del process-store
// invalida los resultados para garantizar que nunca haya resultados "viejos" visibles.
// La comparación por referencia de Zustand asegura que solo fires en cambios reales.
useProcessStore.subscribe((state, prevState) => {
if (
state.activities !== prevState.activities ||