人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
@@ -17,7 +17,15 @@ import type {
SystemWrite,
} from '../../contract.ts';
import { DraftConflictError, ObjectNotMaterializedError } from '../../contract.ts';
import type { Clock, DraftStore, EventPublisher, IdGenerator, LedgerStore, ObjectRef } from '../../port.ts';
import type {
Clock,
DraftStore,
EventPublisher,
IdGenerator,
LedgerStore,
ObjectRef,
SimulationProbe,
} from '../../port.ts';
import { mergeDeclarations, planIsNonDestructive, planSchemaChanges } from '../domain/schema-merge.ts';
import { detectConflicts, keyOf, overlay, pickKey } from '../domain/task-view.ts';
@@ -27,6 +35,8 @@ export type LedgerServiceDeps = {
readonly events: EventPublisher;
readonly clock: Clock;
readonly ids: IdGenerator;
/** 试跑判定 —— 放行写据此把主数据挡在正式表外(见 port.ts `SimulationProbe`) */
readonly simulation: SimulationProbe;
};
function groupDeclarations(declarations: readonly ObjectDeclaration[]): Map<string, ObjectDeclaration[]> {
@@ -97,6 +107,11 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
continue;
}
if (plan.changes.length > 0) await deps.store.applyChanges(ref, plan.changes);
/*
* 性质每次都记 —— 它随声明走,不随建表走。
* 声明合并已经定了口径(平台的 master 优先于活动的缺省 record),这里照写。
*/
await deps.store.setObjectMeta(ref, merge.merged.kind, merge.merged.caseKey);
results.push({
merged: merge.merged,
notices: [...merge.notices, ...plan.notices],
@@ -148,13 +163,51 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
});
},
/**
* 撤掉本单草稿层的一行 —— **只撤自己写过的**。
*
* 回 `false` 表示草稿层里根本没有这一行:那多半是主键写法对不上
* (`DAG…` 与 `DAG…-R0` 是两行),如实说出来比静默成功有用 ——
* 静默成功会让模型以为撤掉了,下一拍判据照旧不过,它又去撤一次。
*/
async draftDrop(drop) {
const mine = await deps.drafts.listForObject(drop.taskId, drop.address);
const schema = await requireSchema(drop.address);
const keys = schema.primaryKey.length > 0 ? schema.primaryKey : Object.keys(drop.key);
const wanted = pickKey({ ...drop.key }, keys);
const hit = mine.some((d) => keys.every((k) => String((d.key as Record<string, unknown>)[k] ?? '') === String(wanted[k] ?? '')));
if (!hit) return false;
await deps.drafts.drop(drop.taskId, drop.address, wanted);
return true;
},
/** ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件 */
async releaseWrite(taskId): Promise<ReleaseWriteResult> {
const conflicts = await this.checkConflicts(taskId);
if (conflicts.length > 0) throw new DraftConflictError(conflicts);
const drafts = await deps.drafts.list(taskId);
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0, merged: [] };
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0, simulatedRows: 0, merged: [] };
/*
* **试跑单不落主数据** —— 挡在这一处,是因为这里是草稿层通往世界的唯一一道口子。
*
* 试跑照旧是真跑:过装载两审、过预算、过放行门,单据照写。挡的只有主数据 ——
* 它按业务实体分行,试跑往 `customer.profile` 建一位客户,那位客户就永久留在库里,
* 「新客户来函」那条样例第二次跑就成了老客户,这条路从此试不出来。
* 单据不挡:它按单标识分行,一次试跑写自己那一行,清运行数据就干净了。
*
* 被挡下的行仍然进 `merged`(出口载荷取的就是它)—— 拦的是**落到世界**,
* 不是拦这一单自己看得见:活动缓存里它一直在,这一单该怎么跑还怎么跑。
*/
const simulated = await deps.simulation.isSimulated(taskId);
const masters = new Set<string>();
if (simulated) {
for (const ref of await deps.store.masterRefs()) masters.add(`${ref.domain}.${ref.object}`);
}
const heldBack = (draft: { domain: string; object: string }): boolean =>
simulated && masters.has(`${draft.domain}.${draft.object}`);
let simulatedRows = 0;
const pending: EnvelopeDraft[] = [];
/*
@@ -171,6 +224,23 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
await deps.store.transaction(async (store, draftStore) => {
for (const draft of drafts) {
const ref: ObjectRef = { domain: draft.domain, object: draft.object };
if (heldBack(draft)) {
simulatedRows += 1;
if (draft.tombstone) continue; // 试跑不删主数据,连一行也不许少
/*
* 值取「表里现有的 + 这一单改的」——没真写进去,读不回 PG 归型后的那一份,
* 所以这里的值是草稿的原样(人的回传天生是字符串)。如实记在这:
* 主数据不进出口载荷,下游拿不到它,归不归型都影响不到谁。
*/
const current = (await store.selectByKeys(ref, [draft.key]))[0];
landed.push({
domain: draft.domain,
object: draft.object,
key: draft.key,
values: { ...(current ?? {}), ...draft.patch },
});
continue;
}
if (draft.tombstone) {
await store.deleteRow(ref, draft.key);
pending.push(
@@ -203,7 +273,12 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
});
for (const envelope of pending) await deps.events.publish(envelope);
return { mergedRows: drafts.length, emittedEvents: pending.length, merged: landed };
return {
mergedRows: drafts.length - simulatedRows,
emittedEvents: pending.length,
simulatedRows,
merged: landed,
};
},
async query(query: LedgerQuery): Promise<LedgerPage> {