diff --git a/src/libs/vice/consts/index.ts b/src/libs/vice/consts/index.ts new file mode 100644 index 0000000..b687680 --- /dev/null +++ b/src/libs/vice/consts/index.ts @@ -0,0 +1,5 @@ + + + +export * from './messages.ts'; + diff --git a/src/libs/vice/docs/catalogo.md b/src/libs/vice/docs/catalogo.md new file mode 100644 index 0000000..e51506e --- /dev/null +++ b/src/libs/vice/docs/catalogo.md @@ -0,0 +1,783 @@ +# VICE — Guía de configuración de catálogos + +Esta guía explica cómo modelar un catálogo configurable completo usando el sistema VICE. +Cubre todos los conceptos del sistema con ejemplos tomados directamente del catálogo de viviendas. + +--- + +## Índice + +1. [Conceptos fundamentales](#conceptos-fundamentales) +2. [Opciones](#opciones) +3. [Atributos](#atributos) +4. [Secciones](#secciones) +5. [Vistas y templates visuales](#vistas-y-templates-visuales) +6. [Objetos configurables](#objetos-configurables) +7. [Reglas de validación](#reglas-de-validación) +8. [Precios](#precios) +9. [El catálogo completo](#el-catálogo-completo) +10. [Uso con ConfigurationEngine](#uso-con-configurationengine) + +--- + +## Conceptos fundamentales + +El sistema VICE modela productos configurables en torno a cinco entidades: + +``` +Catálogo + └─ Objetos (modelos configurables: Apartamento, Dúplex, Coche...) + ├─ Atributos globales (afectan a todo el objeto: Calidad) + └─ Secciones (partes configurables: Salón, Baño, Cocina...) + ├─ Atributos locales (Suelo, Pared, Encimera...) + └─ Vistas (imágenes que se actualizan con las selecciones) +``` + +**IDs** — todos los IDs siguen una convención con prefijo de tipo: + +| Prefijo | Tipo | Ejemplo | +|---------|------------|----------------------| +| `op:` | Opción | `op:suelo_parquet` | +| `at:` | Atributo | `at:suelo` | +| `sc:` | Sección | `sc:salon` | +| `vw:` | Vista | `vw:front` | +| `ob:` | Objeto | `ob:apartamento` | +| `rl:` | Regla | `rl:estandar_no_lujo`| +| `ct:` | Catálogo | `ct:viviendas` | + +--- + +## Opciones + +Las opciones son los valores que el usuario puede elegir. Se definen a nivel de catálogo y se reutilizan en múltiples atributos y objetos. + +### Opción con precio fijo + +```typescript +[OPT_SUELO_PARQUET]: { + id : 'op:suelo_parquet', + code : 'parquet', // usado en templates de imagen + name : { es: 'Parquet', en: 'Parquet' }, + description: { es: 'Suelo de madera natural', en: 'Natural wood floor' }, + pricing : { baseAmount: 45 }, // 45€ fijo + tags : ['madera', 'calido'], // opcional — para filtros en UI +}, +``` + +### Opción sin coste extra + +```typescript +[OPT_PARED_BLANCO]: { + id : 'op:pared_blanco', + code : 'blanco', + name : { es: 'Blanco roto', en: 'Off white' }, + pricing: { baseAmount: 0 }, // incluido en el precio base +}, +``` + +### Opción con precio dinámico (expresión JsonLogic) + +Para opciones cuantificables donde el precio depende de la cantidad seleccionada: + +```typescript +[OPT_ARMARIO_LACADO]: { + id : 'op:armario_lacado', + code : 'lacado', + name : { es: 'Lacado blanco', en: 'White lacquered' }, + pricing: { + baseAmount: 0, + // Descuento por volumen: 2 puertas → 350€/u, 3 puertas → 300€/u + dynamicExpression: { + 'if': [ + { '>=': [{ var: 'quantity' }, 3] }, + { '*': [{ var: 'quantity' }, 300] }, // 3 puertas = 900€ + { '*': [{ var: 'quantity' }, 350] } // 2 puertas = 700€ + ] + } + } +}, +``` + +El contexto disponible en `dynamicExpression` es `{ quantity, baseAmount, attributes }`. + +### Precio de calidad (precio global del modelo) + +La calidad es un atributo global que añade un precio base a toda la configuración: + +```typescript +[OPT_CALIDAD_ESTANDAR]: { pricing: { baseAmount: 0 } }, +[OPT_CALIDAD_PREMIUM] : { pricing: { baseAmount: 15000 } }, +[OPT_CALIDAD_LUJO] : { pricing: { baseAmount: 35000 } }, +``` + +--- + +## Atributos + +Los atributos definen qué puede elegir el usuario. Hay tres tipos. + +### `dynamic` — el usuario elige una opción + +El tipo más común. El usuario selecciona una de las opciones disponibles. + +```typescript +{ + id : 'at:suelo', + code : 'suelo', // usado en placeholders de templates + name : { es: 'Suelo', en: 'Flooring' }, + description : { es: 'Material del suelo', en: 'Floor material' }, + type : 'dynamic', + dataType : 'reference', + defaultValue: 'op:suelo_ceramica', // valor inicial + options : [ + { optionId: 'op:suelo_ceramica', priority: 1 }, + { optionId: 'op:suelo_parquet', priority: 2 }, + { optionId: 'op:suelo_porcelanico', priority: 3 }, + { optionId: 'op:suelo_microcemento', priority: 4 }, + ], + display: { + uiVisible : true, // mostrar en configurador + affectsVisual: true, // cambiar esta opción actualiza la imagen + affectsPrice : true, // cambiar esta opción actualiza el precio + } +} +``` + +#### Opción cuantificable + +Cuando una opción tiene cantidad asociada (ej: número de puertas), se define en `AttributeOption.quantity`. La cantidad puede variar entre opciones del mismo atributo. + +```typescript +{ + id : 'at:armario_puertas', + code : 'armario_puertas', + type : 'dynamic', + dataType : 'reference', + defaultValue : 'op:armario_lacado', + defaultQuantity : 2, // cantidad inicial + userConfigurableQuantity: true, // el usuario puede cambiar la cantidad + options: [ + { + optionId : 'op:armario_lacado', + priority : 1, + quantity : { default: 2, min: 2, max: 3, step: 1, + unit: { es: 'puertas', en: 'doors' } }, + // override de dynamicExpression para este atributo específico + // tiene precedencia sobre el pricing de la OptionDefinition + dynamicExpression: { + 'if': [ + { '>=': [{ var: 'quantity' }, 3] }, + { '*': [{ var: 'quantity' }, 300] }, + { '*': [{ var: 'quantity' }, 350] } + ] + } + }, + { + optionId : 'op:armario_madera', + priority : 2, + quantity : { default: 2, min: 2, max: 3, step: 1, + unit: { es: 'puertas', en: 'doors' } }, + dynamicExpression: { '*': [{ var: 'quantity' }, 500] } + }, + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } +} +``` + +> **Nota de diseño**: la cantidad vive en la `AttributeOption`, no en el `Attribute`. Esto permite que distintas opciones del mismo atributo tengan rangos de cantidad diferentes, o que solo algunas sean cuantificables. + +### `fixed` — valor constante, no configurable + +Para datos informativos como dimensiones. No aparece en el configurador pero puede usarse en expresiones de precio. + +```typescript +{ + id : 'at:m2_cocina', + code : 'm2_cocina', + name : { es: 'Superficie', en: 'Surface' }, + type : 'fixed', + dataType: 'number', + value : 10, // 10 m² + unit : { es: 'm²', en: 'sqm' }, + display : { uiVisible: false, readonly: true } +} +``` + +### `computed` — calculado a partir de otros atributos + +Para valores derivados que no edita el usuario. Se calcula con JsonLogic. + +```typescript +{ + id : 'at:precio_suelo_total', + type : 'computed', + dataType : 'number', + expression : { '*': [{ var: 'at_precio_m2' }, { var: 'at_m2_salon' }] }, + dependencies: ['at:precio_m2', 'at:m2_salon'], + display : { uiVisible: false } +} +``` + +### Atributos globales vs locales + +Un **atributo global** se define en `ConfigurableObject.attributes` y afecta a todo el objeto. Los atributos locales se definen en `Section.attributes` y solo afectan a esa sección. + +```typescript +// Atributo global — calidad afecta a todo el apartamento +const APARTAMENTO = { + attributes: [attrCalidadGlobal], // 'at:calidad' + sections: { + salon : { attributes: [attrSuelo(), attrPared()] }, + bano : { attributes: [attrSanitario, attrRevestBano] }, + cocina : { attributes: [attrEncimera, attrMuebleCocina, attrSuelo()] }, + } +}; +``` + +> El mismo atributo puede aparecer en varias secciones (ej: `at:suelo` está en salón, dormitorio y cocina). El sistema deduplica automáticamente — el precio se suma una sola vez. + +--- + +## Secciones + +Las secciones agrupan los atributos configurables de una parte del producto. + +```typescript +const seccionSalon = (): VisualSection => ({ + kind : 'visual', // siempre 'visual' por ahora + id : 'sc:salon', + code : 'salon', // usado en templates + name : { es: 'Salón', en: 'Living room' }, + description: { es: 'Salón-comedor', en: 'Living-dining room' }, + attributes : [attrSuelo(), attrPared()], + views : { + 'vw:front': viewFront('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg'), + 'vw:360' : view360 ('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg'), + }, + defaultView : 'vw:front', + availability: { mode: 'required' }, // 'required' | 'optional' | 'conditional' +}); +``` + +### Disponibilidad de sección + +| Modo | Descripción | +|---------------|----------------------------------------------------------| +| `required` | Siempre visible y activa | +| `optional` | El usuario puede activarla o no (ej: armario) | +| `conditional` | Se activa cuando un atributo tiene un valor específico | + +--- + +## Vistas y templates visuales + +Las vistas definen cómo se renderiza visualmente una sección. El sistema soporta cuatro estrategias. + +### `static_image` — imagen estática con template + +La más común. El template se resuelve sustituyendo placeholders por los valores actuales. + +```typescript +visualConfig: { + strategy : 'static_image', + template : '{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg', + basePath : '/renders/vivienda/', // prefijo de la URL final + fallbackImage: '/renders/fallback.jpg', // imagen si algo falla +} +``` + +#### Placeholders disponibles + +| Placeholder | Se sustituye por | +|-------------------|-------------------------------------------------------| +| `{object}` | `code` del objeto activo (ej: `apartamento`) | +| `{section}` | `code` de la sección (ej: `salon`) | +| `{view}` | `code` de la vista (ej: `front`, `360`) | +| `{at:suelo}` | `code` de la opción seleccionada para `at:suelo` | +| `{at:pared}` | `code` de la opción seleccionada para `at:pared` | + +#### Ejemplo de URL resuelta + +Con el estado `{ suelo: 'op:suelo_parquet', pared: 'op:pared_blanco' }`: + +``` +template : '{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg' +basePath : '/renders/vivienda/' +resultado: /renders/vivienda/apartamento/salon/front/parquet_blanco.jpg +``` + +#### `globalAttrDependencies` + +Atributos globales que afectan a esta vista aunque no aparezcan en el template. Sirve para que el sistema sepa que debe re-renderizar cuando cambian. + +```typescript +viewFront( + '{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg', + [ATTR_CALIDAD] // la calidad afecta visualmente aunque no esté en el template +) +``` + +### `composite_layers` — capas superpuestas + +Para renders que se componen de varias imágenes apiladas (ej: suelo + paredes + muebles como capas independientes): + +```typescript +visualConfig: { + strategy: 'composite_layers', + layers : [ + { attrId: 'at:suelo', template: '{object}/{section}/{view}/suelo_{at:suelo}.png', zIndex: 0 }, + { attrId: 'at:pared', template: '{object}/{section}/{view}/pared_{at:pared}.png', zIndex: 1 }, + { attrId: 'at:mueble', template: '{object}/{section}/{view}/mueble_{at:mueble}.png', zIndex: 2, + optional: true }, // si no hay valor, se omite la capa + ], + basePath : '/renders/vivienda/', + fallbackImage: '/renders/fallback.jpg', +} +``` + +El resolver devuelve las URLs separadas por `|` para que la UI las superponga: +``` +/renders/vivienda/salon/front/suelo_parquet.png|/renders/vivienda/salon/front/pared_blanco.png +``` + +### `api_generated` — imagen generada por API + +Para renders fotorrealistas generados en tiempo real por un servicio externo: + +```typescript +visualConfig: { + strategy : 'api_generated', + apiConfig: { + endpoint: 'https://renders.api.com/generate', + method : 'POST', + timeout : 5000, + }, + // Parámetros a enviar — si no se especifica, envía todos los atributos con affectsVisual: true + params : ['object', 'section', 'view', 'at:suelo', 'at:pared'], + fallbackImage: '/renders/fallback.jpg', +} +``` + +Uso con `resolveViewApi`: +```typescript +const apiParams = engine.resolveViewApi('sc:salon', 'vw:front'); +// apiParams: { endpoint, method, params: { object: 'apartamento', suelo: 'parquet', ... } } +``` + +### `three_d` — modelo 3D + +La UI gestiona las texturas directamente. El resolver devuelve URL vacía. + +```typescript +visualConfig: { + strategy: 'three_d', + modelUrl: '/models/salon.glb', +} +``` + +--- + +## Objetos configurables + +Un objeto es el producto que el usuario configura (Apartamento, Dúplex, coche, etc.). + +```typescript +export const APARTAMENTO: ConfigurableObject = { + id : 'ob:apartamento', + code : 'apartamento', + name : { es: 'Apartamento', en: 'Apartment' }, + description: { es: 'Apartamento de 1 dormitorio (55 m²)', en: '1 bedroom apartment (55 sqm)' }, + + // Atributos globales — afectan a todo el objeto + attributes : [attrCalidadGlobal], + + // Secciones configurables + sections : { + 'sc:salon' : seccionSalon(), + 'sc:bano' : seccionBano(6), // 6 m² + 'sc:dormitorio1': seccionDormitorio('sc:dormitorio1', 'dormitorio', 'Dormitorio', 14), + 'sc:cocina' : seccionCocina(), + 'sc:armario' : seccionArmario(), // sección opcional + }, + + // Orden en el que aparecen las secciones en la UI + sectionOrder: ['sc:salon', 'sc:cocina', 'sc:dormitorio1', 'sc:bano', 'sc:armario'], + + // basePath para templates de imagen — se hereda en secciones y vistas + basePath : '/renders/vivienda/', + category : 'residencial', +}; +``` + +### Herencia de `basePath` + +El `basePath` se resuelve en cascada: vista > sección > objeto > catálogo. El más específico gana. + +``` +Catálogo basePath: '/renders/' + └─ Objeto basePath: '/renders/vivienda/' ← sobreescribe al catálogo + └─ Vista basePath: '/renders/especial/' ← sobreescribe al objeto +``` + +--- + +## Reglas de validación + +Las reglas controlan qué combinaciones de opciones son válidas. El sistema es **preventivo**: el usuario nunca puede llegar a un estado inválido — las opciones incompatibles se deshabilitan antes de que pueda seleccionarlas. + +Las reglas se evalúan con **JsonLogic** — permiten condiciones sobre cualquier combinación de atributos. + +### Estructura de una regla + +```typescript +{ + id : 'rl:estandar_no_lujo', + name : { es: 'Estándar sin lujo', en: 'Standard without luxury' }, + condition: { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] }, + action : { + type : 'forbid', // acción a aplicar + targetAttr: 'at:revest_bano', // atributo afectado + values : ['op:revest_marmol'] // opciones afectadas + }, + priority : 9, // mayor prioridad se evalúa primero + severity : 'error', // 'error' | 'warning' | 'info' + affects : ['at:calidad', 'at:revest_bano'], + message : { es: 'El mármol no está disponible en calidad estándar', en: '...' } +} +``` + +> **Importante**: en el `condition`, los IDs de atributos usan guión bajo en lugar de dos puntos: `at:calidad` → `attributes.at_calidad`. + +### Tipos de acción + +#### `forbid` — prohíbe opciones específicas + +```typescript +// Calidad estándar prohíbe mármol en el baño +{ + condition: { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] }, + action : { type: 'forbid', targetAttr: 'at:revest_bano', values: ['op:revest_marmol'] } +} +``` + +Resultado: con calidad estándar, el mármol aparece deshabilitado con el tooltip del `message`. + +#### `allow` — lista blanca de opciones permitidas + +```typescript +// Con suelo de microcemento, el revestimiento del baño solo puede ser +// microcemento o cerámica (excluye mármol automáticamente) +{ + condition: { '==': [{ var: 'attributes.at_suelo' }, 'op:suelo_microcemento'] }, + action : { + type : 'allow', + targetAttr: 'at:revest_bano', + values : ['op:revest_microcemento', 'op:revest_ceramica'] + } +} +``` + +Si hay varias reglas `allow` activas para el mismo atributo, se intersectan — solo se permiten los valores que aparecen en **todas** las listas blancas. + +#### `require` — obliga a un valor específico + +```typescript +// Calidad lujo recomienda (advierte si no) mármol en el baño +{ + condition: { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_lujo'] }, + action : { type: 'require', targetAttr: 'at:revest_bano', values: ['op:revest_marmol'] }, + severity : 'warning' // warning = recomienda pero no bloquea +} +``` + +Si `values` está vacío, solo requiere que haya algún valor seleccionado. Si `values` tiene opciones, requiere que el valor actual sea una de ellas. + +#### `suggest` — sugerencia informativa + +No genera violaciones, solo información para la UI: + +```typescript +{ + condition: { '==': [{ var: 'attributes.at_suelo' }, 'op:suelo_parquet'] }, + action : { type: 'suggest', targetAttr: 'at:pared', values: ['op:pared_beige'] }, + severity : 'info', + message : { es: 'El parquet combina bien con tonos beige', en: '...' } +} +``` + +### Severidad + +| Severidad | Comportamiento | +|-----------|-------------------------------------------------------------| +| `error` | Bloquea la selección — la opción queda deshabilitada | +| `warning` | La opción se puede seleccionar pero muestra aviso | +| `info` | Solo informativo, sin restricción | + +### Condiciones complejas con JsonLogic + +Las condiciones pueden combinar múltiples atributos: + +```typescript +// Solo activa si calidad es premium Y suelo es parquet +condition: { + 'and': [ + { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_premium'] }, + { '==': [{ var: 'attributes.at_suelo' }, 'op:suelo_parquet'] } + ] +} + +// Activa si calidad es premium O lujo +condition: { + 'in': [ + { var: 'attributes.at_calidad' }, + ['op:calidad_premium', 'op:calidad_lujo'] + ] +} +``` + +### Las 6 reglas del catálogo de viviendas + +| Regla | Condición | Acción | Severidad | +|-------------------------|-------------------------|--------------------------------------|-----------| +| `rl:lujo_requiere_marmol` | calidad = lujo | require mármol en baño | warning | +| `rl:estandar_no_lujo` | calidad = estándar | forbid mármol en baño | error | +| `rl:microcemento_consistente`| suelo = microcemento | allow solo microcemento/cerámica baño| warning | +| `rl:madera_no_bano` | encimera = madera | forbid sanitario negro | warning | +| `rl:negro_requiere_premium` | sanitario = negro | forbid calidad estándar | error | +| `rl:estandar_no_negro` | calidad = estándar | forbid sanitario negro | error | + +--- + +## Precios + +El precio total es la suma de todos los atributos con `affectsPrice: true`. + +### Precio fijo + +```typescript +// La opción contribuye con su baseAmount directamente +pricing: { baseAmount: 45 } // 45€ +``` + +### Precio dinámico con expresión + +```typescript +pricing: { + baseAmount : 0, + dynamicExpression: { '*': [{ var: 'quantity' }, 500] } // 500€ × cantidad +} +``` + +El contexto disponible en `dynamicExpression`: + +```typescript +{ + quantity : number, // cantidad seleccionada (si aplica) + baseAmount: number, // baseAmount de la opción + attributes: Record // estado completo normalizado +} +``` + +### Override de expresión por `AttributeOption` + +Si una `AttributeOption` tiene `dynamicExpression`, tiene precedencia sobre la de `OptionDefinition`. Permite que el mismo tipo de puerta tenga precios distintos en atributos distintos: + +```typescript +options: [ + { + optionId : 'op:armario_lacado', + dynamicExpression: { '*': [{ var: 'quantity' }, 350] }, // este override + // tiene precedencia sobre pricing.dynamicExpression de 'op:armario_lacado' + } +] +``` + +### Deduplicación de atributos compartidos + +Si `at:suelo` aparece en salón, dormitorio y cocina, **se suma una sola vez**. El sistema deduplica por `attrId` al calcular el precio. + +### Tax + +```typescript +// En el catálogo +tax: { + rate : 0.21, + included: false, // el IVA se añade al subtotal + label : { es: 'IVA', en: 'VAT' } +} +``` + +Si `included: true`, `taxAmount` es 0 y `total === subtotal`. + +### Ejemplo de cálculo + +Con el estado base del fixture (calidad estándar, cerámica, blanco, granito, muebles blancos, puerta lacada, armario lacado 2 puertas): + +| Atributo | Opción | Precio | +|-----------------------|------------------|---------| +| `at:calidad` | estándar | 0 € | +| `at:suelo` | cerámica | 30 € | +| `at:pared` | blanco | 0 € | +| `at:sanitario` | blanco | 0 € | +| `at:revest_bano` | cerámica | 40 € | +| `at:encimera` | granito | 400 € | +| `at:mueble_cocina` | blanco mate | 3.500 € | +| `at:puerta` | lacada | 250 € | +| `at:armario_puertas` | lacado × 2 | 700 € | +| **Subtotal** | | **4.920 €** | + +--- + +## El catálogo completo + +La estructura final que une todo: + +```typescript +export const CATALOGO_VIVIENDAS: ConfigurationCatalog = { + id : 'ct:viviendas', + code : 'viviendas', + name : { es: 'Catálogo de Viviendas', en: 'Housing Catalog' }, + description: { es: 'Configurador de acabados para viviendas de obra nueva', en: '...' }, + basePath : '/renders/', // basePath raíz — heredado por objetos y vistas + + // Pool de opciones — todas las opciones disponibles en el catálogo + options: { ...OPTIONS }, + + // Objetos configurables + objects: { + 'ob:apartamento': APARTAMENTO, + 'ob:duplex' : DUPLEX, + }, + + // Reglas de validación — aplican a todos los objetos del catálogo + rules: { ...RULES }, + + // Opcional + tax: { rate: 0.21, included: false, label: { es: 'IVA', en: 'VAT' } }, +}; +``` + +--- + +## Uso con ConfigurationEngine + +### Inicialización + +```typescript +import { ConfigurationEngine } from '@/libs/vice'; + +const engine = new ConfigurationEngine(CATALOGO_VIVIENDAS, logr); + +// Activar un objeto — crea su SelectionState con los defaultValues +engine.setActiveObject('ob:apartamento'); +``` + +### Seleccionar opciones + +```typescript +// Selección simple +engine.select('at:suelo', 'op:suelo_parquet'); + +// Selección con cantidad (opción cuantificable) +engine.select('at:armario_puertas', 'op:armario_lacado', 3); + +// El resultado indica si fue aceptada y qué cambió +const result = engine.select('at:revest_bano', 'op:revest_marmol'); +// Con calidad estándar: +// { success: false, reason: 'El mármol no está disponible en calidad estándar' } +``` + +### Renderizar opciones disponibles + +```typescript +const attrState = engine.getAttributeState('at:revest_bano'); + +attrState.options.forEach(({ optionId, option, available, reason, quantityConfig }) => { + // available: false → deshabilitar en UI + // reason: string → mostrar en tooltip + // quantityConfig → mostrar selector de cantidad si está presente +}); +``` + +### Resolver la imagen de una sección + +```typescript +const { url, isFallback } = engine.resolveView('sc:salon', 'vw:front'); +// url: '/renders/vivienda/apartamento/salon/front/parquet_blanco.jpg' +``` + +### Obtener el precio + +```typescript +const { subtotal, taxAmount, total, breakdown } = engine.getPrice(); +// subtotal: 4920 +// breakdown: [{ attrId, optionId, amount, isDynamic }, ...] +``` + +### Reactividad + +```typescript +// Suscribirse a cambios +const unsubscribe = engine.onChange((event) => { + if (event.affectsVisual) { + // actualizar imagen de la sección afectada + const { url } = engine.resolveView(sectionId, viewId); + updateImage(url); + } + if (event.affectsPrice) { + // actualizar precio mostrado + updatePrice(engine.getPrice()); + } +}); + +// Limpiar al destruir el componente +onDestroy(unsubscribe); +``` + +### Multi-objeto — configuraciones paralelas + +El engine mantiene un estado independiente por objeto. El usuario puede configurar varios modelos sin perder datos: + +```typescript +// Configurar apartamento +engine.setActiveObject('ob:apartamento'); +engine.select('at:suelo', 'op:suelo_parquet'); + +// Cambiar a dúplex — el estado del apartamento se preserva +engine.setActiveObject('ob:duplex'); +engine.select('at:suelo', 'op:suelo_porcelanico'); + +// Volver al apartamento — recupera su estado anterior +engine.setActiveObject('ob:apartamento'); +engine.getValue('at:suelo'); // → { optionId: 'op:suelo_parquet' } +``` + +--- + +## Referencia rápida de tipos + +```typescript +// ID con prefijo tipado +type OptionID = `op:${string}` +type AttrID = `at:${string}` +type SectionID = `sc:${string}` +type ViewID = `vw:${string}` +type ObjectID = `ob:${string}` +type RuleID = `rl:${string}` + +// Estado de selección +type OptionSelection = { optionId: OptionID; quantity?: number } +type SelectionMap = Record + +// Atributos +type Attribute = FixedAttribute | DynamicAttribute | ComputedAttribute + +// Estrategias de vista +type VisualStrategy = 'static_image' | 'composite_layers' | 'api_generated' | 'three_d' + +// Acciones de regla +type RuleActionType = 'forbid' | 'allow' | 'require' | 'suggest' + +// Severidad +type Severity = 'error' | 'warning' | 'info' +``` \ No newline at end of file diff --git a/src/libs/vice/engines/configuration.ts b/src/libs/vice/engines/configuration.ts new file mode 100644 index 0000000..1d58ea5 --- /dev/null +++ b/src/libs/vice/engines/configuration.ts @@ -0,0 +1,263 @@ +/** + * ============================================================================ + * CONFIGURATION ENGINE + * ============================================================================ + * + * Orquestador principal — única API que consume la UI. + * Gestiona múltiples objetos en paralelo con un objeto activo. + * + * Uso: + * const engine = new ConfigurationEngine(catalog, logr); + * engine.setActiveObject('ob:apartamento'); + * engine.select('at:suelo', 'op:parquet'); + * engine.getPrice(); + */ + +import type { Logr } from '@/libs/logr'; +import type { + ConfigurationCatalog, + ObjectID, + OptionID, + AttrID, + OptionSelection, + SelectionMap, + SectionID, + ViewID +} from '../types'; + + +import type { AttributeState } from './selection-state'; +import type { PricingResult } from './pricing'; +import type { ResolveResult, ApiResolveResult } from './template-resolver'; +import { SelectionState } from './selection-state'; +import { PricingEngine } from './pricing'; +import { TemplateResolver } from './template-resolver'; +import { ENGINE_CATEGORIES, SELECTION_ERRORS } from '../consts/messages'; + + +// ============================================================================ +// TYPES +// ============================================================================ + +export type ChangeEventType = 'selection' | 'object'; + +export interface ChangeEvent { + type : ChangeEventType; + objectId: ObjectID; + /** Solo presente cuando type === 'selection' */ + attrId? : AttrID; + affectsVisual: boolean; + affectsPrice : boolean; +} + +export type ChangeListener = (event: ChangeEvent) => void; + +export interface SelectionOutcome { + success : boolean; + reason? : string; + affectsVisual: boolean; + affectsPrice : boolean; +} + +// ============================================================================ +// CONFIGURATION ENGINE +// ============================================================================ + +export class ConfigurationEngine { + private readonly catalog : ConfigurationCatalog; + private readonly logr : Logr; + private readonly pricingEngine : PricingEngine; + private readonly templateResolver: TemplateResolver; + + /** SelectionState por objectId */ + private readonly states : Map = new Map(); + private activeObjectId : ObjectID | null = null; + private readonly listeners : Set = new Set(); + + constructor(catalog: ConfigurationCatalog, logr: Logr) { + this.catalog = catalog; + this.logr = logr; + this.pricingEngine = new PricingEngine(logr); + this.templateResolver = new TemplateResolver(logr); + } + + // ------------------------------------------------------------------------- + // Objeto activo + // ------------------------------------------------------------------------- + + /** + * Activa un objeto del catálogo. + * Crea su SelectionState si no existe todavía. + * Emite evento 'object'. + */ + setActiveObject(objectId: ObjectID): void { + if (!this.catalog.objects[objectId]) { + this.logr.error( + ENGINE_CATEGORIES.SELECTION, + SELECTION_ERRORS.OBJECT_NOT_FOUND(objectId), + { objectId } + ); + return; + } + + if (!this.states.has(objectId)) { + this.states.set( + objectId, + new SelectionState(objectId, this.catalog, this.logr) + ); + } + + this.activeObjectId = objectId; + this.emit({ type: 'object', objectId, affectsVisual: true, affectsPrice: true }); + } + + getActiveObjectId(): ObjectID | null { + return this.activeObjectId; + } + + // ------------------------------------------------------------------------- + // Selección + // ------------------------------------------------------------------------- + + /** + * Selecciona una opción para un atributo del objeto activo. + * Solo acepta valores permitidos por el RuleEngine — modelo preventivo. + * Emite evento 'selection' si tiene éxito. + */ + select(attrId: AttrID, optionId: OptionID, quantity?: number): SelectionOutcome { + const state = this.activeState(); + if (!state) return { success: false, reason: 'No active object', affectsVisual: false, affectsPrice: false }; + + const value: OptionSelection = { optionId, quantity }; + const result = state.select(attrId, value); + + if (!result.success) { + return { ...result, affectsVisual: false, affectsPrice: false }; + } + + const { affectsVisual, affectsPrice } = this.getAttrDisplayFlags(attrId); + + this.emit({ + type : 'selection', + objectId: this.activeObjectId!, + attrId, + affectsVisual, + affectsPrice, + }); + + return { success: true, affectsVisual, affectsPrice }; + } + + getValue(attrId: AttrID): OptionSelection | unknown { + return this.activeState()?.getValue(attrId); + } + + getSelection(): SelectionMap { + return this.activeState()?.getSelection() ?? {}; + } + + // ------------------------------------------------------------------------- + // Estado calculado + // ------------------------------------------------------------------------- + + getAttributeState(attrId: AttrID): AttributeState | null { + return this.activeState()?.getAttributeState(attrId) ?? null; + } + + getAllAttributeStates(): Map { + return this.activeState()?.getAllAttributeStates() ?? new Map(); + } + + // ------------------------------------------------------------------------- + // Precio + // ------------------------------------------------------------------------- + + getPrice(): PricingResult | null { + if (!this.activeObjectId) return null; + return this.pricingEngine.calculate( + this.activeObjectId, + this.getSelection(), + this.catalog + ); + } + + // ------------------------------------------------------------------------- + // Visual + // ------------------------------------------------------------------------- + + resolveView(sectionId: SectionID, viewId: ViewID): ResolveResult { + const objectId = this.activeObjectId; + if (!objectId) return { url: '', isFallback: true }; + + return this.templateResolver.resolve( + { objectId, sectionId, viewId }, + this.getSelection(), + this.catalog + ); + } + + resolveViewApi(sectionId: SectionID, viewId: ViewID): ApiResolveResult | null { + const objectId = this.activeObjectId; + if (!objectId) return null; + + return this.templateResolver.resolveApi( + { objectId, sectionId, viewId }, + this.getSelection(), + this.catalog + ); + } + + // ------------------------------------------------------------------------- + // Validación + // ------------------------------------------------------------------------- + + isValid(): boolean { + return this.activeState()?.isValid() ?? true; + } + + // ------------------------------------------------------------------------- + // Eventos + // ------------------------------------------------------------------------- + + onChange(listener: ChangeListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private activeState(): SelectionState | null { + if (!this.activeObjectId) return null; + return this.states.get(this.activeObjectId) ?? null; + } + + private emit(event: ChangeEvent): void { + for (const listener of this.listeners) { + try { listener(event); } catch { /* listener errors no deben romper el engine */ } + } + } + + private getAttrDisplayFlags(attrId: AttrID): { affectsVisual: boolean; affectsPrice: boolean } { + if (!this.activeObjectId) return { affectsVisual: false, affectsPrice: false }; + + const obj = this.catalog.objects[this.activeObjectId]; + if (!obj) return { affectsVisual: false, affectsPrice: false }; + + // Buscar el atributo en global y en secciones + const attrs = [ + ...obj.attributes, + ...Object.values(obj.sections).flatMap(s => s.attributes ?? []) + ]; + + const attr = attrs.find(a => a.id === attrId); + if (!attr) return { affectsVisual: false, affectsPrice: false }; + + const display = attr.display; + return { + affectsVisual: Boolean(display.affectsVisual), + affectsPrice : Boolean(display.affectsPrice), + }; + } +} \ No newline at end of file diff --git a/src/libs/vice/engines/evaluator.ts b/src/libs/vice/engines/evaluator.ts index ae7f6df..10e59e8 100644 --- a/src/libs/vice/engines/evaluator.ts +++ b/src/libs/vice/engines/evaluator.ts @@ -5,7 +5,7 @@ */ import type { Logr } from '@/libs/logr'; import type { JsonLogic } from '../types'; -import { ENGINE_CATEGORIES, JSON_LOGIC_WARNINGS } from '../consts/messages'; +import { ENGINE_CATEGORIES, JSON_LOGIC_WARNINGS } from '../consts'; /** * Evaluador de expresiones JsonLogic. diff --git a/src/libs/vice/engines/index.ts b/src/libs/vice/engines/index.ts new file mode 100644 index 0000000..ede6bd9 --- /dev/null +++ b/src/libs/vice/engines/index.ts @@ -0,0 +1,8 @@ + + +export * from './configuration.ts'; +export * from './evaluator.ts'; +export * from './pricing.ts'; +export * from './rule.ts'; +export * from './selection-state.ts'; +export * from './template-resolver.ts'; diff --git a/src/libs/vice/engines/pricing.ts b/src/libs/vice/engines/pricing.ts index 2f18095..889a1b3 100644 --- a/src/libs/vice/engines/pricing.ts +++ b/src/libs/vice/engines/pricing.ts @@ -13,19 +13,19 @@ * El tax se aplica al total, no por opción. */ +import type { Logr } from '@/libs/logr'; import type { ConfigurationCatalog, ConfigurableObject, Attribute, - QuantifiableAttribute, - TaxInfo, ObjectID, + DynamicAttribute, + TaxInfo, + ObjectID, + SelectionMap, } from '../types'; -import type { Logr } from '@/libs/logr'; -import type { SelectionMap } from './rule'; +import { isOptionSelection } from '../guards'; import { JsonLogicEvaluator } from './evaluator'; -import { ENGINE_CATEGORIES, PRICING_ERRORS } from '../consts/messages'; - - +import { ENGINE_CATEGORIES, PRICING_ERRORS } from '../consts'; // ============================================================================ // TYPES @@ -112,10 +112,11 @@ export class PricingEngine { for (const attr of allAttrs) { if (!attr.display.affectsPrice) continue; - const selectedValue = state[attr.id]; - if (!selectedValue) continue; + const rawValue = state[attr.id]; + if (!rawValue) continue; - const optionId = String(selectedValue); + const selection = isOptionSelection(rawValue) ? rawValue : null; + const optionId = selection ? selection.optionId : String(rawValue); const optionDef = catalog.options[optionId as keyof typeof catalog.options]; if (!optionDef) { @@ -138,7 +139,17 @@ export class PricingEngine { continue; } - const amount = this.resolveAmount(attr, pricing.baseAmount, pricing.dynamicExpression, state); + // Quantity: from OptionSelection, or from AttributeOption.quantity.default + const attrOption = attr.type === 'dynamic' + ? (attr as DynamicAttribute).options.find(o => o.optionId === optionId) + : undefined; + const quantity = selection?.quantity + ?? attrOption?.quantity?.default; + + // dynamicExpression override: AttributeOption takes precedence over OptionDefinition + const dynamicExpr = attrOption?.dynamicExpression ?? pricing.dynamicExpression; + + const amount = this.resolveAmount(attr, pricing.baseAmount, dynamicExpr, quantity, state); subtotal += amount; if (amount !== 0) { @@ -167,13 +178,13 @@ export class PricingEngine { attr : Attribute, baseAmount : number, dynamicExpression: unknown, + quantity : number | undefined, state : SelectionMap, ): number { if (!dynamicExpression) return baseAmount; try { - const quantity = this.isQuantifiable(attr) ? attr.quantity : undefined; - const data = { + const data = { quantity, baseAmount, attributes: this.normalizeStateKeys(state), @@ -214,10 +225,6 @@ export class PricingEngine { return attrs; } - private isQuantifiable(attr: Attribute): attr is QuantifiableAttribute { - return attr.type === 'quantifiable'; - } - private normalizeStateKeys(state: SelectionMap): Record { const normalized: Record = {}; for (const [key, value] of Object.entries(state)) { @@ -251,4 +258,4 @@ export class PricingEngine { private emptyResult(tax?: TaxInfo): PricingResult { return this.buildResult(0, [], undefined, tax); } -} \ No newline at end of file +} diff --git a/src/libs/vice/engines/rule.ts b/src/libs/vice/engines/rule.ts index 2e4a3f0..8fe7d8f 100644 --- a/src/libs/vice/engines/rule.ts +++ b/src/libs/vice/engines/rule.ts @@ -9,16 +9,18 @@ */ import type { Logr } from '@/libs/logr'; -import type { - ValidationRule, - ValidationRuleAction, - AttrID, - OptionID, - Value, - Severity +import { + type ValidationRule, + type ValidationRuleAction, + type AttrID, + type OptionID, + type Value, + type Severity, + type OptionSelection } from '../types'; +import { isOptionSelection } from '../guards'; import { JsonLogicEvaluator } from './evaluator'; -import { ENGINE_CATEGORIES, RULE_ENGINE_ERRORS } from '../consts/messages'; +import { ENGINE_CATEGORIES, RULE_ENGINE_ERRORS } from '../consts'; // ============================================================================ @@ -27,9 +29,9 @@ import { ENGINE_CATEGORIES, RULE_ENGINE_ERRORS } from '../consts/messages'; /** * Estado de selección actual del usuario. - * Mapa de AttrID → valor seleccionado. + * Mapa de AttrID → OptionSelection (DynamicAttribute) o Value (FixedAttribute). */ -export type SelectionMap = Record; +export type SelectionMap = Record; /** * Resultado de evaluar una regla contra el estado actual. @@ -67,7 +69,7 @@ export interface RuleViolation { // RULE ENGINE // ============================================================================ -export class Rule { +export class RuleEngine { private readonly evaluator: JsonLogicEvaluator; private readonly logr : Logr; @@ -166,17 +168,22 @@ export class Rule { ...(action.values as OptionID[]) ]; // Violación si el valor actual está prohibido - if (action.values.includes(state[attrId])) { + if (action.values.includes(getOptionId(state[attrId]) as OptionID)) { result.violations.push({ rule, severity: rule.severity }); } break; case 'require': result.required = true; - // Violación si no hay valor seleccionado - if (!state[attrId]) { - result.violations.push({ rule, severity: rule.severity }); - } + // Violación si no hay valor seleccionado, o si hay valores + // requeridos específicos y el actual no está entre ellos + { + const current = getOptionId(state[attrId]); + const violated = action.values?.length + ? !current || !action.values.includes(current as OptionID) + : !current; + if (violated) result.violations.push({ rule, severity: rule.severity }); + } break; case 'suggest': @@ -206,9 +213,9 @@ export class Rule { if (!result) return true; if (result.forbiddenValues?.includes(value as OptionID)) return false; - if (result.allowedValues && !result.allowedValues.includes(value as OptionID)) return false; + return !(result.allowedValues && !result.allowedValues.includes(value as OptionID)); + - return true; } /** @@ -273,12 +280,27 @@ export class Rule { * Normaliza las claves del estado para JsonLogic. * 'at:calidad' → 'at_calidad' (los ':' no son válidos como nombres de variable) */ -function normalizeKeys(state: SelectionMap): Record { - const normalized: Record = {}; +/** + * Extrae el OptionID de un valor del estado — maneja tanto OptionSelection como Value. + */ +function getOptionId(value: OptionSelection | Value | undefined): OptionID | undefined { + if (value === undefined || value === null) return undefined; + if (isOptionSelection(value)) return value.optionId as OptionID; + return value as OptionID; +} + +function normalizeKeys(state: SelectionMap): Record { + const result: Record = {}; for (const [key, value] of Object.entries(state)) { - normalized[key.replace(':', '_')] = value; + const normalized = key.replace(':', '_'); + if (isOptionSelection(value)) { + result[normalized] = value.optionId; + if (value.quantity !== undefined) result[normalized + '_quantity'] = value.quantity; + } else { + result[normalized] = value; + } } - return normalized; + return result; } function severityOrder(severity: Severity): number { @@ -295,4 +317,4 @@ function severityOrder(severity: Severity): number { // ============================================================================ // El singleton se crea en el wiring del engine, no aquí. -// Ejemplo: export const ruleEngine = new Rule(logr); \ No newline at end of file +// Ejemplo: export const ruleEngine = new RuleEngine(logr); \ No newline at end of file diff --git a/src/libs/vice/engines/selection-state.ts b/src/libs/vice/engines/selection-state.ts index 7e3124f..1f87530 100644 --- a/src/libs/vice/engines/selection-state.ts +++ b/src/libs/vice/engines/selection-state.ts @@ -10,20 +10,25 @@ * El usuario nunca puede seleccionar un valor prohibido. */ +import type {Logr} from '@/libs/logr'; import type { - ConfigurationCatalog, - ConfigurableObject, Attribute, + AttrID, + ConfigurableObject, + ConfigurationCatalog, DynamicAttribute, - OptionDefinition, ObjectID, -} from '../types'; -import type { AttrID, OptionID } from '../types'; -import type { Value } from '../types'; -import type { Logr } from '@/libs/logr'; -import { Rule } from './rule.ts'; -import type { SelectionMap } from './rule.ts'; -import { ENGINE_CATEGORIES, SELECTION_ERRORS } from '../consts/messages.ts'; + OptionDefinition, + OptionID, + OptionSelection, + QuantityConfig, + Value +} from '../types'; +import type {SelectionMap} from './rule'; + +import { isOptionSelection } from '../guards'; +import {RuleEngine} from './rule'; +import {ENGINE_CATEGORIES, SELECTION_ERRORS} from '../consts'; // ============================================================================ // TYPES @@ -33,11 +38,13 @@ import { ENGINE_CATEGORIES, SELECTION_ERRORS } from '../consts/messages.ts'; * Opción de un atributo con su disponibilidad calculada. */ export interface AvailableOption { - optionId : OptionID; - option : OptionDefinition; - available : boolean; + optionId : OptionID; + option : OptionDefinition; + available : boolean; + /** Si la opción es cuantificable — configuración de cantidad */ + quantityConfig?: QuantityConfig; /** Razón por la que no está disponible — mensaje de la regla que la prohíbe */ - reason? : string; + reason? : string; } /** @@ -45,7 +52,7 @@ export interface AvailableOption { */ export interface AttributeState { attr : Attribute; - value : Value; + value : OptionSelection | Value; options : AvailableOption[]; // solo para DynamicAttribute required: boolean; } @@ -63,7 +70,7 @@ export type SelectionResult = export class SelectionState { private readonly logr : Logr; - private readonly ruleEngine : Rule; + private readonly ruleEngine : RuleEngine; private readonly catalog : ConfigurationCatalog; private readonly object : ConfigurableObject; private selection : SelectionMap; @@ -74,18 +81,15 @@ export class SelectionState { logr : Logr, ) { this.logr = logr; - this.ruleEngine = new Rule(logr); + this.ruleEngine = new RuleEngine(logr); this.catalog = catalog; this.selection = {}; const object = catalog.objects[objectId]; if (!object) { - this.logr.error( - ENGINE_CATEGORIES.SELECTION, - SELECTION_ERRORS.OBJECT_NOT_FOUND(objectId), - { objectId } - ); - throw new Error(`Object "${objectId}" not found in catalog`); + const msg = SELECTION_ERRORS.OBJECT_NOT_FOUND(objectId); + this.logr.error(ENGINE_CATEGORIES.SELECTION, msg, { objectId }); + throw new Error(msg); } this.object = object; @@ -101,11 +105,12 @@ export class SelectionState { * Solo acepta valores que el RuleEngine permite dado el estado actual. * Modelo preventivo — el estado siempre es válido tras la selección. */ - select(attrId: AttrID, value: Value): SelectionResult { + select(attrId: AttrID, value: OptionSelection | Value): SelectionResult { const rules = Object.values(this.catalog.rules ?? {}); - if (!this.ruleEngine.isValueAllowed(attrId, value, rules, this.selection)) { - const reason = this.getProhibitedReason(attrId, value); + const optionId = isOptionSelection(value) ? value.optionId : value; + if (!this.ruleEngine.isValueAllowed(attrId, optionId, rules, this.selection)) { + const reason = this.getProhibitedReason(attrId, optionId); this.logr.warn( ENGINE_CATEGORIES.SELECTION, SELECTION_ERRORS.INVALID_OPTION(attrId, String(value) as OptionID), @@ -121,7 +126,7 @@ export class SelectionState { /** * Devuelve el valor actual de un atributo. */ - getValue(attrId: AttrID): Value { + getValue(attrId: AttrID): OptionSelection | Value { return this.selection[attrId]; } @@ -187,10 +192,16 @@ export class SelectionState { const selection: SelectionMap = {}; for (const attr of this.collectAttrs()) { - if (attr.type === 'dynamic' || attr.type === 'quantifiable') { + if (attr.type === 'dynamic') { const dynamic = attr as DynamicAttribute; if (dynamic.defaultValue !== undefined) { - selection[attr.id] = dynamic.defaultValue; + const defaultOption = dynamic.options.find(o => o.optionId === dynamic.defaultValue); + selection[attr.id] = { + optionId: dynamic.defaultValue, + quantity: defaultOption?.quantity + ? (dynamic.defaultQuantity ?? defaultOption.quantity.default) + : undefined, + }; } } } @@ -210,12 +221,13 @@ export class SelectionState { attr : DynamicAttribute, rules: any[] ): AvailableOption[] { - return attr.options.map(({ optionId }) => { - const option = this.catalog.options[optionId]; - const available = this.ruleEngine.isValueAllowed(attr.id, optionId, rules, this.selection); - const reason = available ? undefined : this.getProhibitedReason(attr.id, optionId); + return attr.options.map((attrOption) => { + const { optionId } = attrOption; + const option = this.catalog.options[optionId]; + const available = this.ruleEngine.isValueAllowed(attr.id, optionId, rules, this.selection); + const reason = available ? undefined : this.getProhibitedReason(attr.id, optionId); - return { optionId, option, available, reason }; + return { optionId, option, available, reason, quantityConfig: attrOption.quantity }; }); } diff --git a/src/libs/vice/engines/template-resolver.ts b/src/libs/vice/engines/template-resolver.ts index 601c4ec..0eed065 100644 --- a/src/libs/vice/engines/template-resolver.ts +++ b/src/libs/vice/engines/template-resolver.ts @@ -3,9 +3,20 @@ * TEMPLATE RESOLVER * ============================================================================ * - * Resuelve templates de imagen a URLs completas combinando: - * - basePath heredado en cascada (vista → sección → objeto → catálogo) - * - template con placeholders {at:} resueltos al code de la opción activa + * Resuelve templates de imagen a URLs completas (static_image, composite_layers) + * o construye los parámetros para llamadas API (api_generated). + * + * Sistema de placeholders unificado para todas las estrategias: + * + * Contexto: + * {object} → code del ConfigurableObject + * {section} → code de la VisualSection + * {view} → code de la SectionView + * + * Atributos: + * {at:} → code de la OptionDefinition activa para ese atributo + * + * basePath hereda en cascada: vista → sección → objeto → catálogo */ import type { Logr } from '@/libs/logr'; @@ -20,8 +31,9 @@ import type { ViewID, CompositeLayersConfig, ApiGeneratedConfig, + OptionID, } from '../types'; -import { isOptionID } from '../types'; +import {isOptionID, isOptionSelection} from '../guards'; import { ENGINE_CATEGORIES, @@ -30,7 +42,6 @@ import { } from '../consts/messages'; import type { SelectionMap } from './rule.ts'; - // ============================================================================ // TYPES // ============================================================================ @@ -344,7 +355,8 @@ export class TemplateResolver { return null; } - const selectedValue = state[attr.id]; + const rawValue = state[attr.id]; + const selectedValue = isOptionSelection(rawValue) ? rawValue.optionId : rawValue; if (!selectedValue) { this.logr.error( @@ -364,7 +376,7 @@ export class TemplateResolver { return null; } - return catalog.options[selectedValue].code; + return catalog.options[selectedValue as OptionID].code; } // ------------------------------------------------------------------------- diff --git a/src/libs/vice/tests/housing-catalog.fixture.ts b/src/libs/vice/fixtures/housing-catalog.ts similarity index 95% rename from src/libs/vice/tests/housing-catalog.fixture.ts rename to src/libs/vice/fixtures/housing-catalog.ts index 6a1c901..19649d9 100644 --- a/src/libs/vice/tests/housing-catalog.fixture.ts +++ b/src/libs/vice/fixtures/housing-catalog.ts @@ -15,12 +15,11 @@ import type { VisualSection, DynamicAttribute, FixedAttribute, - SectionView, QuantifiableAttribute, QuantifiableOptionAttribute, + SectionView, } from '../types'; import type { AttrID, SectionID, OptionID, ObjectID, RuleID, ViewID } from '../types'; - // ============================================================================ // IDs — OPCIONES // ============================================================================ @@ -584,23 +583,39 @@ const seccionArmario = (): VisualSection => ({ description: { es: 'Armario empotrado con puertas correderas', en: 'Built-in wardrobe with sliding doors' }, attributes : [ { - id : ATTR_ARMARIO_PUERTAS, - code : 'armario_puertas', - name : { es: 'Puertas de armario', en: 'Wardrobe doors' }, - description : { es: 'Número y material de las puertas del armario', en: 'Number and material of wardrobe doors' }, - type : 'quantifiable', - dataType : 'reference', - defaultValue: OPT_ARMARIO_LACADO, - quantity : 2, - minQuantity : 2, - maxQuantity : 3, - unit : { es: 'puertas', en: 'doors' }, - options : [ - { optionId: OPT_ARMARIO_LACADO, priority: 1 }, - { optionId: OPT_ARMARIO_MADERA, priority: 2 }, + id : ATTR_ARMARIO_PUERTAS, + code : 'armario_puertas', + name : { es: 'Puertas de armario', en: 'Wardrobe doors' }, + description : { es: 'Material y número de puertas del armario', en: 'Wardrobe door material and count' }, + type : 'dynamic', + dataType : 'reference', + defaultValue : OPT_ARMARIO_LACADO, + defaultQuantity : 2, + userConfigurableQuantity: true, + options : [ + { + optionId: OPT_ARMARIO_LACADO, + priority: 1, + // 2 puertas → 350€/u, 3 puertas → 300€/u (descuento volumen) + quantity: { default: 2, min: 2, max: 3, step: 1, unit: { es: 'puertas', en: 'doors' } }, + dynamicExpression: { + 'if': [ + { '>=': [{ var: 'quantity' }, 3] }, + { '*': [{ var: 'quantity' }, 300] }, + { '*': [{ var: 'quantity' }, 350] } + ] + } + }, + { + optionId: OPT_ARMARIO_MADERA, + priority: 2, + // Precio fijo por puerta sin descuento + quantity: { default: 2, min: 2, max: 3, step: 1, unit: { es: 'puertas', en: 'doors' } }, + dynamicExpression: { '*': [{ var: 'quantity' }, 500] } + }, ], - display : { uiVisible: true, affectsVisual: true, affectsPrice: true } - } as QuantifiableOptionAttribute, + display : { uiVisible: true, affectsVisual: true, affectsPrice: true } + } as DynamicAttribute, { id : ATTR_ARMARIO_MATERIAL, code : 'armario_material', diff --git a/src/libs/vice/fixtures/index.ts b/src/libs/vice/fixtures/index.ts new file mode 100644 index 0000000..a93c5db --- /dev/null +++ b/src/libs/vice/fixtures/index.ts @@ -0,0 +1,3 @@ + +export * from './housing-catalog.ts'; + diff --git a/src/libs/vice/guards/ids.ts b/src/libs/vice/guards/ids.ts new file mode 100644 index 0000000..6fdcd95 --- /dev/null +++ b/src/libs/vice/guards/ids.ts @@ -0,0 +1,16 @@ + + +// ============================================================================ +// TYPE GUARDS +// ============================================================================ + +import type {AttrID, OptionID} from "@/libs/vice/types"; + +export function isOptionID(value: unknown): value is OptionID { + return typeof value === 'string' && value.startsWith('op:'); +} + +export function isAttrID(value: unknown): value is AttrID { + return typeof value === 'string' && value.startsWith('at:'); +} + diff --git a/src/libs/vice/guards/index.ts b/src/libs/vice/guards/index.ts new file mode 100644 index 0000000..0298bc9 --- /dev/null +++ b/src/libs/vice/guards/index.ts @@ -0,0 +1,5 @@ + + +export * from './ids.ts'; +export * from './option.ts'; + diff --git a/src/libs/vice/guards/option.ts b/src/libs/vice/guards/option.ts new file mode 100644 index 0000000..695c38a --- /dev/null +++ b/src/libs/vice/guards/option.ts @@ -0,0 +1,14 @@ +import type {AttrID, OptionID, OptionSelection, Value} from "../types"; + + +/** + * Helper para narrowear el valor de un atributo dinámico. + */ +export function isOptionSelection(value: OptionSelection | Value): value is OptionSelection { + return ( + typeof value === 'object' && + value !== null && + 'optionId' in value + ); +} + diff --git a/src/libs/vice/tests/configuration.test.ts b/src/libs/vice/tests/configuration.test.ts new file mode 100644 index 0000000..15f369d --- /dev/null +++ b/src/libs/vice/tests/configuration.test.ts @@ -0,0 +1,361 @@ +/** + * ============================================================================ + * CONFIGURATION ENGINE — TESTS + * ============================================================================ + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ConfigurationEngine } from '../engines/configuration'; +import type { Logr } from '@/libs/logr'; +import { + CATALOGO_VIVIENDAS, + IDS_TEST, +} from '../fixtures/housing-catalog.ts'; + +const { + OBJ_APARTAMENTO, + OBJ_DUPLEX, + ATTR_CALIDAD, + ATTR_SUELO, + ATTR_REVEST_BANO, + ATTR_SANITARIO, + ATTR_ARMARIO_PUERTAS, + OPT_CALIDAD_ESTANDAR, + OPT_CALIDAD_PREMIUM, + OPT_CALIDAD_LUJO, + OPT_SUELO_PARQUET, + OPT_SUELO_CERAMICA, + OPT_REVEST_CERAMICA, + OPT_REVEST_MARMOL, + OPT_SANIT_NEGRO, + OPT_ARMARIO_LACADO, + OPT_ARMARIO_MADERA, + SEC_SALON, + VIEW_FRONT, +} = IDS_TEST; + +// ============================================================================ +// SETUP +// ============================================================================ + +function makeLogr(): Logr { + return { + debug : vi.fn(), + info : vi.fn(), + warn : vi.fn(), + error : vi.fn(), + getLogs : vi.fn(), + clear : vi.fn(), + serialize: vi.fn(), + setLevel : vi.fn(), + setMaxLogs: vi.fn(), + } as unknown as Logr; +} + +function makeEngine() { + return new ConfigurationEngine(CATALOGO_VIVIENDAS, makeLogr()); +} + +let engine: ConfigurationEngine; + +beforeEach(() => { + engine = makeEngine(); + engine.setActiveObject(OBJ_APARTAMENTO); +}); + +// ============================================================================ +// setActiveObject +// ============================================================================ + +describe('setActiveObject', () => { + + it('establece el objeto activo', () => { + expect(engine.getActiveObjectId()).toBe(OBJ_APARTAMENTO); + }); + + it('objeto inexistente no cambia el activo', () => { + engine.setActiveObject('ob:noexiste' as any); + expect(engine.getActiveObjectId()).toBe(OBJ_APARTAMENTO); + }); + + it('cambia el objeto activo', () => { + engine.setActiveObject(OBJ_DUPLEX); + expect(engine.getActiveObjectId()).toBe(OBJ_DUPLEX); + }); + + it('emite evento object al cambiar', () => { + const listener = vi.fn(); + engine.onChange(listener); + engine.setActiveObject(OBJ_DUPLEX); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ type: 'object', objectId: OBJ_DUPLEX }) + ); + }); + + it('preserva el estado al volver al objeto anterior', () => { + engine.select(ATTR_SUELO, OPT_SUELO_PARQUET); + engine.setActiveObject(OBJ_DUPLEX); + engine.setActiveObject(OBJ_APARTAMENTO); + const val = engine.getValue(ATTR_SUELO) as any; + expect(val.optionId).toBe(OPT_SUELO_PARQUET); + }); +}); + +// ============================================================================ +// select +// ============================================================================ + +describe('select', () => { + + it('acepta una selección válida', () => { + const result = engine.select(ATTR_SUELO, OPT_SUELO_PARQUET); + expect(result.success).toBe(true); + }); + + it('rechaza una selección prohibida', () => { + const result = engine.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); + expect(result.success).toBe(false); + }); + + it('devuelve reason al rechazar', () => { + const result = engine.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); + expect(result.success).toBe(false); + if (!result.success) expect(result.reason).toBeTruthy(); + }); + + it('acepta selección con cantidad', () => { + const result = engine.select(ATTR_ARMARIO_PUERTAS, OPT_ARMARIO_LACADO, 3); + expect(result.success).toBe(true); + const val = engine.getValue(ATTR_ARMARIO_PUERTAS) as any; + expect(val.quantity).toBe(3); + }); + + it('affectsVisual true para atributo visual', () => { + const result = engine.select(ATTR_SUELO, OPT_SUELO_PARQUET); + expect(result.affectsVisual).toBe(true); + }); + + it('affectsPrice true para atributo con precio', () => { + const result = engine.select(ATTR_SUELO, OPT_SUELO_PARQUET); + expect(result.affectsPrice).toBe(true); + }); + + it('falla si no hay objeto activo', () => { + const eng = makeEngine(); // sin setActiveObject + const result = eng.select(ATTR_SUELO, OPT_SUELO_PARQUET); + expect(result.success).toBe(false); + }); + + it('emite evento selection al seleccionar', () => { + const listener = vi.fn(); + engine.onChange(listener); + engine.select(ATTR_SUELO, OPT_SUELO_PARQUET); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + type : 'selection', + objectId: OBJ_APARTAMENTO, + attrId : ATTR_SUELO, + }) + ); + }); + + it('no emite evento si la selección es rechazada', () => { + const listener = vi.fn(); + engine.onChange(listener); + engine.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +// ============================================================================ +// getSelection / getValue +// ============================================================================ + +describe('getSelection / getValue', () => { + + it('getSelection devuelve estado del objeto activo', () => { + const sel = engine.getSelection(); + expect(sel[ATTR_CALIDAD]).toBeDefined(); + }); + + it('getSelection devuelve objeto vacío sin objeto activo', () => { + const eng = makeEngine(); + expect(eng.getSelection()).toEqual({}); + }); + + it('getValue devuelve OptionSelection tras selección', () => { + engine.select(ATTR_SUELO, OPT_SUELO_PARQUET); + const val = engine.getValue(ATTR_SUELO) as any; + expect(val.optionId).toBe(OPT_SUELO_PARQUET); + }); + + it('estados de objetos distintos son independientes', () => { + engine.select(ATTR_SUELO, OPT_SUELO_PARQUET); + engine.setActiveObject(OBJ_DUPLEX); + const val = engine.getValue(ATTR_SUELO) as any; + // Dúplex tiene su propio estado — no el del apartamento + expect(val?.optionId).not.toBe(OPT_SUELO_PARQUET); + }); +}); + +// ============================================================================ +// getAttributeState / getAllAttributeStates +// ============================================================================ + +describe('getAttributeState', () => { + + it('devuelve opciones disponibles para un atributo', () => { + const s = engine.getAttributeState(ATTR_SUELO); + expect(s?.options.length).toBeGreaterThan(0); + }); + + it('devuelve null sin objeto activo', () => { + const eng = makeEngine(); + expect(eng.getAttributeState(ATTR_SUELO)).toBeNull(); + }); + + it('mármol no disponible con calidad estándar', () => { + const s = engine.getAttributeState(ATTR_REVEST_BANO); + const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL); + expect(marmol?.available).toBe(false); + }); + + it('mármol disponible tras cambiar a premium', () => { + engine.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); + const s = engine.getAttributeState(ATTR_REVEST_BANO); + const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL); + expect(marmol?.available).toBe(true); + }); +}); + +describe('getAllAttributeStates', () => { + + it('devuelve todos los atributos del objeto activo', () => { + const states = engine.getAllAttributeStates(); + expect(states.size).toBeGreaterThan(0); + expect(states.has(ATTR_CALIDAD)).toBe(true); + expect(states.has(ATTR_SUELO)).toBe(true); + }); + + it('devuelve mapa vacío sin objeto activo', () => { + const eng = makeEngine(); + expect(eng.getAllAttributeStates().size).toBe(0); + }); +}); + +// ============================================================================ +// getPrice +// ============================================================================ + +describe('getPrice', () => { + + it('devuelve un PricingResult', () => { + const price = engine.getPrice(); + expect(price).not.toBeNull(); + expect(typeof price?.subtotal).toBe('number'); + expect(typeof price?.total).toBe('number'); + }); + + it('el precio cambia al cambiar una opción con precio', () => { + const before = engine.getPrice()?.subtotal ?? 0; + engine.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM); + const after = engine.getPrice()?.subtotal ?? 0; + expect(after).toBeGreaterThan(before); + }); + + it('devuelve null sin objeto activo', () => { + const eng = makeEngine(); + expect(eng.getPrice()).toBeNull(); + }); + + it('precio con armario 3 puertas lacadas = base + 900', () => { + const base = engine.getPrice()!.subtotal; + engine.select(ATTR_ARMARIO_PUERTAS, OPT_ARMARIO_LACADO, 3); + const after = engine.getPrice()!.subtotal; + // Con 2 puertas ya sumaba 700 en el estado inicial + // Con 3 puertas suma 900 → diferencia de 200 + expect(after - base).toBe(200); + }); +}); + +// ============================================================================ +// resolveView +// ============================================================================ + +describe('resolveView', () => { + + it('devuelve una URL para una vista válida', () => { + const result = engine.resolveView(SEC_SALON, VIEW_FRONT); + expect(typeof result.url).toBe('string'); + expect(result.url.length).toBeGreaterThan(0); + }); + + it('la URL cambia al cambiar una opción visual', () => { + const before = engine.resolveView(SEC_SALON, VIEW_FRONT).url; + engine.select(ATTR_SUELO, OPT_SUELO_PARQUET); + const after = engine.resolveView(SEC_SALON, VIEW_FRONT).url; + expect(after).not.toBe(before); + }); + + it('devuelve isFallback false para vista válida', () => { + const result = engine.resolveView(SEC_SALON, VIEW_FRONT); + expect(result.isFallback).toBe(false); + }); + + it('devuelve isFallback true sin objeto activo', () => { + const eng = makeEngine(); + const result = eng.resolveView(SEC_SALON, VIEW_FRONT); + expect(result.isFallback).toBe(true); + }); +}); + +// ============================================================================ +// isValid +// ============================================================================ + +describe('isValid', () => { + + it('estado inicial es válido', () => { + expect(engine.isValid()).toBe(true); + }); + + it('true sin objeto activo', () => { + const eng = makeEngine(); + expect(eng.isValid()).toBe(true); + }); +}); + +// ============================================================================ +// onChange — eventos +// ============================================================================ + +describe('onChange', () => { + + it('devuelve función de cleanup', () => { + const unsubscribe = engine.onChange(vi.fn()); + expect(typeof unsubscribe).toBe('function'); + }); + + it('cleanup elimina el listener', () => { + const listener = vi.fn(); + const unsubscribe = engine.onChange(listener); + unsubscribe(); + engine.select(ATTR_SUELO, OPT_SUELO_PARQUET); + expect(listener).not.toHaveBeenCalled(); + }); + + it('múltiples listeners reciben el mismo evento', () => { + const a = vi.fn(); + const b = vi.fn(); + engine.onChange(a); + engine.onChange(b); + engine.select(ATTR_SUELO, OPT_SUELO_PARQUET); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('error en listener no rompe el engine', () => { + engine.onChange(() => { throw new Error('listener crash'); }); + expect(() => engine.select(ATTR_SUELO, OPT_SUELO_PARQUET)).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/src/libs/vice/tests/pricing-dynamic.test.ts b/src/libs/vice/tests/pricing-dynamic.test.ts index 3964ecc..28743de 100644 --- a/src/libs/vice/tests/pricing-dynamic.test.ts +++ b/src/libs/vice/tests/pricing-dynamic.test.ts @@ -11,7 +11,7 @@ import type { SelectionMap } from '../engines/rule'; import { CATALOGO_VIVIENDAS, IDS_TEST, -} from './housing-catalog.fixture'; +} from '../fixtures/housing-catalog.ts'; const { OBJ_APARTAMENTO, @@ -36,6 +36,7 @@ const { OPT_ARMARIO_MADERA, } = IDS_TEST; + // ============================================================================ // SETUP // ============================================================================ @@ -57,15 +58,15 @@ function makeLogr(): Logr { /** Estado base con armario lacado 2 puertas */ function baseState(): SelectionMap { return { - [ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR, - [ATTR_SUELO] : OPT_SUELO_CERAMICA, - [ATTR_PARED] : OPT_PARED_BLANCO, - [ATTR_SANITARIO] : OPT_SANIT_BLANCO, - [ATTR_REVEST_BANO] : OPT_REVEST_CERAMICA, - [ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO, - [ATTR_MUEBLE_COC] : OPT_MUEBLE_BLANCO, - [ATTR_PUERTA] : OPT_PUERTA_LACADA, - [ATTR_ARMARIO_PUERTAS] : OPT_ARMARIO_LACADO, + [ATTR_CALIDAD] : { optionId: OPT_CALIDAD_ESTANDAR }, + [ATTR_SUELO] : { optionId: OPT_SUELO_CERAMICA }, + [ATTR_PARED] : { optionId: OPT_PARED_BLANCO }, + [ATTR_SANITARIO] : { optionId: OPT_SANIT_BLANCO }, + [ATTR_REVEST_BANO] : { optionId: OPT_REVEST_CERAMICA }, + [ATTR_ENCIMERA] : { optionId: OPT_ENCIMERA_GRANITO }, + [ATTR_MUEBLE_COC] : { optionId: OPT_MUEBLE_BLANCO }, + [ATTR_PUERTA] : { optionId: OPT_PUERTA_LACADA }, + [ATTR_ARMARIO_PUERTAS] : { optionId: OPT_ARMARIO_LACADO, quantity: 2 }, }; } @@ -103,7 +104,7 @@ describe('dynamicExpression — armario lacado', () => { describe('dynamicExpression — armario madera', () => { it('2 puertas madera → 500 × 2 = 1000', () => { - const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: OPT_ARMARIO_MADERA }; + const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: { optionId: OPT_ARMARIO_MADERA, quantity: 2 } }; const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS); expect(entry?.amount).toBe(1000); @@ -118,27 +119,15 @@ describe('dynamicExpression — armario madera', () => { describe('QuantifiableAttribute — cantidad', () => { it('3 puertas lacadas → 300 × 3 = 900 (descuento volumen)', () => { - // Modificamos el fixture en memoria cambiando la cantidad del atributo - const catalogWith3Doors = structuredClone(CATALOGO_VIVIENDAS); - const dormSection = catalogWith3Doors.objects[OBJ_APARTAMENTO] - .sections['sc:armario' as any] as any; - const attr = dormSection.attributes.find((a: any) => a.id === ATTR_ARMARIO_PUERTAS); - attr.quantity = 3; - - const result = engine.calculate(OBJ_APARTAMENTO, baseState(), catalogWith3Doors); + const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: { optionId: OPT_ARMARIO_LACADO, quantity: 3 } }; + const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS); expect(entry?.amount).toBe(900); }); it('3 puertas madera → 500 × 3 = 1500', () => { - const catalogWith3Doors = structuredClone(CATALOGO_VIVIENDAS); - const dormSection = catalogWith3Doors.objects[OBJ_APARTAMENTO] - .sections['sc:armario' as any] as any; - const attr = dormSection.attributes.find((a: any) => a.id === ATTR_ARMARIO_PUERTAS); - attr.quantity = 3; - - const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: OPT_ARMARIO_MADERA }; - const result = engine.calculate(OBJ_APARTAMENTO, state, catalogWith3Doors); + const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: { optionId: OPT_ARMARIO_MADERA, quantity: 3 } }; + const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS); expect(entry?.amount).toBe(1500); }); @@ -156,22 +145,22 @@ describe('QuantifiableAttribute — cantidad', () => { }); }); -// ============================================================================ -// dynamicExpression — fallback a baseAmount si la expresión falla -// ============================================================================ - describe('dynamicExpression — fallback a baseAmount', () => { - it('expresión malformada → usa baseAmount y logea error', () => { + it('expresión malformada → cae a baseAmount y el amount es 0', () => { const logr = makeLogr(); const eng = new PricingEngine(logr); const broken = structuredClone(CATALOGO_VIVIENDAS); - const opt = broken.options[OPT_ARMARIO_LACADO] as any; + // Romper la dynamicExpression en AttributeOption (tiene precedencia) + const sec = broken.objects[OBJ_APARTAMENTO].sections['sc:armario' as any] as any; + const attr = sec.attributes.find((a: any) => a.id === ATTR_ARMARIO_PUERTAS); + attr.options[0].dynamicExpression = { 'operadorFalso': null }; + // También romper la de OptionDefinition por si acaso + const opt = broken.options[OPT_ARMARIO_LACADO] as any; opt.pricing.dynamicExpression = { 'operadorFalso': null }; - // operadorFalso devuelve null → no es number → cae a baseAmount (0) + // operadorFalso → null → no es number → cae a baseAmount = 0 → no aparece en breakdown const result = eng.calculate(OBJ_APARTAMENTO, baseState(), broken); const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS); - // baseAmount es 0 → no aparece en breakdown expect(entry).toBeUndefined(); }); }); \ No newline at end of file diff --git a/src/libs/vice/tests/pricing.test.ts b/src/libs/vice/tests/pricing.test.ts index 66cba08..461661a 100644 --- a/src/libs/vice/tests/pricing.test.ts +++ b/src/libs/vice/tests/pricing.test.ts @@ -3,15 +3,16 @@ * PRICING ENGINE — TESTS * ============================================================================ */ -import type { Logr } from '@/libs/logr'; + import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { PricingEngine } from '../engines/pricing'; -import type { SelectionMap } from '../engines/rule'; +import type { Logr } from '@/libs/logr'; +import type { SelectionMap } from '../engines'; import type { TaxInfo } from '../types'; +import { PricingEngine } from '../engines'; import { CATALOGO_VIVIENDAS, IDS_TEST, -} from './housing-catalog.fixture'; +} from '@/libs/vice/fixtures'; const { OBJ_APARTAMENTO, @@ -23,6 +24,7 @@ const { ATTR_ENCIMERA, ATTR_MUEBLE_COC, ATTR_PUERTA, + ATTR_M2_SALON, OPT_CALIDAD_ESTANDAR, OPT_CALIDAD_PREMIUM, OPT_CALIDAD_LUJO, @@ -62,14 +64,14 @@ function makeLogr(): Logr { /** Estado base — opciones de precio conocido para verificar sumas */ function baseState(): SelectionMap { return { - [ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR, // 0 - [ATTR_SUELO] : OPT_SUELO_CERAMICA, // 30 - [ATTR_PARED] : OPT_PARED_BLANCO, // 0 - [ATTR_SANITARIO] : OPT_SANIT_BLANCO, // 0 - [ATTR_REVEST_BANO]: OPT_REVEST_CERAMICA, // 40 - [ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO, // 400 - [ATTR_MUEBLE_COC] : OPT_MUEBLE_BLANCO, // 3500 - [ATTR_PUERTA] : OPT_PUERTA_LACADA, // 250 + [ATTR_CALIDAD] : { optionId: OPT_CALIDAD_ESTANDAR }, // 0 + [ATTR_SUELO] : { optionId: OPT_SUELO_CERAMICA }, // 30 + [ATTR_PARED] : { optionId: OPT_PARED_BLANCO }, // 0 + [ATTR_SANITARIO] : { optionId: OPT_SANIT_BLANCO }, // 0 + [ATTR_REVEST_BANO]: { optionId: OPT_REVEST_CERAMICA }, // 40 + [ATTR_ENCIMERA] : { optionId: OPT_ENCIMERA_GRANITO }, // 400 + [ATTR_MUEBLE_COC] : { optionId: OPT_MUEBLE_BLANCO }, // 3500 + [ATTR_PUERTA] : { optionId: OPT_PUERTA_LACADA }, // 250 }; // subtotal base = 30 + 40 + 400 + 3500 + 250 = 4220 } @@ -112,38 +114,38 @@ describe('cálculo básico', () => { }); it('calidad premium suma 15000 al total', () => { - const state = { ...baseState(), [ATTR_CALIDAD]: OPT_CALIDAD_PREMIUM }; // +15000 + const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } }; // +15000 const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); expect(result.subtotal).toBe(4220 + 15000); }); it('calidad lujo suma 35000 al total', () => { - const state = { ...baseState(), [ATTR_CALIDAD]: OPT_CALIDAD_LUJO }; // +35000 + const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_LUJO } }; // +35000 const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); expect(result.subtotal).toBe(4220 + 35000); }); it('cambiar suelo de cerámica a parquet suma 30 - 30 + 45', () => { const base = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); - const state = { ...baseState(), [ATTR_SUELO]: OPT_SUELO_PARQUET }; // 45 en lugar de 30 + const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_PARQUET } }; // 45 en lugar de 30 const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); expect(result.subtotal).toBe(base.subtotal - 30 + 45); }); it('pared efecto piedra añade 800', () => { - const state = { ...baseState(), [ATTR_PARED]: OPT_PARED_PIEDRA }; // +800 + const state = { ...baseState(), [ATTR_PARED]: { optionId: OPT_PARED_PIEDRA } }; // +800 const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); expect(result.subtotal).toBe(4220 + 800); }); it('sanitario negro añade 1200', () => { - const state = { ...baseState(), [ATTR_SANITARIO]: OPT_SANIT_NEGRO }; // +1200 + const state = { ...baseState(), [ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO } }; // +1200 const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); expect(result.subtotal).toBe(4220 + 1200); }); it('puerta cristal en lugar de lacada — diferencia de 250', () => { - const state = { ...baseState(), [ATTR_PUERTA]: OPT_PUERTA_CRISTAL }; // 500 en lugar de 250 + const state = { ...baseState(), [ATTR_PUERTA]: { optionId: OPT_PUERTA_CRISTAL } }; // 500 en lugar de 250 const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); expect(result.subtotal).toBe(4220 - 250 + 500); }); @@ -228,7 +230,7 @@ describe('breakdown', () => { it('el breakdown refleja el cambio de opción', () => { const base = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); - const state = { ...baseState(), [ATTR_REVEST_BANO]: OPT_REVEST_MARMOL }; // 120 en lugar de 40 + const state = { ...baseState(), [ATTR_REVEST_BANO]: { optionId: OPT_REVEST_MARMOL } }; // 120 en lugar de 40 const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const baseEntry = base.breakdown.find(e => e.attrId === ATTR_REVEST_BANO); @@ -263,7 +265,7 @@ describe('casos límite', () => { }); it('encimera silestone suma 600', () => { - const state = { ...baseState(), [ATTR_ENCIMERA]: OPT_ENCIMERA_SILESTONE }; + const state = { ...baseState(), [ATTR_ENCIMERA]: { optionId: OPT_ENCIMERA_SILESTONE } }; const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const entry = result.breakdown.find(e => e.attrId === ATTR_ENCIMERA); expect(entry?.amount).toBe(600); diff --git a/src/libs/vice/tests/rule.test.ts b/src/libs/vice/tests/rule.test.ts new file mode 100644 index 0000000..2c5d9d0 --- /dev/null +++ b/src/libs/vice/tests/rule.test.ts @@ -0,0 +1,329 @@ +/** + * ============================================================================ + * RULE ENGINE — TESTS + * ============================================================================ + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RuleEngine } from '../engines/rule'; +import type { Logr } from '@/libs/logr'; +import type { SelectionMap } from '../engines/rule'; +import { + CATALOGO_VIVIENDAS, + IDS_TEST +} from '../fixtures/housing-catalog.ts'; + +const { + ATTR_CALIDAD, + ATTR_SUELO, + ATTR_REVEST_BANO, + ATTR_SANITARIO, + ATTR_ENCIMERA, + ATTR_PARED, + ATTR_PUERTA, + OPT_CALIDAD_ESTANDAR, + OPT_CALIDAD_PREMIUM, + OPT_CALIDAD_LUJO, + OPT_SUELO_MICROCEMENTO, + OPT_SUELO_PARQUET, + OPT_REVEST_MARMOL, + OPT_REVEST_MICROCEMENTO, + OPT_REVEST_CERAMICA, + OPT_SANIT_NEGRO, + OPT_SANIT_BLANCO, + OPT_ENCIMERA_MADERA, + OPT_ENCIMERA_GRANITO, + OPT_PARED_BLANCO, + OPT_PUERTA_LACADA, +} = IDS_TEST; + +// ============================================================================ +// SETUP +// ============================================================================ + +function makeLogr(): Logr { + return { + debug : vi.fn(), + info : vi.fn(), + warn : vi.fn(), + error : vi.fn(), + getLogs : vi.fn(), + clear : vi.fn(), + serialize: vi.fn(), + setLevel : vi.fn(), + setMaxLogs: vi.fn(), + } as unknown as Logr; +} + +const rules = Object.values(CATALOGO_VIVIENDAS.rules ?? {}); + +/** + * Estado base válido — todas las opciones compatibles entre sí. + * Calidad premium + parquet + pared blanca + sanitario blanco + + * revestimiento cerámica + encimera granito + puerta lacada. + */ +function baseState(): SelectionMap { + return { + [ATTR_CALIDAD] : OPT_CALIDAD_PREMIUM, + [ATTR_SUELO] : OPT_SUELO_PARQUET, + [ATTR_PARED] : OPT_PARED_BLANCO, + [ATTR_SANITARIO] : OPT_SANIT_BLANCO, + [ATTR_REVEST_BANO]: { optionId: OPT_REVEST_CERAMICA }, + [ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO, + [ATTR_PUERTA] : OPT_PUERTA_LACADA, + }; +} + +let engine: RuleEngine; + +beforeEach(() => { + engine = new RuleEngine(makeLogr()); +}); + +// ============================================================================ +// isValid — estado base +// ============================================================================ + +describe('isValid', () => { + + it('estado base compatible es válido', () => { + expect(engine.isValid(rules, baseState())).toBe(true); + }); + + it('calidad estándar + mármol → inválido (error)', () => { + const state = { + ...baseState(), + [ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR, + [ATTR_REVEST_BANO]: { optionId: OPT_REVEST_MARMOL }, + }; + expect(engine.isValid(rules, state)).toBe(false); + }); + + it('sanitario negro + calidad estándar → inválido (error)', () => { + const state = { + ...baseState(), + [ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR, + [ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO }, + }; + expect(engine.isValid(rules, state)).toBe(false); + }); + + it('sanitario negro + calidad premium → válido', () => { + const state = { + ...baseState(), + [ATTR_CALIDAD] : OPT_CALIDAD_PREMIUM, + [ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO }, + }; + expect(engine.isValid(rules, state)).toBe(true); + }); + + it('encimera madera + sanitario negro → inválido (warning no bloquea)', () => { + // warning no bloquea isValid — solo los errores lo hacen + const state = { + ...baseState(), + [ATTR_ENCIMERA] : OPT_ENCIMERA_MADERA, + [ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO }, + }; + // severity: 'warning' → isValid sigue siendo true + expect(engine.isValid(rules, state)).toBe(true); + }); + + it('estado vacío sin reglas activas → válido', () => { + expect(engine.isValid([], baseState())).toBe(true); + }); +}); + +// ============================================================================ +// getViolations — detección de violaciones +// ============================================================================ + +describe('getViolations', () => { + + it('estado base compatible → sin violaciones', () => { + expect(engine.getViolations(rules, baseState())).toHaveLength(0); + }); + + it('calidad estándar + mármol → violación de error', () => { + const state = { + ...baseState(), + [ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR, + [ATTR_REVEST_BANO]: { optionId: OPT_REVEST_MARMOL }, + }; + const violations = engine.getViolations(rules, state); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].severity).toBe('error'); + }); + + it('las violaciones se ordenan: error primero, warning después', () => { + // sanitario negro + calidad estándar genera error (negro_requiere_premium) + // encimera madera + sanitario negro genera warning (madera_no_bano) + const state = { + ...baseState(), + [ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR, + [ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO }, + [ATTR_ENCIMERA] : OPT_ENCIMERA_MADERA, + }; + const violations = engine.getViolations(rules, state); + const severities = violations.map(v => v.severity); + const errorIdx = severities.indexOf('error'); + const warnIdx = severities.indexOf('warning'); + if (errorIdx !== -1 && warnIdx !== -1) { + expect(errorIdx).toBeLessThan(warnIdx); + } + }); + + it('calidad lujo sin mármol → violación de warning (require)', () => { + const state = { + ...baseState(), + [ATTR_CALIDAD] : OPT_CALIDAD_LUJO, + [ATTR_REVEST_BANO]: { optionId: OPT_REVEST_CERAMICA }, + }; + const violations = engine.getViolations(rules, state); + expect(violations.some(v => v.severity === 'warning')).toBe(true); + }); + + it('contiene referencia a la regla violada', () => { + const state = { + ...baseState(), + [ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR, + [ATTR_REVEST_BANO]: { optionId: OPT_REVEST_MARMOL }, + }; + const violations = engine.getViolations(rules, state); + expect(violations[0].rule).toBeDefined(); + expect(violations[0].rule.id).toBeTruthy(); + }); +}); + +// ============================================================================ +// isValueAllowed +// ============================================================================ + +describe('isValueAllowed', () => { + + it('mármol permitido con calidad premium', () => { + const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } }; + expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules, state)).toBe(true); + }); + + it('mármol prohibido con calidad estándar', () => { + const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_ESTANDAR } }; + expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules, state)).toBe(false); + }); + + it('sanitario negro prohibido con calidad estándar', () => { + const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_ESTANDAR } }; + expect(engine.isValueAllowed(ATTR_SANITARIO, OPT_SANIT_NEGRO, rules, state)).toBe(false); + }); + + it('sanitario negro permitido con calidad premium', () => { + const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } }; + expect(engine.isValueAllowed(ATTR_SANITARIO, OPT_SANIT_NEGRO, rules, state)).toBe(true); + }); + + it('microcemento en baño permitido con suelo microcemento (allow)', () => { + const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_MICROCEMENTO } }; + expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MICROCEMENTO, rules, state)).toBe(true); + }); + + it('mármol en baño no permitido con suelo microcemento (allow limita lista)', () => { + const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_MICROCEMENTO } }; + expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules, state)).toBe(false); + }); + + it('cualquier valor permitido para atributo sin reglas activas', () => { + expect(engine.isValueAllowed(ATTR_PARED, OPT_PARED_BLANCO, rules, baseState())).toBe(true); + }); +}); + +// ============================================================================ +// getAllowedValues +// ============================================================================ + +describe('getAllowedValues', () => { + + it('devuelve undefined si no hay restricción de allow activa', () => { + const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_PARQUET } }; + expect(engine.getAllowedValues(ATTR_REVEST_BANO, rules, state)).toBeUndefined(); + }); + + it('con suelo microcemento devuelve lista restringida para revest_bano', () => { + const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_MICROCEMENTO } }; + const allowed = engine.getAllowedValues(ATTR_REVEST_BANO, rules, state); + expect(allowed).toBeDefined(); + expect(allowed).toContain(OPT_REVEST_MICROCEMENTO); + expect(allowed).toContain(OPT_REVEST_CERAMICA); + expect(allowed).not.toContain(OPT_REVEST_MARMOL); + }); +}); + +// ============================================================================ +// isRequired +// ============================================================================ + +describe('isRequired', () => { + + it('revest_bano no requerido con calidad premium', () => { + const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } }; + expect(engine.isRequired(ATTR_REVEST_BANO, rules, state)).toBe(false); + }); + + it('revest_bano requerido con calidad lujo', () => { + const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_LUJO } }; + expect(engine.isRequired(ATTR_REVEST_BANO, rules, state)).toBe(true); + }); + + it('atributo sin regla de require → no requerido', () => { + expect(engine.isRequired(ATTR_PARED, rules, baseState())).toBe(false); + }); +}); + +// ============================================================================ +// evaluateAll — prioridad +// ============================================================================ + +describe('evaluateAll — prioridad', () => { + + it('las reglas se devuelven ordenadas por prioridad descendente', () => { + const results = engine.evaluateAll(rules, baseState()); + const priorities = results.map(r => r.rule.priority); + for (let i = 0; i < priorities.length - 1; i++) { + expect(priorities[i]).toBeGreaterThanOrEqual(priorities[i + 1]); + } + }); + + it('regla con condición que no se cumple → triggered: false', () => { + // Con calidad premium la regla estandar_no_lujo no se activa + const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } }; + const results = engine.evaluateAll(rules, state); + const estandarNoLujo = results.find(r => r.rule.id === IDS_TEST.RULE_ESTANDAR_NO_LUJO); + expect(estandarNoLujo?.triggered).toBe(false); + }); + + it('regla con condición que se cumple → triggered: true', () => { + const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_ESTANDAR } }; + const results = engine.evaluateAll(rules, state); + const estandarNoLujo = results.find(r => r.rule.id === IDS_TEST.RULE_ESTANDAR_NO_LUJO); + expect(estandarNoLujo?.triggered).toBe(true); + }); +}); + +// ============================================================================ +// evaluateRule — error handling +// ============================================================================ + +describe('evaluateRule — error handling', () => { + + it('regla con condición malformada → triggered: false y logea error', () => { + const logr = makeLogr(); + const eng = new RuleEngine(logr); + const badRule = { + ...Object.values(CATALOGO_VIVIENDAS.rules ?? {})[0], + id : 'rl:test_bad' as any, + condition: { 'operadorQueNoExiste': null } as any, + }; + const result = eng.evaluateRule(badRule, baseState()); + // El evaluador devuelve null para operadores desconocidos → triggered: false + expect(result.triggered).toBe(false); + }); +}); + diff --git a/src/libs/vice/tests/selection-state.test.ts b/src/libs/vice/tests/selection-state.test.ts index 152a2ff..92af7a7 100644 --- a/src/libs/vice/tests/selection-state.test.ts +++ b/src/libs/vice/tests/selection-state.test.ts @@ -10,7 +10,7 @@ import type { Logr } from '@/libs/logr'; import { CATALOGO_VIVIENDAS, IDS_TEST, -} from './housing-catalog.fixture'; +} from '../fixtures/housing-catalog.ts'; const { OBJ_APARTAMENTO, @@ -33,6 +33,7 @@ const { OPT_REVEST_MICROCEMENTO, } = IDS_TEST; + // ============================================================================ // SETUP // ============================================================================ @@ -69,12 +70,12 @@ describe('inicialización', () => { it('carga los defaultValues de todos los atributos', () => { const state = makeState(); - // Todos los atributos dynamic tienen defaultValue en el fixture - expect(state.getValue(ATTR_CALIDAD)).toBe(OPT_CALIDAD_ESTANDAR); - expect(state.getValue(ATTR_SUELO)).toBe(OPT_SUELO_CERAMICA); - expect(state.getValue(ATTR_PARED)).toBe(OPT_PARED_BLANCO); - expect(state.getValue(ATTR_SANITARIO)).toBe(OPT_SANIT_BLANCO); - expect(state.getValue(ATTR_REVEST_BANO)).toBe(OPT_REVEST_CERAMICA); + // getValue devuelve OptionSelection — comparamos el optionId + expect((state.getValue(ATTR_CALIDAD) as any).optionId).toBe(OPT_CALIDAD_ESTANDAR); + expect((state.getValue(ATTR_SUELO) as any).optionId).toBe(OPT_SUELO_CERAMICA); + expect((state.getValue(ATTR_PARED) as any).optionId).toBe(OPT_PARED_BLANCO); + expect((state.getValue(ATTR_SANITARIO) as any).optionId).toBe(OPT_SANIT_BLANCO); + expect((state.getValue(ATTR_REVEST_BANO) as any).optionId).toBe(OPT_REVEST_CERAMICA); }); it('el estado inicial es válido', () => { @@ -120,7 +121,7 @@ describe('select — valores permitidos', () => { const snapshot = state.getSelection(); state.select(ATTR_SUELO, OPT_SUELO_PARQUET); // snapshot no debe haber cambiado - expect(snapshot[ATTR_SUELO]).toBe(OPT_SUELO_CERAMICA); + expect((snapshot[ATTR_SUELO] as any).optionId).toBe(OPT_SUELO_CERAMICA); }); }); @@ -139,7 +140,7 @@ describe('select — valores prohibidos', () => { it('el valor no cambia al rechazar', () => { const state = makeState(); state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); - expect(state.getValue(ATTR_REVEST_BANO)).toBe(OPT_REVEST_CERAMICA); + expect((state.getValue(ATTR_REVEST_BANO) as any).optionId).toBe(OPT_REVEST_CERAMICA); }); it('rechaza sanitario negro con calidad estándar', () => { @@ -217,7 +218,7 @@ describe('getAttributeState', () => { it('devuelve el valor actual del atributo', () => { const state = makeState(); const s = state.getAttributeState(ATTR_SUELO); - expect(s?.value).toBe(OPT_SUELO_CERAMICA); + expect((s?.value as any).optionId).toBe(OPT_SUELO_CERAMICA); }); it('devuelve las opciones del atributo', () => { @@ -297,5 +298,4 @@ describe('getAllAttributeStates', () => { expect(states.has(ATTR_SUELO)).toBe(true); expect([...states.keys()].filter(k => k === ATTR_SUELO)).toHaveLength(1); }); -}); - +}); \ No newline at end of file diff --git a/src/libs/vice/tests/template-resolver.test.ts b/src/libs/vice/tests/template-resolver.test.ts index 5b8754b..85ca4ae 100644 --- a/src/libs/vice/tests/template-resolver.test.ts +++ b/src/libs/vice/tests/template-resolver.test.ts @@ -11,7 +11,7 @@ import type { SelectionMap } from '../engines/rule'; import { CATALOGO_VIVIENDAS, IDS_TEST, -} from './housing-catalog.fixture'; +} from '../fixtures/housing-catalog.ts'; const { OBJ_APARTAMENTO, diff --git a/src/libs/vice/types/attribute.ts b/src/libs/vice/types/attribute.ts index 8e9db06..b36fecd 100644 --- a/src/libs/vice/types/attribute.ts +++ b/src/libs/vice/types/attribute.ts @@ -14,29 +14,24 @@ import type { JsonLogic } from './json-logic'; // ATTRIBUTE DISPLAY // ============================================================================ -/** - * Configuración de presentación del atributo en UI. - * Los flags pueden ser estáticos o evaluados dinámicamente con JsonLogic. - */ export interface AttributeDisplay { /** Si se muestra en UI */ - uiVisible? : boolean | JsonLogic; + uiVisible? : boolean | JsonLogic; /** - * Si el valor de este atributo afecta la imagen renderizada. - * Los atributos con affectsVisual: true son candidatos a aparecer - * como placeholders en los templates de vista. + * Si el valor afecta la imagen renderizada. + * Candidatos a aparecer como placeholders en templates de vista. */ - affectsVisual? : boolean | JsonLogic; + affectsVisual?: boolean | JsonLogic; /** Si afecta al precio */ - affectsPrice? : boolean | JsonLogic; + affectsPrice? : boolean | JsonLogic; /** Si es de solo lectura */ - readonly? : boolean; + readonly? : boolean; /** Orden de visualización */ - order? : number; + order? : number; /** Icono */ - icon? : string; + icon? : string; /** Texto de ayuda */ - helpText? : I18nString; + helpText? : I18nString; } // ============================================================================ @@ -58,10 +53,9 @@ export type AttributeCategory = export interface BaseAttribute { id : AttrID; /** - * Identificador legible usado en placeholders de templates. - * Debe ser único dentro del scope (sección u objeto). - * - * @example 'suelo' | 'pintura' | 'carpinteria' + * Identificador legible para placeholders de templates. + * Único dentro del scope (sección u objeto). + * @example 'suelo' | 'pintura' | 'luz' */ code : Code; name : I18nString; @@ -71,9 +65,67 @@ export interface BaseAttribute { metadata? : Metadata; } +// ============================================================================ +// QUANTITY CONFIG +// Configuración de cantidad para opciones cuantificables. +// La cantidad vive en AttributeOption — es la OPCIÓN la que es cuantificable, +// no el atributo. +// +// Ejemplo: +// Atributo: 'iluminación' +// Opciones: +// - 'luz_analogica' → quantity: undefined (no cuantificable) +// - 'luz_led' → quantity: { default: 1, min: 1, max: 4, step: 1, unit: 'puntos' } +// - 'puertas_lacado' → quantity: { default: 2, min: 2, max: 3, step: 1, unit: 'puertas' } +// ============================================================================ + +export interface QuantityConfig { + /** Valor por defecto */ + default : number; + /** Mínimo permitido */ + min : number; + /** Máximo permitido */ + max : number; + /** Incremento — por defecto 1 */ + step? : number; + /** Unidad de medida para mostrar en UI */ + unit? : I18nString; +} + +// ============================================================================ +// ATTRIBUTE OPTION +// ============================================================================ + +export interface AttributeOption { + optionId: OptionID; + priority?: number; + + /** + * Si está presente, esta opción es cuantificable. + * El usuario elige la opción Y una cantidad dentro del rango. + * La cantidad se usa en Pricing.dynamicExpression como { var: 'quantity' }. + */ + quantity?: QuantityConfig; + + /** + * Override de precio fijo para esta opción en este atributo concreto. + * Tiene precedencia sobre Pricing.baseAmount de la OptionDefinition. + */ + pricingOverride?: number; + + /** + * Override de expresión dinámica para esta opción en este atributo concreto. + * Tiene precedencia sobre Pricing.dynamicExpression de la OptionDefinition. + * Contexto disponible: { quantity, baseAmount, attributes } + */ + dynamicExpression?: JsonLogic; + + metadata?: Metadata; +} + // ============================================================================ // FIXED ATTRIBUTE -// Valor fijo — no lo elige el usuario +// Valor fijo — no lo elige el usuario. Ej: m² de una habitación. // ============================================================================ export interface FixedAttribute extends BaseAttribute { @@ -85,76 +137,41 @@ export interface FixedAttribute extends BaseAttribute { // ============================================================================ // DYNAMIC ATTRIBUTE -// El usuario elige entre opciones del catálogo +// El usuario elige entre opciones del catálogo. +// Si alguna opción tiene quantity, el usuario también elige la cantidad. // ============================================================================ export interface DynamicAttribute extends BaseAttribute { - type : 'dynamic'; - dataType : 'reference'; - defaultValue : OptionID; + type : 'dynamic'; + dataType : 'reference'; + defaultValue: OptionID; + /** + * Cantidad por defecto para la opción defaultValue, + * si esta opción es cuantificable. + */ + defaultQuantity?: number; options : AttributeOption[]; required? : boolean; /** * Si este atributo controla la disponibilidad de secciones. * Ejemplo: elegir 'con_terraza' activa la sección 'sc:terraza' */ - controls? : SectionID[]; + controls? : SectionID[]; + /** + * Expresión para filtrar opciones disponibles dinámicamente. + * Complementa al RuleEngine para casos de filtrado simple. + */ filterExpression?: JsonLogic; + /** + * Si el usuario puede modificar la cantidad de la opción seleccionada. + * Por defecto true si la opción seleccionada tiene quantity. + */ + userConfigurableQuantity?: boolean; } -export interface AttributeOption { - optionId : OptionID; - priority? : number; - pricingOverride?: number; - metadata? : Metadata; -} - -// ============================================================================ -// QUANTIFIABLE ATTRIBUTE -// Atributo con cantidad — puede ser puramente numérico o una opción con cantidad -// -// Dos variantes: -// -// 1. Numérico puro (dataType: 'number'): -// Valor numérico directo, sin opciones. Ej: m² de suelo, vatios de iluminación. -// -// 2. Opción con cantidad (dataType: 'reference'): -// El usuario elige una opción del catálogo Y una cantidad. -// Ej: armario con 2 o 3 puertas lacadas. -// El precio se calcula con dynamicExpression usando { quantity, ...state } -// ============================================================================ - -export interface QuantifiableNumericAttribute extends BaseAttribute { - type : 'quantifiable'; - dataType : 'number'; - quantity : number; - minQuantity? : number; - maxQuantity? : number; - unit? : I18nString; - userConfigurable?: boolean; - pricePerUnit? : number; -} - -export interface QuantifiableOptionAttribute extends BaseAttribute { - type : 'quantifiable'; - dataType : 'reference'; - quantity : number; - minQuantity? : number; - maxQuantity? : number; - unit? : I18nString; - userConfigurable?: boolean; - defaultValue : OptionID; - options : AttributeOption[]; - required? : boolean; -} - -export type QuantifiableAttribute = - | QuantifiableNumericAttribute - | QuantifiableOptionAttribute; - // ============================================================================ // COMPUTED ATTRIBUTE -// Calculado a partir de otros atributos — nunca editable por el usuario +// Calculado a partir de otros atributos — nunca editable por el usuario. // ============================================================================ export interface ComputedAttribute extends BaseAttribute { @@ -172,5 +189,4 @@ export interface ComputedAttribute extends BaseAttribute { export type Attribute = | FixedAttribute | DynamicAttribute - | QuantifiableAttribute | ComputedAttribute; \ No newline at end of file diff --git a/src/libs/vice/types/ids.ts b/src/libs/vice/types/ids.ts index 741f3de..31cca2d 100644 --- a/src/libs/vice/types/ids.ts +++ b/src/libs/vice/types/ids.ts @@ -16,14 +16,3 @@ export type HotspotID = ID<'hs'>; export type CatalogID = ID<'ct'>; -// ============================================================================ -// TYPE GUARDS -// ============================================================================ - -export function isOptionID(value: unknown): value is OptionID { - return typeof value === 'string' && value.startsWith('op:'); -} - -export function isAttrID(value: unknown): value is AttrID { - return typeof value === 'string' && value.startsWith('at:'); -} \ No newline at end of file diff --git a/src/libs/vice/types/index.ts b/src/libs/vice/types/index.ts index 93f625b..497da74 100644 --- a/src/libs/vice/types/index.ts +++ b/src/libs/vice/types/index.ts @@ -1,16 +1,22 @@ -export * from './primitives.ts'; -export * from './ids.ts'; -export * from './datas.ts'; + export * from './api.ts'; export * from './attribute.ts'; +export * from './catalog.ts'; +export * from './datas.ts'; export * from './hotspot.ts'; +export * from './ids.ts'; export * from './json-logic.ts'; +export * from './object.ts'; export * from './option.ts'; export * from './pricing.ts'; +export * from './primitives.ts'; export * from './rule.ts'; export * from './section.ts'; +export * from './selection.ts'; export * from './view.ts'; -export * from './object.ts'; -export * from './catalog.ts'; \ No newline at end of file + + + + diff --git a/src/libs/vice/types/selection.ts b/src/libs/vice/types/selection.ts new file mode 100644 index 0000000..a557ebc --- /dev/null +++ b/src/libs/vice/types/selection.ts @@ -0,0 +1,41 @@ +// ============================================================================ +// OPTION SELECTION +// ============================================================================ + +import type {AttrID, OptionID} from "./ids.ts"; +import type {Value} from "./primitives"; + +// ============================================================================ +// OPTION SELECTION +// ============================================================================ + +/** + * Selección de una opción — optionId + cantidad opcional. + * La cantidad solo está presente si la AttributeOption tiene QuantityConfig. + * + * @example + * // Opción simple + * { optionId: 'op:suelo_parquet' } + * + * // Opción cuantificable + * { optionId: 'op:luz_led', quantity: 3 } + */ +export interface OptionSelection { + optionId : OptionID; + quantity?: number; +} + +// ============================================================================ +// SELECTION MAP +// ============================================================================ + +/** + * Estado de selección del usuario. + * Clave: AttrID — Valor: OptionSelection o Value (para FixedAttribute) + * + * Los atributos FixedAttribute y ComputedAttribute usan Value directamente. + * Los atributos DynamicAttribute usan OptionSelection. + */ +export type SelectionMap = Record; + +