Keep pending field and price reviews accessible across report dates

This commit is contained in:
Wyndham ARR committed 2026-10-08 22:49:52 +08:00
1 parent f46efd6d9a
commit 52e5d7433b
11 files changed
+325 -26

No files matched your search

+147 -1
View File
@@ -3,12 +3,158 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const {harness,response} = require('./helpers/arr_ui_harness.cjs');
const {harness,response,scopedKey} = require('./helpers/arr_ui_harness.cjs');
const requestId='a'.repeat(32);
const task={request_id:requestId,report_date:'2026-10-07',status:'needs_data_review',job_id:null,can_retry:false};
const item=(extra={})=>({item_id:'1:BLOCK_CODE',source_sequence:1,confirmation_no:'SYNTHETIC-001',room_no:'SYNTHETIC-ROOM',company_name:'Synthetic Company',rate_code:'SYNTHETIC',field:'BLOCK_CODE',field_label:'团队代码',reason_code:'MISSING',can_be_empty:true,value:null,confirmed:false,...extra});
const review=(items,revision=1)=>({request_id:requestId,report_date:task.report_date,status:'editing',revision,items,pending_count:items.filter(x=>!x.confirmed).length,total_count:items.length,can_finalize:items.every(x=>x.confirmed)});
const plain=value=>JSON.parse(JSON.stringify(value));
const priceTask={request_id:'c'.repeat(32),report_date:'2026-09-16',status:'needs_review',
job_id:'fixture-price-september',can_retry:false};
const priceReview=(price=null)=>({case_id:'fixture-price-case',case_status:'open',revision:1,
completed_items:price===null?0:1,total_items:1,
items:[{item_id:1,company_key:'Fixture Company',rate_code:'FIXTURE',effective_rate_amount:100,
real_price:price,affected_records:1,affected_rooms:1,affected_room_nights:2,candidate_prices:[]}],
pagination:{offset:0,limit:50,total:1}});
function pendingAPI(url,currentPrice=priceTask) {
if(url===`/api/arr-downloads/${requestId}`) return response(200,task);
if(url===`/api/arr-downloads/${priceTask.request_id}`) return response(200,currentPrice);
if(url.endsWith('/data-review')) return response(200,review([item()]));
if(url.includes('/review?')) return response(200,priceReview());
if(url.endsWith('/trace')) return response(200,{job:{job_id:priceTask.job_id,
business_date:priceTask.report_date,status:currentPrice.status,active:currentPrice.status==='needs_review'},logs:[]});
if(url.startsWith('/api/jobs?')) return response(200,[]);
throw new Error('unexpected request '+url);
}
test('cross-month field and price dates restore, stay clickable after polling, and open their own pages by GET',async()=>{
const h=harness(url=>url==='/api/arr-downloads'
? response(200,{context_id:'production',ready:true,default_date:priceTask.report_date,
pending_reviews:[task,priceTask],pending_data_reviews:[task],latest_task:priceTask})
: pendingAPI(url));
h.storage.set(scopedKey(),JSON.stringify({request_id:priceTask.request_id,report_date:priceTask.report_date}));
await h.initARRDownload();
assert.equal(h.state.arrDownloadPendingReviews.length,2);
assert.equal(h.element('#arr-download-pending-title').textContent,'arr_download.pending_dates');
assert.match(h.element('#arr-download-pending-list').innerHTML,/2026-10-07.*arr_download.pending_date/);
assert.match(h.element('#arr-download-pending-list').innerHTML,/2026-09-16.*arr_download.pending_price/);
assert.doesNotMatch(h.element('#arr-download-pending-list').innerHTML,/ disabled/);
await h.selectARRPendingReview(requestId);
h.trackARRDataReviewDraft(h.row('1:BLOCK_CODE','OCTOBER-DRAFT').input);
await h.selectARRPendingReview(priceTask.request_id);
assert.equal(h.state.jobsMonth,'2026-09');
assert.equal(h.element('#jobs-history-month').value,'2026-09');
assert.equal(h.state.selectedJobId,priceTask.job_id);
assert.equal(h.state.dailyPriceReviewJobId,priceTask.job_id);
assert.equal(h.element('#daily-price-review-panel').hidden,false);
assert.equal(h.element('#arr-data-review-panel').hidden,true);
assert.equal(h.element('#arr-download-date').value,'2026-09-16');
assert.doesNotMatch(h.element('#arr-download-pending-list').innerHTML,/ disabled/);
await h.loadARRDownloadTask();
assert.doesNotMatch(h.element('#arr-download-pending-list').innerHTML,/ disabled/,
'a finished status poll must unlock the pending buttons again');
await h.selectARRPendingReview(requestId);
assert.equal(h.element('#daily-price-review-panel').hidden,true);
assert.equal(h.element('#arr-data-review-panel').hidden,false);
assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'OCTOBER-DRAFT');
assert.equal(h.calls.every(call=>call.method==='GET'),true,'restoring reviews never downloads or changes prices');
});
test('an authoritative empty pending list overrides legacy and stale current review state',async()=>{
let cleared=false;
const h=harness(url=>url==='/api/arr-downloads'
? response(200,{context_id:'production',ready:true,default_date:priceTask.report_date,
pending_reviews:cleared?[]:[priceTask],pending_data_reviews:[task],latest_task:priceTask})
: pendingAPI(url));
h.storage.set(scopedKey(),JSON.stringify({request_id:priceTask.request_id,report_date:priceTask.report_date}));
await h.initARRDownload();
assert.deepEqual(plain(h.state.arrDownloadPendingReviews).map(x=>x.request_id),[priceTask.request_id]);
cleared=true;
await h.initARRDownload();
assert.equal(h.state.arrDownloadTask.status,'needs_review','the fixture deliberately returns an older task snapshot');
assert.equal(h.state.arrDownloadPendingReviews.length,0);
assert.equal(h.element('#arr-download-pending').hidden,true);
assert.equal(h.calls.every(call=>call.method==='GET'),true);
});
test('a source request can return as a price review after processing with the same request id',async()=>{
const h=harness(()=>response(200,review([item()])));
await h.acceptARRDownloadTask(task,{sync:false});
await h.acceptARRDownloadTask({...task,status:'processing'},{sync:false});
assert.equal(h.state.arrDownloadPendingReviews.length,0);
await h.acceptARRDownloadTask({...task,status:'needs_review',job_id:'same-request-price-job'},{sync:false});
assert.equal(h.state.arrDownloadPendingReviews.length,1);
assert.equal(h.state.arrDownloadPendingReviews[0].status,'needs_review');
assert.match(h.element('#arr-download-pending-list').innerHTML,/arr_download.pending_price/);
});
for(const status of ['succeeded','failed']) {
test(`a stale price date whose authoritative task is ${status} leaves the pending list without reopening prices`,async()=>{
const h=harness(url=>pendingAPI(url,{...priceTask,status}));
h.state.arrDownloadPendingReviews=[priceTask];
await h.selectARRPendingReview(priceTask.request_id);
assert.equal(h.state.arrDownloadTask.status,status);
assert.equal(h.state.arrDownloadPendingReviews.length,0);
assert.equal(h.element('#arr-download-pending').hidden,true);
assert.equal(h.element('#daily-price-review-panel').hidden,true);
assert.equal(h.calls.some(call=>call.url.includes('/review?')),false);
assert.equal(h.calls.every(call=>call.method==='GET'),true);
});
}
test('unsaved prices prevent a pending date switch without clearing the current price input',async()=>{
const h=harness(()=>{throw new Error('switch must not send a request');});
h.state.arrDownloadPendingReviews=[task,priceTask];
h.state.arrDownloadTask=priceTask;
h.state.dailyPriceReviewJobId=priceTask.job_id;
h.state.dailyPriceReview=priceReview();
const input={value:'0',closest:()=>({dataset:{dailyPriceReviewItemId:'1'}})};
h.element('#daily-price-review-body').querySelectorAll=()=>[input];
assert.equal(h.dailyPriceReviewHasUnsavedChanges(),true);
await h.selectARRPendingReview(requestId);
assert.equal(h.calls.length,0);
assert.equal(h.state.arrDownloadTask.request_id,priceTask.request_id);
assert.equal(input.value,'0');
assert.equal(h.element('#daily-price-review-message').textContent,'daily_review.save_before_switch');
h.state.dailyPriceReview=priceReview('0');
assert.equal(h.dailyPriceReviewHasUnsavedChanges(),false,'a saved zero is not an unsaved draft');
});
for(const operation of ['finalize','cancel']) {
test(`price ${operation} refreshes the same request and removes its terminal pending date`,async()=>{
const h=harness((url,options)=>{
if(options.method==='POST') {
assert.equal(url,`/api/jobs/${priceTask.job_id}/review/${operation}`);
return response(200,{status:operation==='finalize'?'succeeded':'cancelled'});
}
return pendingAPI(url,{...priceTask,status:operation==='finalize'?'succeeded':'failed'});
});
h.state.arrDownloadPendingReviews=[priceTask];
h.state.arrDownloadTask=priceTask;
h.state.dailyPriceReviewJobId=priceTask.job_id;
h.state.dailyPriceReview=priceReview('0');
if(operation==='finalize') await h.finalizeDailyPriceReview();
else await h.confirmDailyPriceReviewCancel();
assert.equal(h.state.arrDownloadPendingReviews.length,0);
assert.equal(h.element('#arr-download-pending').hidden,true);
assert.equal(h.calls.filter(call=>call.method==='POST').length,1);
assert.equal(h.calls.some(call=>call.url===`/api/arr-downloads/${priceTask.request_id}`),true);
assert.equal(h.calls.some(call=>call.url==='/api/arr-downloads'),false,'no replacement download is created');
assert.equal(h.state.arrDownloadTask.status,operation==='finalize'?'succeeded':'failed');
});
}
test('pending date labels have Chinese, English and Thai translations',()=>{
const context=vm.createContext({window:{},document:{readyState:'loading',addEventListener(){}}});
vm.runInContext(fs.readFileSync(path.resolve(__dirname,'../../arr_web/static/i18n.js'),'utf8'),context);
const catalog=context.window.ARRI18n.CATALOG;
for(const key of ['arr_download.pending_dates','arr_download.pending_date','arr_download.pending_price','daily_review.save_before_switch']) {
assert.equal(catalog[key].length,3);
catalog[key].forEach(text=>assert.equal(typeof text==='string' && text.length>0,true));
}
assert.equal(catalog['arr_download.pending_dates'][0],'待处理日期');
assert.equal(catalog['arr_download.pending_price'][0],'待复核价格');
});
test('cancelled source rows need no fields and the exclusion stays visible before continuation',async()=>{
const h=harness(()=>response(200,{...review([]),excluded_cancelled_count:6}));
+4 -4
View File
@@ -11,18 +11,18 @@ assert(source.endsWith(boot+'\n') || source.endsWith(boot));
function harness(respond) {
const elements=new Map(), storage=new Map(), storageReads=[], calls=[];
const element=selector => {
if (!elements.has(selector)) elements.set(selector,{value:'', disabled:false, hidden:false, textContent:'', innerHTML:'',
classList:{add(){},remove(){},toggle(){},contains(){return false;}},setAttribute(){},scrollIntoView(){},focus(){}});
if (!elements.has(selector)) elements.set(selector,{value:'', disabled:false, hidden:false, textContent:'', innerHTML:'',style:{},
classList:{add(){},remove(){},toggle(){},contains(){return false;}},setAttribute(){},removeAttribute(){},querySelectorAll(){return [];},scrollIntoView(){},focus(){}});
return elements.get(selector);
};
const context=vm.createContext({Headers, console, Date, Intl, Uint8Array,
document:{hidden:false,querySelector:element},
window:{ARRI18n:{t:key=>key,text:value=>value,errorMessage:code=>code,formatInteger:value=>String(value)},crypto:webcrypto,
window:{ARRI18n:{t:key=>key,text:value=>value,errorMessage:code=>code,formatInteger:value=>String(value),formatMonth:value=>value},crypto:webcrypto,
setTimeout(){return 1;},clearTimeout(){},location:{replace(){}}},
localStorage:{setItem:(k,v)=>storage.set(k,v),getItem:k=>{storageReads.push(k);return storage.get(k);},removeItem:k=>storage.delete(k)},
fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body,csrf:options.headers.get('X-ARR-CSRF')});return respond(url,options,calls);},
});
const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview, strictReviewPrice, reviewPriceInput, renderDailyPriceReview, saveDailyPriceReviewItem, loadDailyPriceReview, dailyPriceReviewCanFinalize';
const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview, strictReviewPrice, reviewPriceInput, renderDailyPriceReview, saveDailyPriceReviewItem, loadDailyPriceReview, dailyPriceReviewCanFinalize, dailyPriceReviewHasUnsavedChanges, finalizeDailyPriceReview, confirmDailyPriceReviewCancel';
vm.runInContext(source.replace(boot,` globalThis.subject = {${exports}};\n})();`),context);
const subject=context.subject;
Object.assign(subject.state,{arrDownloadReady:true,arrDownloadLoaded:true,arrDownloadStorageKey:'test',arrDownloadUsername:'operator',csrf:'fixture'});
+3
View File
@@ -78,11 +78,14 @@ class LocalOHIPTests(unittest.TestCase):
pending = [{"request_id": "a" * 32, "report_date": "2026-10-07", "status": "needs_data_review"}]
queue = Mock(ready=True)
queue.pending_data_reviews.return_value = pending
queue.pending_reviews.return_value = pending + [{"request_id": "b" * 32,
"report_date": "2026-09-16", "status": "needs_review", "job_id": "arrjob-prices"}]
access = Mock()
access.status.return_value = {"ready": False}
downloads = local.PermissionDownloads(queue, access)
self.assertFalse(downloads.ready)
self.assertEqual(downloads.pending_data_reviews(), pending)
self.assertEqual(downloads.pending_reviews(), queue.pending_reviews.return_value)
queue.pending_data_reviews.assert_called_once_with()
access.require_ready.assert_not_called()
queue.create.assert_not_called()
+46
View File
@@ -141,6 +141,24 @@ class DownloadTests(unittest.TestCase):
self.assertEqual([task["report_date"] for task in expected], ["2026-10-07", "2026-09-17"])
self.assertEqual(len(self.executor.calls), 2)
def test_mixed_pending_reviews_survive_month_switch_and_restart_without_acquisition(self):
self.executor.release.set()
self.executor.outcome = DownloadOutcome("needs_review", "arrjob-october-prices")
self.service.create("2026-10-07", "a" * 32)
october = self.finished()
self.executor.outcome = DownloadOutcome("needs_data_review")
self.service.create("2026-09-17", "b" * 32)
september = self.finished("b" * 32)
self.executor.outcome = DownloadOutcome("succeeded", "arrjob-finished")
self.service.create("2026-09-16", "c" * 32)
self.finished("c" * 32)
self.assertEqual(self.service.pending_reviews(), [october, september])
self.assertEqual(self.service.pending_data_reviews(), [september])
self.service.close(wait=True)
self.service = PersistentARRDownloads(self.root, self.executor)
self.assertEqual(self.service.pending_reviews(), [october, september])
self.assertEqual(len(self.executor.calls), 3)
def test_pending_source_review_list_excludes_other_outcomes(self):
self.assertEqual(self.service.pending_data_reviews(), [])
self.executor.release.set()
@@ -271,6 +289,7 @@ class DownloadRoutesTests(unittest.TestCase):
self.assertFalse(config["ready"])
self.assertIsNone(config["latest_task"])
self.assertEqual(config["pending_data_reviews"], [])
self.assertEqual(config["pending_reviews"], [])
response = app.handle("POST", "/api/arr-downloads", headers,
json.dumps({"report_date": "2026-09-15", "request_id": "a" * 32}).encode())
self.assertEqual(response.status, 503)
@@ -314,6 +333,33 @@ class DownloadRoutesTests(unittest.TestCase):
with patch.object(self.repo, "get_job_trace", return_value={"job": {"status": "accepted"}}):
self.assertEqual(self.app._arr_download_snapshot(task)["status"], "succeeded")
def test_configuration_mixed_pending_reviews_filters_completed_and_cancelled_price_jobs(self):
tasks = [
{"request_id": "a" * 32, "report_date": "2026-10-07", "status": "needs_review", "job_id": "arrjob-open"},
{"request_id": "b" * 32, "report_date": "2026-09-17", "status": "needs_data_review", "job_id": None},
{"request_id": "c" * 32, "report_date": "2026-09-16", "status": "needs_review", "job_id": "arrjob-completed"},
{"request_id": "d" * 32, "report_date": "2026-09-16", "status": "needs_review", "job_id": "arrjob-cancelled"},
]
states = {"arrjob-open": "review_required", "arrjob-completed": "succeeded", "arrjob-cancelled": "cancelled"}
with patch.object(self.service, "pending_reviews", return_value=tasks), \
patch.object(self.repo, "get_job_trace", side_effect=lambda job: {"job": {"status": states[job]}}):
response = self.app.handle("GET", "/api/arr-downloads", self.headers)
self.assertEqual(response.status, 200)
config = json.loads(response.body)["data"]
self.assertEqual(config["pending_reviews"], tasks[:2])
self.assertEqual(config["pending_data_reviews"], [tasks[1]])
self.assertEqual(self.executor.calls, [])
self.assertIsNone(self.service.latest())
def test_pending_price_review_is_not_lost_when_job_lookup_is_unavailable(self):
from arr_web.repository import PortalDataError
task = {"request_id": "a" * 32, "report_date": "2026-10-07", "status": "needs_review", "job_id": "arrjob-open"}
with patch.object(self.service, "pending_reviews", return_value=[task]), \
patch.object(self.repo, "get_job_trace", side_effect=PortalDataError("UNAVAILABLE", "not available")):
response = self.app.handle("GET", "/api/arr-downloads", self.headers)
self.assertEqual(response.status, 200)
self.assertEqual(json.loads(response.body)["data"]["pending_reviews"], [task])
if __name__ == "__main__":
unittest.main()