線稿索引所有 spec最後更新 2026-06-23
specs/bulk-trip-import.md

批次開團(Excel 上傳建檔)

一句話:上傳一份 Excel / CSV,一次建立大量 行程 / 梯次(月開上百團人工逐筆太慢)。兩階段:dry-run 預覽驗證 → 提交;建檔走既有 createTrip / createDeparture repo,不另開寫入路徑。

畫面(UI = wireframe):批次開團

1. 目標 / 問題

荒野現行用 Google Sheet(總表-六月 / 總表-七月 月曆式排團主檔)規劃整月團期,再人工逐筆敲進系統。一個月上百團,逐筆建檔是上線最痛的瓶頸(0617 會議列為首要任務)。本 spec 讓營運上傳一份對齊欄位的試算表,系統解析、驗證、預覽、一次建檔。

範圍:建 trips(行程定義)+ departures(梯次);若 group-code 對照碼齊備則一併產團號。可對既有行程補梯次,也可新建行程含多梯次不做:AI / 對話式建檔(MCP/CLI)——0617 列為 future,本檔只做檔案上傳;定價解析、訂金、同業價各歸 trip-pricing;配套住宿 / 分房不在匯入範圍(§3.4 串接)。

2. 資料模型

匯入是寫入既有 trips / departures 的工作流,欄位對齊 trips §1:

  • 行程層:slugtitlesummaryduration_daysdestinationis_climbing / is_overseasprice_from_twd 由匯入時取該行程各梯次最低 price_twd 帶入,不在檔表達;status 預設 draft)。
  • 梯次層:departure_datereturn_dateprice_twdcapacitynotesregion_code / airline_code(團號來源碼,選填)。

2.1 import_batches(新增;audit 用)

一次匯入 commit 寫一列,逐筆結果不落表(回前端即可):

型別說明
idtext PK(imp_<ulid>
source_filenametext上傳檔名。
uploaded_by_user_idtext FK → user(同庫)上傳者。
row_countinteger資料列數(不含表頭)。
created_trip_countinteger新建行程數。
created_departure_countinteger新建梯次數。
skipped_countinteger去重 skip 的列數。
error_countinteger失敗列數。
created_attimestamptz

2.2 上傳檔欄位契約

一列 = 一個梯次;同 slug 多列 = 同行程多梯次。提供可下載 .xlsx 範本(含表頭 + 範例列 + 中文說明分頁)。表頭解析以中文表頭或英文 key 皆可(去空白、大小寫不敏感);範本預設中文表頭。

範本表頭(中文)key對應必填格式 / 驗證
行程代碼slugtrips.slug既有→補梯次;不存在→新建行程
行程名稱titletrips.title新行程✅補梯次時可空(以既有行程為準)
目的地destinationtrips.destination新行程✅補梯次時可空
天數duration_daystrips.duration_days新行程✅整數 > 0
行程簡介summarytrips.summary
登山團is_climbingtrips.is_climbing是/否(true/1/Y 等寬鬆解析),預設否;與既有行程衝突→error
海外團is_overseastrips.is_overseas同上
出發日departure_datedepartures.departure_dateYYYY-MM-DD(民國 / 西元 / Excel 序號容錯)
回程日return_datedepartures.return_dateYYYY-MM-DD≥ departure_date
售價price_twddepartures.price_twd整數 TWD、> 0
名額capacitydepartures.capacity整數、> 0
備註notesdepartures.notes
地區碼region_codedepartures.region_code與航空碼成對:兩者皆填→產團號;查無對照碼→error
航空碼airline_codedepartures.airline_code同上

訂金 / 同業價不在匯入檔(走 trip-pricing 的行程 / 梯次編輯)。price_from_twd 不由使用者填:新行程建立時取其各梯次最低 price_twd

3. 邏輯 / 存取層

3.1 兩階段(dry-run → commit)

packages/core/src/catalog/bulk-import.ts(first arg db: ScopedDb):

  1. parse + validate(dry-run):解析檔案(.xlsxexceljs.csv 內建解析)→ 逐列驗證 → 回預覽:每列標 new_trip / append_departure / skip(已存在)/ error(附原因)。不寫任何資料
  2. commit:使用者確認後,僅就有效列(new_trip / append_departure)建檔。沿用 createTrip / createDepartureregion_code + airline_code 皆備則建梯次後呼叫 generateGroupCode。寫 import_batches + 逐筆 audit_log

dry-run 與 commit 共用同一套純函式分類(解析後列 + DB snapshot + 模組授權 → 逐列狀態);commit 以提交當下 DB snapshot 與真實模組授權重判(防 dry-run 後資料變動的 TOCTOU),不得在 commit 端放寬模組 gate。

解析:server-side。.xlsxexceljs(維護良好;SheetJS 的 npm 版已棄),.csv 用內建解析(UTF-8 + BOM 容錯)。上限 1000 資料列,超過於 dry-run 直接擋。

3.2 驗證規則(dry-run 階段全擋)

  • 必填 / 型別 / 日期合理(return ≥ departurecapacity > 0price 為正整數、duration > 0)。
  • 批次內 slug 一致性:同 slug 各列的行程層欄位須一致,否則報錯(避免同行程兩種定義)。
  • 批次內梯次去重:同檔內重複的 (slug, departure_date) 第二列起標 skip(避免單檔複製貼上建出重複梯次)。
  • 模組旗標衝突:新列 is_climbing/is_overseas 與既有行程不符 → 報錯(不靜默覆寫;改行程屬性走行程編輯)。
  • 平台層模組未授權卻匯入 is_climbing/is_overseas=true → 報錯(modules 兩層 gate)。此 gate 於 dry-run 與 commit 兩階段皆強制
  • 團號碼成對region_code / airline_code 只填其一 → 報錯;兩者皆填但 region_codes / airline_codes 對照表查無 → 報錯。

3.3 冪等與部分失敗

  • 去重(slug, departure_date) 已存在(DB)→ 標 skip,不重複建、不覆寫(重傳同檔安全);同檔內重複亦 skip(§3.2)。不支援更新既有梯次價格 / 容量(避免覆寫線上在賣梯次;要改走梯次編輯)。
  • 隔離:commit 以每行程一交易;單一行程失敗只影響該行程,其餘照建,結果逐筆回報。不靜默丟列——skip / error 都要在預覽與結果中現形。
  • 每筆建出的 trip / departure 寫 audit_log;批次寫一筆 import_batches

3.4 與其他 spec 串接(不在本檔重做)

  • 團號group-code 已落地region_code + airline_code 兩欄皆備 → commit 建梯次後呼叫 generateGroupCode(db, { departureId, regionCode, airlineCode, departureDate }) 產團號(巢狀 savepoint,撞號使該行程 tx rollback);缺則團號留空(之後於團號頁產)。本檔不自刻團號邏輯。
  • 定價:匯入只寫 price_twd;通路 / 訂金 / 同業價走 trip-pricing,不在匯入檔表達。
  • 配套住宿 / 分房:不在批次匯入範圍,建檔後於行程編輯設定。

4. UI 與權限

  • 入口/admin/trips 加「批次匯入」;上傳 → 預覽表(逐列狀態 + 錯誤)→ 確認建檔 → 結果摘要(建 N 行程 / M 梯次、skip K、error J)。
  • 範本下載/admin/trips/import/template 產固定欄名的 .xlsx 範本(含範例列 + 中文說明分頁)。
  • 權限:寫入閘走 trip.manage(匯入屬寫入;比照 content-authoring 的 read-gates-write)。頁面、parse / commit action、範本 route 皆 requirePermission('trip.manage')。現況 admin / salestrip.manage;若要 op 可匯入,於 dynamic-rbac/admin/roles 授予 op trip.manage,不在本檔寫死角色。

5. 已裁決(原開放議題)

  • 檔案來源:固定 .xlsx 範本(穩、可驗證);不直接吃現行月曆式總表,總表轉範本由營運手動。
  • 既有梯次更新:不支援。(slug, departure_date) 重複一律 skip,不覆寫線上在賣梯次。
  • 批次規模:同步處理 + 每行程一交易,上限 1000 列;過大再轉背景 job(future)。

Changelog

版本日期變更Commit
12026-06-20初版(草案):Excel/CSV 兩階段批次建檔(dry-run 預覽 → 提交)、欄位契約、去重 / 部分失敗隔離、與團號 / 定價串接邊界。未實作。
22026-06-23定義定案(過夜 /loop):§5 三開放議題裁決(固定 .xlsx 範本 / 不支援更新 / 同步上限 1000 列);§2.1 欄位契約做到可實作精度(中文表頭 ↔ key、寬鬆布林、團號碼成對欄);§2.1 新增 import_batches audit 表;§3.1 解析用 exceljs(.xlsx)+內建(.csv)、commit 以真實模組授權重判(不放寬 gate);§3.2 補批次內梯次去重;§3.4 團號已落地 → 碼齊備則 commit 後 generateGroupCode;§4 權限對齊 code(trip.manage = admin/sales,op 經 /admin/roles 授予)。
32026-06-23一版實作落地:exceljs + import_batches(migration 0017)+ catalog/bulk-import.ts(parseAndValidate/commitImport,純函式 resolveRows)+ /admin/trips/import 三步驟 UI + .xlsx 範本下載 route。tsc 0 / vitest(bulk-import 22)。