一句話:租戶層可重用的成本範本庫,行程綁定一份預設範本後,其梯次自動帶入預設成本行(品項 × 單價 × 數量),數量可動態綁報名人數 / 領隊數即時重算,金額可逐行手改。建構於 control-finance 的
departure_costs之上,範本只負責產生成本行初值,產生後即為一般departure_costs列,受同一套覆核 / 轉請款 / 不變式管轄。畫面(已實作):成本範本庫
/admin/cost-templates· 行程編輯頁綁定範本欄 · 團控成本登記頁範本卡/admin/control/[depId]/finance。高擬真藍圖見 wireframe 範本庫 · 團控成本登記頁
旅行社有大量系列團(常態固定會開的團,如「玉山主峰三日」每月開)。同一系列團的成本結構幾乎固定:車資、餐費、保險、嚮導費、入山證、雜支…… 現行 departure_costs 全靠線控逐筆手填(control-finance §4),同型行程每開一梯就要重打一次相同明細,且金額隨人數浮動還要心算。
本檔讓租戶建立可重用成本範本,行程綁定一份預設範本;該行程的梯次自動帶入範本定義的成本行,其中「數量」可動態綁定報名人數 / 領隊數,套用與人數變動時即時重算金額,線控仍可逐行手改覆寫。
範圍:成本範本庫 CRUD + 行程綁定 + 梯次自動套用 + 動態數量重算 + 後台 UI / 權限。 不做:
departure_costs 的金流語意:範本產生的列就是一般成本列,內外帳 / 可申報額 / 淨利 / 覆核仍由 internal-external-ledger 擁有;轉請款仍走 control-finance §5。cost_templates(新表)可重用的命名範本,租戶 DB 內。
| 欄位 | 型別 | 說明 |
|---|---|---|
id | text PK(ct_<ulid>) | 主鍵。 |
name | text NOT NULL | 範本名稱(如「玉山主峰三日系列團」)。 |
description | text | 備註。 |
status | text NOT NULL default 'active' | active(可綁定 / 套用)→ archived(停用,既有綁定不受影響、不可新綁)。 |
created_at / updated_at | timestamptz | — |
cost_template_lines(新表)範本的成本行定義。每行 = 品項(自訂)× 單價(自訂)× 數量。
| 欄位 | 型別 | 說明 |
|---|---|---|
id | text PK(ctl_<ulid>) | 主鍵。 |
template_id | text FK → cost_templates.id(onDelete cascade) | 屬於哪個範本。 |
category | text NOT NULL | 品項(自訂;可參照 DEPARTURE_COST_CATEGORIES 但不限於該集合)。 |
vendor_name | text | 預設供應商(可空,套用後可改)。 |
description | text | 成本摘要範本。 |
unit_price_twd | integer(≥ 0)nullable | 單價,整數 TWD。currency='TWD' 時必填;外幣行為 NULL(改填 unit_price_foreign)。 |
currency | text NOT NULL default 'TWD' | 幣別(ISO 4217)。'TWD' = 現行台幣行為;非 TWD = 外幣行。 |
unit_price_foreign | integer(≥ 0)nullable | 外幣每單位單價(最小單位整數);外幣行必填、TWD 行 NULL。 |
internal_rate_source | text nullable | 外幣行內帳匯率來源:fx_account(外幣帳戶 WAC)/ published(台銀牌告)。範本層不支援 manual(手填實際成交率本質 per-transaction,套用後可逐列改)。TWD 行 NULL。 |
fx_account_id | text FK → receiving_accounts.id(onDelete restrict)nullable | internal_rate_source='fx_account' 時支出的外幣帳戶(套用時取其 WAC,且須幣別相符)。 |
quantity_mode | text NOT NULL | 數量 kind:fixed(固定值)/ headcount(人頭公式,見下)。 |
quantity_fixed | integer(≥ 0)nullable | quantity_mode='fixed' 時的固定數量;headcount 時為 NULL。 |
headcount_components | text[] nullable | quantity_mode='headcount' 時要相加的人頭,子集 {passenger, leader, assistant, driver}(至少一項);fixed 時為 NULL。 |
group_divisor | integer(≥ 1)nullable | headcount 時的「每 N 人一組」組距:NULL = 直接用人頭總和;填 N = ceil(總和 / N)(無條件進位,不足一組仍算一組)。fixed 時為 NULL。 |
is_declarable | boolean NOT NULL default true | 套用後成本行的預設憑證狀態(internal-external-ledger §2.1);套用後可改。 |
sort_order | integer NOT NULL default 0 | 顯示 / 套用順序。 |
created_at / updated_at | timestamptz | — |
數量 = 固定值,或
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 >= 0、unit_price_foreign >= 0、quantity_fixed >= 0、group_divisor >= 1(各 NULL 允許)。quantity_mode IN ('fixed','headcount')。fixed ⟺ quantity_fixed IS NOT NULL 且 headcount_components IS NULL 且 group_divisor IS NULL。headcount ⟺ headcount_components 非空陣列 且 quantity_fixed IS NULL(group_divisor 選填)。headcount_components 各元素 ∈ {passenger, leader, assistant, driver}。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 NULL 且 unit_price_twd IS NULL 且 internal_rate_source IN ('fx_account','published')。fx_account 來源 ⟺ fx_account_id IS NOT NULL。外幣帳戶「存在且幣別與成本幣別相符」由 app 層把關(跨表,DB check 表達不全;比照 departure-costs 的 FX_ACCOUNT_CURRENCY_MISMATCH)。trip_cost_templates(行程 × 範本綁定 junction,多範本疊加)一行程可綁多份範本疊加(如「基礎 + 登山加購」),套用時依範本層 sort_order → 行層 sort_order 展開。
| 欄位 | 型別 | 說明 |
|---|---|---|
trip_id | text FK → trips.id(onDelete cascade) | PK 之一。 |
cost_template_id | text FK → cost_templates.id(onDelete cascade) | PK 之一。junction PK (trip_id, cost_template_id) 天然擋重複綁定。 |
sort_order | integer NOT NULL default 0 | 範本層疊加順序(小→大)。 |
created_at | timestamptz | — |
多範本疊加:綁定為整批替換語意(
setTripCostTemplates(db, tripId, [id…])):每份須active(否則整批 rejectTEMPLATE_NOT_ACTIVE),依傳入順序寫sort_order;空集合 = 全解綁。同 category 行不合併(跨範本兩列並存,責任清晰)。範本 / 行程刪除時 cascade 清 junction(不連帶刪既有成本列)。
trips.cost_template_id舊單一 FK 欄已移除(migration 0041 backfill 進 junction 後,0042 直接 DROP;junction 是唯一綁定真相)。
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_id | text FK → cost_template_lines.id(onDelete set null)nullable | 此成本列的來源範本行。NULL = 手動 ad-hoc 列(同現行手填)。 |
quantity_mode | text nullable | 複製自範本行(fixed / headcount);動態重算用。手動列為 NULL。 |
headcount_components | text[] nullable | 複製自範本行(headcount 用,動態重算的人頭組合)。 |
group_divisor | integer nullable | 複製自範本行(headcount 的組距)。 |
quantity | integer nullable | 解析後(headcount)或自訂(fixed)的數量。 |
unit_price_twd | integer nullable | 複製自 TWD 範本行的單價。 |
unit_price_foreign | integer nullable | 複製自外幣範本行的外幣單價;外幣 headcount 列重算基礎(foreign_amount = unit_price_foreign × quantity)。TWD 列 NULL。 |
is_manual_override | boolean 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(範本行刪除後仍正確)。
依 quantity_mode 與當下梯次資料解析:
fixed → quantity = quantity_fixed(不隨人數變)。headcount → 先加總選定人頭 sum = Σ component_count,再套組距:
group_divisor 為 NULL → quantity = sum。group_divisor = N → quantity = ceil(sum / N)(無條件進位,不足一組仍算一組)。各人頭 component 的當下來源:
| component | 來源 |
|---|---|
passenger | departures.booked_count(報名席次)。 |
leader | departure_guide_assignments 中 status='confirmed' 且 role='leader' 的數量。 |
assistant | departure_guide_assignments 中 status='confirmed' 且 role='assistant' 的數量。 |
driver | departure_transport(departure-prep §2.4)的司機數。 |
departure_costs.quantity;amount_twd = unit_price_twd × quantity。roundup((旅客+司機)/8) = headcount_components={passenger,driver} + group_divisor=8;旅客 24 + 司機 1 → ceil(25/8)=4 組。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 帶範本預設。amount_twd = unit_price_twd × quantity;is_declarable=true 時 invoice_amount_twd = amount_twd(internal-external-ledger §3.3)。internal_rate_source(fx_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 × quantity;amount_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_log(action='departure_cost.create',metadata 標 templateLineId)。套用時機 = 自動:
template_line_id 去重(line-level,跨範本天然不衝突)——已存在該來源行的成本列不重插。範本之後新增的行、或行程新綁的範本,可在梯次成本登記頁按「套用範本」補套(只補缺的行)。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 × quantity → amount_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=true,invoice_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)才會再進重算。
範本產生的列就是一般 departure_costs 列:
invoice_amount_twd 預設 = amount_twd、可上修可下修;is_declarable=false 強制可申報額 0 + 掛支出證明單。payable_id 非 NULL 的列不可改 / 刪(含重算跳過)。套用路徑不開支出證明單(已知設計取捨):套用(§3.2)不走
createDepartureCost,而是直接 INSERT。因此is_declarable=false的範本成本列在套用當下不自動開立支出證明單(expense_proof_id為 NULL)—— 範本只產生成本行初值(review_status='approved'、invoice_amount_twd=0),證明單留待團控 / 會計於登記頁編輯該列(轉為走createDepartureCost/updateDepartureCost)或轉請款前補。DB checkdeparture_costs_declarable_invoice僅要求is_declarable OR invoice=0,不要求expense_proof_id,故此列合法。聚合(淨利 / 不可申報額)仍正確(amount − invoice計入不可申報),唯缺一張可列印的內部憑證。
status='archived'。
fixed → 填固定數量;headcount → 直接勾人頭多選(旅客 / 領隊 / 協作 / 司機,至少一)+ 選填組距(空 = 加總;填 N = 每 N 人一組、ceil 無條件進位)。即時預覽算式(如 = ceil((旅客24 + 司機1) / 8) × 單價)。不提供 preset 下拉——租戶直接組自己要的公式(人頭組合因租戶 / 行程型態而異,預設模板會綁死假設),最大化多租戶彈性。寫的就是 quantity_mode / headcount_components / group_divisor 三欄。<Combobox> 選 active 範本(選項來自 DB,依後台 UI 治理下拉政策)。/admin/control/[departureId]/costs):
cost_template)範本庫是成本結構設定(財務 setup),與「逐梯成本登記」(現場作業)分權限:
| action | 用途 | op | admin | accountant | sales |
|---|---|---|---|---|---|
cost_template.read | 看範本庫 / 行程綁定欄 | ✅ | ✅ | ✅ | ❌ |
cost_template.manage | 建 / 編 / 停用範本、設行程綁定 | ❌ | ✅ | ✅ | ❌ |
cost_template.manage:範本是會計 / 主管定義的成本結構 → admin / accountant(op 是否可 manage 見 §5)。行程綁定欄的存檔同時需 trip.manage(編行程本體)。departure_cost.create / departure_cost.manage(internal-external-ledger §4.2),不在本 resource。departure.update / trip.manage)。driver 取 departure_transport 的司機數;若一梯多車、或司機兼領隊,計數規則(去重 / 以車計)待 departure-prep 對齊。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,待營運回饋。| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-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)。未實作。 | — |
| 2 | 2026-06-24 | 數量模型重構為公式(§2.2 / §2.4 / §3.1 / §3.3 / §4.1):離散四 mode → quantity_mode(fixed / headcount)+ headcount_components text[]({passenger, leader, assistant, driver},新增司機 / 協作)+ group_divisor(ceil(Σ人頭 / 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人一組已做;新開司機多車計數)。未實作。 | — |
| 4 | 2026-07-03 | 多範本疊加 + 外幣範本行(migration 0041)。§2.3 一對一 trips.cost_template_id → trip_cost_templates junction(多範本疊加、整批替換 setTripCostTemplates、依 sort_order 展開、line-level 冪等;舊 FK 欄 backfill 後於 migration 0042 移除)。§2.2 加外幣行欄(currency / unit_price_foreign / internal_rate_source(fx_account/published,拒 manual)/ fx_account_id)+ shape / 幣別相符約束;unit_price_twd 改 nullable。§2.4 departure_costs 加 unit_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 全綠。已實作。 | — |
| 3 | 2026-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.ts:resolveQuantity 純函式 + getDepartureHeadcounts(driver=有司機名 transport 列數,§5 第一版每筆算一個)+ CRUD + applyTemplateToDeparture(冪等,附 applyTemplateInTx 供建梯次同 tx 複用)+ recalculateDepartureTemplateCosts(Pick<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 |