parent
4c0e0a33f0
commit
9bf050f990
@ -0,0 +1,325 @@
|
|||||||
|
# Arquitectura Reactiva - Documentación
|
||||||
|
|
||||||
|
## 🏗️ Estructura Híbrida
|
||||||
|
|
||||||
|
La arquitectura separa **estado agnóstico del framework** de los **adapters específicos**:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── state/ # ← Estado puro (sin framework)
|
||||||
|
│ ├── SelectionState.ts
|
||||||
|
│ ├── ValidationState.ts
|
||||||
|
│ └── ConfigurationState.ts
|
||||||
|
│
|
||||||
|
└── adapters/ # ← Adapters por framework
|
||||||
|
└── svelte/
|
||||||
|
├── stores/ # Wrappers Svelte
|
||||||
|
└── components/ # Componentes UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Capa 1: Estado Agnóstico (`/state`)
|
||||||
|
|
||||||
|
### `SelectionState.ts`
|
||||||
|
|
||||||
|
Gestiona qué opciones están seleccionadas.
|
||||||
|
|
||||||
|
**Responsabilidades:**
|
||||||
|
- Almacenar selecciones: `Record<AttrID, OptionID>`
|
||||||
|
- Notificar cambios via listeners
|
||||||
|
- Historial de cambios (undo/redo)
|
||||||
|
- Persistencia (toJSON/fromJSON)
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
```typescript
|
||||||
|
const selection = new SelectionState();
|
||||||
|
|
||||||
|
// Establecer valor
|
||||||
|
selection.set('at:suelo', 'op:parquet');
|
||||||
|
|
||||||
|
// Obtener valor
|
||||||
|
selection.get('at:suelo'); // → 'op:parquet'
|
||||||
|
|
||||||
|
// Limpiar
|
||||||
|
selection.clear('at:suelo');
|
||||||
|
|
||||||
|
// Suscribirse a cambios
|
||||||
|
const unsubscribe = selection.subscribe((event) => {
|
||||||
|
console.log(`${event.attrId} cambió a ${event.newValue}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deshacer
|
||||||
|
selection.undo();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `ValidationState.ts`
|
||||||
|
|
||||||
|
Gestiona validaciones y violaciones usando el `RuleEngine`.
|
||||||
|
|
||||||
|
**Responsabilidades:**
|
||||||
|
- Validar selecciones contra reglas
|
||||||
|
- Detectar violaciones (errores/warnings)
|
||||||
|
- Verificar valores permitidos/prohibidos
|
||||||
|
- Determinar atributos requeridos
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
```typescript
|
||||||
|
const validation = new ValidationState();
|
||||||
|
|
||||||
|
// Configurar reglas
|
||||||
|
validation.setRules(catalog.rules);
|
||||||
|
|
||||||
|
// Validar estado
|
||||||
|
const result = validation.validate(selectionState);
|
||||||
|
|
||||||
|
console.log(result.isValid); // → boolean
|
||||||
|
console.log(result.violations); // → Violation[]
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
validation.isValueAllowed('at:suelo', 'op:marmol', state);
|
||||||
|
validation.getAllowedValues('at:suelo', state);
|
||||||
|
validation.isRequired('at:material', state);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `ConfigurationState.ts`
|
||||||
|
|
||||||
|
**Orquestador principal** que coordina todo.
|
||||||
|
|
||||||
|
**Responsabilidades:**
|
||||||
|
- Gestionar catálogo y objeto
|
||||||
|
- Coordinar SelectionState + ValidationState
|
||||||
|
- Navegación (sección/vista actual)
|
||||||
|
- Auto-validación en cambios
|
||||||
|
- Persistencia completa
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
```typescript
|
||||||
|
const config = new ConfigurationState({
|
||||||
|
catalog: CATALOGO_VIVIENDAS,
|
||||||
|
objectId: 'ob:apartamento',
|
||||||
|
autoValidate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Selección
|
||||||
|
config.selectOption('at:suelo', 'op:parquet');
|
||||||
|
config.getSelection('at:suelo');
|
||||||
|
|
||||||
|
// Navegación
|
||||||
|
config.setCurrentSection('sc:salon');
|
||||||
|
config.setCurrentView('vw:front');
|
||||||
|
|
||||||
|
// Validación
|
||||||
|
config.validate();
|
||||||
|
config.isValid();
|
||||||
|
config.getViolationsForAttribute('at:suelo');
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
config.getAllowedValues('at:suelo');
|
||||||
|
config.isRequired('at:material');
|
||||||
|
|
||||||
|
// Persistencia
|
||||||
|
const saved = config.toJSON();
|
||||||
|
config.fromJSON(saved);
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
config.reset();
|
||||||
|
config.undo();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Capa 2: Adapters Svelte (`/adapters/svelte`)
|
||||||
|
|
||||||
|
### `stores/configuration.ts`
|
||||||
|
|
||||||
|
Wrapper que convierte `ConfigurationState` en Svelte store.
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- ✅ Reactivo automático (subscribe)
|
||||||
|
- ✅ API idéntica a ConfigurationState
|
||||||
|
- ✅ Derived stores para valores específicos
|
||||||
|
- ✅ Type-safe
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```typescript
|
||||||
|
import { createConfigurationStore } from '@/adapters/svelte/stores';
|
||||||
|
|
||||||
|
const config = createConfigurationStore({
|
||||||
|
catalog: CATALOGO_VIVIENDAS,
|
||||||
|
objectId: 'ob:apartamento'
|
||||||
|
});
|
||||||
|
|
||||||
|
// En componentes Svelte
|
||||||
|
$: selection = $config.selection;
|
||||||
|
$: isValid = $config.isValid;
|
||||||
|
$: currentSection = $config.currentSection;
|
||||||
|
|
||||||
|
// Acciones
|
||||||
|
config.selectOption('at:suelo', 'op:parquet');
|
||||||
|
config.setCurrentSection('sc:salon');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Derived stores:**
|
||||||
|
```typescript
|
||||||
|
const currentSection = deriveCurrentSection(config);
|
||||||
|
const validation = deriveValidation(config);
|
||||||
|
const isValid = deriveIsValid(config);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Capa 3: Componentes UI (`/adapters/svelte/components`)
|
||||||
|
|
||||||
|
### `Configurator.svelte`
|
||||||
|
|
||||||
|
Componente principal de ejemplo.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- ✅ Navegación de secciones
|
||||||
|
- ✅ Selector de vistas
|
||||||
|
- ✅ Panel de atributos
|
||||||
|
- ✅ Preview visual
|
||||||
|
- ✅ Validación en tiempo real
|
||||||
|
- ✅ Undo/Reset
|
||||||
|
|
||||||
|
**Uso:**
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import Configurator from '@/adapters/svelte/components/Configurator.svelte';
|
||||||
|
import { CATALOGO_VIVIENDAS } from './fixtures';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Configurator
|
||||||
|
catalog={CATALOGO_VIVIENDAS}
|
||||||
|
objectId="ob:apartamento"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Flujo de Datos
|
||||||
|
|
||||||
|
```
|
||||||
|
User Action (click)
|
||||||
|
↓
|
||||||
|
Component Handler
|
||||||
|
↓
|
||||||
|
Store Action (config.selectOption)
|
||||||
|
↓
|
||||||
|
ConfigurationState
|
||||||
|
↓
|
||||||
|
SelectionState.set()
|
||||||
|
↓
|
||||||
|
Notifica listeners
|
||||||
|
↓
|
||||||
|
ValidationState.validate() (si autoValidate)
|
||||||
|
↓
|
||||||
|
Notifica cambio
|
||||||
|
↓
|
||||||
|
Svelte Store actualiza
|
||||||
|
↓
|
||||||
|
UI re-renderiza (reactive $config)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Ventajas de esta arquitectura
|
||||||
|
|
||||||
|
### 1. **Separación de concerns**
|
||||||
|
- Estado = Lógica de negocio (testeable sin UI)
|
||||||
|
- Adapter = Integración con framework
|
||||||
|
- Components = Solo presentación
|
||||||
|
|
||||||
|
### 2. **Framework agnostic**
|
||||||
|
El 90% del código está en `/state` y no depende de Svelte.
|
||||||
|
|
||||||
|
Añadir React es fácil:
|
||||||
|
```
|
||||||
|
/adapters/react/
|
||||||
|
└── hooks/
|
||||||
|
└── useConfiguration.ts ← Wrapper con useState/useEffect
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Testing simple**
|
||||||
|
```typescript
|
||||||
|
// Test estado SIN montar componentes
|
||||||
|
const config = new ConfigurationState({...});
|
||||||
|
config.selectOption('at:suelo', 'op:parquet');
|
||||||
|
expect(config.getSelection('at:suelo')).toBe('op:parquet');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **Type-safety completo**
|
||||||
|
Todo tipado con TypeScript. El editor te ayuda en cada paso.
|
||||||
|
|
||||||
|
### 5. **Reactivo por defecto**
|
||||||
|
Cambias el estado → UI se actualiza automáticamente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Ejemplo completo
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import { createConfigurationStore } from '@/adapters/svelte/stores';
|
||||||
|
|
||||||
|
const config = createConfigurationStore({
|
||||||
|
catalog: MY_CATALOG,
|
||||||
|
objectId: 'ob:producto'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reactivo automático
|
||||||
|
$: selection = $config.selection;
|
||||||
|
$: isValid = $config.isValid;
|
||||||
|
$: violations = $config.validation?.violations || [];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- UI reactiva -->
|
||||||
|
<div>
|
||||||
|
<h1>{$config.object.name.es}</h1>
|
||||||
|
|
||||||
|
<select on:change={(e) => config.selectOption('at:color', e.target.value)}>
|
||||||
|
<option value="">Seleccionar color</option>
|
||||||
|
<option value="op:rojo">Rojo</option>
|
||||||
|
<option value="op:azul">Azul</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{#if !isValid}
|
||||||
|
<div class="errors">
|
||||||
|
{#each violations as v}
|
||||||
|
<p>{v.rule.message.es}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button disabled={!isValid}>
|
||||||
|
Confirmar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Próximos pasos
|
||||||
|
|
||||||
|
1. ✅ Estado agnóstico implementado
|
||||||
|
2. ✅ Adapter Svelte implementado
|
||||||
|
3. ✅ Componente de ejemplo creado
|
||||||
|
4. ⏳ Implementar renderizado de imágenes
|
||||||
|
5. ⏳ Añadir precio dinámico
|
||||||
|
6. ⏳ Implementar más componentes (AttributePanel, ValidationPanel, etc.)
|
||||||
|
7. ⏳ Testing de stores
|
||||||
|
8. ⏳ Documentación de componentes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Archivos clave
|
||||||
|
|
||||||
|
- `/state/ConfigurationState.ts` - Orquestador principal
|
||||||
|
- `/adapters/svelte/stores/configuration.ts` - Svelte store
|
||||||
|
- `/adapters/svelte/components/Configurator.svelte` - Componente ejemplo
|
||||||
|
- `/docs/ARCHITECTURE.md` - Este archivo
|
||||||
@ -0,0 +1,813 @@
|
|||||||
|
# Modelo de datos - Documentación completa
|
||||||
|
|
||||||
|
## 📋 Tabla de contenidos
|
||||||
|
|
||||||
|
- [Visión general](#visión-general)
|
||||||
|
- [Catálogo](#catálogo)
|
||||||
|
- [Objetos configurables](#objetos-configurables)
|
||||||
|
- [Secciones](#secciones)
|
||||||
|
- [Atributos](#atributos)
|
||||||
|
- [Opciones](#opciones)
|
||||||
|
- [Reglas de validación](#reglas-de-validación)
|
||||||
|
- [Ejemplo completo](#ejemplo-completo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visión general
|
||||||
|
|
||||||
|
El modelo CPQ está organizado jerárquicamente:
|
||||||
|
|
||||||
|
```
|
||||||
|
ConfigurationCatalog
|
||||||
|
└── Objects (ConfigurableObject)
|
||||||
|
├── Attributes (global)
|
||||||
|
└── Sections
|
||||||
|
└── Attributes (per-section)
|
||||||
|
└── Options
|
||||||
|
|
||||||
|
Rules (aplican a cualquier combinación)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Catálogo
|
||||||
|
|
||||||
|
### `ConfigurationCatalog`
|
||||||
|
|
||||||
|
Contenedor principal de todo el modelo.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ConfigurationCatalog {
|
||||||
|
name : I18nString;
|
||||||
|
description? : I18nString;
|
||||||
|
supportedLocales: string[];
|
||||||
|
defaultLocale : string;
|
||||||
|
options : Record<OptionID, OptionDefinition>;
|
||||||
|
objects : Record<ObjectID, ConfigurableObject>;
|
||||||
|
rules? : Record<RuleID, ValidationRule>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejemplo:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const CATALOGO_VIVIENDAS: ConfigurationCatalog = {
|
||||||
|
name: {
|
||||||
|
es: 'Catálogo de Viviendas',
|
||||||
|
en: 'Housing Catalog'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
es: 'Configurador de acabados',
|
||||||
|
en: 'Finish configurator'
|
||||||
|
},
|
||||||
|
supportedLocales: ['es', 'en'],
|
||||||
|
defaultLocale: 'es',
|
||||||
|
options: { /* ... */ },
|
||||||
|
objects: { /* ... */ },
|
||||||
|
rules: { /* ... */ }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objetos configurables
|
||||||
|
|
||||||
|
### `ConfigurableObject`
|
||||||
|
|
||||||
|
Representa un producto/objeto que puede configurarse (ej: casa, coche, mueble).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ConfigurableObject {
|
||||||
|
id : ObjectID; // 'ob:apartamento'
|
||||||
|
name : I18nString;
|
||||||
|
description : I18nString;
|
||||||
|
attributes : Attribute[]; // Atributos GLOBALES
|
||||||
|
sections : Record<SectionID, Section>;
|
||||||
|
sectionOrder?: SectionID[];
|
||||||
|
category? : string;
|
||||||
|
metadata? : Metadata;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Atributos globales vs de sección
|
||||||
|
|
||||||
|
- **Globales**: Afectan a TODO el objeto (ej: calidad general, color principal)
|
||||||
|
- **De sección**: Solo afectan a una sección específica (ej: suelo del salón)
|
||||||
|
|
||||||
|
**Ejemplo:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const APARTAMENTO: ConfigurableObject = {
|
||||||
|
id: 'ob:apartamento',
|
||||||
|
name: { es: 'Apartamento', en: 'Apartment' },
|
||||||
|
description: { es: 'Apartamento de 1 dormitorio', en: '1 bedroom apartment' },
|
||||||
|
|
||||||
|
// Atributos GLOBALES (afectan a todo)
|
||||||
|
attributes: [
|
||||||
|
{
|
||||||
|
id: 'at:calidad',
|
||||||
|
name: { es: 'Calidad de acabados', en: 'Finish quality' },
|
||||||
|
type: 'dynamic',
|
||||||
|
dataType: 'reference',
|
||||||
|
defaultValue: 'op:calidad_estandar',
|
||||||
|
options: [
|
||||||
|
{ optionId: 'op:calidad_estandar' },
|
||||||
|
{ optionId: 'op:calidad_premium' },
|
||||||
|
{ optionId: 'op:calidad_lujo' }
|
||||||
|
],
|
||||||
|
isController: true, // ✅ Controla visibilidad/precios globalmente
|
||||||
|
display: {
|
||||||
|
uiVisible: true,
|
||||||
|
affectsPrice: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Secciones del objeto
|
||||||
|
sections: {
|
||||||
|
'sc:salon': { /* ... */ },
|
||||||
|
'sc:bano': { /* ... */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
sectionOrder: ['sc:salon', 'sc:bano']
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secciones
|
||||||
|
|
||||||
|
### `Section`
|
||||||
|
|
||||||
|
Representa una parte del objeto configurable (ej: salón, baño, motor).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Section {
|
||||||
|
id : SectionID;
|
||||||
|
name : I18nString;
|
||||||
|
description : I18nString;
|
||||||
|
attrs : Attribute[]; // Atributos DE ESTA SECCIÓN
|
||||||
|
visualConfig: SectionVisualConfig;
|
||||||
|
availability: SectionAvailability;
|
||||||
|
order? : number;
|
||||||
|
icon? : string;
|
||||||
|
metadata? : Metadata;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Config
|
||||||
|
|
||||||
|
Controla cómo se renderiza visualmente la sección:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SectionVisualConfig {
|
||||||
|
strategy: RenderingStrategy;
|
||||||
|
|
||||||
|
// Para 'static_image' o 'dynamic_image'
|
||||||
|
imageUrlTemplate? : string;
|
||||||
|
globalAttrDependencies? : AttrID[];
|
||||||
|
sectionAttrDependencies?: AttrID[];
|
||||||
|
basePath? : string;
|
||||||
|
|
||||||
|
// Para 'api_generated'
|
||||||
|
apiConfig?: APIConfig;
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
fallbackImage?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estrategias de renderizado:**
|
||||||
|
|
||||||
|
| Estrategia | Uso |
|
||||||
|
|------------|-----|
|
||||||
|
| `static_image` | URL con placeholders: `{basePath}/salon/{at_suelo}/{at_pared}.jpg` |
|
||||||
|
| `dynamic_image` | URL dinámica calculada |
|
||||||
|
| `api_generated` | Llamada a API externa para generar imagen |
|
||||||
|
| `three_d` | Modelo 3D con texturas dinámicas |
|
||||||
|
| `composite_layers` | Capas PNG superpuestas |
|
||||||
|
| `none` | Sin renderizado visual |
|
||||||
|
|
||||||
|
### Availability
|
||||||
|
|
||||||
|
Controla cuándo la sección está disponible:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SectionAvailability {
|
||||||
|
mode: 'required' | 'optional' | 'conditional';
|
||||||
|
|
||||||
|
// Si mode es 'conditional'
|
||||||
|
condition?: JsonLogic;
|
||||||
|
dependsOn?: AttrID[];
|
||||||
|
|
||||||
|
// Qué hacer al desactivarse
|
||||||
|
onDeactivate?: {
|
||||||
|
action: 'clear' | 'preserve' | 'reset';
|
||||||
|
confirmWithUser?: boolean;
|
||||||
|
confirmMessage?: I18nString;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejemplo de sección:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const seccionSalon: Section = {
|
||||||
|
id: 'sc:salon',
|
||||||
|
name: { es: 'Salón', en: 'Living room' },
|
||||||
|
description: { es: 'Salón - comedor principal', en: 'Main living - dining room' },
|
||||||
|
|
||||||
|
// Atributos DE ESTA SECCIÓN
|
||||||
|
attrs: [
|
||||||
|
{
|
||||||
|
id: 'at:suelo',
|
||||||
|
name: { es: 'Suelo', en: 'Flooring' },
|
||||||
|
type: 'dynamic',
|
||||||
|
dataType: 'reference',
|
||||||
|
defaultValue: 'op:suelo_ceramica',
|
||||||
|
options: [
|
||||||
|
{ optionId: 'op:suelo_ceramica' },
|
||||||
|
{ optionId: 'op:suelo_parquet' }
|
||||||
|
],
|
||||||
|
display: {
|
||||||
|
uiVisible: true,
|
||||||
|
affectsVisual: true, // ✅ Afecta al render
|
||||||
|
affectsPrice: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'at:pared',
|
||||||
|
name: { es: 'Color paredes', en: 'Wall color' },
|
||||||
|
type: 'dynamic',
|
||||||
|
dataType: 'reference',
|
||||||
|
defaultValue: 'op:pared_blanco',
|
||||||
|
options: [ /* ... */ ],
|
||||||
|
display: { uiVisible: true, affectsVisual: true }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Configuración visual
|
||||||
|
visualConfig: {
|
||||||
|
strategy: 'static_image',
|
||||||
|
imageUrlTemplate: '{basePath}/salon/{at_suelo}/{at_pared}/view.jpg',
|
||||||
|
basePath: '/renders/vivienda',
|
||||||
|
|
||||||
|
// Atributos GLOBALES que afectan esta imagen
|
||||||
|
globalAttrDependencies: ['at:calidad'],
|
||||||
|
|
||||||
|
// Atributos DE SECCIÓN que afectan
|
||||||
|
sectionAttrDependencies: ['at:suelo', 'at:pared']
|
||||||
|
},
|
||||||
|
|
||||||
|
// Disponibilidad
|
||||||
|
availability: {
|
||||||
|
mode: 'required' // Siempre visible
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Atributos
|
||||||
|
|
||||||
|
### Tipos de atributos
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Attribute =
|
||||||
|
| FixedAttribute
|
||||||
|
| DynamicAttribute
|
||||||
|
| QuantifiableAttribute
|
||||||
|
| ComputedAttribute;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Fixed Attribute
|
||||||
|
|
||||||
|
Valor fijo, no modificable por el usuario.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FixedAttribute {
|
||||||
|
type : 'fixed';
|
||||||
|
dataType: DataType;
|
||||||
|
value : Value;
|
||||||
|
unit? : I18nString;
|
||||||
|
display : AttributeDisplay;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejemplo:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'at:m2_salon',
|
||||||
|
name: { es: 'Superficie', en: 'Surface' },
|
||||||
|
type: 'fixed',
|
||||||
|
dataType: 'number',
|
||||||
|
value: 25,
|
||||||
|
unit: { es: 'm²', en: 'sqm' },
|
||||||
|
display: {
|
||||||
|
uiVisible: false, // ❌ No se muestra al usuario
|
||||||
|
readonly: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Dynamic Attribute
|
||||||
|
|
||||||
|
Seleccionable por el usuario entre opciones.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DynamicAttribute {
|
||||||
|
type : 'dynamic';
|
||||||
|
dataType : 'reference';
|
||||||
|
defaultValue : OptionID;
|
||||||
|
options : AttributeOption[];
|
||||||
|
required? : boolean;
|
||||||
|
isController? : boolean;
|
||||||
|
controls? : SectionID[];
|
||||||
|
filterExpression?: JsonLogic;
|
||||||
|
display : AttributeDisplay;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opciones:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AttributeOption {
|
||||||
|
optionId : OptionID;
|
||||||
|
priority? : number; // Orden de presentación
|
||||||
|
pricingOverride?: number; // Precio custom para esta combinación
|
||||||
|
metadata? : Metadata;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejemplo:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'at:calidad',
|
||||||
|
name: { es: 'Calidad de acabados', en: 'Finish quality' },
|
||||||
|
type: 'dynamic',
|
||||||
|
dataType: 'reference',
|
||||||
|
defaultValue: 'op:calidad_estandar',
|
||||||
|
options: [
|
||||||
|
{ optionId: 'op:calidad_estandar', priority: 1 },
|
||||||
|
{ optionId: 'op:calidad_premium', priority: 2 },
|
||||||
|
{ optionId: 'op:calidad_lujo', priority: 3 }
|
||||||
|
],
|
||||||
|
required: true,
|
||||||
|
isController: true, // ✅ Controla otras secciones/atributos
|
||||||
|
display: {
|
||||||
|
uiVisible: true,
|
||||||
|
affectsPrice: true,
|
||||||
|
affectsVisual: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Quantifiable Attribute
|
||||||
|
|
||||||
|
Valor numérico configurable (cantidad, tamaño).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface QuantifiableAttribute {
|
||||||
|
type : 'quantifiable';
|
||||||
|
dataType : 'number';
|
||||||
|
quantity : QuantityDefinition;
|
||||||
|
unit? : I18nString;
|
||||||
|
userConfigurable?: boolean;
|
||||||
|
pricePerUnit? : number;
|
||||||
|
min? : number;
|
||||||
|
max? : number;
|
||||||
|
display : AttributeDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuantityDefinition =
|
||||||
|
| number // Fijo
|
||||||
|
| { min: number; max: number; step?: number; default: number } // Rango
|
||||||
|
| { values: number[]; default: number }; // Lista discreta
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejemplo:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'at:cantidad_lamparas',
|
||||||
|
name: { es: 'Número de lámparas', en: 'Number of lamps' },
|
||||||
|
type: 'quantifiable',
|
||||||
|
dataType: 'number',
|
||||||
|
quantity: {
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
|
step: 1,
|
||||||
|
default: 3
|
||||||
|
},
|
||||||
|
unit: { es: 'uds', en: 'units' },
|
||||||
|
userConfigurable: true,
|
||||||
|
pricePerUnit: 150, // 150€ por lámpara
|
||||||
|
display: {
|
||||||
|
uiVisible: true,
|
||||||
|
affectsPrice: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Computed Attribute
|
||||||
|
|
||||||
|
Valor calculado dinámicamente basado en otros atributos.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ComputedAttribute {
|
||||||
|
type : 'computed';
|
||||||
|
dataType : DataType;
|
||||||
|
unit? : I18nString;
|
||||||
|
expression : JsonLogic;
|
||||||
|
dependencies: AttrID[];
|
||||||
|
display : AttributeDisplay;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejemplo:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'at:precio_total',
|
||||||
|
name: { es: 'Precio total', en: 'Total price' },
|
||||||
|
type: 'computed',
|
||||||
|
dataType: 'number',
|
||||||
|
unit: { es: '€', en: '€' },
|
||||||
|
expression: {
|
||||||
|
'+': [
|
||||||
|
{ var: 'attributes.at_precio_base' },
|
||||||
|
{ var: 'attributes.at_extras' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
dependencies: ['at:precio_base', 'at:extras'],
|
||||||
|
display: {
|
||||||
|
uiVisible: true,
|
||||||
|
readonly: true, // ✅ Siempre readonly
|
||||||
|
affectsPrice: false // Ya ES el precio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Display Config
|
||||||
|
|
||||||
|
Controla cómo y cuándo se muestra un atributo:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AttributeDisplay {
|
||||||
|
uiVisible? : boolean | JsonLogic; // ¿Se muestra?
|
||||||
|
affectsVisual? : boolean | JsonLogic; // ¿Afecta al render?
|
||||||
|
affectsPrice? : boolean | JsonLogic; // ¿Afecta al precio?
|
||||||
|
readonly? : boolean; // ¿Solo lectura?
|
||||||
|
order? : number; // Orden en UI
|
||||||
|
icon? : string;
|
||||||
|
helpText? : I18nString;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visibilidad condicional:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
display: {
|
||||||
|
uiVisible: {
|
||||||
|
'==': [{ var: 'attributes.at_tipo' }, 'avanzado']
|
||||||
|
},
|
||||||
|
affectsVisual: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opciones
|
||||||
|
|
||||||
|
### `OptionDefinition`
|
||||||
|
|
||||||
|
Define una opción seleccionable (valor concreto de un atributo).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface OptionDefinition {
|
||||||
|
id : OptionID;
|
||||||
|
name : I18nString;
|
||||||
|
description : I18nString;
|
||||||
|
media? : Media;
|
||||||
|
pricing? : Pricing;
|
||||||
|
tags? : string[];
|
||||||
|
style? : string;
|
||||||
|
metadata? : Metadata;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pricing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Pricing {
|
||||||
|
baseAmount: number | 'consultation';
|
||||||
|
currency?: string;
|
||||||
|
dynamicExpression?: JsonLogic;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ejemplo:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'op:suelo_parquet',
|
||||||
|
name: { es: 'Parquet', en: 'Parquet' },
|
||||||
|
description: {
|
||||||
|
es: 'Suelo de madera natural',
|
||||||
|
en: 'Natural wood floor'
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
baseAmount: 45, // €/m²
|
||||||
|
currency: 'EUR',
|
||||||
|
dynamicExpression: {
|
||||||
|
// Descuento si calidad es lujo
|
||||||
|
'if': [
|
||||||
|
{ '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_lujo'] },
|
||||||
|
{ '*': [45, 0.9] }, // 10% descuento
|
||||||
|
45
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: ['madera', 'calido', 'natural'],
|
||||||
|
media: {
|
||||||
|
thumbnail: '/images/parquet-thumb.jpg',
|
||||||
|
images: ['/images/parquet-1.jpg', '/images/parquet-2.jpg']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reglas de validación
|
||||||
|
|
||||||
|
### `ValidationRule`
|
||||||
|
|
||||||
|
Define restricciones y dependencias entre atributos.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ValidationRule {
|
||||||
|
id : RuleID;
|
||||||
|
name : I18nString;
|
||||||
|
condition : JsonLogic; // Cuándo se activa
|
||||||
|
action : ValidationRuleAction; // Qué hace
|
||||||
|
priority : number; // Mayor = más importante
|
||||||
|
bidirectional: boolean;
|
||||||
|
affects : AttrID[]; // Atributos involucrados
|
||||||
|
severity : Severity; // 'error' | 'warning' | 'info'
|
||||||
|
message : I18nString;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tipos de acción
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ValidationRuleActionType =
|
||||||
|
| 'allow' // Lista blanca de valores permitidos
|
||||||
|
| 'forbid' // Prohíbe valores específicos
|
||||||
|
| 'require' // Hace obligatorio un atributo
|
||||||
|
| 'suggest'; // Sugiere valores (no fuerza)
|
||||||
|
|
||||||
|
interface ValidationRuleAction {
|
||||||
|
type : ValidationRuleActionType;
|
||||||
|
targetAttr : AttrID;
|
||||||
|
values : Value[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplos de reglas
|
||||||
|
|
||||||
|
#### 1. Regla FORBID
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'rl:estandar_no_lujo',
|
||||||
|
name: {
|
||||||
|
es: 'Estándar sin lujo',
|
||||||
|
en: 'Standard without luxury'
|
||||||
|
},
|
||||||
|
|
||||||
|
// CUANDO: calidad es estándar
|
||||||
|
condition: {
|
||||||
|
'==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar']
|
||||||
|
},
|
||||||
|
|
||||||
|
// ENTONCES: prohíbe mármol en baño
|
||||||
|
action: {
|
||||||
|
type: 'forbid',
|
||||||
|
targetAttr: 'at:revest_bano',
|
||||||
|
values: ['op:revest_marmol']
|
||||||
|
},
|
||||||
|
|
||||||
|
priority: 9,
|
||||||
|
bidirectional: false,
|
||||||
|
affects: ['at:calidad', 'at:revest_bano'],
|
||||||
|
severity: 'error',
|
||||||
|
|
||||||
|
message: {
|
||||||
|
es: 'El mármol no está disponible en calidad estándar',
|
||||||
|
en: 'Marble is not available in standard quality'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Regla ALLOW
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'rl:roble_colores',
|
||||||
|
name: {
|
||||||
|
es: 'Roble colores permitidos',
|
||||||
|
en: 'Oak allowed colors'
|
||||||
|
},
|
||||||
|
|
||||||
|
// CUANDO: material es roble
|
||||||
|
condition: {
|
||||||
|
'==': [{ var: 'attributes.at_material' }, 'op:material_roble']
|
||||||
|
},
|
||||||
|
|
||||||
|
// ENTONCES: solo permite blanco y natural
|
||||||
|
action: {
|
||||||
|
type: 'allow',
|
||||||
|
targetAttr: 'at:color',
|
||||||
|
values: ['op:color_blanco', 'op:color_natural']
|
||||||
|
},
|
||||||
|
|
||||||
|
priority: 5,
|
||||||
|
bidirectional: false,
|
||||||
|
affects: ['at:material', 'at:color'],
|
||||||
|
severity: 'error',
|
||||||
|
|
||||||
|
message: {
|
||||||
|
es: 'Roble solo admite blanco o natural',
|
||||||
|
en: 'Oak only allows white or natural'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Regla REQUIRE
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'rl:premium_require_material',
|
||||||
|
name: {
|
||||||
|
es: 'Premium requiere material',
|
||||||
|
en: 'Premium requires material'
|
||||||
|
},
|
||||||
|
|
||||||
|
// CUANDO: acabado es premium
|
||||||
|
condition: {
|
||||||
|
'==': [{ var: 'attributes.at_acabado' }, 'op:acabado_premium']
|
||||||
|
},
|
||||||
|
|
||||||
|
// ENTONCES: material es obligatorio
|
||||||
|
action: {
|
||||||
|
type: 'require',
|
||||||
|
targetAttr: 'at:material',
|
||||||
|
values: [] // No importa qué valor, pero debe tener uno
|
||||||
|
},
|
||||||
|
|
||||||
|
priority: 8,
|
||||||
|
bidirectional: false,
|
||||||
|
affects: ['at:acabado', 'at:material'],
|
||||||
|
severity: 'error',
|
||||||
|
|
||||||
|
message: {
|
||||||
|
es: 'El acabado premium requiere seleccionar material',
|
||||||
|
en: 'Premium finish requires material selection'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Regla BIDIRECCIONAL
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'rl:negro_premium',
|
||||||
|
name: {
|
||||||
|
es: 'Negro requiere premium',
|
||||||
|
en: 'Black requires premium'
|
||||||
|
},
|
||||||
|
|
||||||
|
// DIRECCIÓN 1: sanitario negro → prohíbe calidad estándar
|
||||||
|
condition: {
|
||||||
|
'==': [{ var: 'attributes.at_sanitario' }, 'op:sanit_negro']
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forbid',
|
||||||
|
targetAttr: 'at:calidad',
|
||||||
|
values: ['op:calidad_estandar']
|
||||||
|
},
|
||||||
|
|
||||||
|
// ⚠️ IMPORTANTE: Para bidireccional, crear 2 reglas separadas es más claro:
|
||||||
|
// - Regla 1: negro → prohíbe estándar
|
||||||
|
// - Regla 2: estándar → prohíbe negro
|
||||||
|
|
||||||
|
priority: 8,
|
||||||
|
bidirectional: false, // ✅ Usar false y crear regla inversa manualmente
|
||||||
|
affects: ['at:sanitario', 'at:calidad'],
|
||||||
|
severity: 'error',
|
||||||
|
|
||||||
|
message: {
|
||||||
|
es: 'Los sanitarios negros requieren calidad premium o superior',
|
||||||
|
en: 'Black sanitary ware requires premium quality or higher'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prioridades
|
||||||
|
|
||||||
|
Las reglas se evalúan en orden de prioridad **descendente**:
|
||||||
|
|
||||||
|
- **10-15**: Reglas críticas (incompatibilidades técnicas)
|
||||||
|
- **5-9**: Reglas importantes (restricciones de negocio)
|
||||||
|
- **1-4**: Reglas sugerencias (recomendaciones estéticas)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo completo
|
||||||
|
|
||||||
|
Ver archivo completo: [`housing-catalog.fixture.ts`](../tests/fixtures/housing-catalog.fixture.ts)
|
||||||
|
|
||||||
|
### Estructura mínima
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const CATALOGO_MINIMO: ConfigurationCatalog = {
|
||||||
|
name: { es: 'Mi Catálogo', en: 'My Catalog' },
|
||||||
|
supportedLocales: ['es', 'en'],
|
||||||
|
defaultLocale: 'es',
|
||||||
|
|
||||||
|
// 1. Definir opciones
|
||||||
|
options: {
|
||||||
|
'op:color_rojo': {
|
||||||
|
id: 'op:color_rojo',
|
||||||
|
name: { es: 'Rojo', en: 'Red' },
|
||||||
|
description: { es: 'Color rojo', en: 'Red color' },
|
||||||
|
pricing: { baseAmount: 0 }
|
||||||
|
},
|
||||||
|
'op:color_azul': {
|
||||||
|
id: 'op:color_azul',
|
||||||
|
name: { es: 'Azul', en: 'Blue' },
|
||||||
|
description: { es: 'Color azul', en: 'Blue color' },
|
||||||
|
pricing: { baseAmount: 50 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. Definir objetos
|
||||||
|
objects: {
|
||||||
|
'ob:producto': {
|
||||||
|
id: 'ob:producto',
|
||||||
|
name: { es: 'Producto', en: 'Product' },
|
||||||
|
description: { es: 'Mi producto', en: 'My product' },
|
||||||
|
|
||||||
|
// Atributos globales
|
||||||
|
attributes: [
|
||||||
|
{
|
||||||
|
id: 'at:color',
|
||||||
|
name: { es: 'Color', en: 'Color' },
|
||||||
|
type: 'dynamic',
|
||||||
|
dataType: 'reference',
|
||||||
|
defaultValue: 'op:color_rojo',
|
||||||
|
options: [
|
||||||
|
{ optionId: 'op:color_rojo' },
|
||||||
|
{ optionId: 'op:color_azul' }
|
||||||
|
],
|
||||||
|
display: { uiVisible: true, affectsPrice: true }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Secciones
|
||||||
|
sections: {
|
||||||
|
'sc:principal': {
|
||||||
|
id: 'sc:principal',
|
||||||
|
name: { es: 'Principal', en: 'Main' },
|
||||||
|
description: { es: 'Sección principal', en: 'Main section' },
|
||||||
|
attrs: [],
|
||||||
|
visualConfig: {
|
||||||
|
strategy: 'static_image',
|
||||||
|
imageUrlTemplate: '/images/{at_color}.jpg'
|
||||||
|
},
|
||||||
|
availability: { mode: 'required' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sectionOrder: ['sc:principal']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. Definir reglas (opcional)
|
||||||
|
rules: {}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Recursos adicionales
|
||||||
|
|
||||||
|
- [Sistema de tipos](./TYPES.md)
|
||||||
|
- [Reglas de validación](./RULES.md)
|
||||||
|
- [i18n](./I18N.md)
|
||||||
|
- [Guía de creación](./CATALOG_GUIDE.md)
|
||||||
@ -0,0 +1,258 @@
|
|||||||
|
# CPQ Engine - Motor de Configuración de Producto
|
||||||
|
|
||||||
|
Sistema TypeScript type-safe para configuración de productos complejos (Configure-Price-Quote) con soporte i18n, reglas de validación declarativas y renderizado visual condicional.
|
||||||
|
|
||||||
|
## 🎯 Características principales
|
||||||
|
|
||||||
|
- **Type-safe**: Sistema de tipos robusto con IDs con prefijos branded
|
||||||
|
- **i18n nativo**: Soporte multiidioma en todos los elementos del modelo
|
||||||
|
- **Reglas declarativas**: Motor de validación basado en JsonLogic
|
||||||
|
- **Visual rendering**: Generación dinámica de imágenes basada en selecciones
|
||||||
|
- **Arquitectura modular**: Separación clara entre modelo, motor y utilidades
|
||||||
|
- **Testing completo**: >180 tests unitarios con Vitest
|
||||||
|
|
||||||
|
## 📦 Estructura del proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── types/ # Definiciones de tipos TypeScript
|
||||||
|
│ ├── core/ # Tipos base (Value, ID, JsonLogic)
|
||||||
|
│ ├── model/ # Tipos del modelo (Attribute, Section, Rule)
|
||||||
|
│ └── i18n/ # Sistema de internacionalización
|
||||||
|
├── engine/ # Motores de evaluación
|
||||||
|
│ ├── json-logic/ # Evaluador JsonLogic
|
||||||
|
│ └── rule-engine.ts # Motor de reglas de validación
|
||||||
|
├── utils/ # Utilidades
|
||||||
|
│ ├── ids/ # Sistema de IDs con prefijos
|
||||||
|
│ └── i18n/ # Funciones de traducción
|
||||||
|
├── messages/ # Sistema de mensajes centralizados
|
||||||
|
│ ├── errors.ts # Mensajes de error
|
||||||
|
│ ├── warnings.ts # Mensajes de advertencia
|
||||||
|
│ └── logger.ts # Logger estructurado
|
||||||
|
└── constants/ # Constantes globales
|
||||||
|
└── defaults.ts # Valores por defecto
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Inicio rápido
|
||||||
|
|
||||||
|
### Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejecutar tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Todos los tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Con coverage
|
||||||
|
npm test -- --coverage
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
npm test -- --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo básico
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CATALOGO_VIVIENDAS } from './fixtures/housing-catalog';
|
||||||
|
import { RuleEngine } from './engine/rule-engine';
|
||||||
|
|
||||||
|
// Obtener un objeto configurable
|
||||||
|
const apartamento = CATALOGO_VIVIENDAS.objects['ob:apartamento'];
|
||||||
|
|
||||||
|
// Crear estado de selección
|
||||||
|
const seleccion = {
|
||||||
|
'at:calidad': 'op:calidad_premium',
|
||||||
|
'at:suelo': 'op:suelo_parquet',
|
||||||
|
'at:sanitario': 'op:sanit_negro'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validar con reglas
|
||||||
|
const engine = new RuleEngine();
|
||||||
|
const reglas = Object.values(CATALOGO_VIVIENDAS.rules);
|
||||||
|
|
||||||
|
const esValido = engine.isValid(reglas, seleccion);
|
||||||
|
const violaciones = engine.getViolations(reglas, seleccion);
|
||||||
|
|
||||||
|
console.log('¿Configuración válida?', esValido);
|
||||||
|
console.log('Violaciones:', violaciones);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Documentación
|
||||||
|
|
||||||
|
- [**Modelo de datos**](./docs/MODEL.md) - Estructura completa del modelo
|
||||||
|
- [**Sistema de tipos**](./docs/TYPES.md) - Tipos TypeScript y branded IDs
|
||||||
|
- [**Reglas de validación**](./docs/RULES.md) - Cómo crear y usar reglas
|
||||||
|
- [**i18n**](./docs/I18N.md) - Sistema de internacionalización
|
||||||
|
- [**Motor de renderizado**](./docs/RENDERING.md) - Generación de imágenes
|
||||||
|
- [**Guía de creación**](./docs/CATALOG_GUIDE.md) - Cómo crear un catálogo completo
|
||||||
|
|
||||||
|
## 🏗️ Arquitectura
|
||||||
|
|
||||||
|
### Flujo de datos
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Catalog JSON │
|
||||||
|
│ (Definición) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐ ┌──────────────┐
|
||||||
|
│ Type System │────▶│ Validation │
|
||||||
|
│ (Compile-time) │ │ (Runtime) │
|
||||||
|
└────────┬────────┘ └──────┬───────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌──────────────┐
|
||||||
|
│ User Selection │────▶│ Rule Engine │
|
||||||
|
│ (State) │ │ (JsonLogic) │
|
||||||
|
└────────┬────────┘ └──────┬───────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌──────────────┐
|
||||||
|
│ Visual Render │ │ Pricing │
|
||||||
|
│ (Images) │ │ (Calculate) │
|
||||||
|
└─────────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capas
|
||||||
|
|
||||||
|
1. **Types Layer** - Definiciones de tipos, contratos
|
||||||
|
2. **Model Layer** - Estructura del catálogo (Objects, Sections, Attributes)
|
||||||
|
3. **Engine Layer** - Lógica de negocio (Rules, JsonLogic, Rendering)
|
||||||
|
4. **Utils Layer** - Funciones auxiliares (IDs, i18n, logging)
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
El proyecto tiene >180 tests organizados en:
|
||||||
|
|
||||||
|
- **Unit tests** - Utilidades individuales (IDs, i18n, JsonLogic)
|
||||||
|
- **Integration tests** - Motor de reglas completo
|
||||||
|
- **Fixture tests** - Catálogo de viviendas real
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver coverage
|
||||||
|
npm test -- --coverage
|
||||||
|
|
||||||
|
# Coverage actual: ~95%
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌍 i18n
|
||||||
|
|
||||||
|
Sistema de internacionalización con 9 idiomas soportados:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mensaje: I18nString = {
|
||||||
|
es: 'Hola mundo',
|
||||||
|
en: 'Hello world',
|
||||||
|
de: 'Hallo Welt',
|
||||||
|
fr: 'Bonjour le monde',
|
||||||
|
it: 'Ciao mondo',
|
||||||
|
pt: 'Olá mundo',
|
||||||
|
ca: 'Hola món',
|
||||||
|
eu: 'Kaixo mundua',
|
||||||
|
gl: 'Ola mundo'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Uso
|
||||||
|
const texto = translate(mensaje, 'es'); // → "Hola mundo"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Strings simples** también son válidos:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const simple: I18nString = 'OK'; // Válido en todos los idiomas
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Ejemplo de modelo real
|
||||||
|
|
||||||
|
El proyecto incluye un **catálogo completo de viviendas** como fixture:
|
||||||
|
|
||||||
|
- 2 modelos: Apartamento (4 secciones) y Dúplex (5 secciones)
|
||||||
|
- 24 opciones de acabados (suelos, paredes, sanitarios, etc.)
|
||||||
|
- 6 reglas de validación con prioridades
|
||||||
|
- Generación de imágenes con templates
|
||||||
|
- Pricing dinámico
|
||||||
|
|
||||||
|
Ver: [`housing-catalog.fixture.ts`](./tests/fixtures/housing-catalog.fixture.ts)
|
||||||
|
|
||||||
|
## 🔧 Tecnologías
|
||||||
|
|
||||||
|
- **TypeScript 5.x** - Lenguaje principal
|
||||||
|
- **Vitest** - Framework de testing
|
||||||
|
- **JsonLogic** - Motor de reglas declarativas
|
||||||
|
- **Branded Types** - IDs type-safe
|
||||||
|
|
||||||
|
## 📝 Convenciones
|
||||||
|
|
||||||
|
### IDs con prefijos
|
||||||
|
|
||||||
|
Todos los IDs usan prefijos branded:
|
||||||
|
|
||||||
|
| Tipo | Prefijo | Ejemplo |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Attribute | `at:` | `at:calidad` |
|
||||||
|
| Section | `sc:` | `sc:salon` |
|
||||||
|
| Option | `op:` | `op:premium` |
|
||||||
|
| Object | `ob:` | `ob:apartamento` |
|
||||||
|
| Rule | `rl:` | `rl:negro_premium` |
|
||||||
|
| View | `vw:` | `vw:front` |
|
||||||
|
| Hotspot | `hs:` | `hs:punto1` |
|
||||||
|
|
||||||
|
### Mensajes
|
||||||
|
|
||||||
|
Todos los mensajes de error/warning/info están **centralizados** en `src/messages/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ERRORS, logger } from '@/messages';
|
||||||
|
|
||||||
|
// ❌ NO hacer
|
||||||
|
throw new Error('Invalid ID format');
|
||||||
|
|
||||||
|
// ✅ Hacer
|
||||||
|
const errorMsg = ERRORS.INVALID_ID_FORMAT(id);
|
||||||
|
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id });
|
||||||
|
throw new Error(JSON.stringify(errorMsg));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
- **Types**: PascalCase (`AttributeDisplay`, `ValidationRule`)
|
||||||
|
- **Interfaces**: PascalCase con prefijo `I` opcional
|
||||||
|
- **Functions**: camelCase (`extractIdPart`, `isValid`)
|
||||||
|
- **Constants**: UPPER_SNAKE_CASE (`DEFAULTS`, `MESSAGE_CATEGORIES`)
|
||||||
|
- **Files**: kebab-case (`rule-engine.ts`, `id-parser.ts`)
|
||||||
|
|
||||||
|
## 🤝 Contribuir
|
||||||
|
|
||||||
|
1. Fork el proyecto
|
||||||
|
2. Crea una rama (`git checkout -b feature/amazing`)
|
||||||
|
3. Commit cambios (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Push a la rama (`git push origin feature/amazing`)
|
||||||
|
5. Abre un Pull Request
|
||||||
|
|
||||||
|
### Requisitos para PR
|
||||||
|
|
||||||
|
- ✅ Todos los tests pasan
|
||||||
|
- ✅ Coverage >90%
|
||||||
|
- ✅ Sin errores de TypeScript
|
||||||
|
- ✅ Mensajes centralizados (no inline)
|
||||||
|
- ✅ Documentación actualizada
|
||||||
|
|
||||||
|
## 📄 Licencia
|
||||||
|
|
||||||
|
MIT License - ver [LICENSE](LICENSE)
|
||||||
|
|
||||||
|
## 🙏 Agradecimientos
|
||||||
|
|
||||||
|
- [JsonLogic](http://jsonlogic.com/) - Motor de reglas declarativas
|
||||||
|
- [Vitest](https://vitest.dev/) - Framework de testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Versión**: 1.0.0
|
||||||
|
**Última actualización**: Febrero 2026
|
||||||
@ -0,0 +1,338 @@
|
|||||||
|
# Sistema de Vistas - Integración Completa
|
||||||
|
|
||||||
|
## 📐 Conceptos clave
|
||||||
|
|
||||||
|
Cada **Section** tiene múltiples **Views** (vistas) que representan diferentes ángulos o perspectivas visuales de esa sección.
|
||||||
|
|
||||||
|
```
|
||||||
|
Section (ej: Salón)
|
||||||
|
├── View: Front (frontal)
|
||||||
|
├── View: Side (lateral)
|
||||||
|
└── View: Top (cenital)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Estructura
|
||||||
|
|
||||||
|
### Section (actualizada)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Section {
|
||||||
|
id: SectionID;
|
||||||
|
name: I18nString;
|
||||||
|
attrs: Attribute[];
|
||||||
|
|
||||||
|
// ✅ NUEVO: Vistas disponibles (SIEMPRE presente)
|
||||||
|
views: Record<ViewID, SectionView>;
|
||||||
|
|
||||||
|
// ✅ NUEVO: Vista por defecto (SIEMPRE presente)
|
||||||
|
defaultView: ViewID;
|
||||||
|
|
||||||
|
availability: SectionAvailability;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SectionView
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SectionView {
|
||||||
|
id: ViewID; // 'vw:front', 'vw:side', 'vw:top'
|
||||||
|
name: I18nString;
|
||||||
|
description?: I18nString;
|
||||||
|
visualConfig: SectionVisualConfig; // Configuración ESPECÍFICA de esta vista
|
||||||
|
order?: number;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Ejemplo completo
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Section, SectionView } from '@/types';
|
||||||
|
|
||||||
|
const VIEW_FRONT : ViewID = 'vw:front';
|
||||||
|
const VIEW_SIDE : ViewID = 'vw:side';
|
||||||
|
const VIEW_TOP : ViewID = 'vw:top';
|
||||||
|
|
||||||
|
const seccionSalon: Section = {
|
||||||
|
id: 'sc:salon',
|
||||||
|
name: { es: 'Salón', en: 'Living room' },
|
||||||
|
description: { es: 'Salón - comedor principal', en: 'Main living - dining room' },
|
||||||
|
|
||||||
|
attrs: [
|
||||||
|
{
|
||||||
|
id: 'at:suelo',
|
||||||
|
name: { es: 'Suelo', en: 'Flooring' },
|
||||||
|
type: 'dynamic',
|
||||||
|
dataType: 'reference',
|
||||||
|
defaultValue: 'op:suelo_ceramica',
|
||||||
|
options: [
|
||||||
|
{ optionId: 'op:suelo_ceramica' },
|
||||||
|
{ optionId: 'op:suelo_parquet' }
|
||||||
|
],
|
||||||
|
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'at:pared',
|
||||||
|
name: { es: 'Color paredes', en: 'Wall color' },
|
||||||
|
type: 'dynamic',
|
||||||
|
dataType: 'reference',
|
||||||
|
defaultValue: 'op:pared_blanco',
|
||||||
|
options: [
|
||||||
|
{ optionId: 'op:pared_blanco' },
|
||||||
|
{ optionId: 'op:pared_gris' }
|
||||||
|
],
|
||||||
|
display: { uiVisible: true, affectsVisual: true }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// ✅ VISTAS: Diferentes ángulos de la misma sección
|
||||||
|
views: {
|
||||||
|
[VIEW_FRONT]: {
|
||||||
|
id: VIEW_FRONT,
|
||||||
|
name: { es: 'Vista frontal', en: 'Front view' },
|
||||||
|
description: { es: 'Vista de frente del salón', en: 'Front view of living room' },
|
||||||
|
visualConfig: {
|
||||||
|
strategy: 'static_image',
|
||||||
|
// Template incluye {view} para diferenciar ángulos
|
||||||
|
imageUrlTemplate: '{basePath}/{section}/{view}/{at_suelo}/{at_pared}.jpg',
|
||||||
|
basePath: '/renders/vivienda',
|
||||||
|
globalAttrDependencies: ['at:calidad'],
|
||||||
|
sectionAttrDependencies: ['at:suelo', 'at:pared']
|
||||||
|
},
|
||||||
|
order: 1,
|
||||||
|
icon: 'camera-front'
|
||||||
|
},
|
||||||
|
|
||||||
|
[VIEW_SIDE]: {
|
||||||
|
id: VIEW_SIDE,
|
||||||
|
name: { es: 'Vista lateral', en: 'Side view' },
|
||||||
|
description: { es: 'Vista lateral del salón', en: 'Side view of living room' },
|
||||||
|
visualConfig: {
|
||||||
|
strategy: 'static_image',
|
||||||
|
imageUrlTemplate: '{basePath}/{section}/{view}/{at_suelo}.jpg',
|
||||||
|
basePath: '/renders/vivienda',
|
||||||
|
globalAttrDependencies: ['at:calidad'],
|
||||||
|
// Solo el suelo afecta esta vista
|
||||||
|
sectionAttrDependencies: ['at:suelo']
|
||||||
|
},
|
||||||
|
order: 2,
|
||||||
|
icon: 'camera-side'
|
||||||
|
},
|
||||||
|
|
||||||
|
[VIEW_TOP]: {
|
||||||
|
id: VIEW_TOP,
|
||||||
|
name: { es: 'Vista cenital', en: 'Top view' },
|
||||||
|
description: { es: 'Vista desde arriba', en: 'Top-down view' },
|
||||||
|
visualConfig: {
|
||||||
|
strategy: 'static_image',
|
||||||
|
imageUrlTemplate: '{basePath}/{section}/{view}/{at_suelo}/{at_muebles}.jpg',
|
||||||
|
basePath: '/renders/vivienda',
|
||||||
|
globalAttrDependencies: ['at:calidad'],
|
||||||
|
// Vista cenital muestra suelo y distribución de muebles
|
||||||
|
sectionAttrDependencies: ['at:suelo', 'at:muebles']
|
||||||
|
},
|
||||||
|
order: 3,
|
||||||
|
icon: 'camera-top'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ✅ Vista por defecto (la primera que se muestra al usuario)
|
||||||
|
defaultView: VIEW_FRONT,
|
||||||
|
|
||||||
|
availability: { mode: 'required' }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Generación de URLs
|
||||||
|
|
||||||
|
### Placeholders en `imageUrlTemplate`
|
||||||
|
|
||||||
|
El template soporta estos placeholders:
|
||||||
|
|
||||||
|
| Placeholder | Reemplazado por | Ejemplo |
|
||||||
|
|-------------|----------------|---------|
|
||||||
|
| `{basePath}` | basePath de la config | `/renders/vivienda` |
|
||||||
|
| `{section}` | ID de sección sin prefijo | `salon` (de `sc:salon`) |
|
||||||
|
| `{view}` | ID de vista sin prefijo | `front` (de `vw:front`) |
|
||||||
|
| `{at_<id>}` | Valor del atributo normalizado | `parquet` (de `at:suelo` = `op:suelo_parquet`) |
|
||||||
|
|
||||||
|
### Ejemplo de resolución
|
||||||
|
|
||||||
|
**Estado:**
|
||||||
|
```typescript
|
||||||
|
const state = {
|
||||||
|
'at:suelo': 'op:suelo_parquet',
|
||||||
|
'at:pared': 'op:pared_gris',
|
||||||
|
'at:calidad': 'op:calidad_premium'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```
|
||||||
|
{basePath}/{section}/{view}/{at_suelo}/{at_pared}.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
**URL resultante para vista frontal:**
|
||||||
|
```
|
||||||
|
/renders/vivienda/salon/front/parquet/gris.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
**URL resultante para vista lateral:**
|
||||||
|
```
|
||||||
|
/renders/vivienda/salon/side/parquet.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Uso en el UI
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Obtener sección
|
||||||
|
const salon = apartamento.sections['sc:salon'];
|
||||||
|
|
||||||
|
// Listar todas las vistas disponibles
|
||||||
|
const vistasFront = Object.values(salon.views)
|
||||||
|
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
|
|
||||||
|
console.log(vistasFront);
|
||||||
|
// [
|
||||||
|
// { id: 'vw:front', name: { es: 'Vista frontal' }, ... },
|
||||||
|
// { id: 'vw:side', name: { es: 'Vista lateral' }, ... },
|
||||||
|
// { id: 'vw:top', name: { es: 'Vista cenital' }, ... }
|
||||||
|
// ]
|
||||||
|
|
||||||
|
// Vista activa (por defecto)
|
||||||
|
const vistaActual = salon.views[salon.defaultView];
|
||||||
|
|
||||||
|
// Cambiar vista
|
||||||
|
const nuevaVista = salon.views['vw:side'];
|
||||||
|
|
||||||
|
// Generar URL de la vista actual
|
||||||
|
const urlImagen = generarImagenUrl(
|
||||||
|
salon.id,
|
||||||
|
vistaActual.id,
|
||||||
|
vistaActual.visualConfig,
|
||||||
|
state
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏭 Estrategias de renderizado por vista
|
||||||
|
|
||||||
|
Cada vista puede tener su propia estrategia:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
views: {
|
||||||
|
'vw:front': {
|
||||||
|
id: 'vw:front',
|
||||||
|
name: { es: 'Frontal', en: 'Front' },
|
||||||
|
visualConfig: {
|
||||||
|
strategy: 'static_image', // ← Imagen estática
|
||||||
|
imageUrlTemplate: '...'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'vw:360': {
|
||||||
|
id: 'vw:360',
|
||||||
|
name: { es: 'Vista 360º', en: '360º view' },
|
||||||
|
visualConfig: {
|
||||||
|
strategy: 'three_d', // ← Modelo 3D interactivo
|
||||||
|
threeDConfig: {
|
||||||
|
modelUrl: '/models/salon.glb',
|
||||||
|
textureAttrs: [
|
||||||
|
{ attrId: 'at:suelo', scope: 'section' },
|
||||||
|
{ attrId: 'at:pared', scope: 'section' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'vw:ar': {
|
||||||
|
id: 'vw:ar',
|
||||||
|
name: { es: 'Realidad Aumentada', en: 'AR' },
|
||||||
|
visualConfig: {
|
||||||
|
strategy: 'api_generated', // ← API externa
|
||||||
|
apiConfig: {
|
||||||
|
endpoint: 'https://ar.example.com/generate',
|
||||||
|
method: 'POST',
|
||||||
|
timeout: 10000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 Vistas contextuales
|
||||||
|
|
||||||
|
Las vistas pueden mostrarse condicionalmente:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'vw:detail_plumbing',
|
||||||
|
name: { es: 'Detalle fontanería', en: 'Plumbing detail' },
|
||||||
|
visualConfig: {
|
||||||
|
strategy: 'static_image',
|
||||||
|
imageUrlTemplate: '{basePath}/{section}/detail/{at_sanitario}.jpg'
|
||||||
|
},
|
||||||
|
// Nota: No hay campo 'visible' aquí porque las vistas
|
||||||
|
// siempre están disponibles. El UI decide si mostrarlas.
|
||||||
|
order: 10 // Ordenar al final
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔑 Claves de diseño
|
||||||
|
|
||||||
|
### 1. Cada sección DEBE tener al menos una vista
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ MAL
|
||||||
|
views: {}
|
||||||
|
|
||||||
|
// ✅ BIEN
|
||||||
|
views: {
|
||||||
|
'vw:default': {
|
||||||
|
id: 'vw:default',
|
||||||
|
name: { es: 'Vista principal', en: 'Main view' },
|
||||||
|
visualConfig: { /* ... */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. defaultView DEBE existir en views
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ MAL
|
||||||
|
views: { 'vw:front': { /* ... */ } },
|
||||||
|
defaultView: 'vw:side' // ← No existe!
|
||||||
|
|
||||||
|
// ✅ BIEN
|
||||||
|
views: {
|
||||||
|
'vw:front': { /* ... */ },
|
||||||
|
'vw:side': { /* ... */ }
|
||||||
|
},
|
||||||
|
defaultView: 'vw:front' // ← Existe
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Los atributos que afectan CUALQUIER vista deben estar en dependencies
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Vista frontal usa: suelo, pared
|
||||||
|
// Vista lateral usa: suelo
|
||||||
|
// Vista cenital usa: suelo, muebles
|
||||||
|
|
||||||
|
// En la sección, marca affectsVisual: true para todos
|
||||||
|
attrs: [
|
||||||
|
{ id: 'at:suelo', display: { affectsVisual: true } }, // ✅ Afecta 3 vistas
|
||||||
|
{ id: 'at:pared', display: { affectsVisual: true } }, // ✅ Afecta 1 vista
|
||||||
|
{ id: 'at:muebles', display: { affectsVisual: true } } // ✅ Afecta 1 vista
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Ventajas del sistema de vistas
|
||||||
|
|
||||||
|
✅ **Flexibilidad**: Cada vista puede tener diferente estrategia de renderizado
|
||||||
|
✅ **Optimización**: Solo cargar assets de la vista activa
|
||||||
|
✅ **Claridad**: Separación explícita de ángulos/perspectivas
|
||||||
|
✅ **Escalabilidad**: Añadir nuevas vistas sin modificar la estructura
|
||||||
|
✅ **UX**: Usuario puede cambiar de vista según necesidad
|
||||||
|
|
||||||
|
## 📚 Recursos
|
||||||
|
|
||||||
|
- [Modelo completo](./MODEL.md)
|
||||||
|
- [Visual Rendering](./RENDERING.md)
|
||||||
|
- [URL Builder](./URL_BUILDER.md)
|
||||||
@ -0,0 +1,346 @@
|
|||||||
|
<!--
|
||||||
|
============================================================================
|
||||||
|
CONFIGURATOR - Example Svelte Component
|
||||||
|
============================================================================
|
||||||
|
|
||||||
|
Ejemplo de cómo usar el ConfigurationStore en un componente Svelte.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { createConfigurationStore } from '@/svelte/stores';
|
||||||
|
import type { ConfigurationCatalog } from '@/types';
|
||||||
|
|
||||||
|
// Props
|
||||||
|
export let catalog: ConfigurationCatalog;
|
||||||
|
export let objectId: string;
|
||||||
|
|
||||||
|
// Crear el store
|
||||||
|
const config = createConfigurationStore({
|
||||||
|
catalog,
|
||||||
|
objectId,
|
||||||
|
autoValidate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Acceso reactivo al estado
|
||||||
|
$: currentSection = $config.currentSection;
|
||||||
|
$: currentView = $config.currentView;
|
||||||
|
$: selection = $config.selection;
|
||||||
|
$: validation = $config.validation;
|
||||||
|
$: isValid = $config.isValid;
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
function handleOptionSelect(attrId: string, optionId: string) {
|
||||||
|
config.selectOption(attrId, optionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSectionChange(sectionId: string) {
|
||||||
|
config.setCurrentSection(sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewChange(viewId: string) {
|
||||||
|
config.setCurrentView(viewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
config.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUndo() {
|
||||||
|
config.undo();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="configurator">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="configurator-header">
|
||||||
|
<h1>{$config.object.name.es}</h1>
|
||||||
|
<p>{$config.object.description.es}</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button on:click={handleUndo}>
|
||||||
|
⬅️ Deshacer
|
||||||
|
</button>
|
||||||
|
<button on:click={handleReset}>
|
||||||
|
🔄 Resetear
|
||||||
|
</button>
|
||||||
|
<button disabled={!isValid}>
|
||||||
|
✅ Confirmar configuración
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="configurator-content">
|
||||||
|
<!-- Section Navigation -->
|
||||||
|
<nav class="section-nav">
|
||||||
|
<h2>Secciones</h2>
|
||||||
|
<ul>
|
||||||
|
{#each Object.values($config.object.sections) as section}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class:active={section.id === currentSection?.id}
|
||||||
|
on:click={() => handleSectionChange(section.id)}
|
||||||
|
>
|
||||||
|
{section.icon || '📦'} {section.name.es}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Attributes Panel -->
|
||||||
|
<div class="attributes-panel">
|
||||||
|
{#if currentSection}
|
||||||
|
<h2>{currentSection.name.es}</h2>
|
||||||
|
<p>{currentSection.description.es}</p>
|
||||||
|
|
||||||
|
<!-- Atributos de la sección -->
|
||||||
|
{#each currentSection.attrs as attr}
|
||||||
|
{#if attr.type === 'dynamic' && attr.display?.uiVisible}
|
||||||
|
<div class="attribute">
|
||||||
|
<label for={attr.id}>
|
||||||
|
{attr.name.es}
|
||||||
|
{#if config.isRequired(attr.id)}
|
||||||
|
<span class="required">*</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
id={attr.id}
|
||||||
|
value={selection[attr.id] || ''}
|
||||||
|
on:change={(e) => handleOptionSelect(attr.id, e.target.value)}
|
||||||
|
class:error={config.hasErrorsForAttribute(attr.id)}
|
||||||
|
>
|
||||||
|
<option value="">-- Seleccionar --</option>
|
||||||
|
{#each attr.options as opt}
|
||||||
|
{@const option = catalog.options[opt.optionId]}
|
||||||
|
{#if option}
|
||||||
|
<option value={option.id}>
|
||||||
|
{option.name.es}
|
||||||
|
{#if option.pricing?.baseAmount}
|
||||||
|
(+{option.pricing.baseAmount}€)
|
||||||
|
{/if}
|
||||||
|
</option>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Violaciones -->
|
||||||
|
{#each config.getViolationsForAttribute(attr.id) as violation}
|
||||||
|
<div class="violation {violation.severity}">
|
||||||
|
{violation.rule.message.es}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visual Preview -->
|
||||||
|
<div class="visual-panel">
|
||||||
|
{#if currentView}
|
||||||
|
<div class="view-selector">
|
||||||
|
<h3>Vistas</h3>
|
||||||
|
{#each Object.values(currentSection.views || {}) as view}
|
||||||
|
<button
|
||||||
|
class:active={view.id === currentView.id}
|
||||||
|
on:click={() => handleViewChange(view.id)}
|
||||||
|
>
|
||||||
|
{view.icon || '📷'} {view.name.es}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview">
|
||||||
|
<h3>{currentView.name.es}</h3>
|
||||||
|
<!-- Aquí iría el renderizado de la imagen -->
|
||||||
|
<div class="image-placeholder">
|
||||||
|
<p>Vista: {currentView.id}</p>
|
||||||
|
<p>Template: {currentView.visualConfig.imageUrlTemplate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="no-visual">
|
||||||
|
<p>Esta sección no tiene visualización</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Validation Summary -->
|
||||||
|
{#if validation}
|
||||||
|
<footer class="validation-summary">
|
||||||
|
{#if validation.hasErrors}
|
||||||
|
<div class="errors">
|
||||||
|
❌ {validation.violations.filter(v => v.severity === 'error').length} errores
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if validation.hasWarnings}
|
||||||
|
<div class="warnings">
|
||||||
|
⚠️ {validation.violations.filter(v => v.severity === 'warning').length} advertencias
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if validation.isValid}
|
||||||
|
<div class="success">
|
||||||
|
✅ Configuración válida
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.configurator {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configurator-header {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configurator-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr 1fr;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-nav {
|
||||||
|
border-right: 1px solid #e0e0e0;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-nav ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-nav button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-nav button:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-nav button.active {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attributes-panel {
|
||||||
|
padding: 2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute select.error {
|
||||||
|
border-color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.violation {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.violation.error {
|
||||||
|
background: #fee;
|
||||||
|
color: #c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.violation.warning {
|
||||||
|
background: #ffc;
|
||||||
|
color: #840;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-panel {
|
||||||
|
padding: 2rem;
|
||||||
|
background: #f9f9f9;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector button {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector button.active {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-placeholder {
|
||||||
|
background: #ddd;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errors {
|
||||||
|
color: #c00;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings {
|
||||||
|
color: #840;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #080;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* SVELTE CONFIGURATION STORE
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Adapter que expone ConfigurationState como Svelte store.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable, derived, type Readable } from 'svelte/store';
|
||||||
|
import type {
|
||||||
|
ConfigurableObject,
|
||||||
|
ConfigurationCatalog,
|
||||||
|
AttrID,
|
||||||
|
OptionID,
|
||||||
|
SectionID,
|
||||||
|
ViewID,
|
||||||
|
Section,
|
||||||
|
SectionView
|
||||||
|
} from '@/types';
|
||||||
|
import {
|
||||||
|
ConfigurationState,
|
||||||
|
type ConfigurationStateOptions
|
||||||
|
} from '@/state/ConfigurationState';
|
||||||
|
import type { ValidationResult } from '@/state/ValidationState';
|
||||||
|
import type { SelectionStateData } from '@/state/SelectionState';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STORE STATE INTERFACE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ConfigurationStoreState {
|
||||||
|
catalog : ConfigurationCatalog;
|
||||||
|
object : ConfigurableObject;
|
||||||
|
selection : SelectionStateData;
|
||||||
|
validation : ValidationResult | null;
|
||||||
|
currentSection : Section | null;
|
||||||
|
currentSectionId: SectionID | null;
|
||||||
|
currentView : SectionView | null;
|
||||||
|
currentViewId : ViewID | null;
|
||||||
|
isValid : boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONFIGURATION STORE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un store de configuración reactivo para Svelte
|
||||||
|
*/
|
||||||
|
export function createConfigurationStore(options: ConfigurationStateOptions) {
|
||||||
|
// Estado core (framework agnostic)
|
||||||
|
const state = new ConfigurationState(options);
|
||||||
|
|
||||||
|
// Store interno con versión para forzar actualizaciones
|
||||||
|
const { subscribe, set, update } = writable(getStoreState());
|
||||||
|
|
||||||
|
// Función helper para obtener el estado actual del store
|
||||||
|
function getStoreState(): ConfigurationStoreState {
|
||||||
|
return {
|
||||||
|
catalog : state.getCatalog(),
|
||||||
|
object : state.getObject(),
|
||||||
|
selection : state.getAllSelections(),
|
||||||
|
validation : state.getValidationResult(),
|
||||||
|
currentSection : state.getCurrentSection(),
|
||||||
|
currentSectionId: state.getCurrentSectionId(),
|
||||||
|
currentView : state.getCurrentView(),
|
||||||
|
currentViewId : state.getCurrentViewId(),
|
||||||
|
isValid : state.isValid()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suscribirse a cambios del estado core
|
||||||
|
state.subscribe(() => {
|
||||||
|
set(getStoreState());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PUBLIC API
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
// Selection actions
|
||||||
|
selectOption: (attrId: AttrID, optionId: OptionID) => {
|
||||||
|
state.selectOption(attrId, optionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSelection: (attrId: AttrID) => {
|
||||||
|
state.clearSelection(attrId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation actions
|
||||||
|
setCurrentSection: (sectionId: SectionID) => {
|
||||||
|
state.setCurrentSection(sectionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentView: (viewId: ViewID) => {
|
||||||
|
state.setCurrentView(viewId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Validation actions
|
||||||
|
validate: () => {
|
||||||
|
return state.validate();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
getSelection: (attrId: AttrID) => {
|
||||||
|
return state.getSelection(attrId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllowedValues: (attrId: AttrID) => {
|
||||||
|
return state.getAllowedValues(attrId);
|
||||||
|
},
|
||||||
|
|
||||||
|
isRequired: (attrId: AttrID) => {
|
||||||
|
return state.isRequired(attrId);
|
||||||
|
},
|
||||||
|
|
||||||
|
hasErrorsForAttribute: (attrId: AttrID) => {
|
||||||
|
return state.hasErrorsForAttribute(attrId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getViolationsForAttribute: (attrId: AttrID) => {
|
||||||
|
return state.getViolationsForAttribute(attrId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Persistence
|
||||||
|
toJSON: () => state.toJSON(),
|
||||||
|
|
||||||
|
fromJSON: (data: ReturnType<typeof state.toJSON>) => {
|
||||||
|
state.fromJSON(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
reset: () => state.reset(),
|
||||||
|
undo: () => state.undo(),
|
||||||
|
|
||||||
|
// Direct access to state (for advanced use)
|
||||||
|
_state: state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DERIVED STORES (COMPUTED VALUES)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived store para la sección actual
|
||||||
|
*/
|
||||||
|
export function deriveCurrentSection(
|
||||||
|
configStore: ReturnType<typeof createConfigurationStore>
|
||||||
|
): Readable<Section | null> {
|
||||||
|
return derived(configStore, $config => $config.currentSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived store para la vista actual
|
||||||
|
*/
|
||||||
|
export function deriveCurrentView(
|
||||||
|
configStore: ReturnType<typeof createConfigurationStore>
|
||||||
|
): Readable<SectionView | null> {
|
||||||
|
return derived(configStore, $config => $config.currentView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived store para validación
|
||||||
|
*/
|
||||||
|
export function deriveValidation(
|
||||||
|
configStore: ReturnType<typeof createConfigurationStore>
|
||||||
|
): Readable<ValidationResult | null> {
|
||||||
|
return derived(configStore, $config => $config.validation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived store para verificar si es válido
|
||||||
|
*/
|
||||||
|
export function deriveIsValid(
|
||||||
|
configStore: ReturnType<typeof createConfigurationStore>
|
||||||
|
): Readable<boolean> {
|
||||||
|
return derived(configStore, $config => $config.isValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived store para selección
|
||||||
|
*/
|
||||||
|
export function deriveSelection(
|
||||||
|
configStore: ReturnType<typeof createConfigurationStore>
|
||||||
|
): Readable<SelectionStateData> {
|
||||||
|
return derived(configStore, $config => $config.selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPE EXPORTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ConfigurationStore = ReturnType<typeof createConfigurationStore>;
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* VALIDATION MESSAGES - Section & Object
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { I18nString } from '@/types';
|
||||||
|
|
||||||
|
export const VALIDATION_SECTION = {
|
||||||
|
// Section - Views coherence
|
||||||
|
HAS_VIEWS_NO_DEFAULT: (): I18nString => ({
|
||||||
|
es: 'La sección tiene vistas pero no tiene defaultView',
|
||||||
|
en: 'Section has views but no defaultView'
|
||||||
|
}),
|
||||||
|
|
||||||
|
HAS_DEFAULT_NO_VIEWS: (): I18nString => ({
|
||||||
|
es: 'La sección tiene defaultView pero no tiene vistas',
|
||||||
|
en: 'Section has defaultView but no views'
|
||||||
|
}),
|
||||||
|
|
||||||
|
DEFAULT_VIEW_NOT_FOUND: (viewId: string): I18nString => ({
|
||||||
|
es: `La vista por defecto '${viewId}' no existe en las vistas disponibles`,
|
||||||
|
en: `Default view '${viewId}' not found in available views`
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Section - Visual coherence
|
||||||
|
VISUAL_ATTRS_NO_VIEWS: (): I18nString => ({
|
||||||
|
es: 'La sección tiene atributos con affectsVisual=true pero no tiene vistas. Los cambios visuales no tendrán efecto.',
|
||||||
|
en: 'Section has attributes with affectsVisual=true but no views. Visual changes will have no effect.'
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Section - Attributes
|
||||||
|
DUPLICATE_ATTRIBUTE_ID: (attrId: string): I18nString => ({
|
||||||
|
es: `ID de atributo duplicado: ${attrId}`,
|
||||||
|
en: `Duplicate attribute ID: ${attrId}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
// View - Dependencies
|
||||||
|
DEPENDENCY_NOT_IN_SECTION: (attrId: string): I18nString => ({
|
||||||
|
es: `La dependencia '${attrId}' no existe en los atributos de la sección`,
|
||||||
|
en: `Dependency '${attrId}' not found in section attributes`
|
||||||
|
}),
|
||||||
|
|
||||||
|
// View - Template validation
|
||||||
|
TEMPLATE_ATTR_NOT_IN_SECTION: (attrId: string): I18nString => ({
|
||||||
|
es: `El template referencia el atributo '${attrId}' que no existe en la sección`,
|
||||||
|
en: `Template references attribute '${attrId}' not found in section`
|
||||||
|
}),
|
||||||
|
|
||||||
|
TEMPLATE_ATTR_NOT_IN_DEPS: (attrId: string): I18nString => ({
|
||||||
|
es: `El template usa '${attrId}' pero no está en sectionAttrDependencies`,
|
||||||
|
en: `Template uses '${attrId}' but it's not in sectionAttrDependencies`
|
||||||
|
}),
|
||||||
|
|
||||||
|
UNKNOWN_PLACEHOLDER: (placeholder: string): I18nString => ({
|
||||||
|
es: `Placeholder desconocido '${placeholder}' en el template`,
|
||||||
|
en: `Unknown placeholder '${placeholder}' in template`
|
||||||
|
})
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const VALIDATION_OBJECT = {
|
||||||
|
// Object - Visual Strategy
|
||||||
|
MISSING_VISUAL_STRATEGY: (): I18nString => ({
|
||||||
|
es: 'El objeto no tiene visualStrategy definida',
|
||||||
|
en: 'Object missing visualStrategy'
|
||||||
|
}),
|
||||||
|
|
||||||
|
VISUAL_SECTIONS_WITH_NONE_STRATEGY: (): I18nString => ({
|
||||||
|
es: 'El objeto tiene secciones con vistas pero visualStrategy es "none"',
|
||||||
|
en: 'Object has visual sections but visualStrategy is "none"'
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Object - BasePath
|
||||||
|
MISSING_BASE_PATH: (strategy: string): I18nString => ({
|
||||||
|
es: `La estrategia '${strategy}' requiere basePath a nivel de objeto o vista`,
|
||||||
|
en: `visualStrategy '${strategy}' requires basePath at object or view level`
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Object - API Config
|
||||||
|
MISSING_API_CONFIG: (): I18nString => ({
|
||||||
|
es: 'La estrategia "api_generated" requiere apiConfig a nivel de objeto o vista',
|
||||||
|
en: 'visualStrategy "api_generated" requires apiConfig at object or view level'
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Object - Section Order
|
||||||
|
SECTION_ORDER_UNKNOWN_SECTION: (sectionId: string): I18nString => ({
|
||||||
|
es: `sectionOrder referencia una sección desconocida: '${sectionId}'`,
|
||||||
|
en: `sectionOrder references unknown section '${sectionId}'`
|
||||||
|
})
|
||||||
|
} as const;
|
||||||
@ -0,0 +1,377 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* CONFIGURATION STATE - Framework Agnostic
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Orquesta todo el estado de la configuración.
|
||||||
|
* Coordina SelectionState, ValidationState, y el modelo del catálogo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ConfigurableObject,
|
||||||
|
ConfigurationCatalog,
|
||||||
|
AttrID,
|
||||||
|
OptionID,
|
||||||
|
SectionID,
|
||||||
|
ViewID, ObjectID
|
||||||
|
} from '@/types';
|
||||||
|
import { SelectionState, type SelectionStateChangeEvent } from './SelectionState';
|
||||||
|
import { ValidationState, type ValidationResult } from './ValidationState';
|
||||||
|
|
||||||
|
export interface ConfigurationStateOptions {
|
||||||
|
catalog: ConfigurationCatalog;
|
||||||
|
objectId: string;
|
||||||
|
autoValidate?: boolean; // Auto-validar en cada cambio (default: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado completo de la configuración
|
||||||
|
*/
|
||||||
|
export class ConfigurationState {
|
||||||
|
// Core state
|
||||||
|
private catalog: ConfigurationCatalog;
|
||||||
|
private object: ConfigurableObject;
|
||||||
|
private selection: SelectionState;
|
||||||
|
private validation: ValidationState;
|
||||||
|
|
||||||
|
// Options
|
||||||
|
private autoValidate: boolean;
|
||||||
|
|
||||||
|
// Current UI state
|
||||||
|
private currentSectionId: SectionID | null = null;
|
||||||
|
private currentViewId: ViewID | null = null;
|
||||||
|
|
||||||
|
// Listeners
|
||||||
|
private stateChangeListeners: Set<() => void> = new Set();
|
||||||
|
|
||||||
|
constructor(options: ConfigurationStateOptions) {
|
||||||
|
this.catalog = options.catalog;
|
||||||
|
this.autoValidate = options.autoValidate ?? true;
|
||||||
|
|
||||||
|
// Buscar el objeto
|
||||||
|
const obj = this.catalog.objects[options.objectId as ObjectID];
|
||||||
|
if (!obj) {
|
||||||
|
throw new Error(`Object ${options.objectId} not found in catalog`);
|
||||||
|
}
|
||||||
|
this.object = obj;
|
||||||
|
|
||||||
|
// Inicializar estados
|
||||||
|
this.selection = new SelectionState();
|
||||||
|
this.validation = new ValidationState();
|
||||||
|
|
||||||
|
// Configurar reglas de validación
|
||||||
|
if (this.catalog.rules) {
|
||||||
|
this.validation.setRules(Object.values(this.catalog.rules));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suscribirse a cambios de selección para auto-validar
|
||||||
|
this.selection.subscribe((event) => {
|
||||||
|
if (this.autoValidate) {
|
||||||
|
this.validation.validate(this.selection.getAll());
|
||||||
|
}
|
||||||
|
this.notifyStateChange();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suscribirse a cambios de validación
|
||||||
|
this.validation.subscribe(() => {
|
||||||
|
this.notifyStateChange();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inicializar con valores por defecto
|
||||||
|
this.initializeDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// INITIALIZATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicializa con valores por defecto del objeto
|
||||||
|
*/
|
||||||
|
private initializeDefaults(): void {
|
||||||
|
// Atributos globales
|
||||||
|
this.object.attributes.forEach(attr => {
|
||||||
|
if (attr.type === 'dynamic' && attr.defaultValue) {
|
||||||
|
this.selection.set(attr.id, attr.defaultValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atributos de secciones
|
||||||
|
Object.values(this.object.sections).forEach(section => {
|
||||||
|
section.attrs.forEach(attr => {
|
||||||
|
if (attr.type === 'dynamic' && attr.defaultValue) {
|
||||||
|
this.selection.set(attr.id, attr.defaultValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Establecer primera sección como actual
|
||||||
|
const firstSectionId = this.object.sectionOrder?.[0] ||
|
||||||
|
Object.keys(this.object.sections)[0] as SectionID;
|
||||||
|
|
||||||
|
if (firstSectionId) {
|
||||||
|
this.setCurrentSection(firstSectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// CATALOG & OBJECT
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el catálogo completo
|
||||||
|
*/
|
||||||
|
getCatalog(): ConfigurationCatalog {
|
||||||
|
return this.catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el objeto actual
|
||||||
|
*/
|
||||||
|
getObject(): ConfigurableObject {
|
||||||
|
return this.object;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// SELECTION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selecciona una opción para un atributo
|
||||||
|
*/
|
||||||
|
selectOption(attrId: AttrID, optionId: OptionID): void {
|
||||||
|
// Verificar si el valor está permitido
|
||||||
|
if (!this.validation.isValueAllowed(attrId, optionId, this.selection.getAll())) {
|
||||||
|
console.warn(`Option ${optionId} is not allowed for attribute ${attrId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selection.set(attrId, optionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia la selección de un atributo
|
||||||
|
*/
|
||||||
|
clearSelection(attrId: AttrID): void {
|
||||||
|
this.selection.clear(attrId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el valor seleccionado de un atributo
|
||||||
|
*/
|
||||||
|
getSelection(attrId: AttrID): OptionID | undefined {
|
||||||
|
return this.selection.get(attrId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene toda la selección
|
||||||
|
*/
|
||||||
|
getAllSelections(): Record<AttrID, OptionID> {
|
||||||
|
return this.selection.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// VALIDATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida el estado actual
|
||||||
|
*/
|
||||||
|
validate(): ValidationResult {
|
||||||
|
return this.validation.validate(this.selection.getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el resultado de la última validación
|
||||||
|
*/
|
||||||
|
getValidationResult(): ValidationResult | null {
|
||||||
|
return this.validation.getLastResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si la configuración es válida
|
||||||
|
*/
|
||||||
|
isValid(): boolean {
|
||||||
|
const result = this.validation.getLastResult();
|
||||||
|
return result?.isValid ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene violaciones para un atributo
|
||||||
|
*/
|
||||||
|
getViolationsForAttribute(attrId: AttrID) {
|
||||||
|
return this.validation.getViolationsForAttribute(attrId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un atributo tiene errores
|
||||||
|
*/
|
||||||
|
hasErrorsForAttribute(attrId: AttrID): boolean {
|
||||||
|
return this.validation.hasErrorsForAttribute(attrId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene valores permitidos para un atributo
|
||||||
|
*/
|
||||||
|
getAllowedValues(attrId: AttrID): OptionID[] | undefined {
|
||||||
|
return this.validation.getAllowedValues(attrId, this.selection.getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un atributo es requerido
|
||||||
|
*/
|
||||||
|
isRequired(attrId: AttrID): boolean {
|
||||||
|
return this.validation.isRequired(attrId, this.selection.getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// NAVIGATION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establece la sección actual
|
||||||
|
*/
|
||||||
|
setCurrentSection(sectionId: SectionID): void {
|
||||||
|
const section = this.object.sections[sectionId];
|
||||||
|
if (!section) {
|
||||||
|
console.warn(`Section ${sectionId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentSectionId = sectionId;
|
||||||
|
|
||||||
|
// Establecer vista por defecto si tiene vistas
|
||||||
|
if (section.views && section.defaultView) {
|
||||||
|
this.currentViewId = section.defaultView;
|
||||||
|
} else {
|
||||||
|
this.currentViewId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifyStateChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la sección actual
|
||||||
|
*/
|
||||||
|
getCurrentSection() {
|
||||||
|
return this.currentSectionId
|
||||||
|
? this.object.sections[this.currentSectionId]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establece la vista actual
|
||||||
|
*/
|
||||||
|
setCurrentView(viewId: ViewID): void {
|
||||||
|
const section = this.getCurrentSection();
|
||||||
|
if (!section?.views?.[viewId]) {
|
||||||
|
console.warn(`View ${viewId} not found in current section`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentViewId = viewId;
|
||||||
|
this.notifyStateChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la vista actual
|
||||||
|
*/
|
||||||
|
getCurrentView() {
|
||||||
|
const section = this.getCurrentSection();
|
||||||
|
return this.currentViewId && section?.views
|
||||||
|
? section.views[this.currentViewId]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el ID de la sección actual
|
||||||
|
*/
|
||||||
|
getCurrentSectionId(): SectionID | null {
|
||||||
|
return this.currentSectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el ID de la vista actual
|
||||||
|
*/
|
||||||
|
getCurrentViewId(): ViewID | null {
|
||||||
|
return this.currentViewId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// SUBSCRIPTION
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suscribe a cambios de estado
|
||||||
|
*/
|
||||||
|
subscribe(listener: () => void): () => void {
|
||||||
|
this.stateChangeListeners.add(listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.stateChangeListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifica cambios de estado
|
||||||
|
*/
|
||||||
|
private notifyStateChange(): void {
|
||||||
|
this.stateChangeListeners.forEach(listener => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PERSISTENCE
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializa el estado a JSON
|
||||||
|
*/
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
objectId: this.object.id,
|
||||||
|
selection: this.selection.toJSON(),
|
||||||
|
currentSectionId: this.currentSectionId,
|
||||||
|
currentViewId: this.currentViewId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restaura el estado desde JSON
|
||||||
|
*/
|
||||||
|
fromJSON(data: ReturnType<typeof this.toJSON>): void {
|
||||||
|
this.selection.fromJSON(data.selection);
|
||||||
|
|
||||||
|
if (data.currentSectionId) {
|
||||||
|
this.setCurrentSection(data.currentSectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.currentViewId) {
|
||||||
|
this.setCurrentView(data.currentViewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-validar
|
||||||
|
if (this.autoValidate) {
|
||||||
|
this.validation.validate(this.selection.getAll());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// UTILITIES
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resetea toda la configuración
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.selection.clearAll();
|
||||||
|
this.validation.clear();
|
||||||
|
this.initializeDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deshace el último cambio
|
||||||
|
*/
|
||||||
|
undo(): boolean {
|
||||||
|
return this.selection.undo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* SELECTION STATE - Framework Agnostic
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Gestiona el estado de selección de opciones.
|
||||||
|
* No depende de ningún framework, puede usarse con Svelte, React, Vue, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AttrID, OptionID, SectionID } from '@/types';
|
||||||
|
|
||||||
|
export type SelectionStateData = Record<AttrID, OptionID>;
|
||||||
|
|
||||||
|
export interface SelectionStateChangeEvent {
|
||||||
|
attrId: AttrID;
|
||||||
|
previousValue?: OptionID;
|
||||||
|
newValue?: OptionID;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de selección de opciones
|
||||||
|
*/
|
||||||
|
export class SelectionState {
|
||||||
|
private state: SelectionStateData = {};
|
||||||
|
private listeners: Set<(event: SelectionStateChangeEvent) => void> = new Set();
|
||||||
|
private history: SelectionStateChangeEvent[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establece el valor de un atributo
|
||||||
|
*/
|
||||||
|
set(attrId: AttrID, value: OptionID | undefined): void {
|
||||||
|
const previousValue = this.state[attrId];
|
||||||
|
|
||||||
|
// Si el valor no cambia, no hacer nada
|
||||||
|
if (previousValue === value) return;
|
||||||
|
|
||||||
|
const event: SelectionStateChangeEvent = {
|
||||||
|
attrId,
|
||||||
|
previousValue,
|
||||||
|
newValue: value,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value === undefined) {
|
||||||
|
delete this.state[attrId];
|
||||||
|
} else {
|
||||||
|
this.state[attrId] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar en historial
|
||||||
|
this.history.push(event);
|
||||||
|
|
||||||
|
// Notificar listeners
|
||||||
|
this.notifyListeners(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el valor de un atributo
|
||||||
|
*/
|
||||||
|
get(attrId: AttrID): OptionID | undefined {
|
||||||
|
return this.state[attrId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene todo el estado
|
||||||
|
*/
|
||||||
|
getAll(): SelectionStateData {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un atributo tiene valor
|
||||||
|
*/
|
||||||
|
has(attrId: AttrID): boolean {
|
||||||
|
return attrId in this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia el valor de un atributo
|
||||||
|
*/
|
||||||
|
clear(attrId: AttrID): void {
|
||||||
|
this.set(attrId, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia todo el estado
|
||||||
|
*/
|
||||||
|
clearAll(): void {
|
||||||
|
const attrIds = Object.keys(this.state) as AttrID[];
|
||||||
|
attrIds.forEach(attrId => this.clear(attrId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establece múltiples valores a la vez
|
||||||
|
*/
|
||||||
|
setMany(values: Partial<SelectionStateData>): void {
|
||||||
|
Object.entries(values).forEach(([attrId, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
this.set(attrId as AttrID, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene valores de una sección específica
|
||||||
|
*/
|
||||||
|
getBySection(sectionId: SectionID, attrIds: AttrID[]): Partial<SelectionStateData> {
|
||||||
|
const result: Partial<SelectionStateData> = {};
|
||||||
|
attrIds.forEach(attrId => {
|
||||||
|
const value = this.state[attrId];
|
||||||
|
if (value !== undefined) {
|
||||||
|
result[attrId] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suscribe un listener a cambios
|
||||||
|
*/
|
||||||
|
subscribe(listener: (event: SelectionStateChangeEvent) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
|
||||||
|
// Retorna función para desuscribirse
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifica a todos los listeners
|
||||||
|
*/
|
||||||
|
private notifyListeners(event: SelectionStateChangeEvent): void {
|
||||||
|
this.listeners.forEach(listener => listener(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el historial de cambios
|
||||||
|
*/
|
||||||
|
getHistory(): SelectionStateChangeEvent[] {
|
||||||
|
return [...this.history];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia el historial
|
||||||
|
*/
|
||||||
|
clearHistory(): void {
|
||||||
|
this.history = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deshace el último cambio
|
||||||
|
*/
|
||||||
|
undo(): boolean {
|
||||||
|
if (this.history.length === 0) return false;
|
||||||
|
|
||||||
|
const lastEvent = this.history.pop()!;
|
||||||
|
|
||||||
|
// Revertir sin añadir al historial ni notificar
|
||||||
|
if (lastEvent.previousValue === undefined) {
|
||||||
|
delete this.state[lastEvent.attrId];
|
||||||
|
} else {
|
||||||
|
this.state[lastEvent.attrId] = lastEvent.previousValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notificar del cambio
|
||||||
|
this.notifyListeners({
|
||||||
|
attrId: lastEvent.attrId,
|
||||||
|
previousValue: lastEvent.newValue,
|
||||||
|
newValue: lastEvent.previousValue,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el tamaño del estado
|
||||||
|
*/
|
||||||
|
get size(): number {
|
||||||
|
return Object.keys(this.state).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si el estado está vacío
|
||||||
|
*/
|
||||||
|
get isEmpty(): boolean {
|
||||||
|
return this.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializa el estado a JSON
|
||||||
|
*/
|
||||||
|
toJSON(): SelectionStateData {
|
||||||
|
return this.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restaura el estado desde JSON
|
||||||
|
*/
|
||||||
|
fromJSON(data: SelectionStateData): void {
|
||||||
|
this.clearAll();
|
||||||
|
this.setMany(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* VALIDATION STATE - Framework Agnostic
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Gestiona el estado de validación y violaciones.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AttrID, ValidationRule, RuleID } from '@/types';
|
||||||
|
import type { SelectionStateData } from './SelectionState';
|
||||||
|
import { RuleEngine } from '@/engine/rule-engine';
|
||||||
|
|
||||||
|
export interface Violation {
|
||||||
|
rule: ValidationRule;
|
||||||
|
severity: 'error' | 'warning' | 'info';
|
||||||
|
attrId?: AttrID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
hasErrors: boolean;
|
||||||
|
hasWarnings: boolean;
|
||||||
|
violations: Violation[];
|
||||||
|
violationsByAttribute: Map<AttrID, Violation[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estado de validación
|
||||||
|
*/
|
||||||
|
export class ValidationState {
|
||||||
|
private engine: RuleEngine;
|
||||||
|
private rules: ValidationRule[] = [];
|
||||||
|
private lastResult: ValidationResult | null = null;
|
||||||
|
private listeners: Set<(result: ValidationResult) => void> = new Set();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.engine = new RuleEngine();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establece las reglas de validación
|
||||||
|
*/
|
||||||
|
setRules(rules: ValidationRule[]): void {
|
||||||
|
this.rules = rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida el estado actual
|
||||||
|
*/
|
||||||
|
validate(selectionState: SelectionStateData): ValidationResult {
|
||||||
|
const violations = this.engine.getViolations(this.rules, selectionState);
|
||||||
|
|
||||||
|
const hasErrors = violations.some(v => v.severity === 'error');
|
||||||
|
const hasWarnings = violations.some(v => v.severity === 'warning');
|
||||||
|
|
||||||
|
// Agrupar violaciones por atributo
|
||||||
|
const violationsByAttribute = new Map<AttrID, Violation[]>();
|
||||||
|
|
||||||
|
violations.forEach(violation => {
|
||||||
|
if (violation.rule.action?.targetAttr) {
|
||||||
|
const attrId = violation.rule.action.targetAttr;
|
||||||
|
if (!violationsByAttribute.has(attrId)) {
|
||||||
|
violationsByAttribute.set(attrId, []);
|
||||||
|
}
|
||||||
|
violationsByAttribute.get(attrId)!.push(violation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: ValidationResult = {
|
||||||
|
isValid: !hasErrors,
|
||||||
|
hasErrors,
|
||||||
|
hasWarnings,
|
||||||
|
violations,
|
||||||
|
violationsByAttribute
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lastResult = result;
|
||||||
|
this.notifyListeners(result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el último resultado de validación
|
||||||
|
*/
|
||||||
|
getLastResult(): ValidationResult | null {
|
||||||
|
return this.lastResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene violaciones de un atributo específico
|
||||||
|
*/
|
||||||
|
getViolationsForAttribute(attrId: AttrID): Violation[] {
|
||||||
|
if (!this.lastResult) return [];
|
||||||
|
return this.lastResult.violationsByAttribute.get(attrId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un atributo tiene errores
|
||||||
|
*/
|
||||||
|
hasErrorsForAttribute(attrId: AttrID): boolean {
|
||||||
|
const violations = this.getViolationsForAttribute(attrId);
|
||||||
|
return violations.some(v => v.severity === 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un valor es permitido para un atributo
|
||||||
|
*/
|
||||||
|
isValueAllowed(attrId: AttrID, value: OptionID, selectionState: SelectionStateData): boolean {
|
||||||
|
return this.engine.isValueAllowed(attrId, value, this.rules, selectionState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene valores permitidos para un atributo
|
||||||
|
*/
|
||||||
|
getAllowedValues(attrId: AttrID, selectionState: SelectionStateData): OptionID[] | undefined {
|
||||||
|
const results = this.engine.getAttributeResults(this.rules, selectionState);
|
||||||
|
const attrResult = results.get(attrId);
|
||||||
|
|
||||||
|
if (!attrResult) return undefined;
|
||||||
|
|
||||||
|
return attrResult.allowedValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene valores prohibidos para un atributo
|
||||||
|
*/
|
||||||
|
getForbiddenValues(attrId: AttrID, selectionState: SelectionStateData): OptionID[] {
|
||||||
|
const results = this.engine.getAttributeResults(this.rules, selectionState);
|
||||||
|
const attrResult = results.get(attrId);
|
||||||
|
|
||||||
|
if (!attrResult) return [];
|
||||||
|
|
||||||
|
return attrResult.forbiddenValues || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un atributo es requerido
|
||||||
|
*/
|
||||||
|
isRequired(attrId: AttrID, selectionState: SelectionStateData): boolean {
|
||||||
|
const results = this.engine.getAttributeResults(this.rules, selectionState);
|
||||||
|
const attrResult = results.get(attrId);
|
||||||
|
|
||||||
|
return attrResult?.required || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suscribe un listener a cambios de validación
|
||||||
|
*/
|
||||||
|
subscribe(listener: (result: ValidationResult) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
|
||||||
|
// Si ya hay un resultado, notificar inmediatamente
|
||||||
|
if (this.lastResult) {
|
||||||
|
listener(this.lastResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifica a todos los listeners
|
||||||
|
*/
|
||||||
|
private notifyListeners(result: ValidationResult): void {
|
||||||
|
this.listeners.forEach(listener => listener(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia el estado de validación
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.lastResult = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,22 +1,131 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* CONFIGURABLE OBJECT TYPES
|
* CONFIGURABLE OBJECT TYPES - REFACTORED
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* ConfigurableObject ahora define la estrategia de renderizado global.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { I18nString } from '../i18n';
|
import type { I18nString } from '../i18n';
|
||||||
import type { Metadata } from '../core';
|
import type { Metadata, APIConfig } from '../core';
|
||||||
import type { ObjectID, SectionID, Attribute, Section } from '@/types';
|
import type { ObjectID, SectionID, Attribute, Section } from '@/types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDERING STRATEGY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estrategia de renderizado para TODO el objeto configurable.
|
||||||
|
* Todas las vistas de todas las secciones usan esta estrategia.
|
||||||
|
*/
|
||||||
|
export type RenderingStrategy =
|
||||||
|
| 'static_image' // URLs estáticas con placeholders
|
||||||
|
| 'dynamic_image' // URLs dinámicas calculadas en runtime
|
||||||
|
| 'api_generated' // Generación async via API externa
|
||||||
|
| 'three_d' // Modelo 3D con texturas dinámicas
|
||||||
|
| 'composite_layers' // Composición de capas PNG
|
||||||
|
| 'none'; // Sin renderizado visual
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GLOBAL VISUAL CONFIG
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración visual global del objeto.
|
||||||
|
* Actúa como FALLBACK para todas las vistas que no especifiquen sus propios valores.
|
||||||
|
*/
|
||||||
|
export interface GlobalVisualConfig {
|
||||||
|
/**
|
||||||
|
* Ruta base para todas las imágenes del objeto.
|
||||||
|
* Las vistas pueden override este valor.
|
||||||
|
*
|
||||||
|
* Ejemplo: '/renders/vivienda'
|
||||||
|
*/
|
||||||
|
basePath?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración API global.
|
||||||
|
* Solo aplica si visualStrategy es 'api_generated'.
|
||||||
|
* Las vistas pueden override este valor.
|
||||||
|
*/
|
||||||
|
apiConfig?: APIConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imagen de fallback global.
|
||||||
|
* Usada si falla la generación de cualquier vista.
|
||||||
|
* Las vistas pueden override este valor.
|
||||||
|
*
|
||||||
|
* Ejemplo: '/renders/fallback.jpg'
|
||||||
|
*/
|
||||||
|
fallbackImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONFIGURABLE OBJECT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Objeto configurable (producto).
|
||||||
|
*
|
||||||
|
* Ejemplos: casa, coche, mueble, laptop, etc.
|
||||||
|
*/
|
||||||
export interface ConfigurableObject {
|
export interface ConfigurableObject {
|
||||||
id : ObjectID;
|
id: ObjectID;
|
||||||
name : I18nString;
|
name: I18nString;
|
||||||
description : I18nString;
|
description: I18nString;
|
||||||
attributes : Attribute[];
|
|
||||||
sections : Record<SectionID, Section>;
|
/**
|
||||||
|
* ✅ ESTRATEGIA DE RENDERIZADO para TODO el objeto.
|
||||||
|
*
|
||||||
|
* Esta estrategia aplica a TODAS las vistas de TODAS las secciones.
|
||||||
|
* No se puede tener diferentes estrategias por sección/vista.
|
||||||
|
*
|
||||||
|
* Si necesitas estrategias mixtas (excepcional), usa 'dynamic_image'
|
||||||
|
* y maneja la lógica en runtime.
|
||||||
|
*/
|
||||||
|
visualStrategy: RenderingStrategy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ CONFIGURACIÓN VISUAL GLOBAL (fallback).
|
||||||
|
*
|
||||||
|
* Valores por defecto que heredan todas las vistas
|
||||||
|
* si no especifican sus propios valores.
|
||||||
|
*
|
||||||
|
* Opcional si todas las vistas definen sus propios valores.
|
||||||
|
*/
|
||||||
|
visualConfig?: GlobalVisualConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atributos GLOBALES del objeto.
|
||||||
|
* Aplican a todo el objeto independientemente de la sección.
|
||||||
|
*
|
||||||
|
* Ejemplo: 'calidad de acabados', 'color principal'
|
||||||
|
*/
|
||||||
|
attributes: Attribute[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secciones del objeto.
|
||||||
|
*
|
||||||
|
* Ejemplo para una casa:
|
||||||
|
* - 'sc:salon'
|
||||||
|
* - 'sc:bano'
|
||||||
|
* - 'sc:dormitorio'
|
||||||
|
*/
|
||||||
|
sections: Record<SectionID, Section>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orden de presentación de secciones en UI.
|
||||||
|
* Si no se especifica, el orden es arbitrario.
|
||||||
|
*/
|
||||||
sectionOrder?: SectionID[];
|
sectionOrder?: SectionID[];
|
||||||
category? : string;
|
|
||||||
metadata? : Metadata;
|
/**
|
||||||
}
|
* Categoría del objeto (opcional).
|
||||||
|
*
|
||||||
|
* Ejemplo: 'residencial', 'automovil', 'mobiliario'
|
||||||
|
*/
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
/** Metadata adicional */
|
||||||
|
metadata?: Metadata;
|
||||||
|
}
|
||||||
@ -1,41 +1,363 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* SECTION VALIDATOR
|
* SECTION VALIDATOR - UPDATED
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Valida secciones con la nueva arquitectura:
|
||||||
|
* - views opcionales
|
||||||
|
* - strategy a nivel de objeto
|
||||||
|
* - validación de coherencia
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Section, AttrID } from '@/types';
|
import type {
|
||||||
import type { ValidationError } from '@/utils';
|
Section,
|
||||||
import {ERRORS} from "@/messages";
|
SectionView,
|
||||||
|
ConfigurableObject,
|
||||||
/**
|
AttrID,
|
||||||
* Validar sección en runtime
|
ViewID,
|
||||||
*/
|
I18nString,
|
||||||
export function validateSection(section: Section): ValidationError[] {
|
SupportedLocale,
|
||||||
const errors: ValidationError[] = [];
|
Severity
|
||||||
|
} from '@/types';
|
||||||
// Validar que imageUrlTemplate tiene placeholders válidos
|
import { VALIDATION_SECTION, VALIDATION_OBJECT } from '@/messages/validation-section';
|
||||||
if (section.visualConfig.imageUrlTemplate) {
|
import { translate } from '@/utils/i18n/translation.utils';
|
||||||
const template = section.visualConfig.imageUrlTemplate;
|
|
||||||
const placeholders = template.match(/\{[^}]+}/g) || [];
|
// ============================================================================
|
||||||
|
// VALIDATION ERROR
|
||||||
placeholders.forEach(placeholder => {
|
// ============================================================================
|
||||||
const cleanName = placeholder.slice(1, -1); // Remove { }
|
|
||||||
|
export interface SectionValidationError {
|
||||||
if (cleanName.startsWith('global.')) {
|
sectionId : string;
|
||||||
const attrName = cleanName.replace('global.', '');
|
viewId? : ViewID;
|
||||||
const attrId = `at:${attrName}` as AttrID;
|
message : I18nString;
|
||||||
|
severity : Severity;
|
||||||
if (!section.visualConfig.globalAttrDependencies?.includes(attrId)) {
|
}
|
||||||
errors.push({
|
|
||||||
sectionId: section.id,
|
// ============================================================================
|
||||||
message: ERRORS.PLACEHOLDER_NOT_IN_DEPENDENCIES(placeholder, section.id),
|
// SECTION VALIDATION
|
||||||
severity: 'error'
|
// ============================================================================
|
||||||
});
|
|
||||||
}
|
/**
|
||||||
|
* Valida una sección individual
|
||||||
|
*/
|
||||||
|
export function validateSection(section: Section): SectionValidationError[] {
|
||||||
|
const errors: SectionValidationError[] = [];
|
||||||
|
|
||||||
|
// 1. Coherencia views <-> defaultView
|
||||||
|
const hasViews = section.views && Object.keys(section.views).length > 0;
|
||||||
|
const hasDefaultView = section.defaultView !== undefined;
|
||||||
|
|
||||||
|
if (hasViews && !hasDefaultView) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: section.id,
|
||||||
|
message: VALIDATION_SECTION.HAS_VIEWS_NO_DEFAULT(),
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasViews && hasDefaultView) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: section.id,
|
||||||
|
message: VALIDATION_SECTION.HAS_DEFAULT_NO_VIEWS(),
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasViews && hasDefaultView) {
|
||||||
|
// Verificar que defaultView existe en views
|
||||||
|
if (!section.views![section.defaultView!]) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: section.id,
|
||||||
|
message: VALIDATION_SECTION.DEFAULT_VIEW_NOT_FOUND(section.defaultView!),
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar cada vista
|
||||||
|
for (const view of Object.values(section.views!)) {
|
||||||
|
errors.push(...validateView(section, view));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Coherencia affectsVisual <-> views
|
||||||
|
const hasVisualAttrs = section.attrs.some(attr =>
|
||||||
|
attr.display?.affectsVisual === true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasVisualAttrs && !hasViews) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: section.id,
|
||||||
|
message: VALIDATION_SECTION.VISUAL_ATTRS_NO_VIEWS(),
|
||||||
|
severity: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validar IDs únicos de atributos
|
||||||
|
const attrIds = new Set<AttrID>();
|
||||||
|
for (const attr of section.attrs) {
|
||||||
|
if (attrIds.has(attr.id)) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: section.id,
|
||||||
|
message: VALIDATION_SECTION.DUPLICATE_ATTRIBUTE_ID(attr.id),
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
attrIds.add(attr.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida una vista individual
|
||||||
|
*/
|
||||||
|
function validateView(
|
||||||
|
section : Section,
|
||||||
|
view : SectionView
|
||||||
|
): SectionValidationError[] {
|
||||||
|
const errors: SectionValidationError[] = [];
|
||||||
|
|
||||||
|
const config = view.visualConfig;
|
||||||
|
|
||||||
|
// 1. Validar imageUrlTemplate si existe
|
||||||
|
if (config.imageUrlTemplate) {
|
||||||
|
errors.push(...validateImageTemplate(section, view));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validar dependencies
|
||||||
|
if (config.sectionAttrDependencies) {
|
||||||
|
const sectionAttrIds = new Set(section.attrs.map(a => a.id));
|
||||||
|
|
||||||
|
for (const attrId of config.sectionAttrDependencies) {
|
||||||
|
if (!sectionAttrIds.has(attrId)) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: section.id,
|
||||||
|
viewId: view.id,
|
||||||
|
message: VALIDATION_SECTION.DEPENDENCY_NOT_IN_SECTION(attrId),
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida el template de URL de imagen
|
||||||
|
*/
|
||||||
|
function validateImageTemplate(
|
||||||
|
section : Section,
|
||||||
|
view : SectionView
|
||||||
|
): SectionValidationError[] {
|
||||||
|
const errors: SectionValidationError[] = [];
|
||||||
|
const template = view.visualConfig.imageUrlTemplate!;
|
||||||
|
|
||||||
|
// Extraer placeholders: {basePath}, {section}, {view}, {at_suelo}
|
||||||
|
const placeholders = template.match(/\{[^}]+}/g) || [];
|
||||||
|
const sectionAttrIds = new Set(section.attrs.map(a => a.id));
|
||||||
|
|
||||||
|
for (const placeholder of placeholders) {
|
||||||
|
const cleanName = placeholder.slice(1, -1); // Quitar { }
|
||||||
|
|
||||||
|
// Placeholders reservados
|
||||||
|
if (['basePath', 'section', 'view'].includes(cleanName)) {
|
||||||
|
continue; // OK
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholders de atributos: {at_suelo}
|
||||||
|
if (cleanName.startsWith('at_')) {
|
||||||
|
const attrId = cleanName.replace('at_', 'at:') as AttrID;
|
||||||
|
|
||||||
|
// ¿Está en dependencies de sección?
|
||||||
|
const inDeps = view.visualConfig.sectionAttrDependencies?.includes(attrId);
|
||||||
|
|
||||||
|
// ¿Existe en la sección?
|
||||||
|
const exists = sectionAttrIds.has(attrId);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: section.id,
|
||||||
|
viewId: view.id,
|
||||||
|
message: VALIDATION_SECTION.TEMPLATE_ATTR_NOT_IN_SECTION(attrId),
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
} else if (!inDeps) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: section.id,
|
||||||
|
viewId: view.id,
|
||||||
|
message: VALIDATION_SECTION.TEMPLATE_ATTR_NOT_IN_DEPS(attrId),
|
||||||
|
severity: 'warning'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Placeholder desconocido
|
||||||
|
else {
|
||||||
|
errors.push({
|
||||||
|
sectionId: section.id,
|
||||||
|
viewId: view.id,
|
||||||
|
message: VALIDATION_SECTION.UNKNOWN_PLACEHOLDER(placeholder),
|
||||||
|
severity: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OBJECT VALIDATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida un objeto completo y todas sus secciones
|
||||||
|
*/
|
||||||
|
export function validateObject(object: ConfigurableObject): SectionValidationError[] {
|
||||||
|
const errors: SectionValidationError[] = [];
|
||||||
|
|
||||||
|
// 1. Validar estrategia visual
|
||||||
|
if (!object.visualStrategy) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: object.id,
|
||||||
|
message: VALIDATION_OBJECT.MISSING_VISUAL_STRATEGY(),
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validar cada sección
|
||||||
|
for (const section of Object.values(object.sections)) {
|
||||||
|
errors.push(...validateSection(section));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validar coherencia visualStrategy <-> views
|
||||||
|
const hasVisualSections = Object.values(object.sections).some(s =>
|
||||||
|
s.views && Object.keys(s.views).length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasVisualSections && object.visualStrategy === 'none') {
|
||||||
|
errors.push({
|
||||||
|
sectionId: object.id,
|
||||||
|
message: VALIDATION_OBJECT.VISUAL_SECTIONS_WITH_NONE_STRATEGY(),
|
||||||
|
severity: 'warning'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Validar basePath según estrategia
|
||||||
|
const needsBasePath = ['static_image', 'dynamic_image'].includes(object.visualStrategy);
|
||||||
|
const hasBasePath = object.visualConfig?.basePath !== undefined;
|
||||||
|
|
||||||
|
if (needsBasePath && !hasBasePath) {
|
||||||
|
// Verificar si alguna vista lo define
|
||||||
|
const anyViewHasBasePath = Object.values(object.sections).some(section =>
|
||||||
|
section.views && Object.values(section.views).some(view =>
|
||||||
|
view.visualConfig.basePath !== undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!anyViewHasBasePath) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: object.id,
|
||||||
|
message: VALIDATION_OBJECT.MISSING_BASE_PATH(object.visualStrategy),
|
||||||
|
severity: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Validar apiConfig según estrategia
|
||||||
|
if (object.visualStrategy === 'api_generated') {
|
||||||
|
const hasApiConfig = object.visualConfig?.apiConfig !== undefined;
|
||||||
|
|
||||||
|
if (!hasApiConfig) {
|
||||||
|
// Verificar si alguna vista lo define
|
||||||
|
const anyViewHasApiConfig = Object.values(object.sections).some(section =>
|
||||||
|
section.views && Object.values(section.views).some(view =>
|
||||||
|
view.visualConfig.apiConfig !== undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!anyViewHasApiConfig) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: object.id,
|
||||||
|
message: VALIDATION_OBJECT.MISSING_API_CONFIG(),
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Validar sectionOrder
|
||||||
|
if (object.sectionOrder) {
|
||||||
|
const sectionIds = new Set(Object.keys(object.sections));
|
||||||
|
|
||||||
|
for (const sectionId of object.sectionOrder) {
|
||||||
|
if (!sectionIds.has(sectionId)) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: object.id,
|
||||||
|
message: VALIDATION_OBJECT.SECTION_ORDER_UNKNOWN_SECTION(sectionId),
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrupa errores por severidad
|
||||||
|
*/
|
||||||
|
export function groupBySeverity(errors: SectionValidationError[]): {
|
||||||
|
errors : SectionValidationError[];
|
||||||
|
warnings: SectionValidationError[];
|
||||||
|
info : SectionValidationError[];
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
errors: errors.filter(e => e.severity === 'error'),
|
||||||
|
warnings: errors.filter(e => e.severity === 'warning'),
|
||||||
|
info: errors.filter(e => e.severity === 'info')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si hay errores críticos
|
||||||
|
*/
|
||||||
|
export function hasErrors(errors: SectionValidationError[]): boolean {
|
||||||
|
return errors.some(e => e.severity === 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea errores para mostrar
|
||||||
|
*/
|
||||||
|
export function formatErrors(
|
||||||
|
errors: SectionValidationError[],
|
||||||
|
locale: SupportedLocale = 'es'
|
||||||
|
): string {
|
||||||
|
const grouped = groupBySeverity(errors);
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (grouped.errors.length > 0) {
|
||||||
|
lines.push('ERRORS:');
|
||||||
|
grouped.errors.forEach(e => {
|
||||||
|
const location = e.viewId
|
||||||
|
? `${e.sectionId} > ${e.viewId}`
|
||||||
|
: e.sectionId;
|
||||||
|
lines.push(` ❌ [${location}] ${translate(e.message, locale)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grouped.warnings.length > 0) {
|
||||||
|
lines.push('WARNINGS:');
|
||||||
|
grouped.warnings.forEach(e => {
|
||||||
|
const location = e.viewId
|
||||||
|
? `${e.sectionId} > ${e.viewId}`
|
||||||
|
: e.sectionId;
|
||||||
|
lines.push(` ⚠️ [${location}] ${translate(e.message, locale)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
Loading…
Reference in new issue