feat: add daily manual price review workflow

This commit is contained in:
Wyndham ARR
2026-08-06 22:40:18 +08:00
parent aae3d8e1db
commit ca3e8e18fa
77 changed files with 7750 additions and 602 deletions

View File

@@ -171,6 +171,69 @@ class FakeUpload:
return {"job_id": "arr-job-1", "status": "queued"}
class FakeDailyPriceReviews:
case_id = "dailyreview-0123456789abcdef0123456789abcdef"
def __init__(self) -> None:
self.read_calls: list[tuple[str, int, int]] = []
self.update_calls: list[tuple[str, int, str, int, str, str]] = []
self.finalize_calls: list[tuple[str, str, int, str]] = []
self.cancel_calls: list[tuple[str, str, int, str]] = []
def payload(self) -> Dict[str, Any]:
return {
"case_id": self.case_id,
"case_status": "open",
"revision": 3,
"business_date": "2026-07-27",
"completed_items": 1,
"total_items": 2,
"items": [
{
"item_id": 9,
"company_key": "QBD",
"rate_code": "GRPA1",
"effective_rate_amount": 1800,
"candidate_prices": [{"effective_rate_amount": 900, "real_price": 1800}],
"affected_records": 2,
"affected_rooms": 3,
"affected_room_nights": 3,
"real_price": "0",
"revision": 3,
}
],
"pagination": {"limit": 50, "offset": 0, "total": 2, "has_next": False},
}
def get_price_review(self, job_id: str, limit: int, offset: int) -> Dict[str, Any]:
self.read_calls.append((job_id, limit, offset))
return self.payload()
def update_price_review_item(
self,
job_id: str,
item_id: int,
case_id: str,
revision: int,
real_price: str,
actor_username: str,
) -> Dict[str, Any]:
self.update_calls.append((job_id, item_id, case_id, revision, real_price, actor_username))
return self.payload()
def finalize_price_review(
self, job_id: str, case_id: str, revision: int, actor_username: str
) -> Dict[str, Any]:
self.finalize_calls.append((job_id, case_id, revision, actor_username))
return {"job_id": job_id, "status": "needs_review", "review_case_id": case_id}
def cancel_price_review(
self, job_id: str, case_id: str, revision: int, actor_username: str
) -> Dict[str, Any]:
self.cancel_calls.append((job_id, case_id, revision, actor_username))
return {"job_id": job_id, "status": "cancelled", "review_case_id": case_id}
class FakeMonthly:
def __init__(self) -> None:
self.calls: list[tuple[str, date]] = []
@@ -216,10 +279,12 @@ def login(
class PortalApplicationTests(unittest.TestCase):
def setUp(self) -> None:
self.upload = FakeUpload()
self.price_reviews = FakeDailyPriceReviews()
self.monthly = FakeMonthly()
self.app = PortalApplication(
repository=FakeRepository(),
upload=self.upload,
price_reviews=self.price_reviews,
monthly=self.monthly,
health=RuntimeHealth(True, True, True),
sessions=SessionLedger(),
@@ -303,6 +368,11 @@ class PortalApplicationTests(unittest.TestCase):
'id="company-review-delete-selected"',
'id="company-review-select-page"',
'id="company-review-confirm-dialog"',
'id="daily-price-review-panel"',
'id="daily-price-review-body"',
'id="daily-price-review-finalize"',
'id="daily-price-review-cancel"',
'id="daily-price-review-confirm-dialog"',
'id="company-report-confirm-dialog"',
'id="company-report-confirm-cancel"',
'id="company-report-confirm-submit"',
@@ -379,6 +449,8 @@ class PortalApplicationTests(unittest.TestCase):
self.assertIn(".company-review-input:focus-visible", styles)
self.assertIn(".company-review-delete-selected", styles)
self.assertIn(".company-review-confirm-dialog::backdrop", styles)
self.assertIn(".daily-price-review-input:focus-visible", styles)
self.assertIn(".daily-price-review-confirm-dialog::backdrop", styles)
self.assertIn(".company-report-confirm-dialog::backdrop", styles)
script = (STATIC_ROOT / "app.js").read_text(encoding="utf-8")
self.assertIn('navigator.clipboard?.writeText', script)
@@ -393,6 +465,18 @@ class PortalApplicationTests(unittest.TestCase):
self.assertIn('if (event.key !== "Escape") return;', script)
self.assertIn("activateCompanyReviewDraft", script)
self.assertNotIn("window.confirm", script)
self.assertIn("function finalizeDailyPriceReview", script)
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("人工价格必须是非负整数,例如 0。", script)
self.assertIn("return formatInteger(numeric);", script)
self.assertNotIn('inputmode="decimal"', script)
self.assertNotIn("人工价格必须是非负且恰好两位小数", script)
i18n_script = (STATIC_ROOT / "i18n.js").read_text(encoding="utf-8")
self.assertIn('DATABASE_WRITE_FAILED: "error.database_write_failed"', i18n_script)
self.assertIn('api(`/api/jobs/${encodeURIComponent(jobId)}/review', script)
self.assertIn("companyReportConfirmIsOpen", script)
self.assertIn("confirmCompanyReportAction", script)
self.assertIn("BOOKING_EXCEL_SOURCE_ALREADY_ACTIVATED", script)
@@ -690,6 +774,73 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual(decoded(response)["error"]["code"], "UPLOAD_CONTENT_INVALID")
self.assertEqual(self.upload.calls, [])
def test_price_review_routes_are_authenticated_csrf_protected_and_strict(self) -> None:
unauthenticated = self.app.handle("GET", "/api/jobs/job-1/review?limit=5&offset=2", {})
self.assertEqual(unauthenticated.status, 401)
review = self.app.handle(
"GET", "/api/jobs/job-1/review?limit=5&offset=2", self.auth_headers
)
self.assertEqual(review.status, 200)
payload = decoded(review)["data"]
self.assertEqual(payload["case_id"], FakeDailyPriceReviews.case_id)
self.assertEqual(self.price_reviews.read_calls, [("job-1", 5, 2)])
rendered = json.dumps(payload, ensure_ascii=False)
for private_value in ("SYNTHETIC GUEST", "RES_COMMENT", "TRACE_TEXT"):
self.assertNotIn(private_value, rendered)
missing_csrf = self.app.handle(
"PATCH",
"/api/jobs/job-1/review/items/9",
{"Cookie": self.auth_headers["Cookie"], "Content-Type": "application/json"},
json.dumps({"case_id": FakeDailyPriceReviews.case_id, "revision": 3, "real_price": "0"}).encode(),
)
self.assertEqual(missing_csrf.status, 403)
malformed = self.app.handle(
"PATCH",
"/api/jobs/job-1/review/items/9",
{**self.session_headers(), "Content-Type": "application/json"},
json.dumps({"case_id": FakeDailyPriceReviews.case_id, "revision": 3, "real_price": "0.00"}).encode(),
)
self.assertEqual(malformed.status, 400)
self.assertEqual(decoded(malformed)["error"]["code"], "REVIEW_REQUEST_INVALID")
updated = self.app.handle(
"PATCH",
"/api/jobs/job-1/review/items/9",
{**self.session_headers(), "Content-Type": "application/json"},
json.dumps({"case_id": FakeDailyPriceReviews.case_id, "revision": 3, "real_price": "0"}).encode(),
)
self.assertEqual(updated.status, 200)
self.assertEqual(
self.price_reviews.update_calls,
[("job-1", 9, FakeDailyPriceReviews.case_id, 3, "0", TEST_CREDENTIALS.username)],
)
finalized = self.app.handle(
"POST",
"/api/jobs/job-1/review/finalize",
{**self.session_headers(), "Content-Type": "application/json"},
json.dumps({"case_id": FakeDailyPriceReviews.case_id, "revision": 3}).encode(),
)
cancelled = self.app.handle(
"POST",
"/api/jobs/job-1/review/cancel",
{**self.session_headers(), "Content-Type": "application/json"},
json.dumps({"case_id": FakeDailyPriceReviews.case_id, "revision": 3}).encode(),
)
self.assertEqual(finalized.status, 200)
self.assertEqual(cancelled.status, 200)
self.assertEqual(
self.price_reviews.finalize_calls,
[("job-1", FakeDailyPriceReviews.case_id, 3, TEST_CREDENTIALS.username)],
)
self.assertEqual(
self.price_reviews.cancel_calls,
[("job-1", FakeDailyPriceReviews.case_id, 3, TEST_CREDENTIALS.username)],
)
def test_monthly_generation_is_csrf_protected_and_typed(self) -> None:
response = self.app.handle(
"POST",