Quote Request Copy 逐步對照

這份文件的目的不是只回答「有沒有一樣」,而是帶你從入口一路走到回傳,理解舊專案與新專案各自做了什麼。

先釐清 4906.json

  • POST /quote_requests/copy/4906.json 在這裡是 API 路由,不是 repo 內的實體 JSON 檔。
  • 4906 在舊專案代表來源 quote_request_id,也就是要被複製的 request。
  • 舊專案入口長這樣:
    • QuoteRequestsController::copy($id = null, $hash = null)
  • 新專案入口長這樣:
    • Endpoint/V1/QuoteRequests::copy()
    • 先從 body 或 rawParams 取出 quote_request_idhash
    • 再交給 CopyRequestHandler::handle()

換句話說,舊版把 4906 當 path param,新版把它統一整理成 handler 的 $sourceRequestId

一眼看懂流程

  1. 驗證身分
  2. 驗證這張來源 request 可不可以複製
  3. 讀來源 request 主表與 form fields
  4. 建立新的 quote_requests
  5. 複製 quote_form_submissionsquote_form_submission_fields
  6. 重算 fee / summary / expired_on
  7. 做異常掃描與 auto quote
  8. 寫 activity、更新來源 request 的 last_copied_on
  9. 回傳 response

下面開始逐步拆。

Step 1: 入口怎麼收參數

舊專案

檔案:

做法:

  • copy($id = null, $hash = null)
  • 4906 直接進 $id
  • 如果 caller 有 hash,第二段 path 進 $hash

新專案

檔案:

做法:

  • 先讀 rawParams
  • 再讀 body 的 quote_request_idhash
  • body 沒帶時,fallback rawParams[0] / rawParams[1]

理解重點:

  • 這表示新版不是完全放棄舊路由,而是把 path param 轉成統一的 handler input。
  • 入口層面,兩邊是相容思路,不是完全不同 API。

Step 2: 先走 hash,失敗再走 session

舊專案

檔案:

做法:

  • 若有 $hash,查 SecurityHash->getByHash('QuoteRequest', $hash)
  • 只有 foreign_id == $id 才算通過
  • hash 沒過,再走 RestApiHelper::hasValidApiSession($this)

新專案

檔案:

做法:

  • hash !== '',查 SecurityHash()->getByHash('QuoteRequest', $hash)
  • 只有 foreign_id === $sourceRequestId 才通過
  • 不通過再走 ApiValidation::hasValidApiSession(...)

結論:

  • 這段邏輯一致。
  • 兩邊都是「hash 優先,失敗 fallback session」。

Step 3: blocked user 檢查

舊專案

檔案:

回傳:

  • error = 5
  • message = Blocked

新專案

檔案:

回傳:

  • error = 5
  • message = Blocked User

理解重點:

  • 如果只看舊 repo controller 原始碼,會看到 Blocked
  • 但 legacy staging API 實測 response 是 Blocked User
  • 新專案目前已刻意對齊實際 legacy API 行為,不再把這點視為差異。

Step 4: 驗證來源 request 是否屬於本人

舊專案

檔案:

查詢條件:

  • id = $id
  • user_id = $user_id

新專案

檔案:

查詢條件:

  • id = ?
  • user_id = ?

結論:

  • 一致。
  • 新版只是把查詢抽到 SourceRequestLoader

Step 5: reserve_type_a 是否過期

舊專案

檔案:

做法:

  • 查來源 request 的 quote_form_submission_fields
  • type = reserve_type_a
  • DateMatchUtil 判斷是否過期
  • 過期回:
    • error = 9
    • message = 預約日期已過不可複製

新專案

檔案:

做法:

  • 先把 fields 全部載入
  • isReserveTypeAExpired() 逐筆檢查
  • 過期回相同 error / message

結論:

  • 一致。

Step 6: category 必須是 active

舊專案

檔案:

判斷:

  • quote_categories.is_active == 2

新專案

檔案:

判斷:

  • (int)($source['category']['is_active'] ?? 0) !== 2 就擋掉

結論:

  • 一致。

Step 7: 防重點擊與 24 小時同 category 限制

舊專案

檔案:

做法:

  • Redis key: copy_request__{$id},TTL 20 秒
  • 若超過 1 次,回 error = 4
  • 再查 24 小時內同 user 同 category、未 archived、未 closed 的 request 數量
  • >= 3,也回 error = 4

新專案

檔案:

做法:

  • 同樣用 copy_request__{$sourceRequestId}
  • 同樣查 24 小時、同 user、同 category、未 archived、未 closed

結論:

  • 一致。

Step 8: fee 基礎資料

舊專案

檔案:

做法:

  • quote_categories_quote_subscription_types
  • 若查不到,直接回:
    • error = 1
    • message = no site_fee info
  • 若是 booking user,全部 fee = 0,且把 match_type 改成 BOOKING_ADMIN_USER

新專案

檔案:

做法:

  • 也是查 getFees($quoteCategoryId)
  • booking user 也會把 fee 歸零
  • 查不到 fee 時,會回:
    • error = 1
    • message = no site_fee info

結論:

  • 已對齊。

Step 9: 建立新的 quote_requests

舊專案

檔案:

做法:

  • 以來源 request 為基底改寫欄位
  • 重要欄位包含:
    • site_fee/manual_fee/auto_fee/direct_fee/contact_charge_fee
    • copied_quote_request_id = $id
    • request_lang_id
    • send_client
    • block_provider_ids
    • has_preserve = 0
    • is_allow_contact_charge = 0
    • is_expired = 0
    • tags = [ConstJobCenterLabel::MULTI_REQUEST],實際值是 ["回客"]

新專案

檔案:

做法:

  • QuoteRequestsDO
  • 寫入同類型欄位
  • 一樣寫:
    • copied_quote_request_id
    • request_lang_id
    • send_client
    • block_provider_ids
    • has_preserve = 0
    • is_allow_contact_charge = 0
    • is_expired = 0
    • tags = [ConstJobCenterLabel::MULTI_REQUEST],實際值是 ["回客"]

理解重點:

  • 核心主表欄位大方向是一致的。
  • 新版建立主表前,還會先做:
    • hasSiteFeeInfo()
    • resolveExpiredOn()

欄位策略補充:

  • 舊版原始 select 沒有把 match_type / specific_user_tag 撈進來
  • 新版目前已跟著對齊:
    • 一般 copy 不沿用來源 match_type
    • booking user 會強制 match_type = BOOKING_ADMIN_USER
    • specific_user_tag 不會從來源 request 帶到新 request
  • block_provider_ids 要分環境判讀:
    • legacy controller 會把 consumer 歷史已報價 provider 追加進 block list
    • 新版只有在 site_environment=production 時追加
    • 非 production integration test 會保留 source block_provider_ids,避免 shared DB 歷史 bid 讓測試不穩定

結論:

  • production 主線已對齊;非 production 的 block_provider_ids 行為是刻意穩定測試的環境差異。

Step 10: 複製 form submission 與 fields

舊專案

檔案:

做法:

  • 先新增 quote_form_submissions
  • 再把來源 request 的 quote_form_submission_fields 複製到新 request

新專案

檔案:

做法:

  • 同樣先 insert quote_form_submissions
  • 再逐筆 insert quote_form_submission_fields

結論:

  • 一致。

Step 11: recommend_source log

舊專案

檔案:

做法:

  • 如果 request 有 recommend_source
  • 就寫 quote_request_recommend_logs

新專案

檔案:

做法:

  • 一樣寫 quote_request_recommend_logs
  • 另外兼容兩種 payload:
    • top-level recommend_source
    • QuoteRequest[recommend_source]

結論:

  • 新版至少涵蓋舊版,還多做了一層相容。

Step 12: 重新計算 variable fee、summary、expired_on

舊專案

檔案:

做法:

  • updateRequestVariableFee(...)
  • reload_lang_summary(...)
  • 之後再依 due_date 或 category 預設天數寫 expired_on

新專案

檔案:

做法:

  • CopyRequestFeeUpdater->update(...)
  • reloadSummary(...)
  • expired_on 在建主表前就先算好

理解重點:

  • 算法方向一致。
  • 只是新版把責任拆成獨立 class,而且 expired_on 提前到建單時就寫入。
  • 目前 code 也保留了舊版的 variable fee / discount / high value / area special case 調整邏輯。

Step 13: 異常掃描

舊專案

檔案:

做法:

  • 針對海外電話、new user / keyword、category keyword 等多段邏輯判斷
  • 若命中,會把 request 改成:
    • match_type = ABNORMAL_CHECK
    • is_notification_mail_sent = 1

新專案

檔案:

做法:

  • 改由 AbnormalScanWriter()->write(...) 處理

理解重點:

  • 這裡新版不是逐行搬舊碼,而是改走共用元件。
  • 是否完全等價,要看 AbnormalScanWriter 是否已把 copy 情境涵蓋完整。
  • 從現有測試命名看,已有 QuoteRequestCopyAbnormalIntegrationTest.php 幫忙守這段,但你若要做 code-level parity review,這裡仍然是重點區。

Step 14: auto quote 與 task queue

舊專案

檔案:

做法:

  • 只有在這兩個條件都成立時才跑 auto quote:
    • origin_copied_quote_request_idnull
    • origin_created > getJPLastModified($quote_category_id)
  • 且必須 !$is_scan_found
  • hk 區域另外會丟 newQuoteRequest task

新專案

檔案:

做法:

  • abnormal scan 沒命中時,會先檢查 legacy gate:
    • 來源 request 不能本來就是 copy 出來的
    • 來源 request 建立時間要晚於 getJPLastModified($quote_category_id)
  • 只有 gate 通過時才跑:
    • AutoQuoteDispatcher()->dispatch(...)
    • QuoteBidFinalizer()->finalize(...)
  • hk 區域同樣在 !$is_scan_found 時丟 newQuoteRequest task

結論:

  • 已對齊。

Step 15: activity 與 last_copied_on

舊專案

檔案:

做法:

  • 建立 CreateRequest activity
  • 更新來源 request 的 last_copied_on

新專案

檔案:

做法:

  • createRequestActivity(...)
  • touchSourceRequest(...)

結論:

  • 一致。

Step 16: 錯誤回傳 shape

舊專案

可能回傳:

  • error = 1, message = invalid request
  • error = 1, message = no site_fee info
  • error = 2, message = invalid session
  • error = 3, message = failed to create new request
  • error = 4, message = failed to create form submission
  • error = 6, message = failed to create form fields

新專案

檔案:

做法:

  • 交易中拋例外時,現在會保留 legacy 細分:
    • error = 3, message = failed to create new request
    • error = 4, message = failed to create form submission
    • error = 6, message = failed to create form fields

結論:

  • 已對齊。

整體結論

如果你的問題是「新版 copy 主流程大方向有沒有對上舊專案」:

  • 有,大部分核心步驟都在
  • 包含驗證、來源載入、form clone、fee 更新、summary、activity、last_copied_on

如果你的問題是「新版是否已經和舊專案完全一致」:

  • 以目前已確認的主流程、guard、fee、auto quote gate、error shape、欄位寫入策略來看,主要 parity 已經收齊
  • 如果還要再追,會比較偏向零碎邊角案例,不是目前這條 copy 主線的已知大差異

目前已確認收斂的重點差異是這 4 個:

  1. Blocked User response shape
  2. 缺 fee 設定時回 no site_fee info
  3. auto quote 的 legacy gate
  4. 建單後失敗的細分 error code

目前仍要用環境條件判讀的差異:

  1. block_provider_ids:production 追加歷史已報價 provider;非 production 保留 source 值。
  2. HK newQuoteRequest task:forced-HK 測試已過,但完整區域 baseline 需要 HK 環境。
  3. matching 數字:20212 -> 20213 是 reference baseline,今天重跑同 source 可能受 provider 資料漂移影響。

我會怎麼解讀這些差異

  • 第 1 點最後是以 legacy staging 實際回應為準
  • 第 2 點與第 3 點屬於功能性差異,已補回同步
  • 第 4 點屬於 error parity,現在也已補回同步
  • 欄位寫入策略這輪也已補回同步

建議閱讀順序

  1. 先看入口與 guard
  2. 再看新版主流程
  3. 再看兩個拆出的 helper