Archived
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:
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { expandSource } from '../internal/domain/auto-source.ts';
|
||||||
|
|
||||||
|
const condition = (): boolean => false;
|
||||||
|
const ctxWith = (scope: Record<string, unknown>) => ({ scope, lets: {}, condition });
|
||||||
|
|
||||||
|
describe('expandSource', () => {
|
||||||
|
it('once:恰一行,行是空的', () => {
|
||||||
|
expect(expandSource({ once: true }, ctxWith({}))).toEqual([{ row: {} }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rows:字面几行,每格现算', () => {
|
||||||
|
const out = expandSource(
|
||||||
|
{ rows: [{ pn: { in: 'a' }, role: 'original' }, { pn: { in: 'b' }, role: 'candidate' }] },
|
||||||
|
{ scope: { a: 'LM2596S-5.0', b: 'XL2596S-5.0' }, lets: {}, condition },
|
||||||
|
);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ row: { pn: 'LM2596S-5.0', role: 'original' } },
|
||||||
|
{ row: { pn: 'XL2596S-5.0', role: 'candidate' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each:按地址逐行', () => {
|
||||||
|
const out = expandSource({ each: 'mine:sub_eval.part' }, ctxWith({ 'mine:sub_eval.part': [{ pn: 'A' }, { pn: 'B' }] }));
|
||||||
|
expect(out.map((e) => e.row)).toEqual([{ pn: 'A' }, { pn: 'B' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each + where:等值过滤,比值按字符串', () => {
|
||||||
|
const out = expandSource(
|
||||||
|
{ each: 'mine:sub_eval.part', where: { exists_status: 'absent' } },
|
||||||
|
ctxWith({ 'mine:sub_eval.part': [{ pn: 'A', exists_status: 'found' }, { pn: 'B', exists_status: 'absent' }] }),
|
||||||
|
);
|
||||||
|
expect(out.map((e) => e.row['pn'])).toEqual(['B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('两层:里层的 where 认得出外层那一行', () => {
|
||||||
|
const out = expandSource(
|
||||||
|
{ each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } },
|
||||||
|
ctxWith({
|
||||||
|
'mine:sub_eval.part': [{ pn: 'A', chip_type_id: 1 }, { pn: 'B', chip_type_id: 2 }],
|
||||||
|
'parts.type_param': [
|
||||||
|
{ type_id: 1, param_name: '输入电压范围' },
|
||||||
|
{ type_id: 1, param_name: '输出电流' },
|
||||||
|
{ type_id: 2, param_name: '压差' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ outer: { pn: 'A', chip_type_id: 1 }, row: { type_id: 1, param_name: '输入电压范围' } },
|
||||||
|
{ outer: { pn: 'A', chip_type_id: 1 }, row: { type_id: 1, param_name: '输出电流' } },
|
||||||
|
{ outer: { pn: 'B', chip_type_id: 2 }, row: { type_id: 2, param_name: '压差' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('外层的 chip_type_id 还是空的时候,里层一行都不命中 —— 「保存即铺格」靠的就是这一条', () => {
|
||||||
|
const out = expandSource(
|
||||||
|
{ each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } },
|
||||||
|
ctxWith({
|
||||||
|
'mine:sub_eval.part': [{ pn: 'A', chip_type_id: '' }],
|
||||||
|
'parts.type_param': [{ type_id: 1, param_name: '输入电压范围' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(out).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user