fix(vcen): corrección de bugs críticos y mejoras de arquitectura

Bugs críticos:
- session.ts #1: configState se reconstruía desde el Cat original al hacer
  setValue, ignorando los valores del usuario y los derivados. Añadido
  userValues Map y buildCurrentConfigState() que combina base + user + derived.
  getAllPrices() usaba computeCatPrice (que también reconstruía desde Cat);
  ahora usa this.configState.
- incremental.ts #12: evaluateObj se llamaba sin cat.opts, por lo que las
  reglas de opciones nunca se re-evaluaban en evaluación incremental.
  Añadido buildNestedState al import y pasados opts + nestedState.

Mejoras de arquitectura:
- engine.ts #11: lógica de evaluación de opciones duplicada en evaluateObj
  extraída al helper privado applyOptionsForAtt.
- engine.ts #2: evaluateCat ahora evalúa y valida cat.atts directos (antes
  solo procesaba los Obj).
- engine.ts #10: comparación JSON.stringify para detectar convergencia
  reemplazada por Object.keys().some() — O(n) sin serialización.
- engine.ts #24: errors/warnings cambiados de string[] + new Set a
  Set<string> directamente; añadido helper validateAttPath para evitar
  duplicación.
- graph.ts #5: engine.usesData(rule.condition) se llamaba dos veces por
  regla; ahora se reutiliza el resultado de la primera llamada.
- graph.ts #17: casts as unknown as AttPath consolidados en mkPath() helper.
- graph.ts #23: getDependents y getAffectedAtts eran idénticas; ahora una
  delega a la otra.
- node_util.ts #6: keyToAttPath añade console.warn para paths inesperados.
- pricing.ts #16: strings hardcodeados ('A consultar', 'IVA inc.', etc.)
  externalizados via labels? en FormatAmountOpts.
- pricing.ts #22: catch silencioso en evaluateAmount ahora hace console.warn.
- incremental.ts #13: bloque comentado obsoleto de findAtt eliminado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
dev 7 months ago
parent 239b0b500c
commit 25ae98f16d

@ -110,7 +110,7 @@ export function buildConfigState(cat: Cat): ConfigState {
* // state: { 'ct:cat/ob:bmw/at:color': 'rojo' }
* // resultado: { 'ct:cat': { 'ob:bmw': { 'at:color': 'rojo' } } }
*/
function buildNestedState(state: ConfigState): Record<string, unknown> {
export function buildNestedState(state: ConfigState): Record<string, unknown> {
const root: Record<string, unknown> = {};
for (const [key, value] of Object.entries(state)) {
@ -414,6 +414,28 @@ function applyOptionLogic(
return state;
}
/**
* Evalúa las rules de options de un att y aplica la lógica resultante
* al EffectiveState. Factorizado para evitar duplicación en evaluateObj.
*/
function applyOptionsForAtt(
att: Att,
attPath: string,
opts: Opt[],
ctx: EvalContext,
resolvedEngine: JsonLogicEngine,
effectiveState: EffectiveState,
): void {
if (att.value.domain.kind !== 'option' || att.value.domain.mode !== 'list') return;
const { validOptIds, invalidOptIds } = evaluateOptions(
att.value.domain as AttOptionDomain & { mode: 'list' }, opts, ctx, resolvedEngine,
);
const current = effectiveState.get(attPath) ?? defaultEffectiveState();
effectiveState.set(attPath, applyOptionLogic(
att.value as AttValueOption, validOptIds, invalidOptIds, current,
));
}
export function evaluateAtt(
att: Att,
prefix: string,
@ -515,16 +537,7 @@ export function evaluateObj(
if (!effectiveState.has(attPath)) {
effectiveState.set(attPath, defaultEffectiveState());
}
// Evaluar options si el dominio es option/list
if (att.value.domain.kind === 'option' && att.value.domain.mode === 'list') {
const { validOptIds, invalidOptIds } = evaluateOptions(
att.value.domain as AttOptionDomain & { mode: 'list' }, opts, ctx, resolvedEngine,
);
const current = effectiveState.get(attPath) ?? defaultEffectiveState();
effectiveState.set(attPath, applyOptionLogic(
att.value as AttValueOption, validOptIds, invalidOptIds, current,
));
}
applyOptionsForAtt(att, attPath, opts, ctx, resolvedEngine, effectiveState);
}
for (const sec of obj.secs) {
@ -544,16 +557,7 @@ export function evaluateObj(
if (!effectiveState.has(attPath)) {
effectiveState.set(attPath, defaultEffectiveState());
}
// Evaluar options si el dominio es option/list
if (att.value.domain.kind === 'option' && att.value.domain.mode === 'list') {
const { validOptIds, invalidOptIds } = evaluateOptions(
att.value.domain as AttOptionDomain & { mode: 'list' }, opts, ctx, resolvedEngine,
);
const current = effectiveState.get(attPath) ?? defaultEffectiveState();
effectiveState.set(attPath, applyOptionLogic(
att.value as AttValueOption, validOptIds, invalidOptIds, current,
));
}
applyOptionsForAtt(att, attPath, opts, ctx, resolvedEngine, effectiveState);
}
}
@ -595,6 +599,19 @@ export function evaluateCat(cat: Cat, engine: JsonLogicEngine | null = null): Ev
const nestedState = buildNestedState(state);
const nextEffective = new Map<string, AttEffectiveState>();
// Evaluar atributos directos del Cat
const catCtx: EvalContext = { state, nestedState, prefix: cat.id };
const catValuePriorities = new Map<string, { priority: number }>();
for (const att of cat.atts) {
const attPath = joinPath(cat.id, att.id);
if (att.rules) {
applyRuleSet(att.rules, catCtx, nextEffective, attPath, cat.id, catValuePriorities, resolvedEngine);
}
if (!nextEffective.has(attPath)) {
nextEffective.set(attPath, defaultEffectiveState());
}
}
for (const obj of cat.objs) {
const objResult = evaluateObj(obj, cat.id, state, resolvedEngine, cat.opts ?? [], nestedState);
for (const [k, v] of objResult) {
@ -619,62 +636,35 @@ export function evaluateCat(cat: Cat, engine: JsonLogicEngine | null = null): Ev
}
// Si no hay valores derivados que hayan cambiado el estado → convergió
if (JSON.stringify(nextState) === JSON.stringify(state)) break;
const keys = Object.keys(nextState);
if (!keys.some(k => nextState[k] !== state[k])) break;
state = nextState;
}
// ── Capa 0: validación de dominio contra effectiveState final ──────────
const errors: string[] = [];
const warnings: string[] = [];
const errors = new Set<string>();
const warnings = new Set<string>();
// Validar atributos directos del Cat
for (const att of cat.atts) {
const path = joinPath(cat.id, att.id);
validateAttPath(path, att, effectiveState, errors, warnings);
}
for (const obj of cat.objs) {
const allAtts = collectAtts(obj, cat.id);
for (const { path, att } of allAtts) {
const es = effectiveState.get(path) ?? defaultEffectiveState();
// Atributo forbidden con valor → error
if (es.state === 'forbidden' && att.value.value !== undefined && att.value.value !== null) {
errors.push(path);
continue;
}
// Atributo hidden o forbidden → no validar valor
if (es.state === 'hidden' || es.state === 'forbidden') continue;
// Valor en dominio efectivo (aplicar forbiddenValues)
const effectiveValue = es.derivedValue ?? att.value;
const domainResult = validateAttValue(effectiveValue);
if (!domainResult.valid) {
errors.push(path);
continue;
}
// Valor en forbiddenValues
const rawValue = effectiveValue.value;
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
const forbidden = values.some(v => es.forbiddenValues.includes(v as DomainValueRef));
if (forbidden) {
errors.push(path);
continue;
}
// Mensajes de validación acumulados
for (const msg of es.messages) {
if (msg.level === 'error') errors.push(path);
if (msg.level === 'warning') warnings.push(path);
}
validateAttPath(path, att, effectiveState, errors, warnings);
}
}
return {
effectiveState,
valid: errors.length === 0,
errors: [...new Set(errors)],
warnings: [...new Set(warnings)],
valid: errors.size === 0,
errors: [...errors],
warnings: [...warnings],
iterations,
};
}
@ -683,6 +673,43 @@ export function evaluateCat(cat: Cat, engine: JsonLogicEngine | null = null): Ev
// INTERNAL HELPERS
// ─────────────────────────────────────────────────────────────────────────────
/** Valida un Att individual contra el EffectiveState y acumula errors/warnings */
function validateAttPath(
path: string,
att: Att,
effectiveState: EffectiveState,
errors: Set<string>,
warnings: Set<string>,
): void {
const es = effectiveState.get(path) ?? defaultEffectiveState();
// Atributo forbidden con valor → error
if (es.state === 'forbidden' && att.value.value !== undefined && att.value.value !== null) {
errors.add(path);
return;
}
// Atributo hidden o forbidden → no validar valor
if (es.state === 'hidden' || es.state === 'forbidden') return;
// Valor en dominio efectivo
const effectiveValue = es.derivedValue ?? att.value;
const domainResult = validateAttValue(effectiveValue);
if (!domainResult.valid) { errors.add(path); return; }
// Valor en forbiddenValues
const rawValue = effectiveValue.value;
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
const forbidden = values.some(v => es.forbiddenValues.includes(v as DomainValueRef));
if (forbidden) { errors.add(path); return; }
// Mensajes de validación acumulados
for (const msg of es.messages) {
if (msg.level === 'error') errors.add(path);
if (msg.level === 'warning') warnings.add(path);
}
}
/** Recorre un Obj y devuelve todos sus Att con su path absoluto */
function collectAtts(obj: Obj, catId: string): Array<{ path: string; att: Att }> {
const result: Array<{ path: string; att: Att }> = [];

@ -10,6 +10,15 @@ import type { DependencyGraph} from "@/vcen/lib/types/graph.ts";
// ─────────────────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────────────────
/** Construye un AttPath a partir de segmentos de string. */
function mkPath(...parts: string[]): AttPath {
return parts as unknown as AttPath;
}
// ─────────────────────────────────────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────────────────────────────────────
@ -87,8 +96,7 @@ export function buildDependencyGraph(
addRuleToAtt(rule.id, targetKey);
}
const inputKeys = engine.usesData(rule.condition)
.map(p => p ? joinPath(containerKey, p) : containerKey);
const inputKeys = usedPaths.map(p => p ? joinPath(containerKey, p) : containerKey);
const outputKeys = [...(ruleToAtts.get(rule.id) ?? [])];
for (const inputKey of inputKeys) {
@ -114,8 +122,7 @@ export function buildDependencyGraph(
processRuleSet(sec.rules.rules, secPath, secKey, secKey);
}
for (const att of sec.atts) {
const attPath = [...secPath, att.id] as unknown as AttPath;
processAtt(att, attPath, secKey);
processAtt(att, mkPath(...secPath, att.id), secKey);
}
}
@ -125,23 +132,19 @@ export function buildDependencyGraph(
processRuleSet(obj.rules.rules, objPath, objKey, objKey);
}
for (const att of obj.atts) {
const attPath = [...objPath, att.id] as unknown as AttPath;
processAtt(att, attPath, objKey);
processAtt(att, mkPath(...objPath, att.id), objKey);
}
for (const sec of obj.secs) {
const secPath = [...objPath, sec.id] as unknown as AttPath;
processSec(sec, secPath);
processSec(sec, mkPath(...objPath, sec.id));
}
}
for (const att of cat.atts) {
const attPath = [cat.id, att.id] as unknown as AttPath;
processAtt(att, attPath, cat.id);
processAtt(att, mkPath(cat.id, att.id), cat.id);
}
for (const obj of cat.objs) {
const objPath = [cat.id, obj.id] as unknown as AttPath;
processObj(obj, objPath);
processObj(obj, mkPath(cat.id, obj.id));
}
const cycles = detectCycles(attDeps);
@ -201,10 +204,14 @@ function detectCycles(attDeps: Map<AttKey, Set<AttKey>>): AttKey[][] {
export function getAffectedAtts(graph: DependencyGraph, attPath: AttPath): AttPath[] {
export function getDependents(graph: DependencyGraph, attPath: AttPath): AttPath[] {
return [...(graph.attDependents.get(serializeAttPath(attPath)) ?? [])].map(keyToAttPath);
}
export function getAffectedAtts(graph: DependencyGraph, attPath: AttPath): AttPath[] {
return getDependents(graph, attPath);
}
export function getAffectedRules(graph: DependencyGraph, attPath: AttPath): RuleID[] {
return [...(graph.attToRules.get(serializeAttPath(attPath)) ?? [])];
}
@ -213,10 +220,6 @@ export function getDependencies(graph: DependencyGraph, attPath: AttPath): AttPa
return [...(graph.attDeps.get(serializeAttPath(attPath)) ?? [])].map(keyToAttPath);
}
export function getDependents(graph: DependencyGraph, attPath: AttPath): AttPath[] {
return [...(graph.attDependents.get(serializeAttPath(attPath)) ?? [])].map(keyToAttPath);
}
export function getTransitiveAffected(graph: DependencyGraph, attPath: AttPath): AttPath[] {
const visited = new Set<AttKey>();
const queue = [serializeAttPath(attPath)];

@ -9,7 +9,7 @@ import { validateAttValue } from './validation.ts';
import {
keyToAttPath, NODE_SEPARATOR, serializeAttPath
} from './node_util.ts';
import {buildConfigState, evaluateObj} from './engine.ts';
import {buildConfigState, buildNestedState, evaluateObj} from './engine.ts';
import { getTransitiveAffected } from './graph.ts';
@ -80,11 +80,12 @@ export function evaluateIncremental(
for (let i = 0; i < 20; i++) {
const iterEffective = new Map(nextEffective);
const nestedStateForIter = buildNestedState(currentState);
for (const obj of cat.objs) {
const objKey = `${cat.id}${NODE_SEPARATOR}${obj.id}`;
if (!affectedObjKeys.has(objKey)) continue;
const objResult = evaluateObj(obj, cat.id, currentState, resolvedEngine);
const objResult = evaluateObj(obj, cat.id, currentState, resolvedEngine, cat.opts ?? [], nestedStateForIter);
for (const [k, v] of objResult) {
iterEffective.set(k, v);
}
@ -187,30 +188,6 @@ function buildStateFromResult(cat: Cat, prev: EvaluationResult): Record<string,
* Encuentra un `Att` por su path serializado dentro del `Cat`.
*/
function findAtt(cat: Cat, key: string): Att | undefined {
/*
const parts = keyToAttPath(key);
if (parts.length === 2) {
// ct:cat/at:x
return cat.atts.find(a => a.id === parts[1]);
}
if (parts.length === 3) {
// ct:cat/ob:obj/at:x
const obj = cat.objs.find(o => `${cat.id}${NODE_SEPARATOR}${o.id}` === `${parts[0]}${NODE_SEPARATOR}${parts[1]}`);
return obj?.atts.find(a => a.id === parts[2]);
}
if (parts.length === 4) {
// ct:cat/ob:obj/sc:sec/at:x
const obj = cat.objs.find(o => o.id === parts[1]);
const sec = obj?.secs.find(s => s.id === parts[2]);
return sec?.atts.find(a => a.id === parts[3]);
}
return undefined;
*/
const parts = key.split(NODE_SEPARATOR);
// Caso 1: Atributo de Cat [CatID, AttID]

@ -43,7 +43,11 @@ export function parseAttPath(key: string): AttPath {
export function keyToAttPath(key: AttKey): AttPath {
return key.split(NODE_SEPARATOR) as unknown as AttPath;
const parts = key.split(NODE_SEPARATOR);
if (parts.length < 2 || parts.length > 4) {
console.warn(`[vcen/node_util] keyToAttPath: unexpected path length ${parts.length} for key "${key}"`);
}
return parts as unknown as AttPath;
}

@ -66,8 +66,8 @@ function evaluateAmount(
if (typeof result === 'number' && isFinite(result)) {
return { kind: 'amount', amount: result };
}
} catch {
// fallback a baseAmount
} catch (err) {
console.warn('[vcen/pricing] dynamicExpression evaluation failed:', err);
}
}
@ -439,14 +439,22 @@ export function computeCatPrice(
/**
* Opciones de formateo de precio para la UI.
*
* - `showVat` → si true, añade sufijo '(IVA inc.)' o '(+ IVA)'
* - `showVat` → si true, añade sufijo de IVA
* - `vatIncluded` → indica si el amount ya incluye IVA (para el sufijo)
* - `display` → modo delegado a `glob.curr.f()`: 'symbol' | 'code' | 'name'
* - `labels` → textos personalizados para los estados especiales
*/
export interface FormatAmountOpts {
showVat?: boolean;
vatIncluded?: boolean;
display?: 'symbol' | 'code' | 'name';
labels?: {
free?: string; // default: '—'
consultation?: string; // default: 'A consultar'
vatIncluded?: string; // default: 'IVA inc.'
vatExcluded?: string; // default: '+ IVA'
consultation_suffix?: string; // used in formatObjTotal, default: ' + a consultar'
};
}
/**
@ -475,8 +483,9 @@ export function formatAmount(
glob: GlobInstance,
opts: FormatAmountOpts = {},
): string {
if (resolved.kind === 'free') return '—';
if (resolved.kind === 'consultation') return 'A consultar';
const { labels = {} } = opts;
if (resolved.kind === 'free') return labels.free ?? '—';
if (resolved.kind === 'consultation') return labels.consultation ?? 'A consultar';
const { showVat = false, vatIncluded = false, display = 'symbol' } = opts;
@ -484,8 +493,8 @@ export function formatAmount(
if (!showVat) return formatted;
return vatIncluded
? `${formatted} (IVA inc.)`
: `${formatted} (+ IVA)`;
? `${formatted} (${labels.vatIncluded ?? 'IVA inc.'})`
: `${formatted} (${labels.vatExcluded ?? '+ IVA'})`;
}
/**
@ -507,15 +516,16 @@ export function formatAmount(
*/
export function formatObjTotal(
result: ObjPriceResult,
glob: GlobInstance,
glob: GlobInstance,
includeVat: boolean = false,
display: 'symbol' | 'code' | 'name' = 'symbol',
labels: FormatAmountOpts['labels'] = {},
): string {
const amount = includeVat ? result.subtotalGross : result.subtotalNet;
const vatLabel = includeVat ? 'IVA inc.' : '+ IVA';
const vatLabel = includeVat ? (labels?.vatIncluded ?? 'IVA inc.') : (labels?.vatExcluded ?? '+ IVA');
const formatted = glob.curr.f(amount, display);
const warning = result.currenciesHomogeneous ? '' : ' *';
const consult = result.hasConsultation ? ' + a consultar' : '';
const consult = result.hasConsultation ? (labels?.consultation_suffix ?? ' + a consultar') : '';
return `${formatted} (${vatLabel})${consult}${warning}`;
}

@ -12,7 +12,7 @@ import {keyToAttPath, serializeAttPath} from './node_util.ts';
import { buildDependencyGraph } from './graph.ts';
import {buildConfigState, evaluateCat} from './engine.ts';
import { evaluateIncremental } from './incremental.ts';
import {computeCatPrice, computeObjPrice} from "@/vcen/lib/pricing.ts";
import {computeObjPrice} from "@/vcen/lib/pricing.ts";
import {
resolveView as resolveViewFn,
@ -60,6 +60,7 @@ export class Session {
private current: EvaluationResult;
private readonly listeners: Set<SessionListener> = new Set();
private configState: ConfigState;
private readonly userValues: Map<string, unknown> = new Map();
constructor(
cat: Cat,
@ -148,7 +149,9 @@ export class Session {
* @returns Array de `ObjPriceResult`, uno por cada Obj del Cat
*/
getAllPrices(): ObjPriceResult[] {
return computeCatPrice(this.cat, this.glob, this.engine);
return this.cat.objs.map(obj =>
computeObjPrice(obj, this.cat.opts, this.cat.pricingDefaults, this.configState, this.glob, this.engine)
);
}
// ── Visual ────────────────────────────────────────────────────────────────
@ -220,6 +223,8 @@ export class Session {
* @returns El nuevo `EvaluationResult` tras el cambio.
*/
setValue(path: AttPath, value: unknown): EvaluationResult {
this.userValues.set(serializeAttPath(path), value);
const next = evaluateIncremental(
this.cat,
this.graph,
@ -229,7 +234,7 @@ export class Session {
);
this.current = next;
this.configState = buildConfigState(this.cat);
this.configState = this.buildCurrentConfigState();
this.notify();
return next;
}
@ -239,6 +244,7 @@ export class Session {
* Notifica a todos los listeners.
*/
reset(): EvaluationResult {
this.userValues.clear();
this.current = this.initialResult;
this.configState = buildConfigState(this.cat);
this.notify();
@ -263,6 +269,28 @@ export class Session {
// ── Internals ─────────────────────────────────────────────────────────────
/**
* Reconstruye el ConfigState a partir de:
* 1. Valores base del Cat
* 2. Valores establecidos por el usuario (userValues)
* 3. Valores derivados del resultado actual (derivedValues — máxima prioridad)
*/
private buildCurrentConfigState(): ConfigState {
const state = buildConfigState(this.cat);
for (const [key, value] of this.userValues) {
state[key] = value;
}
for (const [key, es] of this.current.effectiveState) {
if (es.derivedValue !== undefined) {
state[key] = es.derivedValue.value;
}
}
return state;
}
private notify() {
for (const listener of this.listeners) {
listener(this.current);

Loading…
Cancel
Save

Powered by TurnKey Linux.