走查逼出的六条:发卡前清单要有行 · 在库要拿得出依据 · 数据工单只建缺的那颗 · 录入卡自带 basis

ACT-WORK-05 差异评估的浏览器全流程走查(六轮六条路径,全部 released),
逐条治了实跑里现形的缺陷。活动包 1.2.2 → 1.2.6:

- H(1.2.3):模型压拍把执行工单发了、清单一行没写,人对着空表 ——
  「先写清单再发卡」只在剧本里,压过拍就只有说明书还在;规矩搬进说明书。
- I(1.2.4,结构治):原 PN 根本不在库,模型两颗都判「在库」,硬阻塞整条路被跳过。
  剧本已写到头 → pn_check 加 found_pn_ref(refersTo + requiredWhen):
  判在库就必须填一个基座查得到的料号,查无此值当场拒、填不出则收不了步。
  这是「机检只读基座数」第六次现形,前五次都是计数,这次是存在性。
- J(1.2.5):数据工单派给了在库的那颗,缺的那颗没人建 ——
  剧本把「建哪几颗」和「要不要问人」写在同一串分岔里,从没写过「found 的不建」;拆成两问。
- K(1.2.6):数据录入卡的参考信息栏摆着引擎内部家具(九个计划项全名)——
  entryCard.basis 契约里可选,活动没声明,基座就兜了内部事实;卡骨架补三行人话。

包内自测 56 → 61 条;门禁 typecheck / lint / depcruise / test(2071)全绿
(api:check 的差异全在 org/* 与 api/*,是工作树里先前那批鉴权改动,本轮未添)。

docs/ACT-WORK-05-端到端测试记录.md:六轮走查的逐轮结论与 A–K 十一条发现总表。
docs/debt.md:四条平台欠账(disable 无路可走 · 前一项不收后一项的件推不出来 ·
拒回数不出缺的是工具调用 · 零行台账的提交闸无牙)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
This commit is contained in:
xcs
2026-08-27 04:29:40 +08:00
co-authored by Claude Opus 5
parent 7ea08fb16a
commit eb80876554
6 changed files with 394 additions and 7 deletions
@@ -125,6 +125,13 @@ describe('台账声明:授权面与《统一方案》口径', () => {
expect(byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'abandon_reason')?.requiredWhen).toEqual({ column: 'exists_status', value: 'abandoned' });
});
it('判「在库」必须填得出引用依据,基座拿它去主数据里查(2026-08-27 实测:原 PN 不在库而模型把两颗都写成 found,硬阻塞那条路整个被跳过)', () => {
const ref = byAddress(D, 'pn_check')?.fields.find((f) => f.name === 'found_pn_ref');
expect(ref?.refersTo).toBe('parts.profile.internal_pn');
expect(ref?.requiredWhen).toEqual({ column: 'exists_status', value: 'found' });
expect(ref?.primaryKey ?? false).toBe(false);
});
it('规则版本常量随包冻结', () => {
expect(RULE_VERSION).toBe('R-2026.07');
expect(manifest.assets.businessModel.dmn.tables.map((t) => t.id)).toEqual(['d01-difference-status', 'd02-recommendation']);
@@ -223,6 +223,35 @@ describe('说明书与剧本的骨架', () => {
expect(s0d10).toMatch(/若它还开着|先.*finish/);
});
it('「发卡前清单必须先有行」写在说明书里,不只在剧本里(2026-08-27 实测:模型把 P-10 收步与 S1 发卡压成一拍,那一拍 S1 剧本没摊开,卡发出去时 input_gap 一行都没有,人对着空表无从下手)', () => {
expect(NARRATIVE).toContain('sub_eval.input_gap` 必须先有行');
expect(NARRATIVE).toMatch(/压拍/);
expect(NARRATIVE).toMatch(/顺序不能反/);
});
it('存在性核对的剧本教的是「填得出依据才算在库」,不是「你看着办」(结构治,配 found_pn_ref 那道墙)', () => {
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
expect(g).toContain('found_pn_ref');
expect(g).toMatch(/查无此值当场拒/);
expect(g).toMatch(/名单里没有就是没有/);
expect(g).toMatch(/把这一行改成 absent/);
});
it('数据工单的名单只认 absent,「建哪几颗」与「要不要问人」分开写(2026-08-27 实测:原 PN 缺库那单,模型把在库的候选派成了数据工单,缺的那颗没人建)', () => {
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
expect(g).toMatch(/问题一 · 建哪几颗/);
expect(g).toMatch(/问题二 · 建之前要不要先问人/);
expect(g).toMatch(/exists_status = "found" 的一行都不建/);
});
it('数据录入卡自带 basis,不让基座兜上内部家具(2026-08-27 实测:没声明 basis 的那张卡,参考信息栏摆的是「这一步 / 当下可为 / 目标缺口」加九个计划项全名)', () => {
const i = NARRATIVE.indexOf('"kind":"entry"');
expect(i).toBeGreaterThan(-1);
const block = NARRATIVE.slice(i, i + 900);
expect(block).toContain('"basis"');
expect(block).toContain('触发原因');
});
it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => {
expect(ASSESSMENT_PHASES).toHaveLength(5);
for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10);
@@ -197,7 +197,12 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该
{"key":"name","label":"客户名称","value":"<customerName,没有就整格不写 value>","required":true},
{"key":"industry","label":"所属行业"},
{"key":"region","label":"区域"}
]}
],
"basis":[{"k":"触发原因","v":"按客户编码 <customerCode> 查客户档案,查无记录"},
{"k":"这一单要评估的","v":"原 PN <originalInternalPn> → <N> 个候选"},
{"k":"确认之后","v":"这家客户落进本单草稿层,评估照常往下走"}]}
**\`basis\` 这三行必须给**:不给的话基座会兜上「这一步 / 当下可为 / 目标缺口」那几行内部家具 ——
把九个计划项的全名摆到人脸上,他一个字也用不上(2026-08-27 实测)。
人确认那一下基座已把这一行落进草稿层,**你不许再 write 一遍 customer.profile**。
② 候选不在库、且候选总数 ≥2 时,一颗一张决策卡(按候选数组顺序,一次只发一张;候选总数 = 1 时不问、直接建):
@@ -230,7 +235,12 @@ PN 在库但个别字段缺 = 「有而不全」,归执行工单 01 补。**该
"standard":"每颗料号至少一行 datasheet 记录,必填列全齐。",
"basis":[{"k":"触发原因","v":"按 internal_pn 查 P-10 查无记录"}]}}
⑤ 执行工单 01 · 评估输入与资料补齐确认(S1_input_confirm;零输入):
⑤ 执行工单 01 · 评估输入与资料补齐确认(S1_input_confirm;零输入)——
**发这张卡之前,\`${D}.input_gap\` 必须先有行**:清单为空的卡就是让人对着一张空表签字,他补不了任何东西。
不管这一拍是不是顺手把上一步收掉的(压拍常发生,那时 S1 的剧本还没摊开),写清单与发卡都得在同一串里,顺序不能反:
已绑定四项(customer / request / original_pn / candidate_N,status="ok")、待补两项(application_scenario / bom_position,status="gap")、
默认三项(substitution_type / rule_version / owner,status="default")、每颗候选的「未披露」参数各一行(status="gap")、原 PN 资料版本(status="ok");
逐列口径见这一步的剧本。
{"kind":"human","itemId":"S1_input_confirm","workOrder":"execution","card":{
"title":"评估输入与资料补齐确认:<原 PN> → <N> 个候选",
"ask":"启动绑定的四项已锁定。请在工作区的件上补齐「待补 / 待确认」行的补充内容,默认值可改可确认。",
@@ -296,9 +306,14 @@ request_description(入参原话,不改写)、input_snapshot_ref、original_inte
current_stage = "assessing"。application_scenario / bom_position **这一步不写**(启动时未收,S1 由人补)。
【逐个料号查库,写 \`${D}.pn_check\`】\`parts.profile\` 整表在你的作用域里,按 \`internal_pn\` 逐个核对:原 PN 一行(role = "original"),
每个候选一行(role = "candidate");在 → exists_status = "found";不在 → exists_status = "absent"(**这一格先写 absent,等数据工单确认后再改**)。
每个候选一行(role = "candidate");在 → exists_status = "found" **且 \`found_pn_ref\` 填你在 \`parts.profile\` 里真找到的那一行的 internal_pn**
(与本行 internal_pn 一模一样);不在 → exists_status = "absent"、\`found_pn_ref\` 留空(**等数据工单确认后再改成 created**)。
一颗都不能少:数组里点了几颗候选,就写几行 + 原 PN 一行。note 选填(如「PCN 转 NRND」)。
**「在库」这句话基座要拿去查**:\`found_pn_ref\` 声明了引用 \`parts.profile.internal_pn\`,写下去那一刻基座就去主数据里查,
**查无此值当场拒**。被拒不是让你换个号去顶 —— 那说明这颗料真的不在库,把这一行改成 absent(依据留空),数据工单那一步的门自己会开。
判 found 却填不出依据也走不动:这一步的收步条件里有「核对表不缺必填」。**别凭料号长得像就判在库,名单里没有就是没有。**
【写 \`${D}.verdict\` 一行】eval_ref、request_id、rule_version = "${RULE_VERSION}"、original_internal_pn、candidate_total = 候选数组长度。
写完这一项就结束。有 absent 的 → 下一项是 S0d_p08_master(数据工单),照常往下办。
@@ -319,9 +334,16 @@ current_stage = "assessing"。application_scenario / bom_position **这一步不
【先数两个数,再分岔】(先看轨迹:这一项里已经发过哪些卡、人答了什么,【当前这一步】下面那行引擎事实说得清楚)
· **候选总数**:入参 \`candidateInternalPns\` 的长度(\`${D}.pn_check\` 里 role = "candidate" 的行数也是它,不随本单进展变化);
· **不在库的候选数**:\`${D}.pn_check\` 里 role = "candidate" 且 exists_status = "absent" 的行数(这个数会随着人放弃/创建而变小)。
**该不该问人,看的是候选总数,不是不在库的颗数**——**「不在库的只有一颗」不是跳过决策卡的理由**:
· 原 PN absent → **硬阻塞**,不问人,直接进入「建」的名单;
· **候选总数 = 1** → 那一颗不许放弃,不问人,直接进入「建」的名单;
**这里要分开答两个问题,别混成一个**:
**问题一 · 建哪几颗?** 名单 = \`${D}.pn_check\` 里 **exists_status = "absent" 的每一行**,不管它是原 PN 还是候选。
**exists_status = "found" 的一行都不建** —— 那几颗的主数据本来就在库里,给它们派数据工单等于让人把已有的东西再填一遍,
而真正缺的那颗还是没人建、单子照样卡死。名单为空(一行 absent 都没有)这一项根本不会出现在你的「当下可为」里。
**问题二 · 建之前要不要先问人?** 只有 **absent 的候选**才谈得上问,**看的是候选总数,不是不在库的颗数**
——**「不在库的只有一颗」不是跳过决策卡的理由**:
· 原 PN absent → **硬阻塞**,不问人,直接进名单(它是评估基准,没有它整单无从谈起);
· **候选总数 = 1** → 那一颗候选**如果是 absent** 就不许放弃、不问人,直接进名单(候选是 found 的话它压根不在名单里,没有可问的);
· **候选总数 ≥ 2** → **每一颗 absent 的候选都要先发一张决策卡问人**(一次一张,按候选数组顺序;哪怕这一单不在库的候选只有一颗,
只要候选总数 ≥ 2 就要问)照说明书②;人选 "abandon"/"custom:" 的,把那一行 \`${D}.pn_check\` 改成
exists_status = "abandoned" + abandon_reason(同 PK 覆盖写),这颗不再评估;选 "create" 的进「建」的名单。
@@ -89,7 +89,7 @@ export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估',
packageVersion: '1.2.2',
packageVersion: '1.2.6',
category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment',
@@ -439,6 +439,20 @@ export const manifest: ActivityManifest = {
col({ name: 'internal_pn', type: 'text', label: '内部料号', primaryKey: true }),
col({ name: 'role', type: 'text', label: '角色', required: true, tally: ['original', 'candidate'] }),
col({ name: 'exists_status', type: 'text', label: '在库状态', required: true, tally: ['found', 'absent', 'created', 'abandoned'] }),
/*
* 「在库」不是模型说了算的一句话 —— 判 found 就必须把**在主数据里真找到的那个料号**填在这里,
* 基座拿它去 `parts.profile` 查(refersTo),查无此值当场拒。requiredWhen 让这一格只对 found 行必填,
* 而 S0_bind 的 doneWhen 里有 `_missing = 0` —— 于是「判了 found 却填不出依据」这一步就收不掉。
* 2026-08-27 实测逼出来的:原 PN 根本不在库,模型把两颗都写成 found、交代里还说「都在库」,
* 硬阻塞那条路整个被跳过(剧本早写着「整表在你的作用域里逐个核对」—— 措辞治不了的结构治)。
*/
col({
name: 'found_pn_ref',
type: 'text',
label: '在库核对依据',
refersTo: 'parts.profile.internal_pn',
requiredWhen: { column: 'exists_status', value: 'found' },
}),
col({ name: 'abandon_reason', type: 'text', label: '放弃原因', requiredWhen: { column: 'exists_status', value: 'abandoned' } }),
col({ name: 'note', type: 'text', label: '备注' }),
],