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:
@@ -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(),
|
||||
])
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
49
src/persistence/supabase/simulation-repo.ts
Normal file
49
src/persistence/supabase/simulation-repo.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user