From 58ff4f1b9037b5e99ffa449eb16602e936bb5d7e Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 03:24:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(loop-engine):=20=E9=85=8D=E6=96=B9?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=E5=B1=95=E5=BC=80=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20once=20/=20rows=20/=20each,=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=E4=B8=A4=E5=B1=82=E5=B0=81=E9=A1=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 里层的 where 认得出外层那一行:外层字段还是空的时候一行都不命中 —— 「人挑了类型、按了保存,参数格才出来」不是特判,是这一条的自然结果。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../loop-engine/__tests__/auto-source.test.ts | 65 +++++++++++++++++++ .../internal/domain/auto-source.ts | 49 ++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts create mode 100644 apps/api/src/modules/loop-engine/internal/domain/auto-source.ts diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts new file mode 100644 index 0000000..5a816a9 --- /dev/null +++ b/apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts @@ -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) => ({ 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([]); + }); +}); diff --git a/apps/api/src/modules/loop-engine/internal/domain/auto-source.ts b/apps/api/src/modules/loop-engine/internal/domain/auto-source.ts new file mode 100644 index 0000000..cb8839e --- /dev/null +++ b/apps/api/src/modules/loop-engine/internal/domain/auto-source.ts @@ -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> | 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 = {}; + 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; +}