feat(loop-engine): 配方数据源展开 —— once / rows / each,嵌套两层封顶

里层的 where 认得出外层那一行:外层字段还是空的时候一行都不命中 ——
「人挑了类型、按了保存,参数格才出来」不是特判,是这一条的自然结果。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 03:24:58 +08:00
co-authored by Claude Opus 5
parent f950a4419d
commit 58ff4f1b90
2 changed files with 114 additions and 0 deletions
@@ -0,0 +1,49 @@
/**
* 配方数据源展开 —— **纯函数,零 IO**。
*
* 嵌套**两层封顶**:两层(某几颗料 × 该类型的参数字段)是已知业务的上限;
* 超出就别 auto —— 退回模型,或者把那一段切出去。
*/
import type { AutoExpr, AutoSource } from '@contracts';
import { evalExpr, rowsAt, type ExprCtx, type Row } from './auto-expr.ts';
export type ExpandedRow = { readonly row: Row; readonly outer?: Row | undefined };
/** where 是等值过滤;比值按字符串(台账里数字与文本混着存) */
function matches(
row: Row,
where: Readonly<Record<string, AutoExpr>> | undefined,
ctx: ExprCtx,
outer?: Row | undefined,
): boolean {
if (where === undefined) return true;
return Object.entries(where).every(([column, valueExpr]) => {
const want = evalExpr(valueExpr, { ...ctx, row, ...(outer === undefined ? {} : { outer }) });
const got = row[column];
return (got === null || got === undefined ? '' : String(got)) === (want === null || want === undefined ? '' : String(want));
});
}
export function expandSource(source: AutoSource, ctx: ExprCtx): readonly ExpandedRow[] {
if ('once' in source) return [{ row: {} }];
if ('rows' in source) {
return source.rows.map((literal) => {
const row: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(literal)) row[name] = evalExpr(expr, { ...ctx, row });
return { row };
});
}
const outers = rowsAt(ctx.scope, source.each).filter((row) => matches(row, source.where, ctx));
if (source.then === undefined) return outers.map((row) => ({ row }));
const inner = source.then;
const out: ExpandedRow[] = [];
for (const outer of outers) {
for (const row of rowsAt(ctx.scope, inner.each)) {
if (matches(row, inner.where, ctx, outer)) out.push({ row, outer });
}
}
return out;
}