From c81ab33d660c1178efdecd1a7e185ef67b21e4fe Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 24 Feb 2026 22:38:48 +0100 Subject: [PATCH] First Commit --- src/libs/vice/tests/evaluator.test.ts | 2 +- .../vice/tests/housing-catalog.fixture.ts | 5 +- src/libs/vice/tests/pricing-dynamic.test.ts | 922 +++--------------- src/libs/vice/tests/pricing.test.ts | 1 - src/libs/vice/tests/selection-state.test.ts | 3 - src/libs/vice/types/attribute.ts | 50 +- 6 files changed, 191 insertions(+), 792 deletions(-) diff --git a/src/libs/vice/tests/evaluator.test.ts b/src/libs/vice/tests/evaluator.test.ts index eb77143..c407fc6 100644 --- a/src/libs/vice/tests/evaluator.test.ts +++ b/src/libs/vice/tests/evaluator.test.ts @@ -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'; // ============================================================================ diff --git a/src/libs/vice/tests/housing-catalog.fixture.ts b/src/libs/vice/tests/housing-catalog.fixture.ts index cb4891c..6a1c901 100644 --- a/src/libs/vice/tests/housing-catalog.fixture.ts +++ b/src/libs/vice/tests/housing-catalog.fixture.ts @@ -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', diff --git a/src/libs/vice/tests/pricing-dynamic.test.ts b/src/libs/vice/tests/pricing-dynamic.test.ts index 493fcab..3964ecc 100644 --- a/src/libs/vice/tests/pricing-dynamic.test.ts +++ b/src/libs/vice/tests/pricing-dynamic.test.ts @@ -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 = { - [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 = { - [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, -}; \ No newline at end of file + 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(); + }); +}); \ No newline at end of file diff --git a/src/libs/vice/tests/pricing.test.ts b/src/libs/vice/tests/pricing.test.ts index 1021a48..66cba08 100644 --- a/src/libs/vice/tests/pricing.test.ts +++ b/src/libs/vice/tests/pricing.test.ts @@ -23,7 +23,6 @@ const { ATTR_ENCIMERA, ATTR_MUEBLE_COC, ATTR_PUERTA, - ATTR_M2_SALON, OPT_CALIDAD_ESTANDAR, OPT_CALIDAD_PREMIUM, OPT_CALIDAD_LUJO, diff --git a/src/libs/vice/tests/selection-state.test.ts b/src/libs/vice/tests/selection-state.test.ts index e17feb0..152a2ff 100644 --- a/src/libs/vice/tests/selection-state.test.ts +++ b/src/libs/vice/tests/selection-state.test.ts @@ -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; // ============================================================================ diff --git a/src/libs/vice/types/attribute.ts b/src/libs/vice/types/attribute.ts index 55e2240..8e9db06 100644 --- a/src/libs/vice/types/attribute.ts +++ b/src/libs/vice/types/attribute.ts @@ -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