feat: add daily manual price review workflow
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user