Keep pending field and price reviews accessible across report dates
This commit is contained in:
1 parent
f46efd6d9a
commit
52e5d7433b
11 files changed
+325
-26
No files matched your search
@@ -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}));
|
||||
|
||||
@@ -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'});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user