First Commit

master
dev 8 months ago
parent 70655bbffc
commit 85a72b7f12

@ -104,11 +104,14 @@ export const TEMPLATE_RESOLVER_WARNINGS = {
export const PRICING_ERRORS = {
OPTION_NOT_FOUND: (optionId: OptionID): string =>
OBJECT_NOT_FOUND: (objectId: string): string =>
`Object "${objectId}" not found in catalog — cannot calculate price`,
OPTION_NOT_FOUND: (optionId: string): string =>
`Option "${optionId}" not found in catalog — cannot calculate price`,
EXPRESSION_FAILED: (optionId: OptionID, error: string): string =>
`Error evaluating price expression for option "${optionId}": ${error}`,
EXPRESSION_FAILED: (attrId: string, error: string): string =>
`Error evaluating price expression for attribute "${attrId}": ${error}`,
} as const;
@ -118,6 +121,9 @@ export const PRICING_ERRORS = {
export const SELECTION_ERRORS = {
OBJECT_NOT_FOUND: (objectId: string): string =>
`Object "${objectId}" not found in catalog`,
ATTRIBUTE_NOT_FOUND: (attrId: AttrID): string =>
`Attribute "${attrId}" not found`,

@ -0,0 +1,254 @@
/**
* ============================================================================
* PRICING ENGINE
* ============================================================================
*
* Calcula el precio total de una configuración sumando el precio
* de cada opción seleccionada con affectsPrice: true.
*
* Dos tipos de precio por opción:
* - Fijo → Pricing.baseAmount
* - Variable → Pricing.dynamicExpression evaluada con { quantity, ...state }
*
* El tax se aplica al total, no por opción.
*/
import type {
ConfigurationCatalog,
ConfigurableObject,
Attribute,
QuantifiableAttribute,
TaxInfo, ObjectID,
} from '../types';
import type { Logr } from '@/libs/logr';
import type { SelectionMap } from './rule';
import { JsonLogicEvaluator } from './evaluator';
import { ENGINE_CATEGORIES, PRICING_ERRORS } from '../consts/messages';
// ============================================================================
// TYPES
// ============================================================================
/**
* Precio desglosado de una opción individual.
*/
export interface OptionPricingResult {
attrId : string;
optionId: string;
amount : number;
isDynamic: boolean;
}
/**
* Resultado de precio total de la configuración.
*/
export interface PricingResult {
/** Suma de todas las opciones antes de impuestos */
subtotal : number;
/** Importe del impuesto — 0 si tax.included o no hay tax */
taxAmount : number;
/** subtotal + taxAmount */
total : number;
/** Desglose por opción */
breakdown : OptionPricingResult[];
/** Moneda — del primer atributo con pricing.currency o undefined */
currency? : string;
/** Info fiscal aplicada */
tax? : TaxInfo;
/** Si alguna opción tiene baseAmount 'consultation' */
hasConsultation : boolean;
}
// ============================================================================
// PRICING ENGINE
// ============================================================================
export class PricingEngine {
private readonly logr : Logr;
private readonly evaluator: JsonLogicEvaluator;
constructor(logr: Logr) {
this.logr = logr;
this.evaluator = new JsonLogicEvaluator(logr);
}
/**
* Calcula el precio total de la configuración actual.
*
* Proceso:
* 1. Recorre todos los atributos del objeto (globales + secciones)
* con affectsPrice: true
* 2. Para cada atributo obtiene la opción seleccionada del estado
* 3. Calcula el precio de esa opción (fijo o dinámico)
* 4. Suma todos los precios
* 5. Aplica el tax al total
*/
calculate(
objectId: ObjectID,
state : SelectionMap,
catalog : ConfigurationCatalog,
tax? : TaxInfo,
): PricingResult {
const object = catalog.objects[objectId as keyof typeof catalog.objects];
if (!object) {
this.logr.error(
ENGINE_CATEGORIES.PRICING,
PRICING_ERRORS.OBJECT_NOT_FOUND(objectId)
);
return this.emptyResult(tax);
}
const breakdown : OptionPricingResult[] = [];
let subtotal = 0;
let hasConsultation = false;
let currency : string | undefined;
// Recopilar todos los atributos — globales + de cada sección
const allAttrs = this.collectAttrs(object);
for (const attr of allAttrs) {
if (!attr.display.affectsPrice) continue;
const selectedValue = state[attr.id];
if (!selectedValue) continue;
const optionId = String(selectedValue);
const optionDef = catalog.options[optionId as keyof typeof catalog.options];
if (!optionDef) {
this.logr.error(
ENGINE_CATEGORIES.PRICING,
PRICING_ERRORS.OPTION_NOT_FOUND(optionId),
{ attrId: attr.id, optionId }
);
continue;
}
const { pricing } = optionDef;
if (!pricing) continue;
// Capturar currency del primer atributo que la tenga
if (!currency && pricing.currency) currency = pricing.currency;
if (pricing.baseAmount === 'consultation') {
hasConsultation = true;
continue;
}
const amount = this.resolveAmount(attr, pricing.baseAmount, pricing.dynamicExpression, state);
subtotal += amount;
if (amount !== 0) {
breakdown.push({
attrId : attr.id,
optionId,
amount,
isDynamic: !!pricing.dynamicExpression,
});
}
}
return this.buildResult(subtotal, breakdown, currency, tax, hasConsultation);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Resuelve el importe de una opción.
* Si tiene dynamicExpression la evalúa con quantity + state.
* Si no, devuelve baseAmount directamente.
*/
private resolveAmount(
attr : Attribute,
baseAmount : number,
dynamicExpression: unknown,
state : SelectionMap,
): number {
if (!dynamicExpression) return baseAmount;
try {
const quantity = this.isQuantifiable(attr) ? attr.quantity : undefined;
const data = {
quantity,
baseAmount,
attributes: this.normalizeStateKeys(state),
};
const result = this.evaluator.evaluate(dynamicExpression as any, data);
return typeof result === 'number' ? result : baseAmount;
} catch (error) {
this.logr.error(
ENGINE_CATEGORIES.PRICING,
PRICING_ERRORS.EXPRESSION_FAILED(attr.id, String(error)),
{ attrId: attr.id }
);
return baseAmount;
}
}
/**
* Recoge todos los atributos del objeto — globales + de cada sección.
* Deduplica por attrId — el mismo atributo puede estar en varias secciones
* (ej: at:suelo en salón, dormitorio y cocina) pero solo se suma una vez.
*/
private collectAttrs(object: ConfigurableObject): Attribute[] {
const seen = new Set<string>();
const attrs : Attribute[] = [];
for (const attr of [
...object.attributes,
...Object.values(object.sections).flatMap(s => s.attributes ?? [])
]) {
if (!seen.has(attr.id)) {
seen.add(attr.id);
attrs.push(attr);
}
}
return attrs;
}
private isQuantifiable(attr: Attribute): attr is QuantifiableAttribute {
return attr.type === 'quantifiable';
}
private normalizeStateKeys(state: SelectionMap): Record<string, unknown> {
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(state)) {
normalized[key.replace(':', '_')] = value;
}
return normalized;
}
private buildResult(
subtotal : number,
breakdown : OptionPricingResult[],
currency? : string,
tax? : TaxInfo,
hasConsultation = false,
): PricingResult {
const taxAmount = tax && !tax.included
? Math.round(subtotal * tax.rate * 100) / 100
: 0;
return {
subtotal,
taxAmount,
total: Math.round((subtotal + taxAmount) * 100) / 100,
breakdown,
currency,
tax,
hasConsultation,
};
}
private emptyResult(tax?: TaxInfo): PricingResult {
return this.buildResult(0, [], undefined, tax);
}
}

@ -17,7 +17,7 @@ import type {
Value,
Severity
} from '../types';
import { JsonLogicEvaluator } from './evaluator.engine';
import { JsonLogicEvaluator } from './evaluator';
import { ENGINE_CATEGORIES, RULE_ENGINE_ERRORS } from '../consts/messages';
@ -67,7 +67,7 @@ export interface RuleViolation {
// RULE ENGINE
// ============================================================================
export class RuleEngine {
export class Rule {
private readonly evaluator: JsonLogicEvaluator;
private readonly logr : Logr;
@ -295,4 +295,4 @@ function severityOrder(severity: Severity): number {
// ============================================================================
// El singleton se crea en el wiring del engine, no aquí.
// Ejemplo: export const ruleEngine = new RuleEngine(logr);
// Ejemplo: export const ruleEngine = new Rule(logr);

@ -0,0 +1,275 @@
/**
* ============================================================================
* SELECTION STATE
* ============================================================================
*
* Gestiona el estado de selección del usuario.
* El estado siempre es válido — modelo preventivo.
*
* Las opciones no disponibles se calculan en tiempo real via RuleEngine.
* El usuario nunca puede seleccionar un valor prohibido.
*/
import type {
ConfigurationCatalog,
ConfigurableObject,
Attribute,
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';
// ============================================================================
// TYPES
// ============================================================================
/**
* Opción de un atributo con su disponibilidad calculada.
*/
export interface AvailableOption {
optionId : OptionID;
option : OptionDefinition;
available : boolean;
/** Razón por la que no está disponible — mensaje de la regla que la prohíbe */
reason? : string;
}
/**
* Estado calculado de un atributo — valor actual + opciones disponibles.
*/
export interface AttributeState {
attr : Attribute;
value : Value;
options : AvailableOption[]; // solo para DynamicAttribute
required: boolean;
}
/**
* Resultado de intentar seleccionar un valor.
*/
export type SelectionResult =
| { success: true }
| { success: false; reason: string };
// ============================================================================
// SELECTION STATE
// ============================================================================
export class SelectionState {
private readonly logr : Logr;
private readonly ruleEngine : Rule;
private readonly catalog : ConfigurationCatalog;
private readonly object : ConfigurableObject;
private selection : SelectionMap;
constructor(
objectId: ObjectID,
catalog : ConfigurationCatalog,
logr : Logr,
) {
this.logr = logr;
this.ruleEngine = new Rule(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`);
}
this.object = object;
this.selection = this.buildInitialSelection();
}
// -------------------------------------------------------------------------
// API pública
// -------------------------------------------------------------------------
/**
* Selecciona un valor para un atributo.
* 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 {
const rules = Object.values(this.catalog.rules ?? {});
if (!this.ruleEngine.isValueAllowed(attrId, value, rules, this.selection)) {
const reason = this.getProhibitedReason(attrId, value);
this.logr.warn(
ENGINE_CATEGORIES.SELECTION,
SELECTION_ERRORS.INVALID_OPTION(attrId, String(value) as OptionID),
{ attrId, value }
);
return { success: false, reason };
}
this.selection = { ...this.selection, [attrId]: value };
return { success: true };
}
/**
* Devuelve el valor actual de un atributo.
*/
getValue(attrId: AttrID): Value {
return this.selection[attrId];
}
/**
* Devuelve una copia inmutable del estado de selección actual.
*/
getSelection(): SelectionMap {
return { ...this.selection };
}
/**
* Devuelve el estado calculado de un atributo:
* valor actual + opciones con su disponibilidad.
*/
getAttributeState(attrId: AttrID): AttributeState | null {
const attr = this.findAttr(attrId);
if (!attr) return null;
const rules = Object.values(this.catalog.rules ?? {});
const options = attr.type === 'dynamic'
? this.buildAvailableOptions(attr as DynamicAttribute, rules)
: [];
return {
attr,
value : this.selection[attrId],
options,
required: this.ruleEngine.isRequired(attrId, rules, this.selection),
};
}
/**
* Devuelve el estado calculado de todos los atributos del objeto.
*/
getAllAttributeStates(): Map<AttrID, AttributeState> {
const result = new Map<AttrID, AttributeState>();
for (const attr of this.collectAttrs()) {
const state = this.getAttributeState(attr.id);
if (state) result.set(attr.id, state);
}
return result;
}
/**
* Verifica si el estado actual es completamente válido.
*/
isValid(): boolean {
const rules = Object.values(this.catalog.rules ?? {});
return this.ruleEngine.isValid(rules, this.selection);
}
// -------------------------------------------------------------------------
// Inicialización
// -------------------------------------------------------------------------
/**
* Construye el estado inicial a partir de los defaultValues de los atributos.
* Garantiza que el estado inicial es válido.
*/
private buildInitialSelection(): SelectionMap {
const selection: SelectionMap = {};
for (const attr of this.collectAttrs()) {
if (attr.type === 'dynamic' || attr.type === 'quantifiable') {
const dynamic = attr as DynamicAttribute;
if (dynamic.defaultValue !== undefined) {
selection[attr.id] = dynamic.defaultValue;
}
}
}
return selection;
}
// -------------------------------------------------------------------------
// Opciones disponibles
// -------------------------------------------------------------------------
/**
* Calcula la disponibilidad de cada opción de un atributo dinámico.
* Las opciones no disponibles incluyen el mensaje de la regla que las prohíbe.
*/
private buildAvailableOptions(
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 { optionId, option, available, reason };
});
}
/**
* Obtiene el mensaje de la regla que prohíbe un valor.
* Usado como sugerencia en el tooltip de opciones deshabilitadas.
*/
private getProhibitedReason(attrId: AttrID, value: Value): string {
const rules = Object.values(this.catalog.rules ?? {});
const results = this.ruleEngine.getAttributeResults(rules, this.selection);
const attrResult = results.get(attrId);
if (!attrResult) return '';
// Buscar regla activa que afecte este atributo con este valor
const triggeredRule = rules.find(rule => {
if (rule.action.targetAttr !== attrId) return false;
if (rule.action.type === 'forbid' && rule.action.values.includes(value)) return true;
return rule.action.type === 'allow' && !rule.action.values.includes(value);
});
return triggeredRule?.message
? (typeof triggeredRule.message === 'object'
? Object.values(triggeredRule.message)[0] as string
: String(triggeredRule.message))
: '';
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private findAttr(attrId: AttrID): Attribute | undefined {
return this.collectAttrs().find(a => a.id === attrId);
}
/**
* Recoge todos los atributos del objeto deduplicados por id.
*/
private collectAttrs(): Attribute[] {
const seen = new Set<string>();
const attrs : Attribute[] = [];
for (const attr of [
...this.object.attributes,
...Object.values(this.object.sections).flatMap(s => s.attributes ?? [])
]) {
if (!seen.has(attr.id)) {
seen.add(attr.id);
attrs.push(attr);
}
}
return attrs;
}
}

@ -1,290 +0,0 @@
/**
* ============================================================================
* TEMPLATE RESOLVER
* ============================================================================
*
* Resuelve templates de imagen a URLs completas combinando:
* - basePath heredado en cascada (vista → sección → objeto → catálogo)
* - template con placeholders {at:<code>} resueltos al code de la opción activa
*/
import type { Logr } from '@/libs/logr';
import {
type ConfigurationCatalog,
type ConfigurableObject,
type VisualSection,
type SectionView,
type StaticImageConfig,
type ObjectID,
type SectionID,
type ViewID, isOptionID, type OptionID
} from '../types';
import {
ENGINE_CATEGORIES,
TEMPLATE_RESOLVER_ERRORS,
TEMPLATE_RESOLVER_WARNINGS
} from '../consts/messages';
import type { SelectionMap } from './rule.engine';
// ============================================================================
// TYPES
// ============================================================================
/**
* Contexto necesario para resolver un template.
* Identifica el objeto, sección y vista activos.
*/
export interface ResolveContext {
objectId : ObjectID;
sectionId: SectionID;
viewId : ViewID;
}
/**
* Resultado de la resolución de un template.
*/
export interface ResolveResult {
/** URL completa resuelta — null si se usó fallback */
url : string;
/** Si se usó el fallbackImage en lugar de la URL generada */
isFallback : boolean;
}
// ============================================================================
// TEMPLATE RESOLVER
// ============================================================================
/** Regex para extraer placeholders del tipo {at:code} */
const PLACEHOLDER_RE = /\{at:([^}]+)\}/g;
export class TemplateResolver {
private readonly logr: Logr;
constructor(logr: Logr) {
this.logr = logr;
}
/**
* Resuelve un template de imagen a una URL completa.
*
* Proceso:
* 1. Resuelve el basePath en cascada (vista → sección → objeto → catálogo)
* 2. Extrae los placeholders {at:<code>} del template
* 3. Resuelve cada placeholder al code de la opción activa en el catálogo
* 4. Concatena basePath + template resuelto
*
* Si algún placeholder no se puede resolver, usa fallbackImage.
*/
resolve(
context : ResolveContext,
state : SelectionMap,
catalog : ConfigurationCatalog
): ResolveResult {
const object = catalog.objects[context.objectId];
const section = object?.sections[context.sectionId];
if (!object || !section) {
this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.OBJECT_NOT_FOUND(context.objectId), { context });
return this.fallback(catalog, object, section as VisualSection | undefined);
}
if (section.kind !== 'visual') {
this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.SECTION_NOT_VISUAL(context.sectionId), { context });
return this.fallback(catalog, object, undefined);
}
const view = section.views[context.viewId];
if (!view) {
this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.VIEW_NOT_FOUND(context.viewId, context.sectionId), { context });
return this.fallback(catalog, object, section);
}
if (view.visualConfig.strategy !== 'static_image') {
this.logr.warn(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_WARNINGS.UNSUPPORTED_STRATEGY(view.visualConfig.strategy), { context });
return this.fallback(catalog, object, section);
}
return this.resolveStaticImage(
view.visualConfig,
view,
section,
object,
catalog,
state,
context
);
}
// -------------------------------------------------------------------------
// Resolución static_image
// -------------------------------------------------------------------------
private resolveStaticImage(
config : StaticImageConfig,
view : SectionView,
section : VisualSection,
object : ConfigurableObject,
catalog : ConfigurationCatalog,
state : SelectionMap,
context : ResolveContext
): ResolveResult {
const basePath = this.resolveBasePath(config, view, section, object, catalog);
const resolved = this.resolveTemplate(config.template, state, catalog, context);
if (resolved === null) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.TEMPLATE_UNRESOLVABLE(config.template),
{ context }
);
return this.fallback(catalog, object, section);
}
return { url: `${basePath}${resolved}`, isFallback: false };
}
// -------------------------------------------------------------------------
// Resolución de template
// -------------------------------------------------------------------------
/**
* Sustituye cada {at:<code>} por el code de la opción activa.
* Devuelve null si algún placeholder no se puede resolver.
*/
private resolveTemplate(
template: string,
state : SelectionMap,
catalog : ConfigurationCatalog,
context : ResolveContext
): string | null {
let result = template;
let success = true;
result = result.replace(PLACEHOLDER_RE, (_, attrCode: string) => {
const resolved = this.resolvePlaceholder(attrCode, state, catalog, context);
if (resolved === null) {
success = false;
return '';
}
return resolved;
});
return success ? result : null;
}
/**
* Resuelve un placeholder {at:<attrCode>} al code de la opción seleccionada.
*
* Busca el atributo por code en:
* 1. Atributos de la sección
* 2. Atributos globales del objeto
*
* Luego resuelve el OptionID seleccionado al code de la OptionDefinition.
*/
private resolvePlaceholder(
attrCode: string,
state : SelectionMap,
catalog : ConfigurationCatalog,
context : ResolveContext
): string | null {
const object = catalog.objects[context.objectId];
const section = object?.sections[context.sectionId];
// Buscar atributo por code — primero en sección, luego en objeto
const sectionAttrs = section?.kind === 'visual' ? section.attributes : [];
const allAttrs = [...sectionAttrs, ...object.attributes];
const attr = allAttrs.find(a => a.code === attrCode);
if (!attr) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NOT_FOUND(attrCode),
{ context, attrCode }
);
return null;
}
// Obtener el valor seleccionado del estado
const selectedValue = state[attr.id];
if (!selectedValue) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NO_VALUE(attr.id),
{ context, attrId: attr.id }
);
return null;
}
// Resolver OptionID → code de la OptionDefinition
if (!isOptionID(selectedValue)) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.OPTION_NOT_FOUND(String(selectedValue)),
{ context, optionId: String(selectedValue) }
);
return null;
}
const optionDef = catalog.options[selectedValue];
return optionDef.code;
}
// -------------------------------------------------------------------------
// basePath en cascada
// -------------------------------------------------------------------------
/**
* Resuelve el basePath en cascada:
* vista → sección → objeto → catálogo
*/
private resolveBasePath(
config : StaticImageConfig,
view : SectionView,
section : VisualSection,
object : ConfigurableObject,
catalog : ConfigurationCatalog
): string {
return config.basePath
?? section.basePath
?? object.basePath
?? catalog.basePath
?? '';
}
// -------------------------------------------------------------------------
// Fallback
// -------------------------------------------------------------------------
/**
* Devuelve la imagen de fallback.
* Busca en cascada entre las views de la sección, y si no hay ninguna
* configurada logea un warning y devuelve string vacío.
*/
private fallback(
catalog : ConfigurationCatalog,
object? : ConfigurableObject,
section?: VisualSection,
): ResolveResult {
// Busca fallbackImage en cualquiera de las vistas de la sección
const viewFallback = section
? Object.values(section.views)
.map(v => (v.visualConfig as StaticImageConfig).fallbackImage)
.find(Boolean)
: undefined;
const fallbackImage = viewFallback ?? '';
if (!fallbackImage) {
this.logr.warn(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_WARNINGS.NO_FALLBACK_IMAGE(object?.id, section?.id), {
objectId : object?.id,
sectionId: section?.id,
});
}
return { url: fallbackImage, isFallback: true };
}
}

@ -0,0 +1,446 @@
/**
* ============================================================================
* TEMPLATE RESOLVER
* ============================================================================
*
* Resuelve templates de imagen a URLs completas combinando:
* - basePath heredado en cascada (vista → sección → objeto → catálogo)
* - template con placeholders {at:<code>} resueltos al code de la opción activa
*/
import type { Logr } from '@/libs/logr';
import type {
ConfigurationCatalog,
ConfigurableObject,
VisualSection,
SectionView,
StaticImageConfig,
ObjectID,
SectionID,
ViewID,
CompositeLayersConfig,
ApiGeneratedConfig,
} from '../types';
import { isOptionID } from '../types';
import {
ENGINE_CATEGORIES,
TEMPLATE_RESOLVER_ERRORS,
TEMPLATE_RESOLVER_WARNINGS
} from '../consts/messages';
import type { SelectionMap } from './rule.ts';
// ============================================================================
// TYPES
// ============================================================================
/**
* Contexto necesario para resolver un template.
*/
export interface ResolveContext {
objectId : ObjectID;
sectionId: SectionID;
viewId : ViewID;
}
/**
* Resultado de la resolución — URL o parámetros API.
*/
export interface ResolveResult {
/** URL completa resuelta, o string vacío si no hay fallback */
url : string;
/** Si se usó el fallbackImage en lugar de la URL generada */
isFallback: boolean;
}
/**
* Resultado de resolución para api_generated.
* Los parámetros ya están resueltos — listos para enviar al servidor.
*/
export interface ApiResolveResult {
endpoint : string;
method : 'GET' | 'POST';
params : Record<string, string>;
isFallback: boolean;
fallbackImage?: string;
}
// ============================================================================
// HELPERS INTERNOS
// ============================================================================
/** Regex para placeholders de contexto: {object}, {section}, {view} */
const CONTEXT_PLACEHOLDER_RE = /\{(object|section|view)}/g;
/** Regex para placeholders de atributo: {at:<code>} */
const ATTR_PLACEHOLDER_RE = /\{at:([^}]+)}/g;
// ============================================================================
// TEMPLATE RESOLVER
// ============================================================================
export class TemplateResolver {
private readonly logr: Logr;
constructor(logr: Logr) {
this.logr = logr;
}
// -------------------------------------------------------------------------
// Entrada principal — despacha por estrategia
// -------------------------------------------------------------------------
/**
* Resuelve la vista activa según su estrategia de renderizado.
* Despacha a `resolveStaticImage`, `resolveCompositeLayers` o `resolveApi`.
*/
resolve(
context : ResolveContext,
state : SelectionMap,
catalog : ConfigurationCatalog
): ResolveResult {
const { object, section, view } = this.resolveEntities(context, catalog);
if (!object || !section || !view) return this.fallback(object, section);
switch (view.visualConfig.strategy) {
case 'static_image':
return this.resolveStaticImage(view.visualConfig, view, section, object, catalog, state, context);
case 'composite_layers':
return this.resolveCompositeLayers(view.visualConfig, view, section, object, catalog, state, context);
case 'api_generated':
// api_generated devuelve ResolveResult con la URL del fallback
// El caller usa resolveApi() si necesita los params completos
return {
url : view.visualConfig.fallbackImage ?? '',
isFallback: true,
};
case 'three_d':
// three_d no genera URLs — lo gestiona el componente 3D directamente
return { url: '', isFallback: false };
default:
this.logr.warn(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_WARNINGS.UNSUPPORTED_STRATEGY((view.visualConfig as any).strategy),
{ context }
);
return this.fallback(object, section);
}
}
/**
* Resuelve los parámetros para una vista api_generated.
* Usar este método cuando se necesita construir la llamada HTTP.
*/
resolveApi(
context : ResolveContext,
state : SelectionMap,
catalog : ConfigurationCatalog
): ApiResolveResult | null {
const { object, section, view } = this.resolveEntities(context, catalog);
if (!object || !section || !view) return null;
if (view.visualConfig.strategy !== 'api_generated') {
this.logr.warn(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_WARNINGS.UNSUPPORTED_STRATEGY(view.visualConfig.strategy),
{ context }
);
return null;
}
return this.buildApiResult(view.visualConfig, view, section, object, catalog, state, context);
}
// -------------------------------------------------------------------------
// static_image
// -------------------------------------------------------------------------
private resolveStaticImage(
config : StaticImageConfig,
view : SectionView,
section : VisualSection,
object : ConfigurableObject,
catalog : ConfigurationCatalog,
state : SelectionMap,
context : ResolveContext
): ResolveResult {
const basePath = this.resolveBasePath(config.basePath, section, object, catalog);
const resolved = this.resolveTemplate(config.template, view, section, object, state, catalog, context);
if (resolved === null) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.TEMPLATE_UNRESOLVABLE(config.template),
{ context }
);
return this.fallback(object, section);
}
return { url: `${basePath}${resolved}`, isFallback: false };
}
// -------------------------------------------------------------------------
// composite_layers
// -------------------------------------------------------------------------
private resolveCompositeLayers(
config : CompositeLayersConfig,
view : SectionView,
section : VisualSection,
object : ConfigurableObject,
catalog : ConfigurationCatalog,
state : SelectionMap,
context : ResolveContext
): ResolveResult {
const basePath = this.resolveBasePath(config.basePath, section, object, catalog);
const urls: string[] = [];
for (const layer of config.layers) {
const resolved = this.resolveTemplate(layer.template, view, section, object, state, catalog, context);
if (resolved === null) {
if (!layer.optional) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.TEMPLATE_UNRESOLVABLE(layer.template),
{ context }
);
return this.fallback(object, section);
}
// Capa opcional sin resolver — se omite
continue;
}
urls.push(`${basePath}${resolved}`);
}
// Devolvemos las URLs de capas separadas por '|' como convención
// El componente UI las parsea y las superpone en orden
return { url: urls.join('|'), isFallback: false };
}
// -------------------------------------------------------------------------
// api_generated
// -------------------------------------------------------------------------
private buildApiResult(
config : ApiGeneratedConfig,
view : SectionView,
section : VisualSection,
object : ConfigurableObject,
catalog : ConfigurationCatalog,
state : SelectionMap,
context : ResolveContext
): ApiResolveResult {
const paramKeys = config.params ?? this.buildDefaultParams(section, object);
const params: Record<string, string> = {};
for (const key of paramKeys) {
if (key === 'object') { params['object'] = object.code; continue; }
if (key === 'section') { params['section'] = section.code; continue; }
if (key === 'view') { params['view'] = view.code; continue; }
// {at:<code>} → resolver atributo
const match = key.match(/^at:(.+)$/);
if (match) {
const resolved = this.resolvePlaceholder(match[1], state, catalog, context);
if (resolved !== null) params[match[1]] = resolved;
continue;
}
this.logr.warn(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_WARNINGS.UNSUPPORTED_STRATEGY(key),
{ context }
);
}
return {
endpoint : config.apiConfig.endpoint,
method : config.apiConfig.method ?? 'POST',
params,
isFallback : false,
fallbackImage: config.fallbackImage,
};
}
/**
* Si no se especifican params, incluye contexto + todos los atributos visualizables.
*/
private buildDefaultParams(
section: VisualSection,
object : ConfigurableObject
): string[] {
const attrParams = [...section.attributes, ...object.attributes]
.filter(a => a.display.affectsVisual === true)
.map(a => `at:${a.code}`);
return ['object', 'section', 'view', ...attrParams];
}
// -------------------------------------------------------------------------
// Resolución de template — compartida por static_image y composite_layers
// -------------------------------------------------------------------------
private resolveTemplate(
template: string,
view : SectionView,
section : VisualSection,
object : ConfigurableObject,
state : SelectionMap,
catalog : ConfigurationCatalog,
context : ResolveContext
): string | null {
let result = template;
let success = true;
// 1. Placeholders de contexto — siempre disponibles
result = result.replace(CONTEXT_PLACEHOLDER_RE, (_, key: string) => {
switch (key) {
case 'object' : return object.code;
case 'section': return section.code;
case 'view' : return view.code;
default : return '';
}
});
// 2. Placeholders de atributos {at:<code>}
result = result.replace(ATTR_PLACEHOLDER_RE, (_, attrCode: string) => {
const resolved = this.resolvePlaceholder(attrCode, state, catalog, context);
if (resolved === null) { success = false; return ''; }
return resolved;
});
return success ? result : null;
}
/**
* Resuelve {at:<attrCode>} al code de la opción seleccionada.
* Busca el atributo por code: primero en sección, luego en objeto global.
*/
private resolvePlaceholder(
attrCode: string,
state : SelectionMap,
catalog : ConfigurationCatalog,
context : ResolveContext
): string | null {
const object = catalog.objects[context.objectId];
const section = object?.sections[context.sectionId];
const sectionAttrs = section?.kind === 'visual' ? section.attributes : [];
const attr = [...sectionAttrs, ...object.attributes].find(a => a.code === attrCode);
if (!attr) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NOT_FOUND(attrCode),
{ context, attrCode }
);
return null;
}
const selectedValue = state[attr.id];
if (!selectedValue) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NO_VALUE(attr.id),
{ context, attrId: attr.id }
);
return null;
}
if (!isOptionID(selectedValue)) {
this.logr.error(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_ERRORS.OPTION_NOT_FOUND(String(selectedValue)),
{ context, optionId: String(selectedValue) }
);
return null;
}
return catalog.options[selectedValue].code;
}
// -------------------------------------------------------------------------
// basePath en cascada
// -------------------------------------------------------------------------
private resolveBasePath(
configBasePath: string | undefined,
section : VisualSection,
object : ConfigurableObject,
catalog : ConfigurationCatalog
): string {
return configBasePath
?? section.basePath
?? object.basePath
?? catalog.basePath
?? '';
}
// -------------------------------------------------------------------------
// Entidades — lookup compartido
// -------------------------------------------------------------------------
private resolveEntities(
context: ResolveContext,
catalog: ConfigurationCatalog
): { object?: ConfigurableObject; section?: VisualSection; view?: SectionView } {
const object = catalog.objects[context.objectId];
if (!object) {
this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.OBJECT_NOT_FOUND(context.objectId), { context });
return {};
}
const section = object.sections[context.sectionId];
if (!section) {
this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.SECTION_NOT_FOUND(context.sectionId, context.objectId), { context });
return { object };
}
if (section.kind !== 'visual') {
this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.SECTION_NOT_VISUAL(context.sectionId), { context });
return { object };
}
const view = section.views[context.viewId];
if (!view) {
this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.VIEW_NOT_FOUND(context.viewId, context.sectionId), { context });
return { object, section };
}
return { object, section, view };
}
// -------------------------------------------------------------------------
// Fallback
// -------------------------------------------------------------------------
private fallback(
object? : ConfigurableObject,
section?: VisualSection,
): ResolveResult {
const viewFallback = section
? Object.values(section.views)
.map(v => (v.visualConfig as StaticImageConfig).fallbackImage)
.find(Boolean)
: undefined;
const fallbackImage = viewFallback ?? '';
if (!fallbackImage) {
this.logr.warn(
ENGINE_CATEGORIES.TEMPLATE_RESOLVER,
TEMPLATE_RESOLVER_WARNINGS.NO_FALLBACK_IMAGE(object?.id, section?.id)
);
}
return { url: fallbackImage, isFallback: true };
}
}

@ -0,0 +1,798 @@
/**
* ============================================================================
* CATÁLOGO DE VIVIENDAS - FIXTURE ACTUALIZADO
* ============================================================================
*
* Modelo A: Apartamento (salón, baño, dormitorio, cocina)
* Modelo B: Dúplex (salón, baño, aseo, 2 dormitorios)
*/
import type {
ConfigurationCatalog,
ConfigurableObject,
OptionDefinition,
ValidationRule,
VisualSection,
DynamicAttribute,
FixedAttribute,
SectionView, QuantifiableAttribute,
} from '../types';
import type { AttrID, SectionID, OptionID, ObjectID, RuleID, ViewID } from '../types';
// ============================================================================
// IDs — OPCIONES
// ============================================================================
const OPT_SUELO_PARQUET : OptionID = 'op:suelo_parquet';
const OPT_SUELO_PORCELANICO : OptionID = 'op:suelo_porcelanico';
const OPT_SUELO_MICROCEMENTO : OptionID = 'op:suelo_microcemento';
const OPT_SUELO_CERAMICA : OptionID = 'op:suelo_ceramica';
const OPT_PARED_BLANCO : OptionID = 'op:pared_blanco';
const OPT_PARED_GRIS : OptionID = 'op:pared_gris';
const OPT_PARED_BEIGE : OptionID = 'op:pared_beige';
const OPT_PARED_PIEDRA : OptionID = 'op:pared_piedra';
const OPT_PUERTA_ROBLE : OptionID = 'op:puerta_roble';
const OPT_PUERTA_LACADA : OptionID = 'op:puerta_lacada';
const OPT_PUERTA_CRISTAL : OptionID = 'op:puerta_cristal';
const OPT_ENCIMERA_GRANITO : OptionID = 'op:encimera_granito';
const OPT_ENCIMERA_SILESTONE : OptionID = 'op:encimera_silestone';
const OPT_ENCIMERA_MADERA : OptionID = 'op:encimera_madera';
const OPT_MUEBLE_BLANCO : OptionID = 'op:mueble_blanco';
const OPT_MUEBLE_GRIS : OptionID = 'op:mueble_gris';
const OPT_MUEBLE_MADERA : OptionID = 'op:mueble_madera';
const OPT_SANIT_BLANCO : OptionID = 'op:sanit_blanco';
const OPT_SANIT_NEGRO : OptionID = 'op:sanit_negro';
const OPT_REVEST_MARMOL : OptionID = 'op:revest_marmol';
const OPT_REVEST_MICROCEMENTO: OptionID = 'op:revest_microcemento';
const OPT_REVEST_CERAMICA : OptionID = 'op:revest_ceramica';
const OPT_CALIDAD_ESTANDAR : OptionID = 'op:calidad_estandar';
// Armario — material puertas
const OPT_ARMARIO_LACADO : OptionID = 'op:armario_lacado';
const OPT_ARMARIO_MADERA : OptionID = 'op:armario_madera';
const OPT_CALIDAD_PREMIUM : OptionID = 'op:calidad_premium';
const OPT_CALIDAD_LUJO : OptionID = 'op:calidad_lujo';
// ============================================================================
// IDs — ATRIBUTOS
// ============================================================================
const ATTR_CALIDAD : AttrID = 'at:calidad';
const ATTR_PUERTA : AttrID = 'at:puerta';
const ATTR_SUELO : AttrID = 'at:suelo';
const ATTR_PARED : AttrID = 'at:pared';
const ATTR_ENCIMERA : AttrID = 'at:encimera';
const ATTR_MUEBLE_COC : AttrID = 'at:mueble_cocina';
const ATTR_M2_COCINA : AttrID = 'at:m2_cocina';
const ATTR_SANITARIO : AttrID = 'at:sanitario';
const ATTR_REVEST_BANO: AttrID = 'at:revest_bano';
const ATTR_M2_BANO : AttrID = 'at:m2_bano';
const ATTR_M2_DORM : AttrID = 'at:m2_dormitorio';
const ATTR_M2_SALON : AttrID = 'at:m2_salon';
// Armario
const ATTR_ARMARIO_PUERTAS : AttrID = 'at:armario_puertas';
const ATTR_ARMARIO_MATERIAL : AttrID = 'at:armario_material';
// ============================================================================
// IDs — SECCIONES
// ============================================================================
const SEC_SALON : SectionID = 'sc:salon';
const SEC_BANO : SectionID = 'sc:bano';
const SEC_ASEO : SectionID = 'sc:aseo';
const SEC_DORMITORIO1: SectionID = 'sc:dormitorio1';
const SEC_DORMITORIO2: SectionID = 'sc:dormitorio2';
const SEC_COCINA : SectionID = 'sc:cocina';
const SEC_ARMARIO : SectionID = 'sc:armario';
// ============================================================================
// IDs — VISTAS
// ============================================================================
const VIEW_FRONT: ViewID = 'vw:front';
const VIEW_360 : ViewID = 'vw:360';
// ============================================================================
// IDs — OBJETOS
// ============================================================================
const OBJ_APARTAMENTO: ObjectID = 'ob:apartamento';
const OBJ_DUPLEX : ObjectID = 'ob:duplex';
// ============================================================================
// IDs — REGLAS
// ============================================================================
const RULE_LUJO_MARMOL : RuleID = 'rl:lujo_requiere_marmol';
const RULE_ESTANDAR_NO_LUJO : RuleID = 'rl:estandar_no_lujo';
const RULE_MICRO_CONSISTENTE: RuleID = 'rl:microcemento_consistente';
const RULE_MADERA_NO_BANO : RuleID = 'rl:madera_no_bano';
const RULE_NEGRO_PREMIUM : RuleID = 'rl:negro_requiere_premium';
const RULE_ESTANDAR_NO_NEGRO: RuleID = 'rl:estandar_no_negro';
// ============================================================================
// OPCIONES
// ============================================================================
export const OPTIONS: Record<OptionID, OptionDefinition> = {
[OPT_SUELO_PARQUET]: {
id: OPT_SUELO_PARQUET, code: 'parquet',
name: { es: 'Parquet', en: 'Parquet' },
description: { es: 'Suelo de madera natural', en: 'Natural wood floor' },
pricing: { baseAmount: 45 }, tags: ['madera', 'calido']
},
[OPT_SUELO_PORCELANICO]: {
id: OPT_SUELO_PORCELANICO, code: 'porcelanico',
name: { es: 'Porcelánico', en: 'Porcelain tile' },
description: { es: 'Suelo porcelánico de gran formato', en: 'Large format porcelain tile' },
pricing: { baseAmount: 55 }, tags: ['ceramica', 'moderno']
},
[OPT_SUELO_MICROCEMENTO]: {
id: OPT_SUELO_MICROCEMENTO, code: 'microcemento',
name: { es: 'Microcemento', en: 'Microcement' },
description: { es: 'Microcemento continuo', en: 'Continuous microcement' },
pricing: { baseAmount: 75 }, tags: ['moderno', 'premium']
},
[OPT_SUELO_CERAMICA]: {
id: OPT_SUELO_CERAMICA, code: 'ceramica',
name: { es: 'Cerámica', en: 'Ceramic tile' },
description: { es: 'Cerámica estándar', en: 'Standard ceramic tile' },
pricing: { baseAmount: 30 }, tags: ['ceramica', 'estandar']
},
[OPT_PARED_BLANCO]: {
id: OPT_PARED_BLANCO, code: 'blanco',
name: { es: 'Blanco roto', en: 'Off white' },
description: { es: 'Pintura blanca rota', en: 'Off white paint' },
pricing: { baseAmount: 0 }
},
[OPT_PARED_GRIS]: {
id: OPT_PARED_GRIS, code: 'gris',
name: { es: 'Gris perla', en: 'Pearl grey' },
description: { es: 'Pintura gris perla', en: 'Pearl grey paint' },
pricing: { baseAmount: 0 }
},
[OPT_PARED_BEIGE]: {
id: OPT_PARED_BEIGE, code: 'beige',
name: { es: 'Beige', en: 'Beige' },
description: { es: 'Pintura beige', en: 'Beige paint' },
pricing: { baseAmount: 0 }
},
[OPT_PARED_PIEDRA]: {
id: OPT_PARED_PIEDRA, code: 'piedra',
name: { es: 'Efecto piedra', en: 'Stone effect' },
description: { es: 'Pintura efecto piedra', en: 'Stone effect paint' },
pricing: { baseAmount: 800 }
},
[OPT_PUERTA_ROBLE]: {
id: OPT_PUERTA_ROBLE, code: 'roble',
name: { es: 'Roble natural', en: 'Natural oak' },
description: { es: 'Puerta en madera de roble', en: 'Oak wood door' },
pricing: { baseAmount: 350 }
},
[OPT_PUERTA_LACADA]: {
id: OPT_PUERTA_LACADA, code: 'lacada',
name: { es: 'Lacada blanca', en: 'White lacquered' },
description: { es: 'Puerta lacada en blanco', en: 'White lacquered door' },
pricing: { baseAmount: 250 }
},
[OPT_PUERTA_CRISTAL]: {
id: OPT_PUERTA_CRISTAL, code: 'cristal',
name: { es: 'Cristal', en: 'Glass' },
description: { es: 'Puerta de cristal templado', en: 'Tempered glass door' },
pricing: { baseAmount: 500 }
},
[OPT_ENCIMERA_GRANITO]: {
id: OPT_ENCIMERA_GRANITO, code: 'granito',
name: { es: 'Granito', en: 'Granite' },
description: { es: 'Encimera de granito natural', en: 'Natural granite countertop' },
pricing: { baseAmount: 400 }
},
[OPT_ENCIMERA_SILESTONE]: {
id: OPT_ENCIMERA_SILESTONE, code: 'silestone',
name: { es: 'Silestone', en: 'Silestone' },
description: { es: 'Encimera de Silestone', en: 'Silestone countertop' },
pricing: { baseAmount: 600 }
},
[OPT_ENCIMERA_MADERA]: {
id: OPT_ENCIMERA_MADERA, code: 'madera',
name: { es: 'Madera', en: 'Wood' },
description: { es: 'Encimera de madera maciza', en: 'Solid wood countertop' },
pricing: { baseAmount: 500 }
},
[OPT_MUEBLE_BLANCO]: {
id: OPT_MUEBLE_BLANCO, code: 'blanco',
name: { es: 'Blanco mate', en: 'Matte white' },
description: { es: 'Muebles lacados en blanco mate', en: 'Matte white lacquered cabinets' },
pricing: { baseAmount: 3500 }
},
[OPT_MUEBLE_GRIS]: {
id: OPT_MUEBLE_GRIS, code: 'gris',
name: { es: 'Gris antracita', en: 'Anthracite grey' },
description: { es: 'Muebles en gris antracita', en: 'Anthracite grey cabinets' },
pricing: { baseAmount: 4000 }
},
[OPT_MUEBLE_MADERA]: {
id: OPT_MUEBLE_MADERA, code: 'madera',
name: { es: 'Madera natural', en: 'Natural wood' },
description: { es: 'Muebles en madera natural', en: 'Natural wood cabinets' },
pricing: { baseAmount: 5000 }
},
[OPT_SANIT_BLANCO]: {
id: OPT_SANIT_BLANCO, code: 'blanco',
name: { es: 'Blanco', en: 'White' },
description: { es: 'Sanitarios blancos', en: 'White sanitary ware' },
pricing: { baseAmount: 0 }
},
[OPT_SANIT_NEGRO]: {
id: OPT_SANIT_NEGRO, code: 'negro',
name: { es: 'Negro mate', en: 'Matte black' },
description: { es: 'Sanitarios en negro mate', en: 'Matte black sanitary ware' },
pricing: { baseAmount: 1200 }
},
[OPT_REVEST_MARMOL]: {
id: OPT_REVEST_MARMOL, code: 'marmol',
name: { es: 'Mármol', en: 'Marble' },
description: { es: 'Revestimiento de mármol natural', en: 'Natural marble cladding' },
pricing: { baseAmount: 120 }
},
[OPT_REVEST_MICROCEMENTO]: {
id: OPT_REVEST_MICROCEMENTO, code: 'microcemento',
name: { es: 'Microcemento', en: 'Microcement' },
description: { es: 'Revestimiento de microcemento', en: 'Microcement cladding' },
pricing: { baseAmount: 90 }
},
[OPT_REVEST_CERAMICA]: {
id: OPT_REVEST_CERAMICA, code: 'ceramica',
name: { es: 'Cerámica', en: 'Ceramic tile' },
description: { es: 'Revestimiento cerámico', en: 'Ceramic tile cladding' },
pricing: { baseAmount: 40 }
},
// Armario — material puertas
[OPT_ARMARIO_LACADO]: {
id: OPT_ARMARIO_LACADO, code: 'lacado',
name: { es: 'Lacado blanco', en: 'White lacquered' },
description: { es: 'Puertas lacadas en blanco mate', en: 'Matte white lacquered doors' },
pricing: {
baseAmount : 0,
// Precio por puerta con descuento por volumen:
// 2 puertas → 350€/u, 3 puertas → 300€/u
dynamicExpression: {
'if': [
{ '>=': [{ var: 'quantity' }, 3] },
{ '*': [{ var: 'quantity' }, 300] },
{ '*': [{ var: 'quantity' }, 350] }
]
}
}
},
[OPT_ARMARIO_MADERA]: {
id: OPT_ARMARIO_MADERA, code: 'madera',
name: { es: 'Madera natural', en: 'Natural wood' },
description: { es: 'Puertas en madera natural', en: 'Natural wood doors' },
pricing: {
baseAmount : 0,
// Precio fijo por puerta sin descuento
dynamicExpression: { '*': [{ var: 'quantity' }, 500] }
}
},
[OPT_CALIDAD_ESTANDAR]: {
id: OPT_CALIDAD_ESTANDAR, code: 'estandar',
name: { es: 'Estándar', en: 'Standard' },
description: { es: 'Acabados estándar', en: 'Standard quality finishes' },
pricing: { baseAmount: 0 }
},
[OPT_CALIDAD_PREMIUM]: {
id: OPT_CALIDAD_PREMIUM, code: 'premium',
name: { es: 'Premium', en: 'Premium' },
description: { es: 'Acabados de alta calidad', en: 'High quality finishes' },
pricing: { baseAmount: 15000 }
},
[OPT_CALIDAD_LUJO]: {
id: OPT_CALIDAD_LUJO, code: 'lujo',
name: { es: 'Lujo', en: 'Luxury' },
description: { es: 'Acabados de lujo exclusivos', en: 'Exclusive luxury finishes' },
pricing: { baseAmount: 35000 }
},
};
// ============================================================================
// ATRIBUTOS COMUNES
// ============================================================================
const attrSuelo = (): DynamicAttribute => ({
id: ATTR_SUELO, code: 'suelo',
name: { es: 'Suelo', en: 'Flooring' },
description: { es: 'Material del suelo', en: 'Floor material' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_SUELO_CERAMICA,
options: [
{ optionId: OPT_SUELO_CERAMICA, priority: 1 },
{ optionId: OPT_SUELO_PARQUET, priority: 2 },
{ optionId: OPT_SUELO_PORCELANICO, priority: 3 },
{ optionId: OPT_SUELO_MICROCEMENTO, priority: 4 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
});
const attrPared = (): DynamicAttribute => ({
id: ATTR_PARED, code: 'pared',
name: { es: 'Color paredes', en: 'Wall color' },
description: { es: 'Color de la pintura de paredes', en: 'Wall paint color' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_PARED_BLANCO,
options: [
{ optionId: OPT_PARED_BLANCO, priority: 1 },
{ optionId: OPT_PARED_GRIS, priority: 2 },
{ optionId: OPT_PARED_BEIGE, priority: 3 },
{ optionId: OPT_PARED_PIEDRA, priority: 4 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
});
// ============================================================================
// VISTAS HELPERS
// ============================================================================
const viewFront = (template: string, globalAttrDependencies?: AttrID[]): SectionView => ({
id : VIEW_FRONT,
code : 'front',
name : { es: 'Vista frontal', en: 'Front view' },
type : 'front',
visualConfig: {
strategy: 'static_image',
template,
globalAttrDependencies,
basePath : '/renders/vivienda/',
fallbackImage: '/renders/fallback.jpg',
},
});
const view360 = (template: string, globalAttrDependencies?: AttrID[]): SectionView => ({
id : VIEW_360,
code : '360',
name : { es: 'Vista 360°', en: '360° view' },
type : 'panoramic',
visualConfig: {
strategy: 'static_image',
template,
globalAttrDependencies,
basePath : '/renders/vivienda/',
fallbackImage: '/renders/fallback.jpg',
},
});
// ============================================================================
// SECCIONES
// ============================================================================
const seccionSalon = (): VisualSection => ({
kind : 'visual',
id : SEC_SALON,
code : 'salon',
name : { es: 'Salón', en: 'Living room' },
description: { es: 'Salón - comedor principal', en: 'Main living - dining room' },
attributes : [
attrSuelo(),
attrPared(),
{
id: ATTR_M2_SALON, code: 'm2_salon',
name: { es: 'Superficie', en: 'Surface' },
description: { es: 'Superficie del salón en m²', en: 'Living room surface in m²' },
type: 'fixed', dataType: 'number', value: 25,
unit: { es: 'm²', en: 'sqm' },
display: { uiVisible: false, readonly: true }
} as FixedAttribute,
{
id: ATTR_PUERTA, code: 'puerta',
name: { es: 'Puerta', en: 'Door' },
description: { es: 'Tipo de puerta', en: 'Door type' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_PUERTA_LACADA,
options: [
{ optionId: OPT_PUERTA_LACADA, priority: 1 },
{ optionId: OPT_PUERTA_ROBLE, priority: 2 },
{ optionId: OPT_PUERTA_CRISTAL, priority: 3 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
],
views : {
[VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg', [ATTR_CALIDAD]),
[VIEW_360] : view360 ('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg', [ATTR_CALIDAD]),
},
defaultView : VIEW_FRONT,
availability: { mode: 'required' },
});
const seccionBano = (m2: number = 6): VisualSection => ({
kind : 'visual',
id : SEC_BANO,
code : 'bano',
name : { es: 'Baño', en: 'Bathroom' },
description: { es: 'Baño principal', en: 'Main bathroom' },
attributes : [
{
id: ATTR_SANITARIO, code: 'sanitario',
name: { es: 'Sanitarios', en: 'Sanitary ware' },
description: { es: 'Color de los sanitarios', en: 'Sanitary ware color' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_SANIT_BLANCO,
options: [
{ optionId: OPT_SANIT_BLANCO, priority: 1 },
{ optionId: OPT_SANIT_NEGRO, priority: 2 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id: ATTR_REVEST_BANO, code: 'revest_bano',
name: { es: 'Revestimiento', en: 'Cladding' },
description: { es: 'Revestimiento de paredes del baño', en: 'Bathroom wall cladding' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_REVEST_CERAMICA,
options: [
{ optionId: OPT_REVEST_CERAMICA, priority: 1 },
{ optionId: OPT_REVEST_MICROCEMENTO, priority: 2 },
{ optionId: OPT_REVEST_MARMOL, priority: 3 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id: ATTR_M2_BANO, code: 'm2_bano',
name: { es: 'Superficie', en: 'Surface' },
description: { es: 'Superficie del baño en m²', en: 'Bathroom surface in m²' },
type: 'fixed', dataType: 'number', value: m2,
unit: { es: 'm²', en: 'sqm' },
display: { uiVisible: false, readonly: true }
} as FixedAttribute,
],
views : {
[VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:sanitario}_{at:revest_bano}.jpg', [ATTR_CALIDAD]),
},
defaultView : VIEW_FRONT,
availability: { mode: 'required' },
});
const seccionAseo = (): VisualSection => ({
kind : 'visual',
id : SEC_ASEO,
code : 'aseo',
name : { es: 'Aseo', en: 'Toilet' },
description: { es: 'Aseo de cortesía', en: 'Guest toilet' },
attributes : [
{
id: ATTR_SANITARIO, code: 'sanitario',
name: { es: 'Sanitarios', en: 'Sanitary ware' },
description: { es: 'Color de los sanitarios', en: 'Sanitary ware color' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_SANIT_BLANCO,
options: [
{ optionId: OPT_SANIT_BLANCO, priority: 1 },
{ optionId: OPT_SANIT_NEGRO, priority: 2 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id: ATTR_REVEST_BANO, code: 'revest_bano',
name: { es: 'Revestimiento', en: 'Cladding' },
description: { es: 'Revestimiento', en: 'Cladding' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_REVEST_CERAMICA,
options: [
{ optionId: OPT_REVEST_CERAMICA, priority: 1 },
{ optionId: OPT_REVEST_MICROCEMENTO, priority: 2 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
],
views : {
[VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:sanitario}_{at:revest_bano}.jpg', [ATTR_CALIDAD]),
},
defaultView : VIEW_FRONT,
availability: { mode: 'required' },
});
const seccionDormitorio = (id: SectionID, code: string, nombre: string, m2: number): VisualSection => ({
kind : 'visual',
id,
code,
name : { es: nombre, en: nombre.replace('Dormitorio', 'Bedroom') },
description: { es: `${nombre}`, en: `${nombre.replace('Dormitorio', 'Bedroom')}` },
attributes : [
attrSuelo(),
attrPared(),
{
id: ATTR_M2_DORM, code: 'm2_dormitorio',
name: { es: 'Superficie', en: 'Surface' },
description: { es: 'Superficie del dormitorio en m²', en: 'Bedroom surface in m²' },
type: 'fixed', dataType: 'number', value: m2,
unit: { es: 'm²', en: 'sqm' },
display: { uiVisible: false, readonly: true }
} as FixedAttribute,
],
views : {
[VIEW_FRONT]: viewFront(`{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg`, [ATTR_CALIDAD]),
},
defaultView : VIEW_FRONT,
availability: { mode: 'required' },
});
const seccionCocina = (): VisualSection => ({
kind : 'visual',
id : SEC_COCINA,
code : 'cocina',
name : { es: 'Cocina', en: 'Kitchen' },
description: { es: 'Cocina equipada', en: 'Equipped kitchen' },
attributes : [
{
id: ATTR_ENCIMERA, code: 'encimera',
name: { es: 'Encimera', en: 'Countertop' },
description: { es: 'Material de la encimera', en: 'Countertop material' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_ENCIMERA_GRANITO,
options: [
{ optionId: OPT_ENCIMERA_GRANITO, priority: 1 },
{ optionId: OPT_ENCIMERA_SILESTONE, priority: 2 },
{ optionId: OPT_ENCIMERA_MADERA, priority: 3 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id: ATTR_MUEBLE_COC, code: 'mueble_cocina',
name: { es: 'Muebles', en: 'Cabinets' },
description: { es: 'Muebles de cocina', en: 'Kitchen cabinets' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_MUEBLE_BLANCO,
options: [
{ optionId: OPT_MUEBLE_BLANCO, priority: 1 },
{ optionId: OPT_MUEBLE_GRIS, priority: 2 },
{ optionId: OPT_MUEBLE_MADERA, priority: 3 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
attrSuelo(),
{
id: ATTR_M2_COCINA, code: 'm2_cocina',
name: { es: 'Superficie', en: 'Surface' },
description: { es: 'Superficie de la cocina en m²', en: 'Kitchen surface in m²' },
type: 'fixed', dataType: 'number', value: 10,
unit: { es: 'm²', en: 'sqm' },
display: { uiVisible: false, readonly: true }
} as FixedAttribute,
],
views : {
[VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:encimera}_{at:mueble_cocina}.jpg', [ATTR_CALIDAD]),
},
defaultView : VIEW_FRONT,
availability: { mode: 'required' },
});
const seccionArmario = (): VisualSection => ({
kind : 'visual',
id : SEC_ARMARIO,
code : 'armario',
name : { es: 'Armario', en: 'Wardrobe' },
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 },
],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as QuantifiableAttribute,
{
id : ATTR_ARMARIO_MATERIAL,
code : 'armario_material',
name : { es: 'Material interior', en: 'Interior material' },
description : { es: 'Material del interior del armario', en: 'Wardrobe interior material' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_ARMARIO_LACADO,
options : [
{ optionId: OPT_ARMARIO_LACADO, priority: 1 },
{ optionId: OPT_ARMARIO_MADERA, priority: 2 },
],
display : { uiVisible: true, affectsVisual: false, affectsPrice: false }
} as DynamicAttribute,
],
views : {
[VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:armario_puertas}.jpg'),
},
defaultView : VIEW_FRONT,
availability: { mode: 'optional' },
});
// ============================================================================
// ATRIBUTO GLOBAL — CALIDAD
// ============================================================================
const attrCalidadGlobal: DynamicAttribute = {
id: ATTR_CALIDAD, code: 'calidad',
name: { es: 'Calidad de acabados', en: 'Finish quality' },
description: { es: 'Nivel de calidad general', en: 'General finish quality level' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_CALIDAD_ESTANDAR,
options: [
{ optionId: OPT_CALIDAD_ESTANDAR, priority: 1 },
{ optionId: OPT_CALIDAD_PREMIUM, priority: 2 },
{ optionId: OPT_CALIDAD_LUJO, priority: 3 },
],
controls: [],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
};
// ============================================================================
// OBJETOS
// ============================================================================
export const APARTAMENTO: ConfigurableObject = {
id : OBJ_APARTAMENTO,
code : 'apartamento',
name : { es: 'Apartamento', en: 'Apartment' },
description: { es: 'Apartamento de 1 dormitorio (55 m²)', en: '1 bedroom apartment (55 sqm)' },
attributes : [attrCalidadGlobal],
sections : {
[SEC_SALON] : seccionSalon(),
[SEC_BANO] : seccionBano(6),
[SEC_DORMITORIO1]: seccionDormitorio(SEC_DORMITORIO1, 'dormitorio', 'Dormitorio', 14),
[SEC_COCINA] : seccionCocina(),
[SEC_ARMARIO] : seccionArmario(),
},
sectionOrder: [SEC_SALON, SEC_COCINA, SEC_DORMITORIO1, SEC_BANO, SEC_ARMARIO],
basePath : '/renders/vivienda/',
category : 'residencial',
};
export const DUPLEX: ConfigurableObject = {
id : OBJ_DUPLEX,
code : 'duplex',
name : { es: 'Dúplex', en: 'Duplex' },
description: { es: 'Dúplex de 2 dormitorios (90 m²)', en: '2 bedroom duplex (90 sqm)' },
attributes : [attrCalidadGlobal],
sections : {
[SEC_SALON] : seccionSalon(),
[SEC_BANO] : seccionBano(8),
[SEC_ASEO] : seccionAseo(),
[SEC_DORMITORIO1]: seccionDormitorio(SEC_DORMITORIO1, 'dormitorio1', 'Dormitorio principal', 18),
[SEC_DORMITORIO2]: seccionDormitorio(SEC_DORMITORIO2, 'dormitorio2', 'Dormitorio secundario', 12),
},
sectionOrder: [SEC_SALON, SEC_DORMITORIO1, SEC_DORMITORIO2, SEC_BANO, SEC_ASEO],
basePath : '/renders/vivienda/',
category : 'residencial',
};
// ============================================================================
// REGLAS
// ============================================================================
export const RULES: Record<RuleID, ValidationRule> = {
[RULE_LUJO_MARMOL]: {
id : RULE_LUJO_MARMOL,
name : { es: 'Lujo requiere mármol', en: 'Luxury requires marble' },
condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_LUJO] },
action : { type: 'require', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MARMOL] },
priority : 10,
affects : [ATTR_CALIDAD, ATTR_REVEST_BANO],
severity : 'warning',
message : { es: 'El acabado de lujo recomienda mármol en el baño', en: 'Luxury finish recommends marble in bathroom' }
},
[RULE_ESTANDAR_NO_LUJO]: {
id : RULE_ESTANDAR_NO_LUJO,
name : { es: 'Estándar sin lujo', en: 'Standard without luxury' },
condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_ESTANDAR] },
action : { type: 'forbid', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MARMOL] },
priority : 9,
affects : [ATTR_CALIDAD, ATTR_REVEST_BANO],
severity : 'error',
message : { es: 'El mármol no está disponible en calidad estándar', en: 'Marble is not available in standard quality' }
},
[RULE_MICRO_CONSISTENTE]: {
id : RULE_MICRO_CONSISTENTE,
name : { es: 'Microcemento consistente', en: 'Consistent microcement' },
condition: { '==': [{ var: 'attributes.at_suelo' }, OPT_SUELO_MICROCEMENTO] },
action : { type: 'allow', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MICROCEMENTO, OPT_REVEST_CERAMICA] },
priority : 5,
affects : [ATTR_SUELO, ATTR_REVEST_BANO],
severity : 'warning',
message : { es: 'Con microcemento en suelo se recomienda microcemento o cerámica en baño', en: 'With microcement floor, microcement or ceramic is recommended in bathroom' }
},
[RULE_MADERA_NO_BANO]: {
id : RULE_MADERA_NO_BANO,
name : { es: 'Madera sin negro', en: 'Wood without black' },
condition: { '==': [{ var: 'attributes.at_encimera' }, OPT_ENCIMERA_MADERA] },
action : { type: 'forbid', targetAttr: ATTR_SANITARIO, values: [OPT_SANIT_NEGRO] },
priority : 7,
affects : [ATTR_ENCIMERA, ATTR_SANITARIO],
severity : 'warning',
message : { es: 'La encimera de madera no combina con sanitarios negros', en: 'Wood countertop does not pair well with black sanitary ware' }
},
[RULE_NEGRO_PREMIUM]: {
id : RULE_NEGRO_PREMIUM,
name : { es: 'Negro requiere premium', en: 'Black requires premium' },
condition: { '==': [{ var: 'attributes.at_sanitario' }, OPT_SANIT_NEGRO] },
action : { type: 'forbid', targetAttr: ATTR_CALIDAD, values: [OPT_CALIDAD_ESTANDAR] },
priority : 8,
affects : [ATTR_SANITARIO, ATTR_CALIDAD],
severity : 'error',
message : { es: 'Los sanitarios negros requieren calidad premium o superior', en: 'Black sanitary ware requires premium quality or higher' }
},
[RULE_ESTANDAR_NO_NEGRO]: {
id : RULE_ESTANDAR_NO_NEGRO,
name : { es: 'Estándar prohíbe negro', en: 'Standard forbids black' },
condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_ESTANDAR] },
action : { type: 'forbid', targetAttr: ATTR_SANITARIO, values: [OPT_SANIT_NEGRO] },
priority : 8,
affects : [ATTR_CALIDAD, ATTR_SANITARIO],
severity : 'error',
message : { es: 'La calidad estándar no permite sanitarios en negro', en: 'Standard quality does not allow black sanitary ware' }
},
};
// ============================================================================
// CATÁLOGO COMPLETO
// ============================================================================
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: 'Finish configurator for new build homes' },
basePath : '/renders/',
options : OPTIONS,
objects : {
[OBJ_APARTAMENTO]: APARTAMENTO,
[OBJ_DUPLEX] : DUPLEX,
},
rules : RULES,
};
// ============================================================================
// EXPORTS DE IDs — para uso en tests
// ============================================================================
export const IDS_TEST = {
OBJ_APARTAMENTO, OBJ_DUPLEX,
SEC_SALON, SEC_BANO, SEC_ASEO, SEC_DORMITORIO1, SEC_DORMITORIO2, SEC_COCINA,
VIEW_FRONT, VIEW_360,
ATTR_CALIDAD, ATTR_SUELO, ATTR_PARED, ATTR_PUERTA,
ATTR_ENCIMERA, ATTR_MUEBLE_COC, ATTR_SANITARIO, ATTR_REVEST_BANO,
ATTR_M2_SALON, ATTR_M2_BANO, ATTR_M2_DORM, ATTR_M2_COCINA,
OPT_CALIDAD_ESTANDAR, OPT_CALIDAD_PREMIUM, OPT_CALIDAD_LUJO,
OPT_SUELO_PARQUET, OPT_SUELO_PORCELANICO, OPT_SUELO_MICROCEMENTO, OPT_SUELO_CERAMICA,
OPT_PARED_BLANCO, OPT_PARED_GRIS, OPT_PARED_BEIGE, OPT_PARED_PIEDRA,
OPT_PUERTA_ROBLE, OPT_PUERTA_LACADA, OPT_PUERTA_CRISTAL,
OPT_ENCIMERA_GRANITO, OPT_ENCIMERA_SILESTONE, OPT_ENCIMERA_MADERA,
OPT_MUEBLE_BLANCO, OPT_MUEBLE_GRIS, OPT_MUEBLE_MADERA,
OPT_SANIT_BLANCO, OPT_SANIT_NEGRO,
OPT_REVEST_MARMOL, OPT_REVEST_MICROCEMENTO, OPT_REVEST_CERAMICA,
RULE_LUJO_MARMOL, RULE_ESTANDAR_NO_LUJO, RULE_MICRO_CONSISTENTE,
RULE_MADERA_NO_BANO, RULE_NEGRO_PREMIUM, RULE_ESTANDAR_NO_NEGRO,
// Armario
SEC_ARMARIO,
ATTR_ARMARIO_PUERTAS,
ATTR_ARMARIO_MATERIAL,
OPT_ARMARIO_LACADO,
OPT_ARMARIO_MADERA,
};

@ -0,0 +1,797 @@
/**
* ============================================================================
* CATÁLOGO DE VIVIENDAS - FIXTURE ACTUALIZADO
* ============================================================================
*
* Modelo A: Apartamento (salón, baño, dormitorio, cocina)
* Modelo B: Dúplex (salón, baño, aseo, 2 dormitorios)
*/
import type {
ConfigurationCatalog,
ConfigurableObject,
OptionDefinition,
ValidationRule,
VisualSection,
DynamicAttribute,
FixedAttribute,
QuantifiableAttribute,
SectionView,
} from '../types';
import type { AttrID, SectionID, OptionID, ObjectID, RuleID, ViewID } from '../types';
// ============================================================================
// IDs — OPCIONES
// ============================================================================
const OPT_SUELO_PARQUET : OptionID = 'op:suelo_parquet';
const OPT_SUELO_PORCELANICO : OptionID = 'op:suelo_porcelanico';
const OPT_SUELO_MICROCEMENTO : OptionID = 'op:suelo_microcemento';
const OPT_SUELO_CERAMICA : OptionID = 'op:suelo_ceramica';
const OPT_PARED_BLANCO : OptionID = 'op:pared_blanco';
const OPT_PARED_GRIS : OptionID = 'op:pared_gris';
const OPT_PARED_BEIGE : OptionID = 'op:pared_beige';
const OPT_PARED_PIEDRA : OptionID = 'op:pared_piedra';
const OPT_PUERTA_ROBLE : OptionID = 'op:puerta_roble';
const OPT_PUERTA_LACADA : OptionID = 'op:puerta_lacada';
const OPT_PUERTA_CRISTAL : OptionID = 'op:puerta_cristal';
const OPT_ENCIMERA_GRANITO : OptionID = 'op:encimera_granito';
const OPT_ENCIMERA_SILESTONE : OptionID = 'op:encimera_silestone';
const OPT_ENCIMERA_MADERA : OptionID = 'op:encimera_madera';
const OPT_MUEBLE_BLANCO : OptionID = 'op:mueble_blanco';
const OPT_MUEBLE_GRIS : OptionID = 'op:mueble_gris';
const OPT_MUEBLE_MADERA : OptionID = 'op:mueble_madera';
const OPT_SANIT_BLANCO : OptionID = 'op:sanit_blanco';
const OPT_SANIT_NEGRO : OptionID = 'op:sanit_negro';
const OPT_REVEST_MARMOL : OptionID = 'op:revest_marmol';
const OPT_REVEST_MICROCEMENTO: OptionID = 'op:revest_microcemento';
const OPT_REVEST_CERAMICA : OptionID = 'op:revest_ceramica';
const OPT_CALIDAD_ESTANDAR : OptionID = 'op:calidad_estandar';
// Armario — material puertas
const OPT_ARMARIO_LACADO : OptionID = 'op:armario_lacado';
const OPT_ARMARIO_MADERA : OptionID = 'op:armario_madera';
const OPT_CALIDAD_PREMIUM : OptionID = 'op:calidad_premium';
const OPT_CALIDAD_LUJO : OptionID = 'op:calidad_lujo';
// ============================================================================
// IDs — ATRIBUTOS
// ============================================================================
const ATTR_CALIDAD : AttrID = 'at:calidad';
const ATTR_PUERTA : AttrID = 'at:puerta';
const ATTR_SUELO : AttrID = 'at:suelo';
const ATTR_PARED : AttrID = 'at:pared';
const ATTR_ENCIMERA : AttrID = 'at:encimera';
const ATTR_MUEBLE_COC : AttrID = 'at:mueble_cocina';
const ATTR_M2_COCINA : AttrID = 'at:m2_cocina';
const ATTR_SANITARIO : AttrID = 'at:sanitario';
const ATTR_REVEST_BANO: AttrID = 'at:revest_bano';
const ATTR_M2_BANO : AttrID = 'at:m2_bano';
const ATTR_M2_DORM : AttrID = 'at:m2_dormitorio';
const ATTR_M2_SALON : AttrID = 'at:m2_salon';
// Armario
const ATTR_ARMARIO_PUERTAS : AttrID = 'at:armario_puertas';
const ATTR_ARMARIO_MATERIAL : AttrID = 'at:armario_material';
// ============================================================================
// IDs — SECCIONES
// ============================================================================
const SEC_SALON : SectionID = 'sc:salon';
const SEC_BANO : SectionID = 'sc:bano';
const SEC_ASEO : SectionID = 'sc:aseo';
const SEC_DORMITORIO1: SectionID = 'sc:dormitorio1';
const SEC_DORMITORIO2: SectionID = 'sc:dormitorio2';
const SEC_COCINA : SectionID = 'sc:cocina';
const SEC_ARMARIO : SectionID = 'sc:armario';
// ============================================================================
// IDs — VISTAS
// ============================================================================
const VIEW_FRONT: ViewID = 'vw:front';
const VIEW_360 : ViewID = 'vw:360';
// ============================================================================
// IDs — OBJETOS
// ============================================================================
const OBJ_APARTAMENTO: ObjectID = 'ob:apartamento';
const OBJ_DUPLEX : ObjectID = 'ob:duplex';
// ============================================================================
// IDs — REGLAS
// ============================================================================
const RULE_LUJO_MARMOL : RuleID = 'rl:lujo_requiere_marmol';
const RULE_ESTANDAR_NO_LUJO : RuleID = 'rl:estandar_no_lujo';
const RULE_MICRO_CONSISTENTE: RuleID = 'rl:microcemento_consistente';
const RULE_MADERA_NO_BANO : RuleID = 'rl:madera_no_bano';
const RULE_NEGRO_PREMIUM : RuleID = 'rl:negro_requiere_premium';
const RULE_ESTANDAR_NO_NEGRO: RuleID = 'rl:estandar_no_negro';
// ============================================================================
// OPCIONES
// ============================================================================
export const OPTIONS: Record<OptionID, OptionDefinition> = {
[OPT_SUELO_PARQUET]: {
id: OPT_SUELO_PARQUET, code: 'parquet',
name: { es: 'Parquet', en: 'Parquet' },
description: { es: 'Suelo de madera natural', en: 'Natural wood floor' },
pricing: { baseAmount: 45 }, tags: ['madera', 'calido']
},
[OPT_SUELO_PORCELANICO]: {
id: OPT_SUELO_PORCELANICO, code: 'porcelanico',
name: { es: 'Porcelánico', en: 'Porcelain tile' },
description: { es: 'Suelo porcelánico de gran formato', en: 'Large format porcelain tile' },
pricing: { baseAmount: 55 }, tags: ['ceramica', 'moderno']
},
[OPT_SUELO_MICROCEMENTO]: {
id: OPT_SUELO_MICROCEMENTO, code: 'microcemento',
name: { es: 'Microcemento', en: 'Microcement' },
description: { es: 'Microcemento continuo', en: 'Continuous microcement' },
pricing: { baseAmount: 75 }, tags: ['moderno', 'premium']
},
[OPT_SUELO_CERAMICA]: {
id: OPT_SUELO_CERAMICA, code: 'ceramica',
name: { es: 'Cerámica', en: 'Ceramic tile' },
description: { es: 'Cerámica estándar', en: 'Standard ceramic tile' },
pricing: { baseAmount: 30 }, tags: ['ceramica', 'estandar']
},
[OPT_PARED_BLANCO]: {
id: OPT_PARED_BLANCO, code: 'blanco',
name: { es: 'Blanco roto', en: 'Off white' },
description: { es: 'Pintura blanca rota', en: 'Off white paint' },
pricing: { baseAmount: 0 }
},
[OPT_PARED_GRIS]: {
id: OPT_PARED_GRIS, code: 'gris',
name: { es: 'Gris perla', en: 'Pearl grey' },
description: { es: 'Pintura gris perla', en: 'Pearl grey paint' },
pricing: { baseAmount: 0 }
},
[OPT_PARED_BEIGE]: {
id: OPT_PARED_BEIGE, code: 'beige',
name: { es: 'Beige', en: 'Beige' },
description: { es: 'Pintura beige', en: 'Beige paint' },
pricing: { baseAmount: 0 }
},
[OPT_PARED_PIEDRA]: {
id: OPT_PARED_PIEDRA, code: 'piedra',
name: { es: 'Efecto piedra', en: 'Stone effect' },
description: { es: 'Pintura efecto piedra', en: 'Stone effect paint' },
pricing: { baseAmount: 800 }
},
[OPT_PUERTA_ROBLE]: {
id: OPT_PUERTA_ROBLE, code: 'roble',
name: { es: 'Roble natural', en: 'Natural oak' },
description: { es: 'Puerta en madera de roble', en: 'Oak wood door' },
pricing: { baseAmount: 350 }
},
[OPT_PUERTA_LACADA]: {
id: OPT_PUERTA_LACADA, code: 'lacada',
name: { es: 'Lacada blanca', en: 'White lacquered' },
description: { es: 'Puerta lacada en blanco', en: 'White lacquered door' },
pricing: { baseAmount: 250 }
},
[OPT_PUERTA_CRISTAL]: {
id: OPT_PUERTA_CRISTAL, code: 'cristal',
name: { es: 'Cristal', en: 'Glass' },
description: { es: 'Puerta de cristal templado', en: 'Tempered glass door' },
pricing: { baseAmount: 500 }
},
[OPT_ENCIMERA_GRANITO]: {
id: OPT_ENCIMERA_GRANITO, code: 'granito',
name: { es: 'Granito', en: 'Granite' },
description: { es: 'Encimera de granito natural', en: 'Natural granite countertop' },
pricing: { baseAmount: 400 }
},
[OPT_ENCIMERA_SILESTONE]: {
id: OPT_ENCIMERA_SILESTONE, code: 'silestone',
name: { es: 'Silestone', en: 'Silestone' },
description: { es: 'Encimera de Silestone', en: 'Silestone countertop' },
pricing: { baseAmount: 600 }
},
[OPT_ENCIMERA_MADERA]: {
id: OPT_ENCIMERA_MADERA, code: 'madera',
name: { es: 'Madera', en: 'Wood' },
description: { es: 'Encimera de madera maciza', en: 'Solid wood countertop' },
pricing: { baseAmount: 500 }
},
[OPT_MUEBLE_BLANCO]: {
id: OPT_MUEBLE_BLANCO, code: 'blanco',
name: { es: 'Blanco mate', en: 'Matte white' },
description: { es: 'Muebles lacados en blanco mate', en: 'Matte white lacquered cabinets' },
pricing: { baseAmount: 3500 }
},
[OPT_MUEBLE_GRIS]: {
id: OPT_MUEBLE_GRIS, code: 'gris',
name: { es: 'Gris antracita', en: 'Anthracite grey' },
description: { es: 'Muebles en gris antracita', en: 'Anthracite grey cabinets' },
pricing: { baseAmount: 4000 }
},
[OPT_MUEBLE_MADERA]: {
id: OPT_MUEBLE_MADERA, code: 'madera',
name: { es: 'Madera natural', en: 'Natural wood' },
description: { es: 'Muebles en madera natural', en: 'Natural wood cabinets' },
pricing: { baseAmount: 5000 }
},
[OPT_SANIT_BLANCO]: {
id: OPT_SANIT_BLANCO, code: 'blanco',
name: { es: 'Blanco', en: 'White' },
description: { es: 'Sanitarios blancos', en: 'White sanitary ware' },
pricing: { baseAmount: 0 }
},
[OPT_SANIT_NEGRO]: {
id: OPT_SANIT_NEGRO, code: 'negro',
name: { es: 'Negro mate', en: 'Matte black' },
description: { es: 'Sanitarios en negro mate', en: 'Matte black sanitary ware' },
pricing: { baseAmount: 1200 }
},
[OPT_REVEST_MARMOL]: {
id: OPT_REVEST_MARMOL, code: 'marmol',
name: { es: 'Mármol', en: 'Marble' },
description: { es: 'Revestimiento de mármol natural', en: 'Natural marble cladding' },
pricing: { baseAmount: 120 }
},
[OPT_REVEST_MICROCEMENTO]: {
id: OPT_REVEST_MICROCEMENTO, code: 'microcemento',
name: { es: 'Microcemento', en: 'Microcement' },
description: { es: 'Revestimiento de microcemento', en: 'Microcement cladding' },
pricing: { baseAmount: 90 }
},
[OPT_REVEST_CERAMICA]: {
id: OPT_REVEST_CERAMICA, code: 'ceramica',
name: { es: 'Cerámica', en: 'Ceramic tile' },
description: { es: 'Revestimiento cerámico', en: 'Ceramic tile cladding' },
pricing: { baseAmount: 40 }
},
// Armario — material puertas
[OPT_ARMARIO_LACADO]: {
id: OPT_ARMARIO_LACADO, code: 'lacado',
name: { es: 'Lacado blanco', en: 'White lacquered' },
description: { es: 'Puertas lacadas en blanco mate', en: 'Matte white lacquered doors' },
pricing: {
baseAmount : 0,
// Precio por puerta con descuento por volumen:
// 2 puertas → 350€/u, 3 puertas → 300€/u
dynamicExpression: {
'if': [
{ '>=': [{ var: 'quantity' }, 3] },
{ '*': [{ var: 'quantity' }, 300] },
{ '*': [{ var: 'quantity' }, 350] }
]
}
}
},
[OPT_ARMARIO_MADERA]: {
id: OPT_ARMARIO_MADERA, code: 'madera',
name: { es: 'Madera natural', en: 'Natural wood' },
description: { es: 'Puertas en madera natural', en: 'Natural wood doors' },
pricing: {
baseAmount : 0,
// Precio fijo por puerta sin descuento
dynamicExpression: { '*': [{ var: 'quantity' }, 500] }
}
},
[OPT_CALIDAD_ESTANDAR]: {
id: OPT_CALIDAD_ESTANDAR, code: 'estandar',
name: { es: 'Estándar', en: 'Standard' },
description: { es: 'Acabados estándar', en: 'Standard quality finishes' },
pricing: { baseAmount: 0 }
},
[OPT_CALIDAD_PREMIUM]: {
id: OPT_CALIDAD_PREMIUM, code: 'premium',
name: { es: 'Premium', en: 'Premium' },
description: { es: 'Acabados de alta calidad', en: 'High quality finishes' },
pricing: { baseAmount: 15000 }
},
[OPT_CALIDAD_LUJO]: {
id: OPT_CALIDAD_LUJO, code: 'lujo',
name: { es: 'Lujo', en: 'Luxury' },
description: { es: 'Acabados de lujo exclusivos', en: 'Exclusive luxury finishes' },
pricing: { baseAmount: 35000 }
},
};
// ============================================================================
// ATRIBUTOS COMUNES
// ============================================================================
const attrSuelo = (): DynamicAttribute => ({
id: ATTR_SUELO, code: 'suelo',
name: { es: 'Suelo', en: 'Flooring' },
description: { es: 'Material del suelo', en: 'Floor material' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_SUELO_CERAMICA,
options: [
{ optionId: OPT_SUELO_CERAMICA, priority: 1 },
{ optionId: OPT_SUELO_PARQUET, priority: 2 },
{ optionId: OPT_SUELO_PORCELANICO, priority: 3 },
{ optionId: OPT_SUELO_MICROCEMENTO, priority: 4 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
});
const attrPared = (): DynamicAttribute => ({
id: ATTR_PARED, code: 'pared',
name: { es: 'Color paredes', en: 'Wall color' },
description: { es: 'Color de la pintura de paredes', en: 'Wall paint color' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_PARED_BLANCO,
options: [
{ optionId: OPT_PARED_BLANCO, priority: 1 },
{ optionId: OPT_PARED_GRIS, priority: 2 },
{ optionId: OPT_PARED_BEIGE, priority: 3 },
{ optionId: OPT_PARED_PIEDRA, priority: 4 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
});
// ============================================================================
// VISTAS HELPERS
// ============================================================================
const viewFront = (template: string, globalAttrDependencies?: AttrID[]): SectionView => ({
id : VIEW_FRONT,
code : 'front',
name : { es: 'Vista frontal', en: 'Front view' },
type : 'front',
visualConfig: {
strategy: 'static_image',
template,
globalAttrDependencies,
basePath : '/renders/vivienda/',
fallbackImage: '/renders/fallback.jpg',
},
});
const view360 = (template: string, globalAttrDependencies?: AttrID[]): SectionView => ({
id : VIEW_360,
code : '360',
name : { es: 'Vista 360°', en: '360° view' },
type : 'panoramic',
visualConfig: {
strategy: 'static_image',
template,
globalAttrDependencies,
basePath : '/renders/vivienda/',
fallbackImage: '/renders/fallback.jpg',
},
});
// ============================================================================
// SECCIONES
// ============================================================================
const seccionSalon = (): VisualSection => ({
kind : 'visual',
id : SEC_SALON,
code : 'salon',
name : { es: 'Salón', en: 'Living room' },
description: { es: 'Salón - comedor principal', en: 'Main living - dining room' },
attributes : [
attrSuelo(),
attrPared(),
{
id: ATTR_M2_SALON, code: 'm2_salon',
name: { es: 'Superficie', en: 'Surface' },
description: { es: 'Superficie del salón en m²', en: 'Living room surface in m²' },
type: 'fixed', dataType: 'number', value: 25,
unit: { es: 'm²', en: 'sqm' },
display: { uiVisible: false, readonly: true }
} as FixedAttribute,
{
id: ATTR_PUERTA, code: 'puerta',
name: { es: 'Puerta', en: 'Door' },
description: { es: 'Tipo de puerta', en: 'Door type' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_PUERTA_LACADA,
options: [
{ optionId: OPT_PUERTA_LACADA, priority: 1 },
{ optionId: OPT_PUERTA_ROBLE, priority: 2 },
{ optionId: OPT_PUERTA_CRISTAL, priority: 3 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
],
views : {
[VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg', [ATTR_CALIDAD]),
[VIEW_360] : view360 ('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg', [ATTR_CALIDAD]),
},
defaultView : VIEW_FRONT,
availability: { mode: 'required' },
});
const seccionBano = (m2: number = 6): VisualSection => ({
kind : 'visual',
id : SEC_BANO,
code : 'bano',
name : { es: 'Baño', en: 'Bathroom' },
description: { es: 'Baño principal', en: 'Main bathroom' },
attributes : [
{
id: ATTR_SANITARIO, code: 'sanitario',
name: { es: 'Sanitarios', en: 'Sanitary ware' },
description: { es: 'Color de los sanitarios', en: 'Sanitary ware color' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_SANIT_BLANCO,
options: [
{ optionId: OPT_SANIT_BLANCO, priority: 1 },
{ optionId: OPT_SANIT_NEGRO, priority: 2 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id: ATTR_REVEST_BANO, code: 'revest_bano',
name: { es: 'Revestimiento', en: 'Cladding' },
description: { es: 'Revestimiento de paredes del baño', en: 'Bathroom wall cladding' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_REVEST_CERAMICA,
options: [
{ optionId: OPT_REVEST_CERAMICA, priority: 1 },
{ optionId: OPT_REVEST_MICROCEMENTO, priority: 2 },
{ optionId: OPT_REVEST_MARMOL, priority: 3 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id: ATTR_M2_BANO, code: 'm2_bano',
name: { es: 'Superficie', en: 'Surface' },
description: { es: 'Superficie del baño en m²', en: 'Bathroom surface in m²' },
type: 'fixed', dataType: 'number', value: m2,
unit: { es: 'm²', en: 'sqm' },
display: { uiVisible: false, readonly: true }
} as FixedAttribute,
],
views : {
[VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:sanitario}_{at:revest_bano}.jpg', [ATTR_CALIDAD]),
},
defaultView : VIEW_FRONT,
availability: { mode: 'required' },
});
const seccionAseo = (): VisualSection => ({
kind : 'visual',
id : SEC_ASEO,
code : 'aseo',
name : { es: 'Aseo', en: 'Toilet' },
description: { es: 'Aseo de cortesía', en: 'Guest toilet' },
attributes : [
{
id: ATTR_SANITARIO, code: 'sanitario',
name: { es: 'Sanitarios', en: 'Sanitary ware' },
description: { es: 'Color de los sanitarios', en: 'Sanitary ware color' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_SANIT_BLANCO,
options: [
{ optionId: OPT_SANIT_BLANCO, priority: 1 },
{ optionId: OPT_SANIT_NEGRO, priority: 2 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id: ATTR_REVEST_BANO, code: 'revest_bano',
name: { es: 'Revestimiento', en: 'Cladding' },
description: { es: 'Revestimiento', en: 'Cladding' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_REVEST_CERAMICA,
options: [
{ optionId: OPT_REVEST_CERAMICA, priority: 1 },
{ optionId: OPT_REVEST_MICROCEMENTO, priority: 2 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
],
views : {
[VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:sanitario}_{at:revest_bano}.jpg', [ATTR_CALIDAD]),
},
defaultView : VIEW_FRONT,
availability: { mode: 'required' },
});
const seccionDormitorio = (id: SectionID, code: string, nombre: string, m2: number): VisualSection => ({
kind : 'visual',
id,
code,
name : { es: nombre, en: nombre.replace('Dormitorio', 'Bedroom') },
description: { es: `${nombre}`, en: `${nombre.replace('Dormitorio', 'Bedroom')}` },
attributes : [
attrSuelo(),
attrPared(),
{
id: ATTR_M2_DORM, code: 'm2_dormitorio',
name: { es: 'Superficie', en: 'Surface' },
description: { es: 'Superficie del dormitorio en m²', en: 'Bedroom surface in m²' },
type: 'fixed', dataType: 'number', value: m2,
unit: { es: 'm²', en: 'sqm' },
display: { uiVisible: false, readonly: true }
} as FixedAttribute,
],
views : {
[VIEW_FRONT]: viewFront(`{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg`, [ATTR_CALIDAD]),
},
defaultView : VIEW_FRONT,
availability: { mode: 'required' },
});
const seccionCocina = (): VisualSection => ({
kind : 'visual',
id : SEC_COCINA,
code : 'cocina',
name : { es: 'Cocina', en: 'Kitchen' },
description: { es: 'Cocina equipada', en: 'Equipped kitchen' },
attributes : [
{
id: ATTR_ENCIMERA, code: 'encimera',
name: { es: 'Encimera', en: 'Countertop' },
description: { es: 'Material de la encimera', en: 'Countertop material' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_ENCIMERA_GRANITO,
options: [
{ optionId: OPT_ENCIMERA_GRANITO, priority: 1 },
{ optionId: OPT_ENCIMERA_SILESTONE, priority: 2 },
{ optionId: OPT_ENCIMERA_MADERA, priority: 3 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id: ATTR_MUEBLE_COC, code: 'mueble_cocina',
name: { es: 'Muebles', en: 'Cabinets' },
description: { es: 'Muebles de cocina', en: 'Kitchen cabinets' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_MUEBLE_BLANCO,
options: [
{ optionId: OPT_MUEBLE_BLANCO, priority: 1 },
{ optionId: OPT_MUEBLE_GRIS, priority: 2 },
{ optionId: OPT_MUEBLE_MADERA, priority: 3 },
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
attrSuelo(),
{
id: ATTR_M2_COCINA, code: 'm2_cocina',
name: { es: 'Superficie', en: 'Surface' },
description: { es: 'Superficie de la cocina en m²', en: 'Kitchen surface in m²' },
type: 'fixed', dataType: 'number', value: 10,
unit: { es: 'm²', en: 'sqm' },
display: { uiVisible: false, readonly: true }
} as FixedAttribute,
],
views : {
[VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:encimera}_{at:mueble_cocina}.jpg', [ATTR_CALIDAD]),
},
defaultView : VIEW_FRONT,
availability: { mode: 'required' },
});
const seccionArmario = (): VisualSection => ({
kind : 'visual',
id : SEC_ARMARIO,
code : 'armario',
name : { es: 'Armario', en: 'Wardrobe' },
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 },
],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as QuantifiableAttribute,
{
id : ATTR_ARMARIO_MATERIAL,
code : 'armario_material',
name : { es: 'Material interior', en: 'Interior material' },
description : { es: 'Material del interior del armario', en: 'Wardrobe interior material' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_ARMARIO_LACADO,
options : [
{ optionId: OPT_ARMARIO_LACADO, priority: 1 },
{ optionId: OPT_ARMARIO_MADERA, priority: 2 },
],
display : { uiVisible: true, affectsVisual: false, affectsPrice: false }
} as DynamicAttribute,
],
views : {
[VIEW_FRONT]: viewFront('{object}/{section}/{view}/{at:armario_puertas}.jpg'),
},
defaultView : VIEW_FRONT,
availability: { mode: 'optional' },
});
// ============================================================================
// ATRIBUTO GLOBAL — CALIDAD
// ============================================================================
const attrCalidadGlobal: DynamicAttribute = {
id: ATTR_CALIDAD, code: 'calidad',
name: { es: 'Calidad de acabados', en: 'Finish quality' },
description: { es: 'Nivel de calidad general', en: 'General finish quality level' },
type: 'dynamic', dataType: 'reference',
defaultValue: OPT_CALIDAD_ESTANDAR,
options: [
{ optionId: OPT_CALIDAD_ESTANDAR, priority: 1 },
{ optionId: OPT_CALIDAD_PREMIUM, priority: 2 },
{ optionId: OPT_CALIDAD_LUJO, priority: 3 },
],
controls: [],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
};
// ============================================================================
// OBJETOS
// ============================================================================
export const APARTAMENTO: ConfigurableObject = {
id : OBJ_APARTAMENTO,
code : 'apartamento',
name : { es: 'Apartamento', en: 'Apartment' },
description: { es: 'Apartamento de 1 dormitorio (55 m²)', en: '1 bedroom apartment (55 sqm)' },
attributes : [attrCalidadGlobal],
sections : {
[SEC_SALON] : seccionSalon(),
[SEC_BANO] : seccionBano(6),
[SEC_DORMITORIO1]: seccionDormitorio(SEC_DORMITORIO1, 'dormitorio', 'Dormitorio', 14),
[SEC_COCINA] : seccionCocina(),
[SEC_ARMARIO] : seccionArmario(),
},
sectionOrder: [SEC_SALON, SEC_COCINA, SEC_DORMITORIO1, SEC_BANO, SEC_ARMARIO],
basePath : '/renders/vivienda/',
category : 'residencial',
};
export const DUPLEX: ConfigurableObject = {
id : OBJ_DUPLEX,
code : 'duplex',
name : { es: 'Dúplex', en: 'Duplex' },
description: { es: 'Dúplex de 2 dormitorios (90 m²)', en: '2 bedroom duplex (90 sqm)' },
attributes : [attrCalidadGlobal],
sections : {
[SEC_SALON] : seccionSalon(),
[SEC_BANO] : seccionBano(8),
[SEC_ASEO] : seccionAseo(),
[SEC_DORMITORIO1]: seccionDormitorio(SEC_DORMITORIO1, 'dormitorio1', 'Dormitorio principal', 18),
[SEC_DORMITORIO2]: seccionDormitorio(SEC_DORMITORIO2, 'dormitorio2', 'Dormitorio secundario', 12),
},
sectionOrder: [SEC_SALON, SEC_DORMITORIO1, SEC_DORMITORIO2, SEC_BANO, SEC_ASEO],
basePath : '/renders/vivienda/',
category : 'residencial',
};
// ============================================================================
// REGLAS
// ============================================================================
export const RULES: Record<RuleID, ValidationRule> = {
[RULE_LUJO_MARMOL]: {
id : RULE_LUJO_MARMOL,
name : { es: 'Lujo requiere mármol', en: 'Luxury requires marble' },
condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_LUJO] },
action : { type: 'require', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MARMOL] },
priority : 10,
affects : [ATTR_CALIDAD, ATTR_REVEST_BANO],
severity : 'warning',
message : { es: 'El acabado de lujo recomienda mármol en el baño', en: 'Luxury finish recommends marble in bathroom' }
},
[RULE_ESTANDAR_NO_LUJO]: {
id : RULE_ESTANDAR_NO_LUJO,
name : { es: 'Estándar sin lujo', en: 'Standard without luxury' },
condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_ESTANDAR] },
action : { type: 'forbid', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MARMOL] },
priority : 9,
affects : [ATTR_CALIDAD, ATTR_REVEST_BANO],
severity : 'error',
message : { es: 'El mármol no está disponible en calidad estándar', en: 'Marble is not available in standard quality' }
},
[RULE_MICRO_CONSISTENTE]: {
id : RULE_MICRO_CONSISTENTE,
name : { es: 'Microcemento consistente', en: 'Consistent microcement' },
condition: { '==': [{ var: 'attributes.at_suelo' }, OPT_SUELO_MICROCEMENTO] },
action : { type: 'allow', targetAttr: ATTR_REVEST_BANO, values: [OPT_REVEST_MICROCEMENTO, OPT_REVEST_CERAMICA] },
priority : 5,
affects : [ATTR_SUELO, ATTR_REVEST_BANO],
severity : 'warning',
message : { es: 'Con microcemento en suelo se recomienda microcemento o cerámica en baño', en: 'With microcement floor, microcement or ceramic is recommended in bathroom' }
},
[RULE_MADERA_NO_BANO]: {
id : RULE_MADERA_NO_BANO,
name : { es: 'Madera sin negro', en: 'Wood without black' },
condition: { '==': [{ var: 'attributes.at_encimera' }, OPT_ENCIMERA_MADERA] },
action : { type: 'forbid', targetAttr: ATTR_SANITARIO, values: [OPT_SANIT_NEGRO] },
priority : 7,
affects : [ATTR_ENCIMERA, ATTR_SANITARIO],
severity : 'warning',
message : { es: 'La encimera de madera no combina con sanitarios negros', en: 'Wood countertop does not pair well with black sanitary ware' }
},
[RULE_NEGRO_PREMIUM]: {
id : RULE_NEGRO_PREMIUM,
name : { es: 'Negro requiere premium', en: 'Black requires premium' },
condition: { '==': [{ var: 'attributes.at_sanitario' }, OPT_SANIT_NEGRO] },
action : { type: 'forbid', targetAttr: ATTR_CALIDAD, values: [OPT_CALIDAD_ESTANDAR] },
priority : 8,
affects : [ATTR_SANITARIO, ATTR_CALIDAD],
severity : 'error',
message : { es: 'Los sanitarios negros requieren calidad premium o superior', en: 'Black sanitary ware requires premium quality or higher' }
},
[RULE_ESTANDAR_NO_NEGRO]: {
id : RULE_ESTANDAR_NO_NEGRO,
name : { es: 'Estándar prohíbe negro', en: 'Standard forbids black' },
condition: { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_ESTANDAR] },
action : { type: 'forbid', targetAttr: ATTR_SANITARIO, values: [OPT_SANIT_NEGRO] },
priority : 8,
affects : [ATTR_CALIDAD, ATTR_SANITARIO],
severity : 'error',
message : { es: 'La calidad estándar no permite sanitarios en negro', en: 'Standard quality does not allow black sanitary ware' }
},
};
// ============================================================================
// CATÁLOGO COMPLETO
// ============================================================================
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: 'Finish configurator for new build homes' },
basePath : '/renders/',
options : OPTIONS,
objects : {
[OBJ_APARTAMENTO]: APARTAMENTO,
[OBJ_DUPLEX] : DUPLEX,
},
rules : RULES,
};
// ============================================================================
// EXPORTS DE IDs — para uso en tests
// ============================================================================
export const IDS_TEST = {
OBJ_APARTAMENTO, OBJ_DUPLEX,
SEC_SALON, SEC_BANO, SEC_ASEO, SEC_DORMITORIO1, SEC_DORMITORIO2, SEC_COCINA,
VIEW_FRONT, VIEW_360,
ATTR_CALIDAD, ATTR_SUELO, ATTR_PARED, ATTR_PUERTA,
ATTR_ENCIMERA, ATTR_MUEBLE_COC, ATTR_SANITARIO, ATTR_REVEST_BANO,
ATTR_M2_SALON, ATTR_M2_BANO, ATTR_M2_DORM, ATTR_M2_COCINA,
OPT_CALIDAD_ESTANDAR, OPT_CALIDAD_PREMIUM, OPT_CALIDAD_LUJO,
OPT_SUELO_PARQUET, OPT_SUELO_PORCELANICO, OPT_SUELO_MICROCEMENTO, OPT_SUELO_CERAMICA,
OPT_PARED_BLANCO, OPT_PARED_GRIS, OPT_PARED_BEIGE, OPT_PARED_PIEDRA,
OPT_PUERTA_ROBLE, OPT_PUERTA_LACADA, OPT_PUERTA_CRISTAL,
OPT_ENCIMERA_GRANITO, OPT_ENCIMERA_SILESTONE, OPT_ENCIMERA_MADERA,
OPT_MUEBLE_BLANCO, OPT_MUEBLE_GRIS, OPT_MUEBLE_MADERA,
OPT_SANIT_BLANCO, OPT_SANIT_NEGRO,
OPT_REVEST_MARMOL, OPT_REVEST_MICROCEMENTO, OPT_REVEST_CERAMICA,
RULE_LUJO_MARMOL, RULE_ESTANDAR_NO_LUJO, RULE_MICRO_CONSISTENTE,
RULE_MADERA_NO_BANO, RULE_NEGRO_PREMIUM, RULE_ESTANDAR_NO_NEGRO,
// Armario
SEC_ARMARIO,
ATTR_ARMARIO_PUERTAS,
ATTR_ARMARIO_MATERIAL,
OPT_ARMARIO_LACADO,
OPT_ARMARIO_MADERA,
};

@ -0,0 +1,272 @@
/**
* ============================================================================
* 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 { TaxInfo } from '../types';
import {
CATALOGO_VIVIENDAS,
IDS_TEST,
} from './housing-catalog.fixture';
const {
OBJ_APARTAMENTO,
ATTR_CALIDAD,
ATTR_SUELO,
ATTR_PARED,
ATTR_SANITARIO,
ATTR_REVEST_BANO,
ATTR_ENCIMERA,
ATTR_MUEBLE_COC,
ATTR_PUERTA,
ATTR_M2_SALON,
OPT_CALIDAD_ESTANDAR,
OPT_CALIDAD_PREMIUM,
OPT_CALIDAD_LUJO,
OPT_SUELO_CERAMICA,
OPT_SUELO_PARQUET,
OPT_PARED_BLANCO,
OPT_PARED_PIEDRA,
OPT_SANIT_BLANCO,
OPT_SANIT_NEGRO,
OPT_REVEST_CERAMICA,
OPT_REVEST_MARMOL,
OPT_ENCIMERA_GRANITO,
OPT_ENCIMERA_SILESTONE,
OPT_MUEBLE_BLANCO,
OPT_PUERTA_LACADA,
OPT_PUERTA_CRISTAL,
} = 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;
}
/** 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
};
// subtotal base = 30 + 40 + 400 + 3500 + 250 = 4220
}
const IVA_ES: TaxInfo = {
included: false,
rate : 0.21,
label : { es: 'IVA', en: 'VAT' },
};
const IVA_INCLUIDO: TaxInfo = {
included: true,
rate : 0.21,
label : { es: 'IVA', en: 'VAT' },
};
let logr : Logr;
let engine: PricingEngine;
beforeEach(() => {
logr = makeLogr();
engine = new PricingEngine(logr);
});
// ============================================================================
// CÁLCULO BÁSICO
// ============================================================================
describe('cálculo básico', () => {
it('suma los precios de todas las opciones con affectsPrice: true', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
expect(result.subtotal).toBe(4220);
});
it('sin tax — taxAmount es 0 y total igual a subtotal', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
expect(result.taxAmount).toBe(0);
expect(result.total).toBe(result.subtotal);
});
it('calidad premium suma 15000 al total', () => {
const state = { ...baseState(), [ATTR_CALIDAD]: 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 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 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 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 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 result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
expect(result.subtotal).toBe(4220 - 250 + 500);
});
});
// ============================================================================
// TAX — impuesto no incluido
// ============================================================================
describe('tax — no incluido', () => {
it('calcula taxAmount como subtotal * rate', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_ES);
expect(result.taxAmount).toBeCloseTo(4220 * 0.21, 2);
});
it('total es subtotal + taxAmount', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_ES);
expect(result.total).toBeCloseTo(result.subtotal + result.taxAmount, 2);
});
it('devuelve el tax info en el resultado', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_ES);
expect(result.tax).toEqual(IVA_ES);
});
it('total con IVA 21% sobre 4220 = 5106.20', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_ES);
expect(result.total).toBeCloseTo(5106.20, 2);
});
});
// ============================================================================
// TAX — impuesto incluido
// ============================================================================
describe('tax — incluido en precio', () => {
it('taxAmount es 0 cuando included: true', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_INCLUIDO);
expect(result.taxAmount).toBe(0);
});
it('total igual a subtotal cuando included: true', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS, IVA_INCLUIDO);
expect(result.total).toBe(result.subtotal);
});
});
// ============================================================================
// DESGLOSE
// ============================================================================
describe('breakdown', () => {
it('el breakdown contiene una entrada por opción con precio > 0', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
expect(result.breakdown.length).toBeGreaterThan(0);
});
it('cada entrada del breakdown tiene attrId, optionId y amount', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
for (const entry of result.breakdown) {
expect(entry.attrId).toBeTruthy();
expect(entry.optionId).toBeTruthy();
expect(typeof entry.amount).toBe('number');
}
});
it('la suma del breakdown es igual al subtotal', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
const sum = result.breakdown.reduce((acc, e) => acc + e.amount, 0);
expect(sum).toBeCloseTo(result.subtotal, 2);
});
it('opciones con precio 0 no aparecen en el breakdown', () => {
// pared blanco = 0, sanit blanco = 0, calidad estandar = 0
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
const zeros = result.breakdown.filter(e => e.amount === 0);
expect(zeros).toHaveLength(0);
});
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 result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
const baseEntry = base.breakdown.find(e => e.attrId === ATTR_REVEST_BANO);
const resultEntry = result.breakdown.find(e => e.attrId === ATTR_REVEST_BANO);
expect(baseEntry?.amount).toBe(40);
expect(resultEntry?.amount).toBe(120);
});
});
// ============================================================================
// CASOS LÍMITE
// ============================================================================
describe('casos límite', () => {
it('objeto inexistente → subtotal 0 y logea error', () => {
const result = engine.calculate('ob:noexiste' as any, baseState(), CATALOGO_VIVIENDAS);
expect(result.subtotal).toBe(0);
expect(result.total).toBe(0);
expect(logr.error).toHaveBeenCalledTimes(1);
});
it('estado vacío → subtotal 0', () => {
const result = engine.calculate(OBJ_APARTAMENTO, {}, CATALOGO_VIVIENDAS);
expect(result.subtotal).toBe(0);
});
it('hasConsultation false en estado base', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
expect(result.hasConsultation).toBe(false);
});
it('encimera silestone suma 600', () => {
const state = { ...baseState(), [ATTR_ENCIMERA]: 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);
});
});

@ -0,0 +1,304 @@
/**
* ============================================================================
* SELECTION STATE — TESTS
* ============================================================================
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SelectionState } from '../engines/selection-state.ts';
import type { Logr } from '@/libs/logr';
import {
CATALOGO_VIVIENDAS,
IDS_TEST,
} from './housing-catalog.fixture';
const {
OBJ_APARTAMENTO,
ATTR_CALIDAD,
ATTR_SUELO,
ATTR_PARED,
ATTR_SANITARIO,
ATTR_REVEST_BANO,
ATTR_ENCIMERA,
OPT_CALIDAD_ESTANDAR,
OPT_CALIDAD_PREMIUM,
OPT_CALIDAD_LUJO,
OPT_SUELO_PARQUET,
OPT_SUELO_CERAMICA,
OPT_SUELO_MICROCEMENTO,
OPT_PARED_BLANCO,
OPT_SANIT_BLANCO,
OPT_SANIT_NEGRO,
OPT_REVEST_CERAMICA,
OPT_REVEST_MARMOL,
OPT_REVEST_MICROCEMENTO,
OPT_ENCIMERA_GRANITO,
OPT_ENCIMERA_MADERA,
} = 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;
}
let logr : Logr;
function makeState() {
return new SelectionState(OBJ_APARTAMENTO, CATALOGO_VIVIENDAS, makeLogr());
}
beforeEach(() => {
logr = makeLogr();
});
// ============================================================================
// INICIALIZACIÓN
// ============================================================================
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);
});
it('el estado inicial es válido', () => {
const state = makeState();
expect(state.isValid()).toBe(true);
});
it('lanza error si el objeto no existe en el catálogo', () => {
expect(() =>
new SelectionState('ob:noexiste' as any, CATALOGO_VIVIENDAS, makeLogr())
).toThrow();
});
});
// ============================================================================
// select — valores permitidos
// ============================================================================
describe('select — valores permitidos', () => {
it('acepta un valor permitido y actualiza el estado', () => {
const state = makeState();
const result = state.select(ATTR_SUELO, OPT_SUELO_PARQUET);
expect(result.success).toBe(true);
expect(state.getValue(ATTR_SUELO)).toBe(OPT_SUELO_PARQUET);
});
it('acepta calidad premium', () => {
const state = makeState();
const result = state.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM);
expect(result.success).toBe(true);
expect(state.getValue(ATTR_CALIDAD)).toBe(OPT_CALIDAD_PREMIUM);
});
it('el estado sigue siendo válido tras selección permitida', () => {
const state = makeState();
state.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM);
expect(state.isValid()).toBe(true);
});
it('getSelection devuelve copia inmutable del estado', () => {
const state = makeState();
const snapshot = state.getSelection();
state.select(ATTR_SUELO, OPT_SUELO_PARQUET);
// snapshot no debe haber cambiado
expect(snapshot[ATTR_SUELO]).toBe(OPT_SUELO_CERAMICA);
});
});
// ============================================================================
// select — valores prohibidos (modelo preventivo)
// ============================================================================
describe('select — valores prohibidos', () => {
it('rechaza mármol con calidad estándar', () => {
const state = makeState(); // calidad = estandar por defecto
const result = state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
expect(result.success).toBe(false);
});
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);
});
it('rechaza sanitario negro con calidad estándar', () => {
const state = makeState();
const result = state.select(ATTR_SANITARIO, OPT_SANIT_NEGRO);
expect(result.success).toBe(false);
});
it('devuelve reason con el mensaje de la regla', () => {
const state = makeState();
const result = state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.reason).toBeTruthy();
expect(typeof result.reason).toBe('string');
}
});
it('el estado sigue siendo válido tras selección rechazada', () => {
const state = makeState();
state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
expect(state.isValid()).toBe(true);
});
it('logea warning al rechazar selección', () => {
const logr = makeLogr();
const state = new SelectionState(OBJ_APARTAMENTO, CATALOGO_VIVIENDAS, logr);
state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
expect(logr.warn).toHaveBeenCalledTimes(1);
});
});
// ============================================================================
// select — permite tras cambio de contexto
// ============================================================================
describe('select — disponibilidad cambia con el contexto', () => {
it('mármol permitido tras cambiar calidad a premium', () => {
const state = makeState();
state.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM);
const result = state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
expect(result.success).toBe(true);
});
it('sanitario negro permitido con calidad premium', () => {
const state = makeState();
state.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM);
const result = state.select(ATTR_SANITARIO, OPT_SANIT_NEGRO);
expect(result.success).toBe(true);
});
it('microcemento en baño restringido al seleccionar suelo microcemento', () => {
const state = makeState();
state.select(ATTR_SUELO, OPT_SUELO_MICROCEMENTO);
// mármol queda fuera de la lista allow → rechazado
const result = state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
expect(result.success).toBe(false);
});
it('microcemento en baño permitido con suelo microcemento', () => {
const state = makeState();
state.select(ATTR_SUELO, OPT_SUELO_MICROCEMENTO);
const result = state.select(ATTR_REVEST_BANO, OPT_REVEST_MICROCEMENTO);
expect(result.success).toBe(true);
});
});
// ============================================================================
// getAttributeState
// ============================================================================
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);
});
it('devuelve las opciones del atributo', () => {
const state = makeState();
const s = state.getAttributeState(ATTR_SUELO);
expect(s?.options.length).toBeGreaterThan(0);
});
it('marca mármol como no disponible con calidad estándar', () => {
const state = makeState();
const s = state.getAttributeState(ATTR_REVEST_BANO);
const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL);
expect(marmol?.available).toBe(false);
});
it('marca mármol como disponible con calidad premium', () => {
const state = makeState();
state.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM);
const s = state.getAttributeState(ATTR_REVEST_BANO);
const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL);
expect(marmol?.available).toBe(true);
});
it('opción no disponible tiene reason', () => {
const state = makeState();
const s = state.getAttributeState(ATTR_REVEST_BANO);
const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL);
expect(marmol?.reason).toBeTruthy();
});
it('opción disponible no tiene reason', () => {
const state = makeState();
const s = state.getAttributeState(ATTR_REVEST_BANO);
const ceramica = s?.options.find(o => o.optionId === OPT_REVEST_CERAMICA);
expect(ceramica?.available).toBe(true);
expect(ceramica?.reason).toBeUndefined();
});
it('devuelve null para atributo inexistente', () => {
const state = makeState();
expect(state.getAttributeState('at:noexiste' as any)).toBeNull();
});
it('required true cuando calidad lujo activa la regla require', () => {
const state = makeState();
state.select(ATTR_CALIDAD, OPT_CALIDAD_LUJO);
const s = state.getAttributeState(ATTR_REVEST_BANO);
expect(s?.required).toBe(true);
});
it('required false sin regla de require activa', () => {
const state = makeState();
const s = state.getAttributeState(ATTR_SUELO);
expect(s?.required).toBe(false);
});
});
// ============================================================================
// getAllAttributeStates
// ============================================================================
describe('getAllAttributeStates', () => {
it('devuelve un estado por cada atributo único', () => {
const state = makeState();
const states = state.getAllAttributeStates();
expect(states.size).toBeGreaterThan(0);
expect(states.has(ATTR_CALIDAD)).toBe(true);
expect(states.has(ATTR_SUELO)).toBe(true);
expect(states.has(ATTR_SANITARIO)).toBe(true);
});
it('no hay atributos duplicados', () => {
const state = makeState();
const states = state.getAllAttributeStates();
// ATTR_SUELO aparece en salón, dormitorio y cocina — solo debe aparecer una vez
expect(states.has(ATTR_SUELO)).toBe(true);
expect([...states.keys()].filter(k => k === ATTR_SUELO)).toHaveLength(1);
});
});

@ -0,0 +1,346 @@
/**
* ============================================================================
* TEMPLATE RESOLVER — TESTS
* ============================================================================
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TemplateResolver } from '../engines/template-resolver';
import type { Logr } from '@/libs/logr';
import type { SelectionMap } from '../engines/rule';
import {
CATALOGO_VIVIENDAS,
IDS_TEST,
} from './housing-catalog.fixture';
const {
OBJ_APARTAMENTO,
SEC_SALON,
SEC_BANO,
SEC_COCINA,
VIEW_FRONT,
VIEW_360,
ATTR_CALIDAD,
ATTR_SUELO,
ATTR_PARED,
ATTR_SANITARIO,
ATTR_REVEST_BANO,
ATTR_ENCIMERA,
ATTR_MUEBLE_COC,
OPT_CALIDAD_PREMIUM,
OPT_SUELO_PARQUET,
OPT_SUELO_MICROCEMENTO,
OPT_PARED_BLANCO,
OPT_PARED_GRIS,
OPT_SANIT_BLANCO,
OPT_SANIT_NEGRO,
OPT_REVEST_CERAMICA,
OPT_REVEST_MICROCEMENTO,
OPT_ENCIMERA_GRANITO,
OPT_ENCIMERA_MADERA,
OPT_MUEBLE_BLANCO,
OPT_MUEBLE_GRIS,
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;
}
/** Estado base válido para el apartamento */
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]: OPT_REVEST_CERAMICA,
[ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO,
[ATTR_MUEBLE_COC] : OPT_MUEBLE_BLANCO,
[IDS_TEST.ATTR_PUERTA]: OPT_PUERTA_LACADA,
};
}
let logr : Logr;
let resolver: TemplateResolver;
beforeEach(() => {
logr = makeLogr();
resolver = new TemplateResolver(logr);
});
// ============================================================================
// RESOLUCIÓN BÁSICA — static_image
// ============================================================================
describe('static_image — resolución básica', () => {
it('resuelve template con placeholders de contexto y atributos', () => {
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
expect(result.isFallback).toBe(false);
expect(result.url).toBe('/renders/vivienda/apartamento/salon/front/parquet_blanco.jpg');
});
it('cambia la URL al cambiar el suelo', () => {
const state = { ...baseState(), [ATTR_SUELO]: OPT_SUELO_MICROCEMENTO };
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
state,
CATALOGO_VIVIENDAS
);
expect(result.url).toBe('/renders/vivienda/apartamento/salon/front/microcemento_blanco.jpg');
});
it('cambia la URL al cambiar la pared', () => {
const state = { ...baseState(), [ATTR_PARED]: OPT_PARED_GRIS };
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
state,
CATALOGO_VIVIENDAS
);
expect(result.url).toBe('/renders/vivienda/apartamento/salon/front/parquet_gris.jpg');
});
it('resuelve la vista 360 con su propio code', () => {
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_360 },
baseState(),
CATALOGO_VIVIENDAS
);
expect(result.url).toBe('/renders/vivienda/apartamento/salon/360/parquet_blanco.jpg');
});
it('resuelve el baño — sección con distintos atributos', () => {
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_BANO, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
expect(result.url).toBe('/renders/vivienda/apartamento/bano/front/blanco_ceramica.jpg');
});
it('resuelve el baño con sanitario negro y microcemento', () => {
const state = {
...baseState(),
[ATTR_SANITARIO] : OPT_SANIT_NEGRO,
[ATTR_REVEST_BANO]: OPT_REVEST_MICROCEMENTO,
};
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_BANO, viewId: VIEW_FRONT },
state,
CATALOGO_VIVIENDAS
);
expect(result.url).toBe('/renders/vivienda/apartamento/bano/front/negro_microcemento.jpg');
});
it('resuelve la cocina con encimera madera y muebles gris', () => {
const state = {
...baseState(),
[ATTR_ENCIMERA] : OPT_ENCIMERA_MADERA,
[ATTR_MUEBLE_COC]: OPT_MUEBLE_GRIS,
};
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_COCINA, viewId: VIEW_FRONT },
state,
CATALOGO_VIVIENDAS
);
expect(result.url).toBe('/renders/vivienda/apartamento/cocina/front/madera_gris.jpg');
});
});
// ============================================================================
// PLACEHOLDERS DE CONTEXTO
// ============================================================================
describe('placeholders de contexto', () => {
it('{object} se resuelve al code del objeto', () => {
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
expect(result.url).toContain('apartamento');
});
it('{section} se resuelve al code de la sección', () => {
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
expect(result.url).toContain('salon');
});
it('{view} se resuelve al code de la vista', () => {
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
expect(result.url).toContain('front');
});
it('la vista 360 tiene code distinto a front', () => {
const front = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
const panoramic = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_360 },
baseState(),
CATALOGO_VIVIENDAS
);
expect(front.url).not.toBe(panoramic.url);
expect(panoramic.url).toContain('360');
});
});
// ============================================================================
// FALLBACK — errores de contexto
// ============================================================================
describe('fallback — contexto inválido', () => {
it('objeto inexistente → isFallback true y logea error', () => {
const result = resolver.resolve(
{ objectId: 'ob:noexiste' as any, sectionId: SEC_SALON, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
expect(result.isFallback).toBe(true);
expect(logr.error).toHaveBeenCalledTimes(1);
});
it('sección inexistente → isFallback true y logea error', () => {
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: 'sc:noexiste' as any, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
expect(result.isFallback).toBe(true);
expect(logr.error).toHaveBeenCalledTimes(1);
});
it('vista inexistente → isFallback true y logea error', () => {
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: 'vw:noexiste' as any },
baseState(),
CATALOGO_VIVIENDAS
);
expect(result.isFallback).toBe(true);
expect(logr.error).toHaveBeenCalledTimes(1);
});
it('fallback usa la fallbackImage configurada en la vista', () => {
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: 'vw:noexiste' as any },
baseState(),
CATALOGO_VIVIENDAS
);
expect(result.url).toBe('/renders/fallback.jpg');
});
});
// ============================================================================
// FALLBACK — atributo sin valor
// ============================================================================
describe('fallback — atributo sin valor en estado', () => {
it('atributo sin valor en estado → isFallback true y logea error', () => {
const state = { ...baseState() };
delete (state as any)[ATTR_SUELO];
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
state,
CATALOGO_VIVIENDAS
);
expect(result.isFallback).toBe(true);
expect(logr.error).toHaveBeenCalled();
});
it('atributo con valor que no es OptionID válido → isFallback true', () => {
const state = { ...baseState(), [ATTR_SUELO]: 42 as any };
const result = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
state,
CATALOGO_VIVIENDAS
);
expect(result.isFallback).toBe(true);
expect(logr.error).toHaveBeenCalled();
});
});
// ============================================================================
// resolveApi
// ============================================================================
describe('resolveApi', () => {
it('devuelve null si la vista no es api_generated', () => {
const result = resolver.resolveApi(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
expect(result).toBeNull();
expect(logr.warn).toHaveBeenCalledTimes(1);
});
});
// ============================================================================
// URLs únicas — no colisión entre secciones
// ============================================================================
describe('no colisión entre secciones', () => {
it('salón y baño generan URLs distintas', () => {
const salon = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
const bano = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_BANO, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
expect(salon.url).not.toBe(bano.url);
});
it('salón y cocina generan URLs distintas', () => {
const salon = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_SALON, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
const cocina = resolver.resolve(
{ objectId: OBJ_APARTAMENTO, sectionId: SEC_COCINA, viewId: VIEW_FRONT },
baseState(),
CATALOGO_VIVIENDAS
);
expect(salon.url).not.toBe(cocina.url);
});
});

@ -10,20 +10,20 @@
export type APIMethod = 'GET' | 'POST';
/**
* Configuración de llamada a una API externa.
* Usada por la estrategia de renderizado `api_generated`.
* Configuración de conexión a una API externa.
* Los parámetros a enviar se definen en cada `ApiGeneratedConfig`
* usando el sistema de placeholders compartido con los templates de imagen.
*/
export interface APIConfig {
/** URL del endpoint */
endpoint: string;
/** Método HTTP — GET por defecto */
method? : APIMethod;
/**
* Parámetros a incluir en la llamada.
* El engine los resuelve desde el estado de configuración actual.
* Método HTTP.
* - GET → parámetros como querystring
* - POST → parámetros como JSON body
* @default 'POST'
*/
params? : string[];
method? : APIMethod;
/** Timeout en milisegundos */
timeout?: number;
}
}

@ -4,11 +4,11 @@
* ============================================================================
*/
import type { Code, Value, DataType } from './primitives.types';
import type { AttrID, OptionID, SectionID } from './ids.types';
import type { Code, Value, DataType } from './primitives.ts';
import type { AttrID, OptionID, SectionID } from './ids.ts';
import type { I18nString } from '@/libs/i18n';
import type { Metadata } from './datas.types';
import type { JsonLogic } from './json-logic.types';
import type { Metadata } from './datas.ts';
import type { JsonLogic } from './json-logic.ts';
// ============================================================================
// ATTRIBUTE DISPLAY

@ -4,12 +4,12 @@
* ============================================================================
*/
import type { Code } from './primitives.types';
import type { CatalogID, OptionID, ObjectID, RuleID } from './ids.types';
import type { Code } from './primitives.ts';
import type { CatalogID, OptionID, ObjectID, RuleID } from './ids.ts';
import type { I18nString } from '@/libs/i18n';
import type { OptionDefinition } from './option.types';
import type { ConfigurableObject } from './object.types';
import type { ValidationRule } from './rule.types';
import type { OptionDefinition } from './option.ts';
import type { ConfigurableObject } from './object.ts';
import type { ValidationRule } from './rule.ts';
/**
* Catálogo de configuración.

@ -4,9 +4,9 @@
* ============================================================================
*/
import type { AttrID, HotspotID } from './ids.types';
import type { AttrID, HotspotID } from './ids.ts';
import type { I18nString } from '@/libs/i18n';
import type { JsonLogic } from './json-logic.types';
import type { JsonLogic } from './json-logic.ts';
/**
* Punto interactivo sobre una imagen de vista.

@ -4,7 +4,7 @@
* ============================================================================
*/
import type { ID } from './primitives.types';
import type { ID } from './primitives.ts';
export type AttrID = ID<'at'>;
export type SectionID = ID<'sc'>;

@ -1,16 +1,16 @@
export * from './primitives.types.ts';
export * from './ids.types.ts';
export * from './datas.types.ts';
export * from './api.types.ts';
export * from './attribute.types.ts';
export * from './hotspot.types.ts';
export * from './json-logic.types.ts';
export * from './option.types.ts';
export * from './pricing.types.ts';
export * from './rule.types.ts';
export * from './section.types.ts';
export * from './view.types.ts';
export * from './object.types.ts';
export * from './catalog.types.ts';
export * from './primitives.ts';
export * from './ids.ts';
export * from './datas.ts';
export * from './api.ts';
export * from './attribute.ts';
export * from './hotspot.ts';
export * from './json-logic.ts';
export * from './option.ts';
export * from './pricing.ts';
export * from './rule.ts';
export * from './section.ts';
export * from './view.ts';
export * from './object.ts';
export * from './catalog.ts';

@ -4,11 +4,11 @@
* ============================================================================
*/
import type { I18nString } from '@/libs/i18n';
import type { Code } from './primitives.types';
import type { ObjectID, SectionID } from './ids.types';
import type { Metadata } from './datas.types';
import type { Attribute } from './attribute.types';
import type { Section } from './section.types';
import type { Code } from './primitives.ts';
import type { ObjectID, SectionID } from './ids.ts';
import type { Metadata } from './datas.ts';
import type { Attribute } from './attribute.ts';
import type { Section } from './section.ts';
// ============================================================================
// CONFIGURABLE OBJECT

@ -4,10 +4,10 @@
* ============================================================================
*/
import type { I18nString } from '@/libs/i18n';
import type { Code } from './primitives.types';
import type { OptionID } from './ids.types';
import type { Media, Metadata } from './datas.types';
import type { Pricing } from './pricing.types';
import type { Code } from './primitives.ts';
import type { OptionID } from './ids.ts';
import type { Media, Metadata } from './datas.ts';
import type { Pricing } from './pricing.ts';
/**
* Opción seleccionable para un atributo dinámico.

@ -4,7 +4,7 @@
* ============================================================================
*/
import type { JsonLogic } from './json-logic.types';
import type { JsonLogic } from './json-logic.ts';
import type { I18nString } from '@/libs/i18n';
/**

@ -6,7 +6,7 @@
import type { AttrID, RuleID, Value } from './index';
import type { I18nString } from '@/libs/i18n';
import type { JsonLogic } from './json-logic.types';
import type { JsonLogic } from './json-logic.ts';
/**
@ -116,7 +116,7 @@ export interface ValidationRule {
* Atributos involucrados en esta regla.
* Incluye los atributos de la condición Y el targetAttr de la acción.
*
* Usado por el RuleEngine para indexar reglas por atributo —
* Usado por el Rule para indexar reglas por atributo —
* permite evaluar solo las reglas relevantes para un atributo
* sin recorrer todas las condiciones JsonLogic.
*

@ -4,13 +4,13 @@
* ============================================================================
*/
import type { I18nString } from '@/libs/i18n';
import type { Code } from './primitives.types';
import type { AttrID, SectionID, ViewID } from './ids.types';
import type { Metadata } from './datas.types';
import type { Attribute } from './attribute.types';
import type { JsonLogic } from './json-logic.types';
import type { ViewType, ViewVisualConfig } from './view.types';
import type { Hotspot } from './hotspot.types';
import type { Code } from './primitives.ts';
import type { AttrID, SectionID, ViewID } from './ids.ts';
import type { Metadata } from './datas.ts';
import type { Attribute } from './attribute.ts';
import type { JsonLogic } from './json-logic.ts';
import type { ViewType, ViewVisualConfig } from './view.ts';
import type { Hotspot } from './hotspot.ts';
// ============================================================================
// SECTION VIEW

@ -4,8 +4,8 @@
* ============================================================================
*/
import type { AttrID } from './ids.types';
import type { APIConfig } from './api.types';
import type { AttrID } from './ids.ts';
import type { APIConfig } from './api.ts';
// ============================================================================
// VIEW TYPE
@ -57,19 +57,40 @@ export interface StaticImageConfig {
strategy: 'static_image';
/**
* Template de nombre de fichero.
* Solo contiene placeholders de atributos visualizables.
* El sistema resuelve cada {at:<code>} al code de la opción activa.
* Template de la URL de imagen.
* Placeholders disponibles:
*
* @example '{at:suelo}_{at:pintura}.png'
* Contexto (resueltos automáticamente):
* {object} → code del ConfigurableObject
* {section} → code de la VisualSection
* {view} → code de la SectionView
*
* Atributos (resueltos al code de la opción seleccionada):
* {at:<code>} → code de la OptionDefinition activa para ese atributo
*
* @example
* '{object}/{section}/{view}/{at:suelo}_{at:pintura}.png'
* → 'vivienda/salon/frontal/madera_blanco.png'
*/
template : string;
template : string;
/** Override local de basePath. Hereda del catálogo si no se especifica. */
basePath? : string;
/**
* Atributos GLOBALES del objeto que afectan esta vista
* aunque no aparezcan en el template.
*
* Útil cuando el servidor usa atributos globales (ej: at:calidad)
* para determinar la imagen sin que aparezcan explícitamente en la URL.
* También permite a la UI saber cuándo regenerar la imagen.
*
* @example ['at:calidad']
*/
globalAttrDependencies?: AttrID[];
/** Override local de basePath. Hereda en cascada si no se especifica. */
basePath? : string;
/** Imagen de fallback si la URL generada no existe */
fallbackImage?: string;
fallbackImage? : string;
}
// ============================================================================
@ -84,7 +105,13 @@ export interface CompositeLayersConfig {
/** Capas en orden de renderizado (de abajo a arriba) */
layers : LayerDefinition[];
/** Override local de basePath */
/**
* Atributos GLOBALES del objeto que afectan esta vista.
* @example ['at:calidad']
*/
globalAttrDependencies?: AttrID[];
/** Override local de basePath. Hereda en cascada si no se especifica. */
basePath? : string;
/** Imagen de fallback */
@ -93,22 +120,29 @@ export interface CompositeLayersConfig {
/**
* Definición de una capa de composición.
* El template funciona igual que en StaticImageConfig.
*
* El template usa los mismos placeholders que StaticImageConfig:
* {object}, {section}, {view}, {at:<code>}
*
* @example
* // Capa de suelo
* { attrId: 'at:suelo', template: '{object}/{section}/{view}/suelo_{at:suelo}.png' }
*/
export interface LayerDefinition {
/** Atributo que controla esta capa */
attrId : AttrID;
/**
* Template del fichero de capa.
* @example 'suelo_{at:suelo}.png'
* Template de la URL de esta capa.
* Mismos placeholders que StaticImageConfig.
* @example '{object}/{section}/{view}/suelo_{at:suelo}.png'
*/
template: string;
template : string;
/** Orden z-index de la capa */
zIndex? : number;
zIndex? : number;
/** Si la capa puede estar ausente (atributo no seleccionado) */
/** Si la capa puede estar ausente cuando el atributo no tiene valor */
optional?: boolean;
}
@ -118,8 +152,36 @@ export interface LayerDefinition {
// ============================================================================
export interface ApiGeneratedConfig {
strategy : 'api_generated';
apiConfig : APIConfig;
strategy : 'api_generated';
apiConfig: APIConfig;
/**
* Parámetros a enviar al servidor.
* Mismos placeholders que en StaticImageConfig:
*
* Contexto:
* 'object' → code del ConfigurableObject
* 'section' → code de la VisualSection
* 'view' → code de la SectionView
*
* Atributos:
* 'at:<code>' → code de la opción seleccionada para ese atributo
*
* GET → construye querystring: ?object=vivienda&section=salon&at_suelo=madera
* POST → construye JSON body: { "object": "vivienda", "suelo": "madera" }
*
* Si no se especifica, envía todos los atributos visualizables.
*
* @example ['object', 'section', 'view', 'at:suelo', 'at:pintura']
*/
params? : string[];
/**
* Atributos GLOBALES del objeto que afectan esta vista.
* @example ['at:calidad']
*/
globalAttrDependencies?: AttrID[];
/** Imagen de fallback si la API falla o tarda demasiado */
fallbackImage?: string;
}
Loading…
Cancel
Save

Powered by TurnKey Linux.