fix: clarify explicit zero prices in manual review

This commit is contained in:
Wyndham ARR committed 2026-10-08 21:22:01 +08:00
1 parent 9487634f83
commit 5acbec75fe
7 files changed
+98 -11

No files matched your search

@@ -24,7 +24,7 @@
- Added source-field completion before deterministic processing: only candidate records' unresolved/invalid fields may be edited. Normal optional block/package omission is now recognized under the narrow Oracle association policy described below; unresolved, failed or contradictory evidence still needs resolution. Original bytes are retained, revisioned staff decisions record the authenticated actor, and confirmation freezes a derived source. The same download request resumes from its saved acquisition without querying OHIP again. Existing price review, Finance validation, daily generation and monthly outbox behavior remain in use; XML processing is retained.
- Browser recovery is scoped to this service/source context and user, preventing an old sandbox request from restoring into the production instance. Lost save/finalize responses, concurrent revisions and interrupted confirmation recover without a second processing intent.
- Updated the existing local service at `http://127.0.0.1:8875/` to run this isolated checkout, retaining hotel57106, credentials, private database and output storage. Previous private launcher/config/plist were backed up before restart. No production deployment, remote push or integration into the unowned dirty primary checkout occurred.
- Reused the complete, immutable real 2026-10-07 capture; verified all531 capture files and both original hashes before importing into request `45e0e20ee75a46e098a0e32d650133a9`. Its initial26 source decisions were1 room,18 block codes and7 package lists. The optional-association follow-up below reinterpreted only corroborated normal omissions, preserving all original bytes and staff decisions. The later approved cancellation follow-up supersedes this pending-room state: the source review is finalized revision4 with zero field decisions, and the same10/7 request has entered the existing two-key price review. No real daily/monthly exists yet. Import, reanalysis and cancellation-scope continuation made zero new Oracle calls.
- Reused the complete, immutable real 2026-10-07 capture; verified all531 capture files and both original hashes before importing into request `45e0e20ee75a46e098a0e32d650133a9`. Its initial26 source decisions were1 room,18 block codes and7 package lists. The optional-association follow-up below reinterpreted only corroborated normal omissions, preserving all original bytes and staff decisions. The later approved cancellation follow-up supersedes this pending-room state: the source review is finalized revision4 with zero field decisions, and the same10/7 request entered the existing two-key price review. During the zero-price follow-up, read-only inspection found user-saved850/1200, completed price review revision2, and a succeeded10/7 daily entry with38 rooms and a download link. This follow-up did not generate or inspect the actual workbook/monthly artifacts. Import, reanalysis and cancellation-scope continuation made zero new Oracle calls.
## Verification
@@ -38,7 +38,7 @@
## Follow-ups
- The user explicitly deferred API/XML population alignment. That issue remains separate and must not block fixing normal optional omission or be represented as resolved by this change. The user has now explicitly approved cancellation exclusion, so300007418 no longer requires a room.10/7 awaits only two existing manual-price keys (LIAN TAI/WHO1/Opera0 and QBD/GRPA3/Opera0); each affects one record/three nights. Do not choose a price, equate an Oracle0 with the processed price, or claim final daily/monthly acceptance. Source population/text alignment remains separately deferred.9/17 now has saved-status evidence and the corroborated optional-source interpretation:0 active field decisions, editing revision3, ready for explicit continuation. It has not been processed or finalized; subsequent existing price review may still apply.
- The user explicitly deferred API/XML population alignment. That issue remains separate and must not block fixing normal optional omission or be represented as resolved by this change. The user has now explicitly approved cancellation exclusion, so300007418 no longer requires a room.10/7's two price items now contain user-saved850/1200 and its daily list shows succeeded; do not replace these values with0 merely because the user requires zero-price support. Do not equate Oracle0 with the processed price or claim independently inspected daily/monthly acceptance from the page status. Source population/text alignment remains separately deferred.9/17 now has saved-status evidence and the corroborated optional-source interpretation:0 active field decisions, editing revision3, ready for explicit continuation. It has not been processed or finalized; subsequent existing price review may still apply.
- Integration owner should promote the accepted behavior and production compatibility evidence, then integrate this branch into the primary project. Keep this checkout while the local service runs from it. It is manually created, not eligible for automatic skill-managed retirement.
- Production deployment/configuration and remote publishing are separate follow-ups. This turn only activated the local instance against the existing production read service.
@@ -46,7 +46,7 @@
- Update canonical current-state/data-flow/success-criteria with the separate upstream source-field review and its boundary with the existing price-only processor/Finance review. Preserve ADR-006's original scope; decide whether an additional ADR is appropriate at integration.
- Promote the four supported direct search parameters, GET profile lookup compatibility, service-context browser recovery and immutable original/manual decision audit into integration-owned documentation.
- Record that real10/7 acquisition is complete and normal optional omission is resolved under a constrained source policy; daily/monthly acceptance still awaits the remaining input and separately deferred source selection. Avoid claiming successful report generation from synthetic regression evidence.
- Record that real10/7 acquisition is complete and normal optional omission is resolved under a constrained source policy; its later user-saved prices and succeeded daily entry are observed in the zero-price follow-up, while actual workbook/monthly inspection and separately deferred source selection remain distinct. Avoid claiming real report acceptance from synthetic regression evidence.
## Concurrent Task Gate
@@ -210,3 +210,13 @@ Read: memory-index, project-positioning, current-state latest September sections
- Verification:56 JavaScript submission/source-review cases passed (8 new focused recovery/boundary cases), including exact body/request-ID retention, one retry limit,401/user-change/invalid-token refusal, other403 refusal, price decision body/extra-header/envelope preservation,500 uncertainty and existing lost-response/404 replay. Both scripts passed node syntax checks and Git whitespace checks. Independent agent reproduced stale-token POST403→GET404 in the actual frontend sandbox and a pure-memory backend: no queue.create call; then reviewed the final retry boundaries with no blocking issues. This establishes the bug mechanism, not the unknown historical HTTP response for the user's submission.
- Local verification: static changes are served immediately without restart. Reloaded the agent result tab only, selected9/16, and confirmed enabled “下载并处理” and “可下载所选日期的报表”;9/17 remains in the pending-date list and10/7 stays at0/2 price decisions. Did not press download, retry, finalize, or save prices; no new Oracle read/Finance write/task creation. The older user-visible tab's failed browser connection was not bypassed; user should refresh that page to receive the fix. Private screenshot `production-validation-20261007/sept16-submission-ready-20261008.png`; existing price tab and result tab retained.
- Follow-up/promotion: exact9/16 historical rejection remains unknown because current path-only logs omit status. Explain this evidence limit rather than claim Oracle no-data or prove session rejection for that event. User can explicitly retry the original9/16 intent from the refreshed page; then assess authoritative queue progress. Canonical UI/API recovery documentation should record bounded same-user refresh and preserved definite-error details at integration. No remote push, production deployment, primary-checkout edit or report generation occurred.
## Same-task Follow-up: Explicit Zero-price Support
- User explicitly requires manual price review to support0. Concurrent gate Passed for the same task/feature/codex/owned checkout/base2417b1a, no peers. Retained Project Context Loaded remains applicable: shared XML/OHIP pricing and audited staff decisions, no change to original zero-price exceptions, no new Oracle calls or fabricated business values. Planning gate Passed.
- Read-only audit: HTTP regex, PostgreSQL numeric constraints/parser, in-memory review, freeze manifest and processor manual-override lookup already accept zero; completion counts use IS NOT NULL/explicit None checks. Frontend string input"0" also passes its regex, but gray placeholder"0" can look like an entered price; numeric0 passed directly to strictReviewPrice is lost through truthiness coercion. No evidence establishes an actual string"0" save rejection. Live10/7 is now completed with user-entered850/1200;9/16 is downloading from the user's new request. Do not replace any real saved prices or finalize other dates merely to test zero.
- Plan: make numeric and string0 explicit valid values, distinguish an empty input from zero, replace misleading placeholder and instructions with explicit0 support/save guidance, run actual frontend save/reload/finalization-readiness regressions plus existing backend zero-price checks. Preserve negative/blank rejection, integer-price policy, immutable finalized reviews and original source/price decisions.
- Outcome: numeric0 and string"0" remain explicit prices; null/undefined/blank stay unfilled. The price input now says “请输入价格(可填 0)” instead of showing a gray0 in an empty field. Chinese/English/Thai instructions explain entering0, saving each item, then generating when complete. No backend, processor, rule pin, pricing exception or database constraint changed.
- Verification:60 JavaScript cases passed, including4 new cases exercising the actual frontend functions for0 validation/rendering, PATCH body, saved completion/finalize readiness, reload retention and blank rejection;56 existing source-review/submission cases still pass.27 Python web/UI cases passed. An independent read-only audit ran8 existing backend/processor/Finance checks, including disposable PostgreSQL0 save→freeze→generation→single Finance commit→same-result repeat. Initial2 new JS failures were a missing formatInteger in the synthetic I18N stub, repaired without product changes. Both static scripts pass node syntax checks; Git whitespace and task documentation ownership checks pass.
- Local verification: static changes are served by the existing local checkout without a service restart. The prior agent tab's control connection timed out; a fresh temporary authenticated tab loaded the service and showed10/7 succeeded with38 rooms/download, and9/16 still acquiring the user's requested data. No active actual price form remained to test0 without changing business state, so end-to-end0 saving/generation was verified only with synthetic fixtures and disposable database tests. Existing user prices850/1200 were untouched; no real zero price was entered, no Oracle query/retry or report generation was triggered by this follow-up. The temporary verification tab is closed after inspection.
- Follow-up/promotion: manual price0 is an explicit staff decision, unlike a blank input or a source Oracle0 awaiting pricing. Carry the clearer guidance and zero/null regression coverage into integration. The user should refresh the local page to load the new instructions before entering and saving0 on an actual pending item.
+4 -4
View File
@@ -1204,7 +1204,7 @@
}
function strictReviewPrice(value) {
return /^(?:0|[1-9]\d{0,15})$/.test(String(value || "").trim());
return /^(?:0|[1-9]\d{0,15})$/.test(String(value ?? "").trim());
}
function setDailyPriceReviewMessage(copy = "", isError = false) {
@@ -1273,7 +1273,7 @@
reviewCopy.hidden = retryable;
reviewCopy.textContent = processing
? (I18N?.t("daily_review.processing_instruction") || "正在使用已冻结的价格清单重放原始 XML;此时无法修改。")
: (I18N?.t("daily_review.default_instruction") || "请为所有缺少固定价的价格键填写每晚价格。相同键只需填写一次。");
: (I18N?.t("daily_review.default_instruction") || "请填写每晚处理价,可填 0。每项填写后点击“暂存”,全部暂存后生成日报。");
const finalize = $("#daily-price-review-finalize");
const cancel = $("#daily-price-review-cancel");
finalize.disabled = busy || !dailyPriceReviewCanFinalize(review);
@@ -1316,7 +1316,7 @@
<td data-label="${escapeHtml(labels.roomsNights)}">${formatInteger(item.affected_rooms)} / ${formatInteger(item.affected_room_nights)}</td>
<td data-label="${escapeHtml(labels.manualPrice)}">
<label class="sr-only" for="${inputId}">${escapeHtml(inputLabel)}</label>
<input class="daily-price-review-input" id="${inputId}" data-daily-price-review-price value="${escapeHtml(reviewPriceInput(item.real_price))}" inputmode="numeric" autocomplete="off" pattern="(?:0|[1-9][0-9]{0,15})" placeholder="0" aria-describedby="daily-price-review-copy" ${disabled ? "disabled" : ""} />
<input class="daily-price-review-input" id="${inputId}" data-daily-price-review-price value="${escapeHtml(reviewPriceInput(item.real_price))}" inputmode="numeric" autocomplete="off" pattern="(?:0|[1-9][0-9]{0,15})" placeholder="${escapeHtml(I18N?.t("daily_review.price_placeholder") || "请输入价格(可填 0)")}" aria-describedby="daily-price-review-copy" ${disabled ? "disabled" : ""} />
</td>
<td class="daily-price-review-row-actions" data-label="${escapeHtml(labels.actions)}">
<span class="daily-price-review-status${saved ? " is-complete" : " is-pending"}">${escapeHtml(saved ? (I18N?.t("daily_review.saved") || "已暂存") : (I18N?.t("daily_review.awaiting_input") || "待填写"))}</span>
@@ -1372,7 +1372,7 @@
const jobId = state.dailyPriceReviewJobId;
const itemId = Number(row?.dataset.dailyPriceReviewItemId);
const input = $("[data-daily-price-review-price]", row);
const realPrice = String(input?.value || "").trim();
const realPrice = String(input?.value ?? "").trim();
if (!review || !jobId || !Number.isInteger(itemId) || itemId < 1) return;
if (!strictReviewPrice(realPrice)) {
setDailyPriceReviewMessage(I18N?.t("daily_review.integer_required") || "人工价格必须是非负整数,例如 0。", true);
+2 -1
View File
@@ -249,7 +249,7 @@
"daily_review.progress_short": ["复核 {completed} / {total}", "Review {completed} / {total}", "ตรวจสอบ {completed} / {total}"],
"daily_review.step": ["价格复核 / 待处理", "PRICE REVIEW / REQUIRED", "ตรวจสอบราคา / ต้องดำเนินการ"],
"daily_review.title": ["人工价格复核", "Manual price review", "ตรวจสอบราคาด้วยตนเอง"],
"daily_review.default_instruction": ["请为所有缺少固定价的价格键填写每晚价格。相同键只需填写一次。", "Enter a nightly price for every price key missing a fixed price. Enter each identical key only once.", "กรอกราคาต่อคืนสำหรับทุกคีย์ราคาที่ไม่มีราคาคงที่ โดยคีย์เดียวกันกรอกเพียงครั้งเดียว"],
"daily_review.default_instruction": ["请填写每晚处理价,可填 0。每项填写后点击“暂存”,全部暂存后生成日报。相同价格项只需填写一次。", "Enter the nightly price; 0 is allowed. Save each item, then generate the daily report when all items are saved. Enter each identical price key only once.", "กรอกราคาต่อคืน สามารถกรอก 0 ได้ กดบันทึกแต่ละรายการ แล้วสร้างรายงานรายวันเมื่อบันทึกครบทุกรายการ คีย์ราคาเดียวกันกรอกเพียงครั้งเดียว"],
"daily_review.processing_instruction": ["正在使用已冻结的价格清单重放原始 XML;此时无法修改。", "The original XML is being replayed with the frozen price list. Prices cannot be changed now.", "กำลังประมวลผล XML ต้นทางซ้ำด้วยรายการราคาที่ตรึงไว้ ขณะนี้ไม่สามารถแก้ไขราคาได้"],
"daily_review.progress_aria": ["人工价格复核进度", "Manual price review progress", "ความคืบหน้าการตรวจสอบราคาด้วยตนเอง"],
"daily_review.completed": ["已填写", "Completed", "กรอกแล้ว"],
@@ -265,6 +265,7 @@
"daily_review.affected_records": ["影响记录", "Affected records", "รายการที่ได้รับผลกระทบ"],
"daily_review.rooms_nights": ["房数 / 房晚", "Rooms / room nights", "ห้อง / คืนห้องพัก"],
"daily_review.manual_price": ["人工价格", "Manual price", "ราคาที่กำหนดด้วยตนเอง"],
"daily_review.price_placeholder": ["请输入价格(可填 0)", "Enter price (0 allowed)", "กรอกราคา (กรอก 0 ได้)"],
"daily_review.no_candidates": ["暂无同公司 / Rate Code 固定价", "No fixed price for the same company / Rate Code", "ไม่มีราคาคงที่สำหรับบริษัท / Rate Code เดียวกัน"],
"daily_review.price_key": ["价格键", "price key", "คีย์ราคา"],
"daily_review.manual_price_label": ["{company} {rateCode} 的人工价格", "Manual price for {company} {rateCode}", "ราคาที่กำหนดด้วยตนเองสำหรับ {company} {rateCode}"],
+75
View File
@@ -0,0 +1,75 @@
// Actual UI price saving against a synthetic API, without live business writes.
const test = require('node:test');
const assert = require('node:assert/strict');
const {harness,response} = require('./helpers/arr_ui_harness.cjs');
const fixture=(price=null)=>({case_id:'fixture-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}});
test('zero is a valid explicit price while blank and negative prices remain invalid',()=>{
const h=harness(()=>{throw new Error('unexpected request');});
for(const value of [0,'0',' 0 ','1500']) assert.equal(h.strictReviewPrice(value),true);
for(const value of [null,undefined,'',' ','-1',-1,'0.00','1.5','abc']) assert.equal(h.strictReviewPrice(value),false);
assert.equal(h.reviewPriceInput(0),'0');
assert.equal(h.reviewPriceInput('0'),'0');
assert.equal(h.reviewPriceInput(null),'');
});
test('zero is rendered as a saved value and permits continuation when all items are saved',()=>{
const h=harness(()=>{throw new Error('unexpected request');});
h.renderDailyPriceReview(fixture(0));
const html=h.element('#daily-price-review-body').innerHTML;
assert.match(html,/value="0"/);
assert.match(html,/daily-price-review-row is-complete/);
assert.match(html,/daily_review.saved/);
assert.equal(h.element('#daily-price-review-finalize').disabled,false);
h.renderDailyPriceReview(fixture(null));
assert.match(h.element('#daily-price-review-body').innerHTML,/value=""/);
assert.match(h.element('#daily-price-review-body').innerHTML,/daily_review.price_placeholder/);
assert.equal(h.element('#daily-price-review-body').innerHTML.includes('placeholder="0"'),false);
assert.equal(h.element('#daily-price-review-finalize').disabled,true);
});
test('explicit zero is saved as zero and survives reloading without becoming empty',async()=>{
let current=fixture();
const h=harness((url,options)=>{
if(options.method==='PATCH') {
assert.equal(url,'/api/jobs/fixture-job/review/items/1');
const decision=JSON.parse(options.body);
assert.deepEqual(decision,{case_id:'fixture-case',revision:1,real_price:'0'});
current={...fixture('0'),revision:2};
return response(200,{revision:2});
}
if(url.startsWith('/api/jobs/fixture-job/review?')) return response(200,current);
if(url.startsWith('/api/jobs?')) return response(200,[]);
throw new Error('unexpected request '+url);
});
h.state.dailyPriceReview=fixture();
h.state.dailyPriceReviewJobId='fixture-job';
const input={value:'0',focus(){}};
const row={dataset:{dailyPriceReviewItemId:'1'},querySelector:()=>input};
await h.saveDailyPriceReviewItem({closest:()=>row});
assert.equal(h.calls.filter(c=>c.method==='PATCH').length,1);
assert.equal(h.state.dailyPriceReview.items[0].real_price,'0');
assert.equal(h.state.dailyPriceReview.completed_items,1);
assert.equal(h.dailyPriceReviewCanFinalize(h.state.dailyPriceReview),true);
assert.equal(h.element('#daily-price-review-finalize').disabled,false);
assert.match(h.element('#daily-price-review-body').innerHTML,/value="0"/);
await h.loadDailyPriceReview();
assert.equal(h.state.dailyPriceReview.items[0].real_price,'0');
assert.equal(h.calls.filter(c=>c.method==='PATCH').length,1,'reload must not submit another decision');
});
test('an empty input cannot be silently saved as zero',async()=>{
const h=harness(()=>{throw new Error('unexpected request');});
h.state.dailyPriceReview=fixture();
h.state.dailyPriceReviewJobId='fixture-job';
const input={value:'',focus(){}};
const row={dataset:{dailyPriceReviewItemId:'1'},querySelector:()=>input};
await h.saveDailyPriceReviewItem({closest:()=>row});
assert.equal(h.calls.length,0);
assert.equal(h.state.dailyPriceReview.items[0].real_price,null);
assert.equal(h.dailyPriceReviewCanFinalize(h.state.dailyPriceReview),false);
});
+2 -2
View File
@@ -17,12 +17,12 @@ function harness(respond) {
};
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},crypto:webcrypto,
window:{ARRI18n:{t:key=>key,text:value=>value,errorMessage:code=>code,formatInteger:value=>String(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';
const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview, strictReviewPrice, reviewPriceInput, renderDailyPriceReview, saveDailyPriceReviewItem, loadDailyPriceReview, dailyPriceReviewCanFinalize';
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'});
+1 -1
View File
@@ -470,7 +470,7 @@ class PortalApplicationTests(unittest.TestCase):
self.assertIn("function confirmDailyPriceReviewCancel", script)
self.assertIn("const DAILY_PRICE_REVIEW_PAGE_SIZE = 50", script)
self.assertIn('inputmode="numeric"', script)
self.assertIn('placeholder="0"', script)
self.assertIn('I18N?.t("daily_review.price_placeholder")', script)
self.assertIn("人工价格必须是非负整数,例如 0。", script)
self.assertIn("return formatInteger(numeric);", script)
self.assertNotIn('inputmode="decimal"', script)
@@ -79,6 +79,7 @@ class DailyPriceReviewUiContractTests(unittest.TestCase):
"daily_review.affected_records",
"daily_review.rooms_nights",
"daily_review.manual_price",
"daily_review.price_placeholder",
"daily_review.no_candidates",
"daily_review.manual_price_label",
"daily_review.saved",