大交通检索修复
This commit is contained in:
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');
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {})
|
||||
|
||||
@@ -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)), /酒店:已安排/);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
Reference in new issue
Block a user