Archived
harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条
human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、 data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task (ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest; 迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。 逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
@@ -32,4 +32,12 @@
|
||||
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
|
||||
活动代码无从自查自守
|
||||
- **落地失败卡是执行工单的 preset**,不构成第五种卡
|
||||
- **数据工单是执行工单的 `variant:'data'`**,同样不构成新的 mode:发卡 / 闸 / 回传全走执行工单那条路,只是对话区换一副面孔(2026-08-27)
|
||||
- **人工验收清单点全才能通过**(2026-08-27 用户定):验收工单 / 审核卡带 `checklist`(剧本 `acceptanceChecklist`,
|
||||
每份工单固定)时,「通过」的回传必须带 `checked ⊇ checklist`,否则 `checklist-incomplete` 拒回;驳回不看清单。
|
||||
审核卡只拦「通过」那一个选项(`reviewApproveValue`:值叫 approve 的 / 推荐的 / 第一个)。点过的条目落进
|
||||
`done.checked` / `chosen.checked` 留痕
|
||||
- **`pendingCards(taskId)`**(2026-08-27):这一单正等人交回的卡 = 没办结的工单 + 没作答、没被顶替的对话卡
|
||||
(`CardStore.listOpenChatCards` 按 `superseded_by is null` 取)。引擎每拍开头问它:有卡没答就不问模型
|
||||
- **数据工单是执行工单的 `variant:'data'`**,同样不构成新的 mode:发卡 / 闸 / 回传全走执行工单那条路,只是对话区换一副面孔(2026-08-27)。
|
||||
**确认即入库**(2026-08-27 下午)不在本模块:回传落定之后由 app 的回传路由调 `landDataOrder`
|
||||
(`apps/api/src/app/data-order-landing.ts`)把这张卡 `gateLedgers` / 绑的表并进正式表 —— 本模块只管回传本身
|
||||
|
||||
@@ -3,11 +3,14 @@ import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
AcceptanceWorkOrder,
|
||||
ChatCard,
|
||||
ChatCardReply,
|
||||
DeliverableId,
|
||||
ExecutionWorkOrder,
|
||||
WorkOrder,
|
||||
WorkOrderId,
|
||||
WorkOrderReply,
|
||||
} from '@contracts';
|
||||
import { chatCardSchema, workOrderSchema } from '@contracts';
|
||||
import {
|
||||
allCriteriaGreen,
|
||||
arrivalOf,
|
||||
@@ -276,3 +279,57 @@ describe('录入卡分段:段号.列名', () => {
|
||||
expect(split({ customer_code: 'CU-1', name: '老写法' }, 1)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 人工验收清单要点全才能通过(2026-08-27 用户定:「只有人把这些判据都点成达标了,才能点击确认通过」)——
|
||||
* 按钮灰掉是给人看的,这里是门:验收工单的「通过」与审核卡的「通过」选项同一道。驳回不拦。
|
||||
*/
|
||||
describe('人工验收清单:点全才能通过', () => {
|
||||
const list = ['核对甲', '核对乙'];
|
||||
// 夹具走契约 schema 造 —— 造出来的就是一张合法工单,不靠 as 硬转(§12.5)
|
||||
const order: WorkOrder = workOrderSchema.parse({
|
||||
id: 'wo-1',
|
||||
at: '2026-08-27T10:00:00.000Z',
|
||||
title: '评审放行与落账确认',
|
||||
basis: [{ k: '来源', v: '收口' }],
|
||||
mode: 'acceptance',
|
||||
deliverable: 'landing',
|
||||
criteria: [],
|
||||
fields: [],
|
||||
checklist: list,
|
||||
});
|
||||
const pass = (checked?: string[]) =>
|
||||
({ mode: 'acceptance', workOrderId: 'wo-1', verdict: 'pass', values: {}, selectedPorts: {}, ...(checked === undefined ? {} : { checked }) }) as WorkOrderReply;
|
||||
|
||||
it('验收工单:少点一条就拒,点全放行;驳回不看清单', () => {
|
||||
expect(checkWorkOrderReply(order, pass(['核对甲'])).map((v) => v.code)).toEqual(['checklist-incomplete']);
|
||||
expect(checkWorkOrderReply(order, pass())).toHaveLength(1);
|
||||
expect(checkWorkOrderReply(order, pass(list))).toEqual([]);
|
||||
const redo = { mode: 'acceptance', workOrderId: 'wo-1', verdict: 'redo', values: {}, comment: '再看看', selectedPorts: {} } as WorkOrderReply;
|
||||
expect(checkWorkOrderReply(order, redo)).toEqual([]);
|
||||
});
|
||||
|
||||
it('审核卡(决策卡):选「通过」那一项要点全,驳回 / 自定义不看', () => {
|
||||
const card: ChatCard = chatCardSchema.parse({
|
||||
k: 'decision',
|
||||
id: 'c1',
|
||||
at: '2026-08-27T10:00:00.000Z',
|
||||
ask: '请审核',
|
||||
basis: [],
|
||||
options: [
|
||||
{ value: 'approve', label: '通过', recommended: true },
|
||||
{ value: 'reject', label: '驳回' },
|
||||
],
|
||||
allowCustom: true,
|
||||
variant: 'review',
|
||||
checklist: list,
|
||||
});
|
||||
const reply = (value: string, checked?: string[], note?: string) =>
|
||||
({ k: 'decision', cardId: 'c1', value, ...(checked === undefined ? {} : { checked }), ...(note === undefined ? {} : { note }) }) as ChatCardReply;
|
||||
expect(checkChatCardReply(card, reply('approve')).map((v) => v.code)).toEqual(['checklist-incomplete']);
|
||||
expect(checkChatCardReply(card, reply('approve', list))).toEqual([]);
|
||||
expect(checkChatCardReply(card, reply('reject'))).toEqual([]);
|
||||
// 自定义收尾项本就必附理由(既有规矩);清单不拦它
|
||||
expect(checkChatCardReply(card, reply('custom:意见', undefined, '意见'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ function cardStore(used: number, sent: string[]): CardStore {
|
||||
},
|
||||
getChatCard: async () => undefined,
|
||||
listChatCards: async () => [],
|
||||
listOpenChatCards: async () => [],
|
||||
updateChatCard: async () => undefined,
|
||||
closeChatCard: async () => undefined,
|
||||
humanCallCount: async () => used,
|
||||
|
||||
@@ -143,6 +143,11 @@ export type HumanToolService = {
|
||||
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
||||
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||
chatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||
/**
|
||||
* **这一单正等人交回的卡**(2026-08-27):没办结的工单 + 没作答、没被顶替的对话卡。
|
||||
* 引擎每拍开头问它 —— 有卡还没答,这一拍就不该问模型(叫了人就等人,不许自己往下推)。
|
||||
*/
|
||||
pendingCards(taskId: TaskId): Promise<Array<{ id: string; title: string; kind: 'work-order' | 'chat-card' }>>;
|
||||
/** org 停用前置检查要问的「待答卡」 */
|
||||
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
|
||||
};
|
||||
|
||||
@@ -99,6 +99,15 @@ export function createCardStore(db: Db): CardStore {
|
||||
return rows.map((row) => chatCardSchema.parse(row.payload));
|
||||
},
|
||||
|
||||
async listOpenChatCards(taskId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(chatCardTable)
|
||||
.where(and(eq(chatCardTable.taskId, taskId), isNull(chatCardTable.supersededBy)))
|
||||
.orderBy(asc(chatCardTable.at));
|
||||
return rows.map((row) => chatCardSchema.parse(row.payload));
|
||||
},
|
||||
|
||||
async updateChatCard(card: ChatCard) {
|
||||
await db.update(chatCardTable).set({ payload: card }).where(eq(chatCardTable.id, card.id));
|
||||
},
|
||||
|
||||
@@ -131,6 +131,16 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
|
||||
* 为什么拦在这里而不是落地(land):回传是同步收单、落地在推进器里异步跑,
|
||||
* 拦晚了就是「工单已答完、任务卡死、推进器循环报错」(实测)。
|
||||
*/
|
||||
/*
|
||||
* **人工验收清单要点全才能通过**(2026-08-27 用户定):卡上那几条是人核这份工单要走的步骤,
|
||||
* 按钮灰掉只是给人看的,这里才是门 —— 直调 API 把「通过」交回来也得把清单点全。
|
||||
*/
|
||||
if (reply.verdict === 'pass') {
|
||||
const missing = checklistMissing((order as AcceptanceWorkOrder).checklist, reply.checked);
|
||||
if (missing.length > 0) {
|
||||
out.push({ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` });
|
||||
}
|
||||
}
|
||||
const choice = (order as AcceptanceWorkOrder).portChoice;
|
||||
if (choice !== undefined && reply.verdict === 'pass') {
|
||||
const picked = reply.selectedPorts[choice.group];
|
||||
@@ -154,6 +164,20 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 清单里还没点成「达标」的那几条;没有清单就是空 */
|
||||
export function checklistMissing(checklist: ReadonlyArray<string> | undefined, checked: ReadonlyArray<string> | undefined): string[] {
|
||||
const done = new Set(checked ?? []);
|
||||
return (checklist ?? []).filter((item) => !done.has(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核卡上哪一个选项是「通过」—— 与前端 `review-face.ts` 同一口径:值叫 `approve` 的,否则推荐的那个,否则第一个。
|
||||
* 清单只拦这一个选项:驳回不需要把清单点全。
|
||||
*/
|
||||
export function reviewApproveValue(card: Extract<ChatCard, { k: 'decision' }>): string | undefined {
|
||||
return (card.options.find((o) => o.value === 'approve') ?? card.options.find((o) => o.recommended === true) ?? card.options[0])?.value;
|
||||
}
|
||||
|
||||
export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardViolation[] {
|
||||
const out: CardViolation[] = [];
|
||||
if (card.id !== reply.cardId) return [{ code: 'mismatch', message: '回传的卡 ID 与卡不符' }];
|
||||
@@ -161,6 +185,11 @@ export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardVi
|
||||
|
||||
if (card.k === 'decision' && reply.k === 'decision') {
|
||||
if (card.chosen !== undefined) return [{ code: 'already-answered', message: '该决策已选,只增不改' }];
|
||||
// 审核卡穿验收工单的衣服:选「通过」也得把人工验收清单点全(与验收工单同一道门)
|
||||
if (reply.value === reviewApproveValue(card)) {
|
||||
const missing = checklistMissing(card.checklist, reply.checked);
|
||||
if (missing.length > 0) return [{ code: 'checklist-incomplete', message: `验收清单还有 ${missing.length} 条待确认:${missing[0] ?? ''}` }];
|
||||
}
|
||||
const known = card.options.some((o) => o.value === reply.value);
|
||||
// 自定义收尾项:allowCustom 打开时允许选项之外的处置(必附理由)
|
||||
if (!known && !card.allowCustom) out.push({ code: 'unknown-option', message: `分支不在选项内:${reply.value}` });
|
||||
|
||||
@@ -197,7 +197,13 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
const at = deps.clock.now().toISOString();
|
||||
const result = reply.mode === 'acceptance' ? reply.verdict : 'submitted';
|
||||
const values = reply.mode === 'acceptance' ? reply.values : reply.values;
|
||||
await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values });
|
||||
await deps.cards.markWorkOrderDone(reply.workOrderId, {
|
||||
at,
|
||||
by,
|
||||
result,
|
||||
values,
|
||||
...(reply.mode === 'acceptance' && reply.checked !== undefined ? { checked: [...reply.checked] } : {}),
|
||||
});
|
||||
|
||||
/*
|
||||
* 出口:**算出来的优先,而且在服务端算数**(2026-08-02 修)。
|
||||
@@ -365,6 +371,15 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
return deps.cards.listChatCards(taskId);
|
||||
},
|
||||
|
||||
async pendingCards(taskId) {
|
||||
const orders = (await deps.cards.listWorkOrders(taskId, true)).filter((o) => o.done === undefined);
|
||||
const chats = (await deps.cards.listOpenChatCards(taskId)).filter((c) => !chatCardAnswered(c));
|
||||
return [
|
||||
...orders.map((o) => ({ id: String(o.id), title: o.title, kind: 'work-order' as const })),
|
||||
...chats.map((c) => ({ id: String(c.id), title: c.ask, kind: 'chat-card' as const })),
|
||||
];
|
||||
},
|
||||
|
||||
async openCardsOf(staffId) {
|
||||
return deps.cards.openCardsOf(staffId);
|
||||
},
|
||||
@@ -375,7 +390,13 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
|
||||
if (card.k === 'decision' && reply.k === 'decision') {
|
||||
return {
|
||||
...card,
|
||||
chosen: { value: reply.value, at, by, ...(reply.note === undefined ? {} : { note: reply.note }) },
|
||||
chosen: {
|
||||
value: reply.value,
|
||||
at,
|
||||
by,
|
||||
...(reply.note === undefined ? {} : { note: reply.note }),
|
||||
...(reply.checked === undefined ? {} : { checked: [...reply.checked] }),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (card.k === 'elicitation' && reply.k === 'elicitation') {
|
||||
@@ -392,3 +413,9 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 对话卡答没答:决策卡看 chosen,其余(录入 / 补全 / 评价)看 done */
|
||||
function chatCardAnswered(card: ChatCard): boolean {
|
||||
if (card.k === 'decision') return card.chosen !== undefined;
|
||||
return 'done' in card && card.done !== undefined;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export type CardStore = {
|
||||
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
|
||||
getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>;
|
||||
listChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||
/** 还开着的对话卡(没被新卡顶替、没作废)—— 「这一单有没有卡在等人」问的是它,不是全部历史 */
|
||||
listOpenChatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||
updateChatCard(card: ChatCard): Promise<void>;
|
||||
closeChatCard(id: string, supersededBy: string, reason: string): Promise<void>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user