First Commit

master
dev 7 months ago
parent 85a72b7f12
commit c81ab33d66

@ -5,7 +5,7 @@
*/
import type { Logr } from '@/libs/logr';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JsonLogicEvaluator } from '../engines/evaluator.engine';
import { JsonLogicEvaluator } from '../engines/evaluator';
// ============================================================================

@ -15,13 +15,12 @@ import type {
VisualSection,
DynamicAttribute,
FixedAttribute,
SectionView, QuantifiableAttribute,
SectionView, QuantifiableAttribute, QuantifiableOptionAttribute,
} from '../types';
import type { AttrID, SectionID, OptionID, ObjectID, RuleID, ViewID } from '../types';
// ============================================================================
// IDs — OPCIONES
// ============================================================================
@ -601,7 +600,7 @@ const seccionArmario = (): VisualSection => ({
{ optionId: OPT_ARMARIO_MADERA, priority: 2 },
],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as QuantifiableAttribute,
} as QuantifiableOptionAttribute,
{
id : ATTR_ARMARIO_MATERIAL,
code : 'armario_material',

@ -1,797 +1,177 @@
/**
* ============================================================================
* CATÁLOGO DE VIVIENDAS - FIXTURE ACTUALIZADO
* PRICING ENGINE — dynamicExpression y QuantifiableAttribute
* ============================================================================
*
* 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';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PricingEngine } from '../engines/pricing';
import type { Logr } from '@/libs/logr';
import type { SelectionMap } from '../engines/rule';
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_ARMARIO_PUERTAS,
OPT_CALIDAD_ESTANDAR,
OPT_SUELO_CERAMICA,
OPT_PARED_BLANCO,
OPT_SANIT_BLANCO,
OPT_REVEST_CERAMICA,
OPT_ENCIMERA_GRANITO,
OPT_MUEBLE_BLANCO,
OPT_PUERTA_LACADA,
OPT_ARMARIO_LACADO,
OPT_ARMARIO_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;
}
/** Estado base con armario lacado 2 puertas */
function baseState(): SelectionMap {
return {
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
[ATTR_SUELO] : OPT_SUELO_CERAMICA,
[ATTR_PARED] : OPT_PARED_BLANCO,
[ATTR_SANITARIO] : OPT_SANIT_BLANCO,
[ATTR_REVEST_BANO] : OPT_REVEST_CERAMICA,
[ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO,
[ATTR_MUEBLE_COC] : OPT_MUEBLE_BLANCO,
[ATTR_PUERTA] : OPT_PUERTA_LACADA,
[ATTR_ARMARIO_PUERTAS] : OPT_ARMARIO_LACADO,
};
}
let engine: PricingEngine;
beforeEach(() => {
engine = new PricingEngine(makeLogr());
});
// ============================================================================
// OPCIONES
// dynamicExpression — armario lacado con descuento por volumen
// ============================================================================
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 }
},
};
describe('dynamicExpression — armario lacado', () => {
// ============================================================================
// 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 }
});
it('2 puertas lacadas → 350 × 2 = 700', () => {
// El fixture tiene quantity: 2 por defecto en ATTR_ARMARIO_PUERTAS
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS);
expect(entry?.amount).toBe(700);
expect(entry?.isDynamic).toBe(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 }
it('el subtotal incluye el precio del armario', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
// subtotal base sin armario = 4220, con armario lacado 2 puertas = 4220 + 700
expect(result.subtotal).toBe(4220 + 700);
});
});
// ============================================================================
// VISTAS HELPERS
// dynamicExpression — armario madera precio fijo por puerta
// ============================================================================
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',
},
});
describe('dynamicExpression — armario madera', () => {
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',
},
it('2 puertas madera → 500 × 2 = 1000', () => {
const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: OPT_ARMARIO_MADERA };
const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS);
expect(entry?.amount).toBe(1000);
expect(entry?.isDynamic).toBe(true);
});
});
// ============================================================================
// 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' },
// QuantifiableAttribute — cantidad afecta al precio
// ============================================================================
describe('QuantifiableAttribute — cantidad', () => {
it('3 puertas lacadas → 300 × 3 = 900 (descuento volumen)', () => {
// Modificamos el fixture en memoria cambiando la cantidad del atributo
const catalogWith3Doors = structuredClone(CATALOGO_VIVIENDAS);
const dormSection = catalogWith3Doors.objects[OBJ_APARTAMENTO]
.sections['sc:armario' as any] as any;
const attr = dormSection.attributes.find((a: any) => a.id === ATTR_ARMARIO_PUERTAS);
attr.quantity = 3;
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), catalogWith3Doors);
const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS);
expect(entry?.amount).toBe(900);
});
it('3 puertas madera → 500 × 3 = 1500', () => {
const catalogWith3Doors = structuredClone(CATALOGO_VIVIENDAS);
const dormSection = catalogWith3Doors.objects[OBJ_APARTAMENTO]
.sections['sc:armario' as any] as any;
const attr = dormSection.attributes.find((a: any) => a.id === ATTR_ARMARIO_PUERTAS);
attr.quantity = 3;
const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: OPT_ARMARIO_MADERA };
const result = engine.calculate(OBJ_APARTAMENTO, state, catalogWith3Doors);
const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS);
expect(entry?.amount).toBe(1500);
});
it('isDynamic true para atributo con dynamicExpression', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS);
expect(entry?.isDynamic).toBe(true);
});
it('isDynamic false para atributo con precio fijo', () => {
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
const entry = result.breakdown.find(e => e.attrId === ATTR_ENCIMERA);
expect(entry?.isDynamic).toBe(false);
});
});
// ============================================================================
// ATRIBUTO GLOBAL — CALIDAD
// dynamicExpression — fallback a baseAmount si la expresión falla
// ============================================================================
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 }
};
describe('dynamicExpression — fallback a baseAmount', () => {
// ============================================================================
// 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,
};
it('expresión malformada → usa baseAmount y logea error', () => {
const logr = makeLogr();
const eng = new PricingEngine(logr);
const broken = structuredClone(CATALOGO_VIVIENDAS);
const opt = broken.options[OPT_ARMARIO_LACADO] as any;
opt.pricing.dynamicExpression = { 'operadorFalso': null };
// operadorFalso devuelve null → no es number → cae a baseAmount (0)
const result = eng.calculate(OBJ_APARTAMENTO, baseState(), broken);
const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS);
// baseAmount es 0 → no aparece en breakdown
expect(entry).toBeUndefined();
});
});

@ -23,7 +23,6 @@ const {
ATTR_ENCIMERA,
ATTR_MUEBLE_COC,
ATTR_PUERTA,
ATTR_M2_SALON,
OPT_CALIDAD_ESTANDAR,
OPT_CALIDAD_PREMIUM,
OPT_CALIDAD_LUJO,

@ -19,7 +19,6 @@ const {
ATTR_PARED,
ATTR_SANITARIO,
ATTR_REVEST_BANO,
ATTR_ENCIMERA,
OPT_CALIDAD_ESTANDAR,
OPT_CALIDAD_PREMIUM,
OPT_CALIDAD_LUJO,
@ -32,8 +31,6 @@ const {
OPT_REVEST_CERAMICA,
OPT_REVEST_MARMOL,
OPT_REVEST_MICROCEMENTO,
OPT_ENCIMERA_GRANITO,
OPT_ENCIMERA_MADERA,
} = IDS_TEST;
// ============================================================================

@ -4,11 +4,11 @@
* ============================================================================
*/
import type { Code, Value, DataType } from './primitives.ts';
import type { AttrID, OptionID, SectionID } from './ids.ts';
import type { I18nString } from '@/libs/i18n';
import type { Metadata } from './datas.ts';
import type { JsonLogic } from './json-logic.ts';
import type { Code, Value, DataType } from './primitives';
import type { AttrID, OptionID, SectionID } from './ids';
import type { Metadata } from './datas';
import type { JsonLogic } from './json-logic';
// ============================================================================
// ATTRIBUTE DISPLAY
@ -111,22 +111,46 @@ export interface AttributeOption {
// ============================================================================
// QUANTIFIABLE ATTRIBUTE
// Valor numérico con rango o conjunto de valores
// ============================================================================
export interface QuantifiableAttribute extends BaseAttribute {
// Atributo con cantidad — puede ser puramente numérico o una opción con cantidad
//
// Dos variantes:
//
// 1. Numérico puro (dataType: 'number'):
// Valor numérico directo, sin opciones. Ej: m² de suelo, vatios de iluminación.
//
// 2. Opción con cantidad (dataType: 'reference'):
// El usuario elige una opción del catálogo Y una cantidad.
// Ej: armario con 2 o 3 puertas lacadas.
// El precio se calcula con dynamicExpression usando { quantity, ...state }
// ============================================================================
export interface QuantifiableNumericAttribute extends BaseAttribute {
type : 'quantifiable';
dataType : 'number';
quantity : QuantityDefinition;
quantity : number;
minQuantity? : number;
maxQuantity? : number;
unit? : I18nString;
userConfigurable?: boolean;
pricePerUnit? : number;
}
export type QuantityDefinition =
| number
| { min: number; max: number; step?: number; default: number }
| { values: number[]; default: number };
export interface QuantifiableOptionAttribute extends BaseAttribute {
type : 'quantifiable';
dataType : 'reference';
quantity : number;
minQuantity? : number;
maxQuantity? : number;
unit? : I18nString;
userConfigurable?: boolean;
defaultValue : OptionID;
options : AttributeOption[];
required? : boolean;
}
export type QuantifiableAttribute =
| QuantifiableNumericAttribute
| QuantifiableOptionAttribute;
// ============================================================================
// COMPUTED ATTRIBUTE

Loading…
Cancel
Save

Powered by TurnKey Linux.