大交通检索修复

This commit is contained in:
andy committed 2026-09-16 11:50:50 +08:00
1 parent 364ef4ae30
commit 8a0f02c9be
29 files changed
+1175 -33

No files matched your search

@@ -0,0 +1,40 @@
-- Successful arrangement history belongs to an employee/group, not a task.
-- Source task/execution identifiers deliberately have NO task foreign key:
-- force deletion must not erase the business record or its receipt snapshot.
CREATE TABLE arrangement_ledger_owners (
organization_id uuid NOT NULL REFERENCES organizations(id),
owner_user_id uuid NOT NULL,
backfilled_at timestamptz,
PRIMARY KEY (organization_id, owner_user_id),
FOREIGN KEY (organization_id, owner_user_id) REFERENCES users(organization_id, id)
);
CREATE TABLE arrangement_ledger_events (
id bigserial PRIMARY KEY,
organization_id uuid NOT NULL REFERENCES organizations(id),
owner_user_id uuid NOT NULL,
group_key text NOT NULL,
action text NOT NULL CHECK (action IN (
'arrangement_guide', 'arrangement_vehicle', 'arrangement_hotel',
'arrangement_transport', 'arrangement_other'
)),
source_task_id text NOT NULL,
execution_id text NOT NULL,
effective_at timestamptz NOT NULL,
recorded_at timestamptz NOT NULL,
source_task_deleted_at timestamptz,
detail_ciphertext text NOT NULL,
UNIQUE (organization_id, owner_user_id, execution_id),
FOREIGN KEY (organization_id, owner_user_id) REFERENCES users(organization_id, id)
);
CREATE INDEX arrangement_ledger_group_idx ON arrangement_ledger_events
(organization_id, owner_user_id, group_key, effective_at, id);
CREATE INDEX arrangement_ledger_source_task_idx ON arrangement_ledger_events
(organization_id, owner_user_id, source_task_id);
CREATE TRIGGER arrangement_ledger_owner_not_admin
BEFORE INSERT OR UPDATE OF organization_id, owner_user_id ON arrangement_ledger_owners
FOR EACH ROW EXECUTE FUNCTION reject_admin_task_principal('owner_user_id', 'arrangement_ledger_owner_not_admin');
CREATE TRIGGER arrangement_ledger_event_owner_not_admin
BEFORE INSERT OR UPDATE OF organization_id, owner_user_id ON arrangement_ledger_events
FOR EACH ROW EXECUTE FUNCTION reject_admin_task_principal('owner_user_id', 'arrangement_ledger_event_owner_not_admin');
+217
View File
@@ -0,0 +1,217 @@
import type pg from 'pg';
import type { AppConfig } from './config.js';
import { decryptText, encryptText, sha256Text } from './crypto.js';
export const ARRANGEMENT_LABELS = {
arrangement_guide: '导游', arrangement_vehicle: '用车', arrangement_hotel: '酒店',
arrangement_transport: '大交通', arrangement_other: '其他/备案'
} as const;
export type ArrangementAction = keyof typeof ARRANGEMENT_LABELS;
type Json = Record<string, unknown>;
type Client = Pick<pg.PoolClient, 'query'>;
export interface ArrangementScope { organizationId: string; userId: string }
export interface ArrangementEvent {
version: 1;
group_number: string;
action: ArrangementAction;
mode: 'create' | 'update' | 'clear';
record_key: string;
source_task_id: string;
execution_id: string;
effective_at: string;
recorded_at: string;
source_task_deleted_at?: string | null;
details: Json;
requested: Json;
evidence: Json;
}
export interface ArrangementSummary {
version: 1;
scope: 'current_account';
group_number: string;
recorded_at: string;
items: Array<{ action: ArrangementAction; label: string; arranged: boolean; count: number }>;
}
function object(value: unknown): Json {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Json : {};
}
function text(value: unknown): string { return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''; }
function first(...values: unknown[]): string { return values.map(text).find(Boolean) || ''; }
export function arrangementGroupNumber(value: unknown): string {
return text(value).normalize('NFKC').replace(/\s+/gu, '').toUpperCase();
}
export function isArrangementAction(value: unknown): value is ArrangementAction {
return typeof value === 'string' && Object.hasOwn(ARRANGEMENT_LABELS, value);
}
function timestamp(value: unknown): string {
const date = value instanceof Date ? value : new Date(String(value || ''));
return Number.isFinite(date.getTime()) ? date.toISOString() : '';
}
function dateValue(value: unknown): string {
return text(value).replace(/^(\d{4})-(\d{1,2})-(\d{1,2})$/, (_, y, m, d) => `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`);
}
// Only confirmed, action-specific ERP evidence enters the ledger. Parser success,
// bare completed flags and uncertain results are never arrangements.
export function arrangementEventFromSuccess(input: {
taskId: string; executionId: string; effectiveAt: unknown; recordedAt: unknown;
operation: unknown; result: unknown; receipt: unknown;
}): ArrangementEvent | null {
const operation = object(input.operation);
if (!isArrangementAction(operation.action)) return null;
const result = object(input.result), report = object(result.report), receipt = object(input.receipt);
const requery = object(report.requery || result.requery);
const server = object(report.server_response || result.server_response);
if (result.status !== 'completed' || receipt.success !== true || server.completed !== true || requery.matched !== true
|| requery.skipped === true || result.uncertain === true || result.manual_review_required === true
|| report.manual_review_required === true
|| [result.blockers, report.blockers].some(value => Array.isArray(value) && value.length)) return null;
const data = object(operation.data), arrangement = object(data.arrangement);
const refs = object(report.resolved_refs || result.resolved_refs);
const mode = text(arrangement.mode) || 'create';
if (!['create', 'update', 'clear'].includes(mode)) return null;
const group = arrangementGroupNumber(first(refs.identifier, refs.group_no, receipt.group_number, object(data.existing_refs).identifier));
const suppliedGroup = arrangementGroupNumber(object(data.existing_refs).identifier);
if (!group || (suppliedGroup && suppliedGroup !== group) || refs.kind === 'shared_child_order') return null;
const recordedAt = timestamp(input.recordedAt), effectiveAt = timestamp(input.effectiveAt) || recordedAt;
if (!recordedAt || !effectiveAt || !input.taskId || !input.executionId) return null;
const subpage = object(requery.subpage);
const target = object(subpage.arrangement_target || requery.arrangement_target || refs.arrangement_target);
const checks = (Array.isArray(subpage.checks) ? subpage.checks : Array.isArray(requery.checks) ? requery.checks : []).map(object);
const actual = (pattern: RegExp): string | undefined => {
const matches = checks.filter(check => check.matched === true && pattern.test(text(check.field)) && check.actual != null);
return matches.length === 1 ? text(matches[0].actual) : undefined;
};
const rowId = first(target.row_id, object(arrangement.target).row_id);
// Guides are genuinely singleton. Indexed row position is not a stable identity.
const recordKey = operation.action === 'arrangement_guide' ? 'guide'
: rowId && rowId !== '0' ? `row:${rowId}` : `execution:${input.executionId}`;
const changes = object(arrangement.changes);
const quantity = first(target.quantity_value, actual(/^(?:jianshu|shuliang|tianshu)\d+$/u), changes.room_count, changes.quantity, arrangement.room_count, arrangement.quantity);
const details: Json = { row_id: rowId };
// Absence is different from a verified empty value. Narrow updates must not
// discard details established by an earlier successful create/update.
const set = (key: string, value: unknown, date = false): void => {
if (value !== undefined && value !== null) details[key] = date ? dateValue(value) : value;
};
set('resource_id', target.resource_id);
// Never label a requested search term as the actual selected resource.
set('resource_name', actual(/^(?:danwei|daoyou)\d+$/u));
set('item', target.item_value ?? actual(/^(?:fangxing|chehao|shuoming)\d+$/u));
if (operation.action === 'arrangement_hotel' || operation.action === 'arrangement_vehicle') {
set('start_date', actual(/^riqi\d+$/u) ?? arrangement.start_date, true);
set('end_date', actual(/^riqis\d+$/u) ?? changes.end_date ?? arrangement.end_date, true);
} else set('date', actual(/^riqi\d+$/u) ?? arrangement.date, true);
if (quantity && Number.isFinite(Number(quantity))) details.quantity = Number(quantity);
set('remark', actual(/^beizhu\d+$/u) ?? arrangement.remark);
set('phone', actual(/^dianhua\d+$/u));
set('grade', actual(/^dengji\d+$/u));
if (operation.action === 'arrangement_other') {
for (const [key, field] of Object.entries({ number: 'beianhao', date: 'beianri', entry_port: 'rujingkou', exit_port: 'chujingkou' })) {
set(`filing_${key}`, actual(new RegExp(`^${field}$`, 'u')) ?? object(arrangement.filing)[key], key === 'date');
}
}
return {
version: 1, group_number: group, action: operation.action, mode: mode as ArrangementEvent['mode'], record_key: recordKey,
source_task_id: input.taskId, execution_id: input.executionId, effective_at: effectiveAt, recorded_at: recordedAt,
details, requested: arrangement,
evidence: { receipt, resolved_refs: refs, arrangement_target: target, checks,
server_response_completed: true, requery_matched: true }
};
}
export function currentArrangements(events: ArrangementEvent[]): ArrangementEvent[] {
const current = new Map<string, ArrangementEvent>();
const seen = new Set<string>();
for (const event of [...events].sort((a, b) => a.effective_at.localeCompare(b.effective_at)
|| a.recorded_at.localeCompare(b.recorded_at) || a.execution_id.localeCompare(b.execution_id))) {
if (seen.has(event.execution_id)) continue;
seen.add(event.execution_id);
const key = `${event.group_number}:${event.action}:${event.record_key}`;
if (event.mode === 'clear') current.delete(key);
else current.set(key, event.mode === 'update' ? { ...event, details: { ...current.get(key)?.details, ...event.details } } : event);
}
return [...current.values()];
}
export function arrangementSummary(events: ArrangementEvent[], groupNumber: string, recordedAt: string): ArrangementSummary {
const group = arrangementGroupNumber(groupNumber);
const current = currentArrangements(events.filter(event => event.group_number === group));
return { version: 1, scope: 'current_account', group_number: group, recorded_at: recordedAt,
items: (Object.keys(ARRANGEMENT_LABELS) as ArrangementAction[]).map(action => {
const count = current.filter(event => event.action === action).length;
return { action, label: ARRANGEMENT_LABELS[action], arranged: count > 0, count };
}) };
}
export function arrangementSummaryText(value: unknown): string {
const summary = object(value);
if (summary.version !== 1 || summary.scope !== 'current_account' || !Array.isArray(summary.items)) return '';
const items = summary.items.map(object);
if (items.length !== 5 || Object.keys(ARRANGEMENT_LABELS).some(action =>
items.filter(item => item.action === action && typeof item.arranged === 'boolean').length !== 1)) return '';
return ['本系统安排情况(当前账号):', ...Object.entries(ARRANGEMENT_LABELS).map(([action, label]) =>
`${label}:${items.find(item => item.action === action)?.arranged ? '已安排' : '未安排'}`),
'仅统计通过本系统成功执行的安排;已安排表示至少一条,不代表全行程已安排齐全。'].join('\n');
}
export class ArrangementLedger {
constructor(private readonly config: AppConfig) {}
// Serialize only this employee's ledger, including lazy historical backfill.
// Callers already hold their task row lock; backfill never locks other tasks.
async prepare(client: Client, scope: ArrangementScope, receiptFromResult: (value: unknown) => Json | null): Promise<void> {
if (!scope.organizationId || !scope.userId) throw new Error('arrangement_ledger_scope_missing');
await client.query(`INSERT INTO arrangement_ledger_owners (organization_id, owner_user_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`, [scope.organizationId, scope.userId]);
const lock = await client.query(`SELECT backfilled_at FROM arrangement_ledger_owners
WHERE organization_id = $1 AND owner_user_id = $2 FOR UPDATE`, [scope.organizationId, scope.userId]);
if (lock.rows[0]?.backfilled_at) return;
let cursor = '';
for (;;) {
const history = await client.query(`SELECT t.task_id, t.operation_ciphertext, t.operation,
t.execution_result_ciphertext, t.execution_result, t.success_receipt_at, t.updated_at,
a.id AS execution_id, a.started_at AS effective_at
FROM tasks t JOIN task_attempts a ON a.task_id = t.id AND a.phase = 'erp'
WHERE t.organization_id = $1 AND t.assigned_user_id = $2 AND t.status = 'completed'
AND t.task_id > $3 AND t.operation->>'action' = ANY($4::text[])
ORDER BY t.task_id LIMIT 200`, [scope.organizationId, scope.userId, cursor, Object.keys(ARRANGEMENT_LABELS)]);
for (const row of history.rows) {
const decode = (encrypted: unknown, plain: unknown) => encrypted
? JSON.parse(decryptText(this.config, text(encrypted))) : plain;
const result = decode(row.execution_result_ciphertext, row.execution_result);
const receipt = receiptFromResult(result);
const event = arrangementEventFromSuccess({ taskId: row.task_id, executionId: row.execution_id,
effectiveAt: row.effective_at, recordedAt: row.success_receipt_at || row.updated_at,
operation: decode(row.operation_ciphertext, row.operation), result, receipt });
if (event) await this.insert(client, scope, event);
}
if (history.rows.length < 200) break;
cursor = String(history.rows.at(-1)?.task_id);
}
await client.query(`UPDATE arrangement_ledger_owners SET backfilled_at = now()
WHERE organization_id = $1 AND owner_user_id = $2`, [scope.organizationId, scope.userId]);
}
async insert(client: Client, scope: ArrangementScope, event: ArrangementEvent): Promise<void> {
await client.query(`INSERT INTO arrangement_ledger_events
(organization_id, owner_user_id, group_key, action, source_task_id, execution_id, effective_at, recorded_at, detail_ciphertext)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (organization_id, owner_user_id, execution_id) DO NOTHING`,
[scope.organizationId, scope.userId, sha256Text(event.group_number), event.action, event.source_task_id,
event.execution_id, event.effective_at, event.recorded_at, encryptText(this.config, JSON.stringify(event))]);
}
async history(client: Client, scope: ArrangementScope, groupNumber: string): Promise<ArrangementEvent[]> {
const result = await client.query(`SELECT detail_ciphertext, source_task_deleted_at FROM arrangement_ledger_events
WHERE organization_id = $1 AND owner_user_id = $2 AND group_key = $3
ORDER BY effective_at, recorded_at, id`, [scope.organizationId, scope.userId, sha256Text(arrangementGroupNumber(groupNumber))]);
return result.rows.map(row => ({ ...JSON.parse(decryptText(this.config, row.detail_ciphertext)),
source_task_deleted_at: row.source_task_deleted_at ? timestamp(row.source_task_deleted_at) : null }));
}
async markTasksDeleted(client: Client, scope: ArrangementScope, taskIds: string[]): Promise<void> {
await client.query(`UPDATE arrangement_ledger_events SET source_task_deleted_at = now()
WHERE organization_id = $1 AND owner_user_id = $2 AND source_task_id = ANY($3::text[])
AND source_task_deleted_at IS NULL`, [scope.organizationId, scope.userId, taskIds]);
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { writeEmergencyDiagnostic } from './diagnostics.js';
const { Pool } = pg;
let pool: pg.Pool | null = null;
export const REQUIRED_SCHEMA_VERSION = '024_arrangement_update_routes';
export const REQUIRED_SCHEMA_VERSION = '025_arrangement_ledger';
export interface DatabaseReadiness {
ready: boolean;
+4 -1
View File
@@ -1,4 +1,5 @@
import { normalizeTaskArtifactFileName } from './artifact-name.js';
import { arrangementSummaryText, isArrangementAction } from './arrangement-ledger.js';
export const AGENTBUS_REPLY_CONTRACT_VERSION = 'agentbus-user-reply-v1';
@@ -561,12 +562,14 @@ export function buildCustomerSuccessReceipt(
): Record<string, unknown> {
const reply = buildCustomerSuccessReply(baseReceipt, resultValue, operationValue);
if (!reply) return baseReceipt;
const action = operationAction(operationValue, object(resultValue), resultContext(object(resultValue)));
const summaryText = isArrangementAction(action) ? arrangementSummaryText(baseReceipt.arrangement_summary) : '';
return {
...baseReceipt,
reply: {
contract: reply.contract,
profile: reply.profile,
text: reply.text,
text: summaryText ? `${reply.text}\n\n${summaryText}` : reply.text,
fields: reply.fields
},
...(reply.attachments.length ? { reply_attachments: reply.attachments } : {})
+25 -1
View File
@@ -31,6 +31,7 @@ import {
} from './reply-contract.js';
import { convertDocumentToPdf, convertDocumentToXlsx } from './document-converter.js';
import { sanitizeOperationTiming } from './operation-timing.js';
import { ArrangementLedger, arrangementEventFromSuccess, arrangementSummary } from './arrangement-ledger.js';
import type { TaskInputAttachmentInput } from './input-attachment.js';
import {
diagnosticMetadataKeys,
@@ -6616,7 +6617,9 @@ export class TaskService {
const executionFailure = failureSummary(resultObject, '', status, stage);
const operation = decryptedJson(this.config, row.operation_ciphertext) || row.operation || null;
const baseSuccessReceipt = status === 'completed' ? successReceiptFromResult(resultObject) : null;
const successReceipt = baseSuccessReceipt
// This snapshot is exclusively derived by the server, never by a plugin.
if (baseSuccessReceipt) delete baseSuccessReceipt.arrangement_summary;
let successReceipt = baseSuccessReceipt
? buildCustomerSuccessReceipt(baseSuccessReceipt, resultObject, operation)
: null;
const latestErrorSummary = isErrorSummaryStatus(status) ? errorSummaryPayload(executionFailure) : null;
@@ -6652,6 +6655,21 @@ export class TaskService {
if (text(row.lease_owner) !== leaseOwner && !canFinalizeTerminalReconciliation) {
throw new TaskError('execution_owner_mismatch', '插件连接与任务领取记录不一致,回执已拒绝。');
}
const arrangementEvent = successReceipt ? arrangementEventFromSuccess({
taskId, executionId, operation, result: resultObject, receipt: baseSuccessReceipt,
effectiveAt: attemptRow.started_at, recordedAt: new Date().toISOString()
}) : null;
if (arrangementEvent) {
const ledger = new ArrangementLedger(this.config);
await ledger.prepare(client, context, successReceiptFromResult);
await ledger.insert(client, context, arrangementEvent);
const history = await ledger.history(client, context, arrangementEvent.group_number);
// Freeze the account/group summary with this receipt in the SAME transaction.
successReceipt = buildCustomerSuccessReceipt({
...successReceipt,
arrangement_summary: arrangementSummary(history, arrangementEvent.group_number, arrangementEvent.recorded_at)
}, resultObject, operation);
}
const updated = await client.query(
`UPDATE tasks
SET execution_result_ciphertext = $1, execution_result = $2,
@@ -7277,6 +7295,12 @@ export class TaskService {
const missingTaskIds = normalizedTaskIds.filter((taskId) => !foundTaskIds.has(taskId));
if (missingTaskIds.length) throw new TaskError('task_not_found', '任务不存在。', 404);
// Preserve historical successes before deleting their only original source.
// Business records deliberately survive task cascades and retain snapshots.
const ledger = new ArrangementLedger(this.config);
await ledger.prepare(client, context, successReceiptFromResult);
await ledger.markTasksDeleted(client, context, normalizedTaskIds);
// Hard delete intentionally has no status or handoff-state gate. The row
// locks settle concurrent transitions; existing task foreign keys then
// remove every task-owned record through ON DELETE CASCADE.
@@ -0,0 +1,269 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import type pg from 'pg';
import { ArrangementLedger, ARRANGEMENT_LABELS, arrangementEventFromSuccess, arrangementGroupNumber,
arrangementSummary, currentArrangements, type ArrangementAction, type ArrangementEvent } from '../src/arrangement-ledger.js';
import { loadConfig } from '../src/config.js';
import { decryptText, encryptText } from '../src/crypto.js';
import { closePool, getPool } from '../src/db.js';
import { TaskService, successReceiptFromResult } from '../src/task-service.js';
import { buildCustomerSuccessReceipt, replyTextFromReceipt } from '../src/reply-contract.js';
const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 29).toString('base64'),
DATABASE_URL: 'postgresql://fixture:fixture@127.0.0.1:1/fixture' });
type Json = Record<string, any>;
const scope = { organizationId: 'org-a', userId: 'user-a' };
function fixture(action: ArrangementAction = 'arrangement_hotel', mode: 'create' | 'update' | 'clear' = 'create',
rowId = '100', executionId = 'execution-1', time = '2026-09-16T01:00:00.000Z') {
const operation: Json = { action, data: { existing_refs: { identifier: 'LW-260901VIP-A' }, arrangement: {
mode, resource: { name: '搜索词TWN' }, start_date: '2026-09-04', end_date: '2026-09-05', room_count: 12,
target: { row_id: rowId, slot_index: 4 }
} } };
const result: Json = { status: 'completed', execution_id: executionId, write_attempted: true, no_erp_write: false,
report: { status: 'lifecycle_completed', manual_review_required: false, blockers: [],
resolved_refs: { kind: 'independent_order', identifier: 'LW-260901VIP-A', tid: '900' },
server_response: { completed: true }, requery: { matched: true, subpage: { matched: true,
arrangement_target: { row_id: rowId, slot_index: 0, resource_id: 'hotel-1', item_value: 'TWN H', quantity_value: '12', cleared: mode === 'clear' },
checks: [
{ field: 'danwei0', actual: '实际酒店', expected: '实际酒店', matched: true },
{ field: 'riqi0', actual: '2026-9-4', matched: true },
{ field: 'riqis0', actual: '2026-9-5', matched: true },
{ field: 'beizhu0', actual: '原生备注', matched: true }
]
} }
} };
return { taskId: `TASK-${executionId}`, executionId, effectiveAt: time, recordedAt: time,
operation, result, receipt: successReceiptFromResult(result) };
}
function event(...args: Parameters<typeof fixture>): ArrangementEvent {
const value = arrangementEventFromSuccess(fixture(...args));
assert.ok(value);
return value;
}
test('successful arrangements capture actual linked fields and retain separate original input/evidence', () => {
const item = event();
assert.equal(item.details.resource_name, '实际酒店');
assert.equal((item.requested.resource as Json).name, '搜索词TWN');
assert.equal(item.details.start_date, '2026-09-04');
assert.equal(item.details.end_date, '2026-09-05');
assert.equal(item.details.item, 'TWN H');
assert.equal(item.details.quantity, 12);
assert.equal(item.details.remark, '原生备注');
assert.equal(item.record_key, 'row:100');
assert.equal(item.evidence.requery_matched, true);
const dated = arrangementEventFromSuccess({ ...fixture(), effectiveAt: new Date('2026-09-16T01:00:00.123Z') });
assert.equal(dated?.effective_at, '2026-09-16T01:00:00.123Z', 'pg Date values retain sub-second ordering');
});
test('parser, failed, queued, dry-run, uncertain and incomplete proof never change arrangement state', () => {
for (const status of ['confirmed', 'queued', 'running', 'blocked', 'failed', 'dry_run', 'reconciliation_pending']) {
const input = fixture(); input.result.status = status;
assert.equal(arrangementEventFromSuccess(input), null, status);
}
for (const patch of [
(i: ReturnType<typeof fixture>) => { i.result.report.server_response.completed = false; },
(i: ReturnType<typeof fixture>) => { i.result.report.requery.matched = false; },
(i: ReturnType<typeof fixture>) => { i.result.report.requery.skipped = true; },
(i: ReturnType<typeof fixture>) => { i.result.report.blockers = ['blocked']; },
(i: ReturnType<typeof fixture>) => { i.result.manual_review_required = true; },
(i: ReturnType<typeof fixture>) => { i.result.uncertain = true; },
(i: ReturnType<typeof fixture>) => { i.receipt = null; }
]) { const input = fixture(); patch(input); assert.equal(arrangementEventFromSuccess(input), null); }
});
test('full group identity is normalized without collapsing suffixes or accepting a different ERP group', () => {
assert.equal(arrangementGroupNumber(' lw-260901vip-a '), 'LW-260901VIP-A');
const input = fixture(); input.result.report.resolved_refs.identifier = 'LW-260901VIP-C';
assert.equal(arrangementEventFromSuccess(input), null);
const summary = arrangementSummary([event()], 'LW-260901VIP-C', 'now');
assert.ok(summary.items.every(item => !item.arranged));
});
test('five arrangement types have independent status and guides remain singleton after replacement', () => {
const items = (Object.keys(ARRANGEMENT_LABELS) as ArrangementAction[]).map((action, i) => event(action, 'create', '100', `e-${i}`));
items.push(event('arrangement_guide', 'update', 'different', 'e-guide-update', '2026-09-16T02:00:00Z'));
assert.equal(currentArrangements(items).length, 5);
assert.ok(arrangementSummary(items, items[0].group_number, 'now').items.every(item => item.arranged && item.count === 1));
});
test('update and clear target stable row IDs, preserving other rows regardless of slot renumbering', () => {
const items = [event(), event('arrangement_hotel', 'create', '200', 'e-2')];
const update = event('arrangement_hotel', 'update', '100', 'e-3', '2026-09-16T02:00:00Z');
update.details.quantity = 8;
items.push(update, update);
assert.equal(currentArrangements(items).length, 2);
assert.equal(currentArrangements(items).find(item => item.record_key === 'row:100')?.details.quantity, 8);
items.push(event('arrangement_hotel', 'clear', '100', 'e-4', '2026-09-16T03:00:00Z'));
assert.deepEqual(currentArrangements(items).map(item => item.record_key), ['row:200']);
items.push(event('arrangement_hotel', 'clear', '200', 'e-5', '2026-09-16T04:00:00Z'));
assert.ok(arrangementSummary(items, items[0].group_number, 'now').items.every(item => !item.arranged));
assert.equal(items.length, 6, 'history remains intact');
});
test('late reconciliation replays by original execution time rather than overwriting newer arrangements', () => {
const early = event(); early.recorded_at = '2026-09-17T01:00:00.000Z';
const clear = event('arrangement_hotel', 'clear', '100', 'e-clear', '2026-09-16T02:00:00Z');
assert.equal(currentArrangements([clear, early]).length, 0);
});
test('narrow hotel updates retain existing resource/date details but honor a verified cleared remark', () => {
const original = event();
const input = fixture('arrangement_hotel', 'update', '100', 'e-narrow', '2026-09-16T02:00:00Z');
input.operation.data.arrangement = { mode: 'update', changes: { room_count: 8 } };
input.result.report.requery.subpage.arrangement_target.quantity_value = '8';
input.result.report.requery.subpage.checks = [{ field: 'beizhu0', actual: '', matched: true }];
const update = arrangementEventFromSuccess(input)!;
const current = currentArrangements([original, update])[0];
assert.equal(current.details.resource_name, '实际酒店');
assert.equal(current.details.start_date, '2026-09-04');
assert.equal(current.details.end_date, '2026-09-05');
assert.equal(current.details.quantity, 8);
assert.equal(current.details.remark, '');
assert.equal(original.details.quantity, 12, 'original history is immutable');
});
test('clearing external/untracked rows does not erase this account recorded arrangements', () => {
assert.equal(currentArrangements([event(), event('arrangement_hotel', 'clear', '900', 'other-clear')]).length, 1);
});
test('receipt summary is a frozen snapshot and remains single when public receipts are rebuilt', () => {
const input = fixture(); const first = event();
const base = { ...input.receipt, arrangement_summary: arrangementSummary([first], first.group_number, first.recorded_at) };
const receipt = buildCustomerSuccessReceipt(base, input.result, input.operation);
const reply = replyTextFromReceipt(receipt);
assert.match(reply, /NEW BOOKING/);
assert.match(reply, /导游:未安排[\s\S]*酒店:已安排/);
assert.match(reply, /当前账号/);
const newer = arrangementSummary([first, event('arrangement_guide', 'create', '', 'e-guide')], first.group_number, 'later');
assert.equal(newer.items[0].arranged, true);
const rebuilt = buildCustomerSuccessReceipt(receipt, input.result, input.operation);
assert.equal(replyTextFromReceipt(rebuilt), reply);
assert.equal(reply.match(/本系统安排情况/g)?.length, 1);
assert.ok(!replyTextFromReceipt(buildCustomerSuccessReceipt({ success: true }, {}, { action: 'order_cancel' })).includes('本系统安排情况'));
});
function memoryClient() {
const owners = new Map<string, Json>();
const records: Json[] = [], tasks: Json[] = [];
const queries: string[] = [];
const answer = (rows: Json[] = []) => ({ rowCount: rows.length, rows });
const client = { release() {}, async query(sql: string, p: any[] = []) {
queries.push(sql);
const ownerKey = `${p[0]}/${p[1]}`;
if (/INSERT INTO arrangement_ledger_owners/.test(sql)) { if (!owners.has(ownerKey)) owners.set(ownerKey, {}); return answer(); }
if (/SELECT backfilled_at/.test(sql)) { assert.match(sql, /FOR UPDATE/); return answer([owners.get(ownerKey)!]); }
if (/UPDATE arrangement_ledger_owners/.test(sql)) { owners.get(ownerKey)!.backfilled_at = 'done'; return answer(); }
if (/FROM tasks t JOIN task_attempts/.test(sql)) {
assert.match(sql, /t.organization_id = \$1 AND t.assigned_user_id = \$2/);
return answer(tasks.filter(t => t.organization_id === p[0] && t.assigned_user_id === p[1] && t.status === 'completed'
&& t.task_id > p[2] && p[3].includes(t.operation.action)).sort((a,b) => a.task_id.localeCompare(b.task_id)).slice(0, 200));
}
if (/INSERT INTO arrangement_ledger_events/.test(sql)) {
assert.match(sql, /ON CONFLICT \(organization_id, owner_user_id, execution_id\) DO NOTHING/);
if (!records.some(r => r.organization_id === p[0] && r.owner_user_id === p[1] && r.execution_id === p[5]))
records.push({ organization_id: p[0], owner_user_id: p[1], group_key: p[2], action: p[3], source_task_id: p[4],
execution_id: p[5], effective_at: p[6], recorded_at: p[7], detail_ciphertext: p[8], source_task_deleted_at: null });
return answer();
}
if (/SELECT detail_ciphertext/.test(sql)) {
assert.match(sql, /organization_id = \$1 AND owner_user_id = \$2 AND group_key = \$3/);
return answer(records.filter(r => r.organization_id === p[0] && r.owner_user_id === p[1] && r.group_key === p[2]));
}
if (/UPDATE arrangement_ledger_events/.test(sql)) {
for (const r of records) if (r.organization_id === p[0] && r.owner_user_id === p[1] && p[2].includes(r.source_task_id)) r.source_task_deleted_at = '2026-09-17T01:00:00Z';
return answer();
}
if (['BEGIN', 'COMMIT', 'ROLLBACK'].includes(sql)) return answer();
throw new Error(`Unexpected SQL: ${sql}`);
} };
return { client: client as unknown as pg.PoolClient, raw: client, owners, records, tasks, queries };
}
test('ledger storage encrypts details, deduplicates and scopes reads to both organization and account', async () => {
const db = memoryClient(), ledger = new ArrangementLedger(config), original = event();
await ledger.prepare(db.client, scope, successReceiptFromResult);
await ledger.insert(db.client, scope, original);
await ledger.insert(db.client, scope, original);
assert.equal(db.records.length, 1);
assert.ok(!db.records[0].detail_ciphertext.includes('实际酒店'));
assert.equal(JSON.parse(decryptText(config, db.records[0].detail_ciphertext)).details.resource_name, '实际酒店');
assert.equal((await ledger.history(db.client, scope, original.group_number)).length, 1);
assert.equal((await ledger.history(db.client, { ...scope, userId: 'user-b' }, original.group_number)).length, 0);
assert.equal((await ledger.history(db.client, { ...scope, organizationId: 'org-b' }, original.group_number)).length, 0);
await ledger.markTasksDeleted(db.client, scope, [original.source_task_id]);
const history = await ledger.history(db.client, scope, original.group_number);
assert.ok(history[0].source_task_deleted_at);
assert.equal(history[0].details.resource_name, '实际酒店');
assert.equal(currentArrangements(history).length, 1);
});
test('backfill includes archived successes and skips failed/other-account tasks, preserving encrypted evidence', async () => {
const db = memoryClient(), ledger = new ArrangementLedger(config), input = fixture();
db.tasks.push({ task_id: input.taskId, execution_id: input.executionId, operation: { action: input.operation.action },
operation_ciphertext: encryptText(config, JSON.stringify(input.operation)),
execution_result_ciphertext: encryptText(config, JSON.stringify(input.result)),
organization_id: scope.organizationId, assigned_user_id: scope.userId, status: 'completed', archived_at: 'archived',
effective_at: input.effectiveAt, success_receipt_at: input.recordedAt });
db.tasks.push({ ...db.tasks[0], task_id: 'TASK-failed', execution_id: 'failed', status: 'failed' },
{ ...db.tasks[0], task_id: 'TASK-other', execution_id: 'other', assigned_user_id: 'other' });
await ledger.prepare(db.client, scope, successReceiptFromResult);
assert.equal(db.records.length, 1);
assert.equal(db.records[0].source_task_id, input.taskId);
await ledger.prepare(db.client, scope, successReceiptFromResult);
assert.equal(db.queries.filter(sql => /FROM tasks t JOIN task_attempts/.test(sql)).length, 1);
});
test('ledger migration has no cascading source-task dependency and blocks admin ownership', async () => {
const sql = await readFile(new URL('../migrations/025_arrangement_ledger.sql', import.meta.url), 'utf8');
assert.doesNotMatch(sql, /REFERENCES\s+(tasks|task_attempts)\b/i);
assert.match(sql, /detail_ciphertext text NOT NULL/);
assert.match(sql, /UNIQUE \(organization_id, owner_user_id, execution_id\)/);
assert.match(sql, /reject_admin_task_principal/);
});
test('real success-result transaction saves summary/history once and force delete preserves details', async t => {
const db = memoryClient(), input = fixture(), pool = getPool(config);
const context = { ...scope, role: 'user' as const, requestId: 'ledger-test' };
let row: Json = { id: 'row-1', task_id: input.taskId, organization_id: scope.organizationId, assigned_user_id: scope.userId,
status: 'running', operation: input.operation, lease_owner: 'browser:connection-1' };
const attempt: Json = { id: input.executionId, started_at: input.effectiveAt, status: 'running', details: { connection_id: 'connection-1' } };
const events: Json[] = [];
const delegate = db.raw.query.bind(db.raw);
db.raw.query = async (sql: string, p: any[] = []) => {
if (/SELECT \* FROM task_attempts/.test(sql)) return { rowCount: 1, rows: [attempt] };
if (/UPDATE tasks\s/.test(sql)) {
row = { ...row, execution_result_ciphertext: p[0], execution_result: p[1], status: p[2], stage: p[3], message: p[4], success_receipt: p[9] };
return { rowCount: 1, rows: [row] };
}
if (/UPDATE task_attempts/.test(sql)) { attempt.status = p[0]; attempt.response_hash = p[1]; return { rowCount: 1, rows: [] }; }
if (/SELECT \* FROM tasks/.test(sql)) return { rowCount: 1, rows: [row] };
if (/FROM task_artifacts artifact|DELETE FROM outbox_events/.test(sql)) return { rowCount: 0, rows: [] };
if (/DELETE FROM tasks/.test(sql)) { const deleted = row; row = {}; return { rowCount: 1, rows: [deleted] }; }
return delegate(sql, p);
};
t.mock.method(pool, 'connect', async () => db.client);
t.after(async () => { t.mock.restoreAll(); await closePool(); });
const service = new TaskService(config);
Object.assign(service, { lockTaskForAccess: async () => row, requireBrowserConnection: async () => {}, getTask: async () => row,
emitEvent: async (_client: unknown, _row: unknown, event: Json) => { events.push(event); return event; },
audit: async () => {}, notify: () => {}, notifyBrowserCommand: () => {} });
await service.recordExecutionResult(context, input.taskId, input.result, 'connection-1', input.executionId, { skipArtifactPersistence: true });
assert.equal(row.status, 'completed');
assert.equal(db.records.length, 1);
assert.match(replyTextFromReceipt(row.success_receipt), /酒店:已安排/);
assert.deepEqual(events[0].payload.success_receipt, row.success_receipt);
await service.recordExecutionResult(context, input.taskId, input.result, 'connection-1', input.executionId, { skipArtifactPersistence: true });
assert.equal(db.records.length, 1);
assert.equal(events.length, 1);
const deleted = await service.hardDeleteTask(context, input.taskId);
assert.equal(deleted.deleted, true);
assert.deepEqual(row, {});
const history = await new ArrangementLedger(config).history(db.client, scope, 'LW-260901VIP-A');
assert.ok(history[0].source_task_deleted_at);
assert.equal(currentArrangements(history).length, 1);
assert.match(replyTextFromReceipt(buildCustomerSuccessReceipt({ success: true, arrangement_summary:
arrangementSummary(history, 'LW-260901VIP-A', 'now') }, input.result, input.operation)), /酒店:已安排/);
});
+2 -2
View File
@@ -443,11 +443,11 @@ test('migration contains the durable state tables and safety fields', async () =
}
});
test('control plane requires the leader summary webhook migration before readiness', async () => {
test('control plane requires the arrangement ledger migration before readiness', async () => {
const { readFile } = await import('node:fs/promises');
const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8');
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
assert.equal(REQUIRED_SCHEMA_VERSION, '024_arrangement_update_routes');
assert.equal(REQUIRED_SCHEMA_VERSION, '025_arrangement_ledger');
assert.match(db, /schema_migrations/);
assert.match(db, /databaseReadiness/);
assert.match(db, /assertDatabaseSchema/);