迭代批量安排酒店

This commit is contained in:
andy committed 2026-09-24 14:43:08 +08:00
1 parent 4cfbefffe8
commit c2c71d35af
48 files changed
+5670 -57

No files matched your search

+2 -2
View File
@@ -208,7 +208,7 @@ test('catalog has exactly 19 ordered operations with adjacent template and examp
assert.deepEqual(operationSections.map((section) => section.directive), allDirectives);
for (const pair of catalogPairs) {
assert.equal(pair.blocks.length, 2, `${pair.directive}: expected template and example`);
assert.equal(pair.blocks.length, pair.directive === '安排酒店' ? 3 : 2, `${pair.directive}: expected template and example (plus hotel batch example)`);
assert.ok(pair.template, `${pair.directive}: missing template block`);
assert.ok(pair.example, `${pair.directive}: missing example block`);
assert.match(pair.template, /(必填):/u);
@@ -220,7 +220,7 @@ test('catalog has exactly 19 ordered operations with adjacent template and examp
assert.deepEqual(blocks.map((block) => block.split('\n')[0]), arrangementUpdateDirectives.flatMap((directive) => [directive, directive]));
continue;
}
assert.equal(textBlocks(section.body).length, 2);
assert.equal(textBlocks(section.body).length, section.directive === '安排酒店' ? 3 : 2);
assert.ok(labeledBlock(section, '模板'));
assert.ok(labeledBlock(section, '示例'));
assert.ok(section.body.indexOf('#### 模板') < section.body.indexOf('#### 示例'));
+129
View File
@@ -0,0 +1,129 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import Ajv from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { validateProgramOperation } from '../LianSyn-platform/external-agent-client.mjs';
const require = createRequire(import.meta.url);
const plans = require('../chrome-extension/ltjt-order-assistant/operation-plans.js');
const batch = require('../chrome-extension/ltjt-order-assistant/hotel-batch.js');
const guard = require('../chrome-extension/ltjt-order-assistant/execution-guard.js');
const timing = require('../chrome-extension/ltjt-order-assistant/operation-timing.js');
const source = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8');
const slice = (a,b) => source.slice(source.indexOf(a),source.indexOf(b,source.indexOf(a)));
const operation = { action:'arrangement_hotel', order_nature:'formal', submit_mode:'dry_run', data:{ existing_refs:{ identifier:'LW-SAMPLE' },
arrangements:[26,27,28].map(day => ({ mode:'create', resource:{ name:`酒店${day}TWN`, keyword:`酒店${day}TWN` }, start_date:`2026-09-${day}`, end_date:`2026-09-${day + 1}`, room_count:1 })) } };
function result(item, index, status = 'completed') {
if (status === 'failed') return { status:'blocked', no_erp_write:true, write_attempted:false, message:'没有匹配候选' };
if (status === 'uncertain') return { status:'execution_uncertain', no_erp_write:false, write_attempted:true };
return { status:'completed', no_erp_write:false, write_attempted:true, report:{ status:'lifecycle_completed', resolved_refs:{ kind:'independent_order', identifier:'LW-SAMPLE' },
server_response:{ completed:true }, requery:{ matched:true, subpage:{ arrangement_target:{ row_id:String(index + 1), quantity_value:'1' }, checks:[
{field:'riqi0', actual:item.start_date, matched:true}, {field:'riqis0', actual:item.end_date, matched:true} ] } } } };
}
function harness(failure = '', failureIndex = 1) {
const saved = { businessTaskExecutions:{ TASK:{ state:'running', execution_id:'EXEC' } }, businessTaskResults:{}, businessTaskTimingStates:{} };
const calls = [], rootResults = [];
const context = { console, performance, Date, Map, Set, Promise, hotelBatch:batch, executionGuard:guard, operationTiming:timing,
operationPlans:plans, operationTimingDetailBuffers:new Map(), runningTasks:new Set(),
isTaskCancelled:()=>false, throwIfTaskCancelled:()=>{}, nowIso:()=>new Date().toISOString(),
withStorageMutation:fn=>fn(), queueTimedDetail:()=>{}, takeOperationTimingDetails:()=>[],
chrome:{ storage:{ local:{ get:async()=>structuredClone(saved), set:async patch=>{
Object.assign(saved,structuredClone(patch));
const root=saved.businessTaskResults.TASK; if(root)rootResults.push(structuredClone(root));
} } } },
executeLifecycleOperation:async(id, op) => {
calls.push(id); const index=calls.length-1;
const status=index===failureIndex && failure ? failure : 'completed';
saved.businessTaskExecutions.TASK.state=status==='failed'?'submitted':'write_started';
if(failure==='throw' && index===failureIndex) throw new Error('lost connection');
await context.setResult(id,result(op.data.arrangement,index,status));
if(status==='completed')saved.businessTaskExecutions.TASK.state='submitted';
}
};
vm.createContext(context);
vm.runInContext(slice('async function setResult(', 'async function publish(')+'\n'+slice('async function publishHotelBatch(', 'async function runAutoTask(')+'\n'+slice('async function reconcileHotelBatch(', 'async function reconcileLifecycleTask('),context);
return { context, saved, calls, rootResults };
}
test('dispatch and schema accept only create batches with all ordinary per-row fields valid',()=>{
const ajv=new Ajv({strict:false});addFormats(ajv);
const validate=ajv.compile(JSON.parse(readFileSync(new URL('../schemas/standard_system_operation.schema.json',import.meta.url))));
assert.deepEqual(validateProgramOperation(operation),[]);
assert.equal(plans.validateDispatchOperation(operation).execution,'browser_hotel_batch');
assert.equal(validate(operation),true,JSON.stringify(validate.errors));
for(const mutate of [op=>{op.data.arrangements[2].room_count=0;},op=>{op.data.arrangements[1].mode='update';},op=>{op.data.arrangement=op.data.arrangements[0];},op=>{op.action='arrangement_vehicle';}]){
const bad=structuredClone(operation);mutate(bad);assert.equal(plans.validateDispatchOperation(bad).ok,false);assert.equal(validate(bad),false);
}
});
test('successful rows execute serially without prematurely finalizing the root task',async()=>{
const h=harness();await h.context.executeHotelBatch('TASK',operation);
assert.deepEqual(h.calls,['TASK#hotel-1','TASK#hotel-2','TASK#hotel-3']);
assert.equal(h.saved.businessTaskExecutions.TASK.state,'completed');
assert.equal(h.saved.businessTaskResults.TASK.status,'completed');
assert.ok(h.rootResults.filter(row=>row.hotel_batch?.items.some(item=>item.status==='running')).every(row=>row.write_attempted===true && row.no_erp_write===false), 'a parent lease expiry must never infer no write from a still-running child');
assert.ok(h.rootResults.filter(row=>row.status==='completed').every(row=>row.hotel_batch.items.every(item=>item.status==='completed')));
});
test('prewrite failure stops subsequent rows, retains saved prefix and stays known partial failure',async()=>{
const h=harness('failed');await h.context.executeHotelBatch('TASK',operation);
assert.equal(h.calls.length,2);const root=h.saved.businessTaskResults.TASK;
assert.equal(root.status,'blocked');assert.equal(root.no_erp_write,false);assert.equal(root.write_attempted,true);
assert.deepEqual(root.hotel_batch.items.map(item=>item.status),['completed','failed','not_started']);
assert.equal(h.saved.businessTaskExecutions.TASK.state,'blocked');assert.match(root.message,/未执行 1 条/);
});
test('uncertain or missing response stops without retry and preserves both prefix and pending tail',async()=>{
for(const failure of ['uncertain','throw']){
const h=harness(failure);await h.context.executeHotelBatch('TASK',operation);
assert.equal(h.calls.length,2);assert.equal(h.saved.businessTaskResults.TASK.status,'execution_uncertain');
assert.equal(h.saved.businessTaskResults.TASK.hotel_batch.items[2].status,'not_started');
}
});
test('read-only reconciliation resolves only the uncertain row and never starts the pending tail',async()=>{
const h=harness('uncertain');await h.context.executeHotelBatch('TASK',operation);
const root=h.saved.businessTaskResults.TASK;
const item=root.hotel_batch.items[1];
const proof=result(item.arrangement,1);
item.result.report={...proof.report,status:'saved_unverified',requery:{matched:false}};
h.saved.businessTasks={'TASK#hotel-2':{operation:{...operation,data:{existing_refs:{identifier:'LW-SAMPLE'},arrangement:item.arrangement}}}};
h.context.operationPlans={...plans,validateOperation:()=>({ok:true,execution:'browser_lifecycle'})};
h.context.findOrOpenErpTab=async()=>({id:1});
let reads=0;
h.context.runLifecycleReadOnlyVerification=async()=>{reads++;return {resolved:true,verification:{requery:proof.report.requery}};};
const reply=await h.context.reconcileHotelBatch('TASK',operation,root,h.saved);
assert.equal(reply.status,'blocked');assert.equal(reply.reconciliation_resolved,true);
assert.equal(reads,1);assert.equal(h.calls.length,2);
assert.deepEqual(h.saved.businessTaskResults.TASK.hotel_batch.items.map(row=>row.status),['completed','completed','not_started']);
assert.equal(h.saved.businessTaskExecutions.TASK.state,'blocked');
});
test('timeout preserves an already-saved child and classifies a missing child as uncertain without replay',async()=>{
for(const savedChild of [true,false]){
const h=harness();
const pending={version:1,group_number:'LW-SAMPLE',items:operation.data.arrangements.map((arrangement,index)=>({index,arrangement,status:index===0?'completed':index===1?'running':'not_started',...(index===0?{result:result(arrangement,0)}:{})}))};
await h.context.publishHotelBatch('TASK',operation,pending);
if(savedChild)h.saved.businessTaskResults['TASK#hotel-2']=result(operation.data.arrangements[1],1);
assert.equal(await h.context.stopHotelBatch('TASK',operation,'timeout'),true);
const root=h.saved.businessTaskResults.TASK;
assert.equal(root.status,savedChild?'blocked':'execution_uncertain');
assert.equal(root.hotel_batch.items[0].status,'completed');
assert.equal(root.hotel_batch.items[1].status,savedChild?'completed':'uncertain');
assert.equal(root.hotel_batch.items[2].status,'not_started');assert.equal(h.calls.length,0);
}
});
test('production Docker runtime includes the shared batch evidence validator',()=>{
const dockerfile=readFileSync(new URL('../Dockerfile',import.meta.url),'utf8');
const runtime=dockerfile.split('AS runtime')[1];
assert.match(runtime,/COPY[^\n]*chrome-extension\/ltjt-order-assistant\/hotel-batch\.js[^\n]*\.\/chrome-extension\/ltjt-order-assistant\//);
});
test('a proven prewrite failure on the first row reports zero writes and leaves the rest untouched',async()=>{
const h=harness('failed',0);await h.context.executeHotelBatch('TASK',operation);
const root=h.saved.businessTaskResults.TASK;
assert.equal(h.calls.length,1);assert.equal(root.status,'blocked');
assert.equal(root.no_erp_write,true);assert.equal(root.write_attempted,false);
assert.deepEqual(root.hotel_batch.items.map(item=>item.status),['failed','not_started','not_started']);
assert.match(root.message,/未完成/);
});
+7 -7
View File
@@ -1374,7 +1374,7 @@ test('passenger explicit server success is terminal without a post-save row requ
assert.doesNotMatch(passengerBranch, /passengerRequery|verifyLifecycleOperation/);
assert.doesNotMatch(background, /attemptAutomaticPassengerReconciliation/);
assert.match(background, /名单已取得 ERP 明确成功响应,按业务规则确认录入成功/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.189'/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.190'/);
});
test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => {
@@ -1584,7 +1584,7 @@ test('lifecycle execution keeps the MV3 worker alive until the durable task sett
assert.match(background, /arrangement_resource_candidate_data_missing/);
});
test('extension 0.5.189 keeps reload dormant unless its durable execution state is fully idle', async () => {
test('extension 0.5.190 keeps reload dormant unless its durable execution state is fully idle', async () => {
const [background, bridge] = await Promise.all([
readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'),
readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8')
@@ -2037,7 +2037,7 @@ test('delete guard distinguishes independent, shared child, and shared parent ro
for (const operation of operations) assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; '));
const mapping = JSON.parse(await readFile(new URL('../mappings/lifecycle.mapping.json', import.meta.url), 'utf8'));
assert.equal(mapping.current_extension_version, '0.5.189');
assert.equal(mapping.current_extension_version, '0.5.190');
assert.equal(
mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'],
'ertrenshu'
@@ -2421,10 +2421,10 @@ test('schema and browser adapters contain the v2 safety fields and no confirm ov
assert.match(inpage, /`ys_danweiid\$\{index\}`, resolvedCustomerId/);
assert.doesNotMatch(inpage, /product_customer_source_region|sourceRegionCheck|source_reference/);
assert.doesNotMatch(teamBatchInpage, /product_customer_source_region|sourceRegionCheck/);
assert.equal(extensionManifest.version, '0.5.189');
assert.match(inpage, /version: '0\.5\.189'/);
assert.match(teamBatchInpage, /version: '0\.5\.189'/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.189'/);
assert.equal(extensionManifest.version, '0.5.190');
assert.match(inpage, /version: '0\.5\.190'/);
assert.match(teamBatchInpage, /version: '0\.5\.190'/);
assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.190'/);
assert.match(inpage, /function strictIsoDate\(value\)/);
assert.match(inpage, /const startDate = strictIsoDate\(current\('riqi'\)\)/);
assert.match(inpage, /const endDate = strictIsoDate\(current\('riqis'\)\)/);
+1 -1
View File
@@ -304,7 +304,7 @@ test('single-source and generated-output boundaries remain explicit', async () =
const diagnoseServer = await readFile(path.join(ROOT, 'infra/diagnose-server.sh'), 'utf8');
const gitignore = await readFile(path.join(ROOT, '.gitignore'), 'utf8');
assert.match(template, /^交付版本:0\.5\.136\s*$/mu);
assert.match(template, /^交付版本:0\.5\.137\s*$/mu);
assert.match(template, /^适用范围:当前 23 条业务路由,按 19 项运营场景分组;执行能力以生命周期发布门槛为准。\s*$/mu);
assert.match(template, /^### 5\. 散拼团多个新增子单\s*$/mu);
assert.doesNotMatch(template, /^事实基线:/mu);