Archived
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。
**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算
**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」
**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。
验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
444 lines
19 KiB
TypeScript
444 lines
19 KiB
TypeScript
/**
|
||
* CMMN 标准生命周期与双向驱动 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 AM 04)。
|
||
*
|
||
* 「状态的权威是 CMMN 规范的标准生命周期:**活动不定义状态,模型不发明状态**;
|
||
* 引擎按规范推进,守卫(时限 / 预算 / 预授权 / 监听)仅可参数化。」
|
||
*/
|
||
import type { CaseModel, Criterion, PlanItem } from './cmmn-parse.ts';
|
||
|
||
/* ── 两副状态表(AM 4.1) ────────────────────────────────────────────── */
|
||
|
||
/** 案计划(整案)五态 */
|
||
export const CASE_STATES = ['Active', 'Completed', 'Suspended', 'Terminated', 'Failed'] as const;
|
||
export type CaseState = (typeof CASE_STATES)[number];
|
||
|
||
/** 计划项八态 */
|
||
export const ITEM_STATES = [
|
||
'Available',
|
||
'Enabled',
|
||
'Disabled',
|
||
'Active',
|
||
'Suspended',
|
||
'Failed',
|
||
'Completed',
|
||
'Terminated',
|
||
] as const;
|
||
export type ItemState = (typeof ITEM_STATES)[number];
|
||
|
||
/** 迁移拓扑 —— 引擎不得私改 */
|
||
const ITEM_TRANSITIONS: Readonly<Record<ItemState, readonly ItemState[]>> = {
|
||
Available: ['Enabled', 'Active', 'Terminated', 'Suspended'],
|
||
Enabled: ['Active', 'Disabled', 'Terminated', 'Suspended'],
|
||
Disabled: ['Enabled', 'Terminated'],
|
||
Active: ['Completed', 'Failed', 'Suspended', 'Terminated'],
|
||
Suspended: ['Available', 'Enabled', 'Active', 'Terminated'],
|
||
Failed: ['Active', 'Terminated'],
|
||
Completed: [],
|
||
Terminated: [],
|
||
};
|
||
|
||
const CASE_TRANSITIONS: Readonly<Record<CaseState, readonly CaseState[]>> = {
|
||
Active: ['Completed', 'Suspended', 'Terminated', 'Failed'],
|
||
Suspended: ['Active', 'Terminated'],
|
||
Failed: ['Active', 'Terminated'],
|
||
Completed: [],
|
||
Terminated: [],
|
||
};
|
||
|
||
export class IllegalTransitionError extends Error {
|
||
constructor(
|
||
readonly from: string,
|
||
readonly to: string,
|
||
) {
|
||
super(`生命周期迁移非法:${from} → ${to}(语义忠实:引擎不得私改迁移拓扑)`);
|
||
this.name = 'IllegalTransitionError';
|
||
}
|
||
}
|
||
|
||
export function canTransitionItem(from: ItemState, to: ItemState): boolean {
|
||
return ITEM_TRANSITIONS[from].includes(to);
|
||
}
|
||
|
||
export function assertItemTransition(from: ItemState, to: ItemState): void {
|
||
if (!canTransitionItem(from, to)) throw new IllegalTransitionError(from, to);
|
||
}
|
||
|
||
export function canTransitionCase(from: CaseState, to: CaseState): boolean {
|
||
return CASE_TRANSITIONS[from].includes(to);
|
||
}
|
||
|
||
/* ── 运行时快照 ─────────────────────────────────────────────────────── */
|
||
|
||
export type ItemRuntime = {
|
||
readonly id: string;
|
||
readonly state: ItemState;
|
||
/** repetitionRule 的实例序号 */
|
||
readonly instance: number;
|
||
/** 语义性前提待裁量评估的标记 */
|
||
readonly awaitingJudgement?: boolean;
|
||
/**
|
||
* **这一实例期间,AI 有没有真的触达过世界**(human / entry / decide / listen 之一)。
|
||
*
|
||
* 只给「触达世界」的项(`manualActivation` = true)当门槛用:那类项完成与否
|
||
* 天生**不该由数据形状推出来**(AM 12.2 的原理一),唯一的证据是「真发过一张卡
|
||
* 或真立过一次监听」。没有这个字段之前,`finish` 只看生命周期迁移合不合法 ——
|
||
* 模型可以在从没给人发过卡的情况下直接宣告某个人工激活项「完成」,
|
||
* 人的那一步就此被悄悄跳过(2026-08-21 用户实测:E04 S5 执行回执从未派单,
|
||
* 覆盖度收口直接用了空回执重算,人根本没被问过)。
|
||
*/
|
||
readonly worldTouched?: boolean;
|
||
};
|
||
|
||
export type CaseRuntime = {
|
||
readonly state: CaseState;
|
||
readonly items: readonly ItemRuntime[];
|
||
/** 已达成的里程碑 */
|
||
readonly milestones: readonly string[];
|
||
};
|
||
|
||
export function initialRuntime(model: CaseModel): CaseRuntime {
|
||
return {
|
||
state: 'Active',
|
||
items: model.items.map((item) => ({
|
||
id: item.id,
|
||
// 裁量项不随阶段自动入场:从 planning table 由 Agent 临场规划
|
||
state: item.discretionary ? 'Available' : 'Available',
|
||
instance: 1,
|
||
})),
|
||
milestones: [],
|
||
};
|
||
}
|
||
|
||
/* ── 前提求值 ───────────────────────────────────────────────────────── */
|
||
|
||
export type ConditionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
||
|
||
export type CriterionVerdict =
|
||
| { readonly kind: 'satisfied' }
|
||
| { readonly kind: 'unsatisfied' }
|
||
/** 语义性前提不可机算 —— 标「待裁量评估」,交上层 Agent,引擎**不代裁量** */
|
||
| { readonly kind: 'needs-judgement'; readonly statement: string };
|
||
|
||
export function evaluateCriterion(
|
||
criterion: Criterion,
|
||
scope: Readonly<Record<string, unknown>>,
|
||
changedRefs: ReadonlySet<string>,
|
||
evaluate: ConditionEvaluator,
|
||
): CriterionVerdict {
|
||
// onPart:监听的数据项写入 / 计划项迁移发生过才算「件到」
|
||
if (criterion.onParts.length > 0) {
|
||
const allArrived = criterion.onParts.every((part) => changedRefs.has(part.ref));
|
||
if (!allArrived) return { kind: 'unsatisfied' };
|
||
}
|
||
if (criterion.ifPart === undefined) return { kind: 'satisfied' };
|
||
if (criterion.semantic) return { kind: 'needs-judgement', statement: criterion.ifPart };
|
||
return evaluate(criterion.ifPart, scope) ? { kind: 'satisfied' } : { kind: 'unsatisfied' };
|
||
}
|
||
|
||
/* ── Enabled 集发布:引擎推进的终点,Agent 裁量的起点 ────────────────── */
|
||
|
||
export type EnabledEntry = {
|
||
readonly id: string;
|
||
readonly name: string;
|
||
readonly type: PlanItem['type'];
|
||
/** manualActivationRule = true:进入 Active 永远出自 Agent 的裁量启动 */
|
||
readonly requiresManualStart: boolean;
|
||
readonly discretionary: boolean;
|
||
};
|
||
|
||
export type Redlight = { readonly criterion: string; readonly statement: string };
|
||
|
||
export type EnabledSet = {
|
||
readonly entries: readonly EnabledEntry[];
|
||
/**
|
||
* **进行中的项** —— 已开工、还没交出成果的那些。
|
||
*
|
||
* Enabled 集回答的是「什么可以**新**开始」;进行中的项按定义不在里面。
|
||
* 可局面一旦推进开,手上的活全是 Active —— 于是 Agent 看到的「当下可为」是空的,
|
||
* 而它正干着的两件事一个也看不见。实测的后果:它开始编计划项 id、反复被拓扑拒回来,
|
||
* 15 拍烧掉 83k token,最后立个监听睡过去 —— 手上明明还有半件活没干完。
|
||
*
|
||
* 所以进行中的项必须一并发布:要么接着干(write / tool),要么 finish。
|
||
*/
|
||
readonly inProgress: readonly EnabledEntry[];
|
||
/** 待裁量评估的语义性前提 */
|
||
readonly judgements: readonly Redlight[];
|
||
/** 目标缺口:未绿的判据 */
|
||
readonly gaps: readonly string[];
|
||
/** 阶段位置:Active 的阶段 */
|
||
readonly activeStages: readonly string[];
|
||
};
|
||
|
||
/**
|
||
* 前提前推(开展):任务视图变化 → 前提满足 → 新计划项 Enabled。
|
||
* 「**Enabled ≠ Active**」—— 引擎按规范推进到 Enabled 为止;
|
||
* 凡触达世界或产出关键交付的计划项必须 manualActivationRule = true。
|
||
*/
|
||
export function publishEnabled(
|
||
model: CaseModel,
|
||
runtime: CaseRuntime,
|
||
scope: Readonly<Record<string, unknown>>,
|
||
changedRefs: ReadonlySet<string>,
|
||
evaluate: ConditionEvaluator,
|
||
unmetCriteria: readonly string[] = [],
|
||
): EnabledSet {
|
||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||
const entries: EnabledEntry[] = [];
|
||
const judgements: Redlight[] = [];
|
||
|
||
for (const item of model.items) {
|
||
const state = byId.get(item.id)?.state ?? 'Available';
|
||
if (state !== 'Available' && state !== 'Enabled') continue;
|
||
// 父阶段未激活的项不入场
|
||
if (item.parent !== undefined && byId.get(item.parent)?.state !== 'Active') continue;
|
||
|
||
let satisfied = true;
|
||
for (const criterion of item.entryCriteria) {
|
||
const verdict = evaluateCriterion(criterion, scope, changedRefs, evaluate);
|
||
if (verdict.kind === 'needs-judgement') {
|
||
judgements.push({ criterion: criterion.id, statement: verdict.statement });
|
||
satisfied = false;
|
||
break;
|
||
}
|
||
if (verdict.kind === 'unsatisfied') {
|
||
satisfied = false;
|
||
break;
|
||
}
|
||
}
|
||
if (!satisfied) continue;
|
||
|
||
entries.push({
|
||
id: item.id,
|
||
name: item.name,
|
||
type: item.type,
|
||
requiresManualStart: item.manualActivation,
|
||
discretionary: item.discretionary,
|
||
});
|
||
}
|
||
|
||
return {
|
||
entries,
|
||
// 进行中 = Active 的项(阶段也算:它开着,Agent 才知道自己站在哪一段里)
|
||
inProgress: model.items
|
||
.filter((item) => byId.get(item.id)?.state === 'Active')
|
||
.map((item) => ({
|
||
id: item.id,
|
||
name: item.name,
|
||
type: item.type,
|
||
requiresManualStart: item.manualActivation,
|
||
discretionary: item.discretionary,
|
||
})),
|
||
judgements,
|
||
gaps: unmetCriteria,
|
||
activeStages: runtime.items
|
||
.filter((i) => i.state === 'Active' && model.items.find((m) => m.id === i.id)?.type === 'stage')
|
||
.map((i) => i.id),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 判据后拉(完成):目标缺口 → 由什么推出 → 落到一个 Enabled 的计划项。
|
||
* 「导航即交集,断点即人环:下一步 = 当下可为(Enabled 集)∩ 目标所需;
|
||
* **交集为空即断点** —— 调人或立监听的判定时机。」
|
||
*/
|
||
export type Navigation =
|
||
| { readonly kind: 'proceed'; readonly candidates: readonly EnabledEntry[] }
|
||
/** 断点:目标所需的缺口无法由任何 Enabled 项补足 */
|
||
| { readonly kind: 'breakpoint'; readonly gaps: readonly string[] }
|
||
| { readonly kind: 'converged' };
|
||
|
||
export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Navigation {
|
||
if (enabled.gaps.length === 0) return { kind: 'converged' };
|
||
const intersection =
|
||
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
|
||
/*
|
||
* 断点的定义是「**无路可走**」—— 手上还有开着的活就不算断点。
|
||
* 从前只看 Enabled 集:局面推进开以后手上全是 Active,每一拍都被判成断点,
|
||
* 而断点的处置是「调人或立监听」—— 于是环把没干完的活丢下,去等一个不会来的事件。
|
||
*/
|
||
if (intersection.length === 0) {
|
||
return enabled.inProgress.length > 0
|
||
? { kind: 'proceed', candidates: enabled.inProgress }
|
||
: { kind: 'breakpoint', gaps: enabled.gaps };
|
||
}
|
||
return { kind: 'proceed', candidates: intersection };
|
||
}
|
||
|
||
/* ── 自动完成与终局 ─────────────────────────────────────────────────── */
|
||
|
||
/** 「齐了即收」(AutoComplete):必做项完成且无进行中项即结案 */
|
||
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
|
||
if (!model.autoComplete) return false;
|
||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||
/*
|
||
* 事件监听器**不是交付物**,是等待岗:等来了就完成,等不来也不该拦着结案。
|
||
* 从前只排除 timerEventListener —— 于是一个 `userEventListener`(如「客户改需求」)
|
||
* 就让 autoComplete 永远为假:必做项全干完了,案子还是判不齐。
|
||
*/
|
||
const required = model.items.filter((i) => !i.discretionary && !i.type.endsWith('EventListener'));
|
||
const allDone = required.every((i) => {
|
||
const state = byId.get(i.id)?.state;
|
||
return state === 'Completed' || state === 'Terminated' || state === 'Disabled';
|
||
});
|
||
const noneRunning = runtime.items.every((i) => i.state !== 'Active');
|
||
return allDone && noneRunning;
|
||
}
|
||
|
||
/**
|
||
* 终局唯放行与 Harness(必须):
|
||
* 案计划 Completed **必经放行卡**;Terminated 只出自 Harness 与负责人;
|
||
* **AI 不得产生终局迁移**。
|
||
*/
|
||
export const TERMINAL_AUTHORITIES = ['release', 'harness', 'owner', 'governance'] as const;
|
||
export type TerminalAuthority = (typeof TERMINAL_AUTHORITIES)[number];
|
||
|
||
export function mayCompleteCase(by: TerminalAuthority): boolean {
|
||
return by === 'release';
|
||
}
|
||
|
||
export function mayTerminateCase(by: TerminalAuthority): boolean {
|
||
return by === 'harness' || by === 'owner' || by === 'governance';
|
||
}
|
||
|
||
/** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */
|
||
/**
|
||
* 前提达成即达成的里程碑 —— **不需要谁去按**。
|
||
*
|
||
* CMMN 的里程碑语义就是「一个前提被满足」,它没有执行体、也没有人去启动它。
|
||
* 从前引擎只在 `onEvent(ref = 里程碑 id)` 时才 occur,而系统里没有任何东西
|
||
* 会发出以里程碑命名的事件 —— 结果每个里程碑都永远停在 Available:
|
||
* 既堵住 autoComplete(它是必做项),又常驻在「当下可为」与「目标缺口」里,
|
||
* 让 Agent 每拍都看到一堆自己按不动的东西。
|
||
*
|
||
* 返回达成了的那些 id(调用方据此留痕)与推进后的运行时。
|
||
*/
|
||
export function achieveMilestones(
|
||
model: CaseModel,
|
||
runtime: CaseRuntime,
|
||
scope: Readonly<Record<string, unknown>>,
|
||
changedRefs: ReadonlySet<string>,
|
||
evaluate: ConditionEvaluator,
|
||
): { runtime: CaseRuntime; achieved: string[] } {
|
||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||
let next = runtime;
|
||
const achieved: string[] = [];
|
||
|
||
for (const item of model.items) {
|
||
if (item.type !== 'milestone') continue;
|
||
if (byId.get(item.id)?.state !== 'Available') continue;
|
||
// 无前提的里程碑不自达成:那是建模漏了前提,不是「一开案就达成」
|
||
if (item.entryCriteria.length === 0) continue;
|
||
const met = item.entryCriteria.every(
|
||
(criterion) => evaluateCriterion(criterion, scope, changedRefs, evaluate).kind === 'satisfied',
|
||
);
|
||
if (!met) continue;
|
||
next = occur(next, item.id);
|
||
achieved.push(item.id);
|
||
}
|
||
return { runtime: next, achieved };
|
||
}
|
||
|
||
export function occur(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||
return {
|
||
...runtime,
|
||
items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: 'Completed' } : i)),
|
||
milestones: runtime.milestones.includes(itemId) ? runtime.milestones : [...runtime.milestones, itemId],
|
||
};
|
||
}
|
||
|
||
export function transitionItem(runtime: CaseRuntime, itemId: string, to: ItemState): CaseRuntime {
|
||
const current = runtime.items.find((i) => i.id === itemId);
|
||
if (current === undefined) throw new IllegalTransitionError('(不存在)', to);
|
||
assertItemTransition(current.state, to);
|
||
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, state: to } : i)) };
|
||
}
|
||
|
||
/** human / entry / decide / listen 真发生过一次 —— 见 `ItemRuntime.worldTouched` 的注释 */
|
||
export function markWorldTouched(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||
return { ...runtime, items: runtime.items.map((i) => (i.id === itemId ? { ...i, worldTouched: true } : i)) };
|
||
}
|
||
|
||
/** repetitionRule:驳回是**迁移不是状态** —— 相关计划项以新实例重新入场 */
|
||
export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||
return {
|
||
...runtime,
|
||
items: runtime.items.map((i) =>
|
||
i.id === itemId ? { id: i.id, state: 'Available', instance: i.instance + 1 } : i,
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 判据名归一 —— 让「模型抄的那一句」和「清单里写的那一句」对得上。
|
||
*
|
||
* `judge` 的 criterion 是让模型「照判据名原样写」的一句中文,可它抄的时候会顺手
|
||
* 把半角标点敲成全角、把空格吃掉:清单里写的是「吃不准降 B,不许臆断 A」,
|
||
* 它交回来的是「吃不准降B,不许臆断A」—— 逐字比对当然对不上,那条判据于是永远未达标,
|
||
* 提交放行一遍遍被拒,直到预算烧完(实测 32 次被拒 → exhausted)。
|
||
*
|
||
* 归一只做两件安全的事:**去掉所有空白** + **全角标点折成半角**。
|
||
* 不做同义词匹配、不做模糊比对 —— 那会把两条不同的判据混成一条,比对不上更糟。
|
||
*/
|
||
export function criterionKey(raw: string): string {
|
||
const folded = raw.replace(/[!-~]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0));
|
||
const tight = folded.replace(/\s+/g, '').replace(/[、,]/g, ',').toLowerCase();
|
||
/*
|
||
* 末尾的等级标记要削掉 —— 那是**引擎自己贴上去的**,不是判据名的一部分。
|
||
*
|
||
* 提交被拒时,门给的话是「…还有 1 条判据没达标 —— 每项数据来源可溯(…)(L3)」,
|
||
* 紧接着又说「judge 的 criterion 照判据名原样写」。模型照做了 ——
|
||
* 它把 `(L3)` 一起抄了进来,于是这条裁定的键与清单里的判据名对不上,
|
||
* 裁了等于没裁:再提交、再被拒、再裁……连拒五拍熔断(实测 A2 死于此)。
|
||
*
|
||
* 这不是模糊匹配:削的是引擎自己印上去的那一小段,不碰判据名本身。
|
||
*/
|
||
return tight.replace(/\(l[1-5]\)$/, '');
|
||
}
|
||
|
||
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
|
||
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
|
||
|
||
/**
|
||
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
|
||
*
|
||
* 只给判据读的那几个,不是整个任务视图:视图有上百个键,全摆出来等于没摆。
|
||
* 没绑上的名字如实写「(没写过)」—— 未绑定的名字在 S-FEEL 里求值为假,
|
||
* 那正是这一步推不动的原因,藏起来只会让模型继续瞎猜。
|
||
*/
|
||
export function readingsOf(predicate: string, scope: Readonly<Record<string, unknown>>): string {
|
||
const names = new Set<string>();
|
||
for (const raw of predicate.replace(/"[^"]*"/g, ' ').match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
|
||
if (!SFEEL_WORDS.has(raw.toLowerCase())) names.add(raw);
|
||
}
|
||
if (names.size === 0) return '';
|
||
const readings = [...names].map((name) => {
|
||
const value = scope[name];
|
||
if (value === undefined || value === null) return `${name}=(没写过)`;
|
||
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||
return `${name}=${text.length <= 120 ? text : `${text.slice(0, 120)}…`}`;
|
||
});
|
||
/*
|
||
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
|
||
*
|
||
* 行数类判据(`quote_intake_record_rows = line_total`)拒回时只给两个数,
|
||
* 模型看得见「10 ≠ 9」,却不知道多的是哪一行 —— 它只能猜:改数、重写、再提,
|
||
* 三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
|
||
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
|
||
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
|
||
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
|
||
* 三种一摆主键就都是一句话能办的事:多的那个 PN 就在名单里对着。
|
||
*
|
||
* **只摆基座数得出来的**:本单在那张表写下的行、它们的主键。不替模型判断谁多谁少 ——
|
||
* 名单摆出来,该删该补它自己看得出来;判断留给它,事实由基座给。
|
||
*/
|
||
const rowKeys = [...names]
|
||
.filter((n) => n.endsWith('_rows') || n.endsWith('_missing'))
|
||
.map((n) => {
|
||
const listed = scope[n.endsWith('_rows') ? `${n.slice(0, -'_rows'.length)}_keys` : `${n}_keys`];
|
||
if (typeof listed !== 'string' || listed === '') return '';
|
||
return n.endsWith('_rows') ? `${n} 是这几行:${listed}` : `${n} 缺在这几行:${listed}`;
|
||
})
|
||
.filter((s) => s !== '');
|
||
return `(现在:${readings.join('、')}${rowKeys.length === 0 ? '' : `;${rowKeys.join(';')}`})`;
|
||
}
|