parent
70655bbffc
commit
85a72b7f12
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
Loading…
Reference in new issue