線稿索引所有 spec最後更新 2026-07-03
specs/cost-template.md

系列團成本範本

一句話:租戶層可重用的成本範本庫,行程綁定一份預設範本後,其梯次自動帶入預設成本行(品項 × 單價 × 數量),數量可動態綁報名人數 / 領隊數即時重算,金額可逐行手改。建構於 control-financedeparture_costs 之上,範本只負責產生成本行初值,產生後即為一般 departure_costs,受同一套覆核 / 轉請款 / 不變式管轄。

畫面(已實作):成本範本庫 /admin/cost-templates · 行程編輯頁綁定範本欄 · 團控成本登記頁範本卡 /admin/control/[depId]/finance。高擬真藍圖見 wireframe 範本庫 · 團控成本登記頁

1. 目標 / 問題

旅行社有大量系列團(常態固定會開的團,如「玉山主峰三日」每月開)。同一系列團的成本結構幾乎固定:車資、餐費、保險、嚮導費、入山證、雜支…… 現行 departure_costs 全靠線控逐筆手填control-finance §4),同型行程每開一梯就要重打一次相同明細,且金額隨人數浮動還要心算。

本檔讓租戶建立可重用成本範本行程綁定一份預設範本;該行程的梯次自動帶入範本定義的成本行,其中「數量」可動態綁定報名人數 / 領隊數,套用與人數變動時即時重算金額,線控仍可逐行手改覆寫。

範圍:成本範本庫 CRUD + 行程綁定 + 梯次自動套用 + 動態數量重算 + 後台 UI / 權限。 不做

  • 不改 departure_costs 的金流語意:範本產生的列就是一般成本列,內外帳 / 可申報額 / 淨利 / 覆核仍由 internal-external-ledger 擁有;轉請款仍走 control-finance §5
  • 不做價格範本:對客報價 / 雙價 / 訂金規則屬 trip-pricing,與本檔的「對供應商成本」無關。
  • 不做「每 N 人一組 roundup」自動分組:第一版以離散數量來源(旅客數 / 旅客+領隊數 / 領隊數 / 自訂固定值)涵蓋,roundup 分組留 follow-up(§5)。

2. 資料模型

2.1 cost_templates(新表)

可重用的命名範本,租戶 DB 內。

欄位型別說明
idtext PK(ct_<ulid>主鍵。
nametext NOT NULL範本名稱(如「玉山主峰三日系列團」)。
descriptiontext備註。
statustext NOT NULL default 'active'active(可綁定 / 套用)→ archived(停用,既有綁定不受影響、不可新綁)。
created_at / updated_attimestamptz

2.2 cost_template_lines(新表)

範本的成本行定義。每行 = 品項(自訂)× 單價(自訂)× 數量

欄位型別說明
idtext PK(ctl_<ulid>主鍵。
template_idtext FK → cost_templates.id(onDelete cascade)屬於哪個範本。
categorytext NOT NULL品項(自訂;可參照 DEPARTURE_COST_CATEGORIES 但不限於該集合)。
vendor_nametext預設供應商(可空,套用後可改)。
descriptiontext成本摘要範本。
unit_price_twdinteger(≥ 0)nullable單價,整數 TWD。currency='TWD' 時必填;外幣行為 NULL(改填 unit_price_foreign)。
currencytext NOT NULL default 'TWD'幣別(ISO 4217)。'TWD' = 現行台幣行為;非 TWD = 外幣行。
unit_price_foreigninteger(≥ 0)nullable外幣每單位單價(最小單位整數);外幣行必填、TWD 行 NULL
internal_rate_sourcetext nullable外幣行內帳匯率來源:fx_account(外幣帳戶 WAC)/ published(台銀牌告)。範本層不支援 manual(手填實際成交率本質 per-transaction,套用後可逐列改)。TWD 行 NULL。
fx_account_idtext FK → receiving_accounts.id(onDelete restrict)nullableinternal_rate_source='fx_account' 時支出的外幣帳戶(套用時取其 WAC,且須幣別相符)。
quantity_modetext NOT NULL數量 kind:fixed(固定值)/ headcount(人頭公式,見下)。
quantity_fixedinteger(≥ 0)nullablequantity_mode='fixed' 時的固定數量;headcount 時為 NULL。
headcount_componentstext[] nullablequantity_mode='headcount' 時要相加的人頭,子集 {passenger, leader, assistant, driver}(至少一項);fixed 時為 NULL。
group_divisorinteger(≥ 1)nullableheadcount 時的「每 N 人一組」組距:NULL = 直接用人頭總和;填 N = ceil(總和 / N)(無條件進位,不足一組仍算一組)。fixed 時為 NULL。
is_declarableboolean NOT NULL default true套用後成本行的預設憑證狀態(internal-external-ledger §2.1);套用後可改。
sort_orderinteger NOT NULL default 0顯示 / 套用順序。
created_at / updated_attimestamptz

數量 = 固定值,或 ceil( Σ選定人頭 / 組距 )。這條小公式取代舊的離散 mode 列舉(passenger / passenger_plus_leader / leader…),用「人頭多選 + 選填組距」一次涵蓋全部情形,且新增人頭(司機 / 協作)或「每 N 人一組」零 schema 改動。對照範例:

想要的headcount_componentsgroup_divisor
旅客數{passenger}
旅客 + 領隊數{passenger, leader}
領隊數{leader}
司機數{driver}
roundup((旅客+司機)/8){passenger, driver}8
每 8 客配 1 領隊 roundup(旅客/8){passenger}8

約束(DB check)

  • unit_price_twd >= 0unit_price_foreign >= 0quantity_fixed >= 0group_divisor >= 1(各 NULL 允許)。
  • quantity_mode IN ('fixed','headcount')
  • fixedquantity_fixed IS NOT NULLheadcount_components IS NULLgroup_divisor IS NULL
  • headcountheadcount_components 非空陣列 且 quantity_fixed IS NULLgroup_divisor 選填)。
  • headcount_components 各元素 ∈ {passenger, leader, assistant, driver}
  • TWD 行currency='TWD')⟺ unit_price_twd IS NOT NULL 且外幣欄(unit_price_foreign / internal_rate_source / fx_account_id)全 NULL。
  • 外幣行currency<>'TWD')⟺ unit_price_foreign IS NOT NULLunit_price_twd IS NULLinternal_rate_source IN ('fx_account','published')
  • fx_account 來源 ⟺ fx_account_id IS NOT NULL外幣帳戶「存在且幣別與成本幣別相符」由 app 層把關(跨表,DB check 表達不全;比照 departure-costs 的 FX_ACCOUNT_CURRENCY_MISMATCH)。

2.3 trip_cost_templates(行程 × 範本綁定 junction,多範本疊加)

一行程可綁多份範本疊加(如「基礎 + 登山加購」),套用時依範本層 sort_order → 行層 sort_order 展開。

欄位型別說明
trip_idtext FK → trips.id(onDelete cascade)PK 之一。
cost_template_idtext FK → cost_templates.id(onDelete cascade)PK 之一。junction PK (trip_id, cost_template_id) 天然擋重複綁定。
sort_orderinteger NOT NULL default 0範本層疊加順序(小→大)。
created_attimestamptz

多範本疊加:綁定為整批替換語意(setTripCostTemplates(db, tripId, [id…])):每份須 active(否則整批 reject TEMPLATE_NOT_ACTIVE),依傳入順序寫 sort_order;空集合 = 全解綁。同 category 行不合併(跨範本兩列並存,責任清晰)。範本 / 行程刪除時 cascade 清 junction(不連帶刪既有成本列)。

trips.cost_template_id 舊單一 FK 欄已移除(migration 0041 backfill 進 junction 後,0042 直接 DROP;junction 是唯一綁定真相)。

2.4 departure_costs 範本來源維度(擴充欄)

SSOT 歸屬departure_costs 基礎欄(category / vendor_name / amount_twd / payable_id 等)主檔在 control-finance §4;內外帳維度(invoice_amount_twd / is_declarable / expense_proof_id / review_status)在 internal-external-ledger §2。本檔只擴充下列範本來源欄,不重定義基礎欄、不另起第二套成本表。

欄位型別說明
template_line_idtext FK → cost_template_lines.id(onDelete set null)nullable此成本列的來源範本行。NULL = 手動 ad-hoc 列(同現行手填)。
quantity_modetext nullable複製自範本行(fixed / headcount);動態重算用。手動列為 NULL。
headcount_componentstext[] nullable複製自範本行(headcount 用,動態重算的人頭組合)。
group_divisorinteger nullable複製自範本行(headcount 的組距)。
quantityinteger nullable解析後(headcount)或自訂(fixed)的數量。
unit_price_twdinteger nullable複製自 TWD 範本行的單價。
unit_price_foreigninteger nullable複製自外幣範本行的外幣單價;外幣 headcount 列重算基礎(foreign_amount = unit_price_foreign × quantity)。TWD 列 NULL。
is_manual_overrideboolean NOT NULL default false線控手改本列金額後設 true停止自動重算(人數變動不再覆蓋手改值)。

外幣列(§3.2):套用外幣範本行時,departure_costs 的外幣維度欄(currency / foreign_amount / internal_rate_source / fx_account_id / internal_rate / external_rate,見 internal-external-ledger §2.1.4)由套用當下解析雙率快照寫入;台幣 amount_twd = round(foreign_amount × internal_rate)invoice_amount_twd = is_declarable ? round(foreign_amount × external_rate) : 0。重算沿用快照(不重解析匯率,currency-exchange §3.2 不漂移)。

amount_twd 仍是真實成本的存量真相(control-finance §4,SQL 直接 SUM)。headcount 列的 amount_twd = unit_price_twd × resolved_quantity(quantity 依 §3.1 公式),由 service 在套用時人頭(旅客 / 領隊 / 協作 / 司機)變動時重算寫回(§3.3),除非 is_manual_override=true 或已轉請款(payable_id 非 NULL)鎖定。departure_costs 自存 headcount_components / group_divisor,重算不依賴 template_line_id(範本行刪除後仍正確)。

3. 邏輯 / 存取層

3.1 數量解析(resolve quantity)

quantity_mode當下梯次資料解析:

  • fixedquantity = quantity_fixed(不隨人數變)。
  • headcount → 先加總選定人頭 sum = Σ component_count,再套組距:
    • group_divisor 為 NULL → quantity = sum
    • group_divisor = Nquantity = ceil(sum / N)(無條件進位,不足一組仍算一組)。

各人頭 component 的當下來源:

component來源
passengerdepartures.booked_count(報名席次)。
leaderdeparture_guide_assignmentsstatus='confirmed'role='leader' 的數量。
assistantdeparture_guide_assignmentsstatus='confirmed'role='assistant' 的數量。
driverdeparture_transportdeparture-prep §2.4)的司機數。
  • 解析後的數量寫入 departure_costs.quantityamount_twd = unit_price_twd × quantity
  • 範例:roundup((旅客+司機)/8) = headcount_components={passenger,driver} + group_divisor=8;旅客 24 + 司機 1 → ceil(25/8)=4 組。

3.2 套用範本(apply)

applyTemplateToDeparture(db, departureId):JOIN trip_cost_templates 取該梯次所屬行程綁定的全部 active 範本,依範本層 sort_order → 行層 sort_order 展開 lines → 為每行 INSERT departure_costs

  • template_line_id / quantity_mode / headcount_components / group_divisor / unit_price_twd 複製自範本行;quantity = §3.1 解析;category / vendor_name / description / is_declarable 帶範本預設。
  • TWD 行amount_twd = unit_price_twd × quantityis_declarable=trueinvoice_amount_twd = amount_twdinternal-external-ledger §3.3)。
  • 外幣行(§2.4)套用當下(taipei 今天)解析雙率——外帳一律台銀牌告即期賣出、內帳依 internal_rate_sourcefx_account → 外幣帳戶 WAC、published → 台銀牌告)——快照進 departure_costs(currency / foreign_amount / internal_rate_source / fx_account_id / internal_rate / external_rate / unit_price_foreign)。foreign_amount = unit_price_foreign × quantityamount_twd = round(foreign_amount × internal_rate)invoice_amount_twd = is_declarable ? round(foreign_amount × external_rate) : 0匯率查無 → degrade 跳過該外幣行(回報 skippedForeign 計數,其餘行照套)——auto-apply 掛在建梯次 / 建單等既有 tx 上,不能因牌告未同步 / 外幣帳戶尚無期初就 abort 整個上游操作;套用冪等(template_line_id 去重),補齊匯率後再套用即自動補回被跳過的行。逐列手動登記外幣成本仍 fail-closed(internal-external-ledger §2.1.4,登記當下必須解析得出雙率)。
  • review_status='approved'created_by_user_id = 套用 actor(系統自動套用時為觸發建梯次者);線控 / 系統套用視為已覆核,無 pending gate(嚮導端不套用範本,見 guide-portal)。
  • 每筆寫 audit_logaction='departure_cost.create',metadata 標 templateLineId)。

套用時機 = 自動

  • 建梯次(departure create)時,若所屬行程已綁範本 → 自動套用一次。
  • 冪等(idempotent):以 template_line_id 去重(line-level,跨範本天然不衝突)——已存在該來源行的成本列不重插。範本之後新增的行、或行程新綁的範本,可在梯次成本登記頁按「套用範本」補套(只補缺的行)。
  • 範本行被移除 / 改值不回寫既有梯次(已套列保留),符合 control-finance §6 promo 快照「改設定只影響未來」原則。

3.3 動態重算(recalculate)

recalculateDepartureTemplateCosts(db, departureId):對該梯次所有滿足

  • quantity_mode = 'headcount'動態列;以 departure_costs 自存的 quantity_mode / headcount_components / group_divisor 為準,不靠 template_line_id——範本行被刪 / set null 後此列仍正確重算,見下「provenance」)
  • is_manual_override = false(未手改)
  • payable_id IS NULL(未轉請款鎖定)

的列,重算 quantity = §3.1 公式,寫回 + 寫 audit。TWD 列amount_twd = unit_price_twd × quantity外幣列foreign_amount = unit_price_foreign × quantityamount_twd = round(foreign_amount × internal_rate 快照)invoice_amount_twd = is_declarable ? round(foreign_amount × external_rate 快照) : 0——沿用登記雙率快照、不重解析匯率currency-exchange §3.2 庫存均價不漂移)。比照 order-money-model 既有 recalculateOrderRoomUpgrade 的「同 tx 重算」模式(折扣 / 應收一致性),不 inline 算式。

template_line_id 只是 provenance(來源溯及),不是重算條件。重算 / 動態與否一律看 departure_costs 自存的 quantity_mode(套用時已複製,§3.2);fixed 與手動 ad-hoc 列(quantity_mode IS NULL)不重算。

invoice 同步:重算 headcount 列時,若 is_declarable=trueinvoice_amount_twd 一併設為新 amount_twd(維持「可申報預設=真實額」,internal-external-ledger §3.3);is_declarable=false 維持 0。一旦線控手改該列(is_manual_override=true),amount 與 invoice 皆凍結、不再自動重算(含手動上修 / 下修的可申報額得以保留)。

觸發點:人頭任一變動——報名成立 / 取消(booked_count)、領隊 / 協作指派確認 / 異動(departure_guide_assignments)、司機派車異動(departure_transport)、套用當下。所有改動上述來源的 mutation 同交易呼叫本 service(漏呼叫即金額 stale)。

手改:線控在成本登記頁編輯某列金額 → 設 is_manual_override=true,之後重算跳過該列(人數再變也不覆蓋)。手改列要恢復動態,明確「重設為範本算法」(清 override)才會再進重算。

3.4 與既有不變式相容

範本產生的列就是一般 departure_costs

  • 內外帳不變式(internal-external-ledger §3.3):invoice_amount_twd 預設 = amount_twd、可上修可下修;is_declarable=false 強制可申報額 0 + 掛支出證明單。
  • 轉請款鎖定(control-finance §5):payable_id 非 NULL 的列不可改 / 刪(含重算跳過)。
  • append-only 沖正:已轉請款的改錯走反向 / 重開,不回改原列。

套用路徑不開支出證明單(已知設計取捨):套用(§3.2)createDepartureCost,而是直接 INSERT。因此 is_declarable=false 的範本成本列在套用當下自動開立支出證明單(expense_proof_id 為 NULL)—— 範本只產生成本行初值(review_status='approved'invoice_amount_twd=0),證明單留待團控 / 會計於登記頁編輯該列(轉為走 createDepartureCost/updateDepartureCost)或轉請款前補。DB check departure_costs_declarable_invoice 僅要求 is_declarable OR invoice=0,不要求 expense_proof_id,故此列合法。聚合(淨利 / 不可申報額)仍正確(amount − invoice 計入不可申報),唯缺一張可列印的內部憑證。

4. UI 與權限

4.1 版面

  • 成本範本庫(後台設定層):範本列表(名稱 / 行數 / 狀態)+ 範本編輯(行 CRUD:品項 / 單價 / 數量設定 / 憑證預設 / 排序)。停用走 status='archived'
    • 數量設定 UI(直接組合,不做 preset 抽象):先選 kind「固定值 / 依人頭」。fixed → 填固定數量;headcount → 直接勾人頭多選(旅客 / 領隊 / 協作 / 司機,至少一)+ 選填組距(空 = 加總;填 N = 每 N 人一組、ceil 無條件進位)。即時預覽算式(如 = ceil((旅客24 + 司機1) / 8) × 單價)。不提供 preset 下拉——租戶直接組自己要的公式(人頭組合因租戶 / 行程型態而異,預設模板會綁死假設),最大化多租戶彈性。寫的就是 quantity_mode / headcount_components / group_divisor 三欄。
  • 行程編輯頁:新增「綁定成本範本」欄,<Combobox>active 範本(選項來自 DB,依後台 UI 治理下拉政策)。
  • 團控成本登記頁/admin/control/[departureId]/costs):
    • 範本來源列標示「範本」badge + 動態算說明(如「ceil((旅客24+司機1)/8) × $6,000」或「旅客數 24 × $300」);手改後標「已手改」並停止重算。
    • 「套用範本」按鈕:預覽要補套的範本行(已套的略過)+ 算出金額,確認後批次寫入。

4.2 權限(新 resource cost_template

範本庫是成本結構設定(財務 setup),與「逐梯成本登記」(現場作業)分權限:

action用途opadminaccountantsales
cost_template.read看範本庫 / 行程綁定欄
cost_template.manage建 / 編 / 停用範本、設行程綁定
  • cost_template.manage:範本是會計 / 主管定義的成本結構 → admin / accountant(op 是否可 manage 見 §5)。行程綁定欄的存檔同時需 trip.manage(編行程本體)。
  • 套用範本 / 手改成本列:是 per-departure 現場作業,走既有 departure_cost.create / departure_cost.manageinternal-external-ledger §4.2),不在本 resource
  • 自動套用(建梯次時)是系統動作,不另設 permission(建梯次本身走 departure.update / trip.manage)。

5. 開放議題

  • 司機數來源(多車 / 兼任)driverdeparture_transport 的司機數;若一梯多車、或司機兼領隊,計數規則(去重 / 以車計)待 departure-prep 對齊。
  • 組距進位只支援 ceil:第一版 group_divisor 一律無條件進位(GS「每 N 人一組」實務);如未來要 round / floor 再加 rounding mode 欄(暫不做)。
  • 一行程多範本已落地):trip_cost_templates junction 多範本疊加(§2.3),整批替換綁定、依 sort_order 展開、line-level 冪等去重。
  • 外幣範本行已落地):外幣範本行帶幣別 + unit_price_foreign + 內帳匯率來源(§2.2),套用當下解析雙率快照(§3.2),重算沿用快照(§3.3)。範本層不支援 manual 匯率(per-transaction 語意,套用後可逐列改成 manual)。
  • 範本改版回寫:第一版改範本只影響未來套用,已套列不回寫(快照語意)。是否提供「以最新範本重套此梯次」批次動作,待需求。
  • op 是否可 cost_template.manage:線控掌成本作業,可能要自建系列團範本;第一版鎖 admin / accountant,待營運回饋。

Changelog

版本日期變更Commit
12026-06-24初版:系列團可重用成本範本庫(cost_templates / cost_template_lines)+ trips.cost_template_id 一對一綁定 + departure_costs 範本來源維度(template_line_id / quantity_mode / quantity / unit_price_twd / is_manual_override)。數量四模式(旅客數 / 旅客+領隊數 / 領隊數 / 自訂固定值)動態解析;自動套用(建梯次)+ 冪等補套 + 人數變動重算(比照 recalculateOrderRoomUpgrade)+ 逐行手改覆寫。新 resource cost_template(read / manage,admin / accountant)。未實作。
22026-06-24數量模型重構為公式(§2.2 / §2.4 / §3.1 / §3.3 / §4.1):離散四 mode → quantity_modefixed / headcount)+ headcount_components text[]({passenger, leader, assistant, driver}新增司機 / 協作)+ group_divisorceil(Σ人頭 / N)新增「每 N 人一組」無條件進位)。一條公式 數量 = 固定值 或 ceil(Σ選定人頭 / 組距) 涵蓋舊四模式 + 司機 + roundup((旅客+司機)/8),吸收 internal-external-ledger §2.1.2 一直 defer 的「每8人一組」。departure_costs 同步加 headcount_components / group_divisor(重算自足、不依賴 template_line_id)。重算條件改 quantity_mode='headcount'。UI 給 preset 快捷 + 自訂(人頭多選 + 組距)。§5 開放議題更新(assistant 已納入、每N人一組已做;新開司機多車計數)。未實作。
42026-07-03多範本疊加 + 外幣範本行(migration 0041)。§2.3 一對一 trips.cost_template_idtrip_cost_templates junction(多範本疊加、整批替換 setTripCostTemplates、依 sort_order 展開、line-level 冪等;舊 FK 欄 backfill 後於 migration 0042 移除)。§2.2 加外幣行欄(currency / unit_price_foreign / internal_rate_sourcefx_account/published,拒 manual)/ fx_account_id)+ shape / 幣別相符約束;unit_price_twd 改 nullable。§2.4 departure_costsunit_price_foreign。§3.2 套用改 JOIN junction 展開全部綁定範本 + 外幣行套用當下解析雙率快照(RATE_UNRESOLVED fail-closed);§3.3 recalc 外幣列沿用快照 foreign × rate round,不重解析。§1 移除「不做外幣範本行」、§5 兩條 follow-up 標已落地。UI:行程綁定改多選 chips、範本行表單加幣別 Select + 外幣單價 + 內帳來源 + 外幣帳戶 Combobox、團控卡外幣算式;api/v1 + CLI 多範本 / 外幣欄。typecheck 0 / vitest cost-template 34 全綠。已實作。
32026-06-29全鏈實作落地(migration 0032)。schema 全進 packages/core/src/schema/business.ts(§2.2 約束以 NULL-safe 蘊含式 mode <> X OR <shape> 表達 fixed⟺/headcount⟺,因 array_length('{}',1)=NULL 不可用 boolean-equality;headcount_components <@ ARRAY[...] 子集 check)。service packages/core/src/cost-templates/repo.tsresolveQuantity 純函式 + getDepartureHeadcounts(driver=有司機名 transport 列數,§5 第一版每筆算一個)+ CRUD + applyTemplateToDeparture(冪等,附 applyTemplateInTx 供建梯次同 tx 複用)+ recalculateDepartureTemplateCostsPick<ScopedDb,'execute'> 型別吃 tx)+ setDepartureCostManualOverride/clear/listDepartureTemplateCosts。重算掛進建單/取消/排班/派車/建梯次 auto-apply 各 mutation 同 tx。權限 resource cost_template(op=read、admin/accountant=manage)。UI:/admin/cost-templates + 行程綁定 Combobox + 團控成本登記頁範本卡。§3.2 套用刻意不走 createDepartureCost(不在套用當下為 is_declarable=false 列開支出證明單,DB check 僅要求 is_declarable OR invoice=0)—— 範本只產初值,證明單留待團控補。 typecheck 0 / vitest 1400 / e2e 72 全綠。已實作。b8e33f6…94fb27c