Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ func main() {
adminHandler.SetFeeSettings(feeSettings)
adminHandler.SetMembershipInvoices(membershipInvoiceRepo)
adminHandler.SetLostItems(lostItemRepo)
adminHandler.SetRideStops(rideStopRepo)

// 乘客認證:註冊/登入(line_user_id + 密碼 JWT)
customerRegistry := service.NewCustomerRegistry(customerRepo)
Expand Down Expand Up @@ -247,6 +248,7 @@ func main() {
authed.Use(middleware.DriverAuth(cfg.JWTSecret))
{
authed.GET("/driver/me", driverHandler.Me)
authed.PUT("/driver/profile", driverHandler.UpdateProfile)
authed.GET("/driver/vehicle", driverHandler.Vehicle)
authed.PUT("/driver/vehicle", driverHandler.UpdateVehicle)
authed.GET("/driver/earnings", driverHandler.Earnings)
Expand Down
41 changes: 38 additions & 3 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,9 +548,13 @@
**空值不帶該鍵**(而非帶空字串)——寧可少一個鍵,也不要讓 App 顯示空白車牌。
**隱私**:`ride.accepted` 的收件人是 `ride.CustomerID` 一人;查詢類一律先授權再組資料(見 O7)。

- [ ] **O5. admin 呈現/審核(可選)**
司機列表顯示車種車牌;是否需要「車輛審核」狀態(pending/approved)待拍板——
若需要,O3 的 gate 條件要改成「已審核」而非「有填」。
- [x] **O5. admin 呈現/審核** ✅ **已實作(2026-07-19,migration `000022`,PR #40)**
使用者 2026-07-19 拍板「先做」。O3 的 gate 條件已升級為「**已審核**」而非「有填」
(`VehicleApproved()` 取代 `HasVehicle()`,派單側+接單側);接單側分
`ErrDriverNoVehicle`(沒填)與 `ErrDriverNotApproved`(待審核),司機知道下一步。
`UpdateVehicle` **原子地**把 review 重置 pending(改車一律重審);
admin `POST /drivers/:id/vehicle-review`(ops 角色,只有 pending 可審、退回必附原因)。
三端同批上線:司機 App 四態路由(fleet-app PR #35)、admin 審核 UI(fleet-frontEnd PR #20)。

- [x] **O6. 寵物用車清潔費** ✅ **已實作(2026-07-17,migration `000019`)**(定案 2026-07-16:比例加收,上限 30%)

Expand Down Expand Up @@ -830,6 +834,37 @@

---

## 🔧 Q. 下游缺口回補(2026-07-22)

> 起因:盤點三端程式碼(不只看勾選)發現後端有三個洞——兩個讓 admin/App 顯示不出已完成的能力,
> 一個讓已拍板的功能**實質從未生效**。三項皆已實作並測過。

- [x] **Q1. 日報表補清潔費分項**(`ReportRepository.DailyDriverStats`)
月報表(F6)與司機收入(F7)早就有 `total_cleaning_fee_cents`,**日報表 F5 漏了**。
少了它,日報表的「營業額 − 手續費」會莫名對不上 `driver_net_cents`——差額正是清潔費,
而 admin 只呈現不算錢,看到的就是一組兜不攏的數字。
驗收:`TestCleaningFeeReports/日報表分項與等式`(testcontainers 真 Postgres);
**反向確認**拿掉 SQL 那行會 FAIL(實測 `得到 0,預期 3700`)。

- [x] **Q2. admin 訂單詳情帶 stops**(`AdminHandler.RideDetail` + `SetRideStops` 注入)
多停靠點行程(N)在後台只看得到由停靠點推導出的單一上車/下車點,中間的乘客完全消失,
客服無法回答「這趟載了誰、停了哪幾站、哪站被跳過」。
形狀共用 `service.StopViews`(=司機/乘客端同一份 `stopView`)——三端說法不一致就沒得對帳。
**停靠點讀取失敗不擋整個詳情頁**;單點訂單不帶 stops 鍵(不是空陣列)。
驗收:`TestAdminRideDetail_帶停靠點`/`_未注入停靠點repo仍可用`;
**反向確認**拿掉 main.go 的 `SetRideStops` 接線會 FAIL(比照 N4 踩過的漏接線坑)。

- [x] **Q3. 司機聯絡電話寫入路徑**(`PUT /api/driver/profile`)
**O7 拍板「電話明碼、乘客可直接撥打」,但 `drivers.phone` 從來沒有任何寫入路徑**——
註冊不收、車輛設定也不收,只能手動改 DB。結果乘客端的 `tel:` 撥號按鈕實質永遠不出現。
新增 `DriverRegistry.SetPhone` + `DriverRepository.UpdatePhone`(寬鬆驗證:去分隔符後
8–15 位數字,可帶 `+`;不綁「09 開頭」,車隊可能有市話或境外號碼)。
**刻意與 `PUT /driver/vehicle` 分開**:電話不是車輛屬性,改電話若重置 O5 審核,
司機為了更新一個號碼就會被鎖出派單池。
讀取端 `GET /driver/vehicle` 順帶回 `phone`(唯讀便利欄位,設定頁一次讀完省一支往返)。
驗收:`TestDriverProfile_*`(授權邊界/參數驗證/`改電話不重置車輛審核` 走真 DB)。
App 端對應:司機車輛設定頁加聯絡電話欄(fleet-app 同批)。

## 下次任務

**新需求(2026-07-16 加入,尚未實作,皆需後端地基先行)**:
Expand Down
45 changes: 45 additions & 0 deletions internal/constants/phone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package constants

import "strings"

// 電話長度界線(正規化後的數字位數)。台灣手機 10 碼、市話含區碼 9–10 碼,
// 放寬到 8–15 是為了容納市話短碼與 E.164 國際碼(最長 15 位)。
const (
MinPhoneDigits = 8
MaxPhoneDigits = 15
)

// NormalizePhone 去掉人類書寫用的分隔符(空白、`-`、`(`、`)`),保留開頭的 `+`。
// 這個值會被乘客端直接組成 `tel:` 連結撥出(O7 電話明碼),
// 留著分隔符在部分機型會撥不出去。
func NormalizePhone(s string) string {
s = strings.TrimSpace(s)
plus := strings.HasPrefix(s, "+")
var b strings.Builder
if plus {
b.WriteByte('+')
}
for _, r := range s {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}

// IsValidPhone 寬鬆驗證:正規化後只允許「可選的 `+` + 8–15 位數字」。
// 刻意不綁「09 開頭」之類的台灣樣式——車隊可能有市話或境外號碼,
// 硬綁會誤擋真號碼,而打不通的號碼本來就只能靠乘客回報,不是後端擋得住的。
// 傳入值須為 NormalizePhone 的輸出。
func IsValidPhone(s string) bool {
digits := strings.TrimPrefix(s, "+")
if len(digits) < MinPhoneDigits || len(digits) > MaxPhoneDigits {
return false
}
for _, r := range digits {
if r < '0' || r > '9' {
return false
}
}
return true
}
20 changes: 19 additions & 1 deletion internal/handler/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type AdminHandler struct {
feeSettings *service.FeeSettings
membershipInvoices *repository.MembershipInvoiceRepository
lostItems *repository.LostItemRepository
rideStops *repository.RideStopRepository
redis *redisstore.Store
jwtSecret string
jwtExpiryHours int
Expand All @@ -55,6 +56,12 @@ func (h *AdminHandler) SetLostItems(repo *repository.LostItemRepository) {
h.lostItems = repo
}

// SetRideStops 注入停靠點 repo(供訂單詳情列出多停靠點,N);可選——
// 未注入時訂單詳情照樣回應,只是不帶 stops(單點訂單本來就沒有)。
func (h *AdminHandler) SetRideStops(repo *repository.RideStopRepository) {
h.rideStops = repo
}

func NewAdminHandler(
admins *service.AdminRegistry,
adminOps *service.AdminOperations,
Expand Down Expand Up @@ -269,7 +276,18 @@ func (h *AdminHandler) RideDetail(c *gin.Context) {
if evts == nil {
evts = []model.RideEvent{}
}
c.JSON(http.StatusOK, gin.H{"ride": ride, "track_geojson": geojson, "events": evts})
resp := gin.H{"ride": ride, "track_geojson": geojson, "events": evts}
// 多停靠點行程(N):客服要能回答「這趟到底載了誰、停了哪幾站、哪站被跳過」。
// 停靠點讀取失敗**不擋整個詳情頁**——訂單本體與軌跡比停靠點重要得多,
// 沒有 stops 鍵時前端退回原本的單一上車/下車呈現。
if h.rideStops != nil {
if stops, err := h.rideStops.ListByRide(id); err == nil {
if views := service.StopViews(stops); views != nil {
resp["stops"] = views
}
}
}
c.JSON(http.StatusOK, resp)
}

// DailyReport GET /api/admin/reports/daily?date=2026-07-06
Expand Down
161 changes: 161 additions & 0 deletions internal/handler/admin_ride_detail_stops_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package handler

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"

"github.com/gin-gonic/gin"

"line-fleet-dispatch/internal/constants"
"line-fleet-dispatch/internal/model"
"line-fleet-dispatch/internal/repository"
)

// TestAdminRideDetail_帶停靠點 驗 GET /api/admin/rides/:id 會帶 stops(N,admin 訂單詳情)。
// 客服要能回答「這趟載了誰、停了哪幾站、哪站被跳過」;沒有這個鍵,後台只看得到
// 由停靠點推導出的單一上車/下車點,中間的乘客完全消失。
func TestAdminRideDetail_帶停靠點(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newMigratedTestDB(t) // Docker 不可用時內部 t.Skip

customers := repository.NewCustomerRepository(db)
cust, err := customers.FindOrCreateByLineUserID("U_admin_stops", "多乘客乘客")
if err != nil {
t.Fatalf("建立乘客失敗:%v", err)
}
now := time.Now()
ride := &model.Ride{
CustomerID: cust.ID,
Status: constants.RideStatusRequested,
PickupPoint: model.GeoPoint{Lat: 25.033, Lng: 121.5654},
PickupAddress: "台北101",
RequestedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(ride).Error; err != nil {
t.Fatalf("建立行程失敗:%v", err)
}

stopRepo := repository.NewRideStopRepository(db)
rows := []repository.StopRow{
{Seq: 1, Kind: constants.StopKindPickup, Lat: 25.033, Lng: 121.5654, Address: "台北101", PassengerLabel: "A"},
{Seq: 2, Kind: constants.StopKindPickup, Lat: 25.0400, Lng: 121.5600, Address: "國父紀念館", PassengerLabel: "B"},
{Seq: 3, Kind: constants.StopKindDropoff, Lat: 25.0478, Lng: 121.5170, Address: "台北車站", PassengerLabel: "A"},
{Seq: 4, Kind: constants.StopKindDropoff, Lat: 25.0421, Lng: 121.5080, Address: "西門町", PassengerLabel: "B"},
}
if err := stopRepo.CreateForRide(ride.ID, rows); err != nil {
t.Fatalf("建立停靠點失敗:%v", err)
}

h := &AdminHandler{
rides: repository.NewRideRepository(db),
tracks: repository.NewTrackRepository(db),
}
h.SetRideStops(stopRepo)
r := gin.New()
r.GET("/api/admin/rides/:id", h.RideDetail)

do := func(id string) map[string]any {
t.Helper()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/admin/rides/"+id, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("預期 200,得到 %d:%s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("回應不是合法 JSON:%v", err)
}
return body
}

body := do(itoa(ride.ID))
raw, ok := body["stops"].([]any)
if !ok {
t.Fatalf("多停靠點行程應帶 stops,實際回應鍵:%v", keysOf(body))
}
if len(raw) != 4 {
t.Fatalf("預期 4 站,得到 %d", len(raw))
}
// 形狀必須與司機/乘客端相同(共用 service.StopViews)——三端說法不一致就沒得對帳。
first, _ := raw[0].(map[string]any)
for _, key := range []string{"id", "seq", "kind", "lat", "lng", "passenger_label", "address"} {
if _, ok := first[key]; !ok {
t.Fatalf("停靠點缺少欄位 %s:%+v", key, first)
}
}
if first["passenger_label"] != "A" || first["kind"] != constants.StopKindPickup {
t.Fatalf("第一站應為乘客 A 的上車點:%+v", first)
}
// 未處理的站不該憑空出現 arrived_at/skipped_at(兩者皆無=待處理)。
if _, ok := first["arrived_at"]; ok {
t.Fatalf("尚未到達的站不該帶 arrived_at:%+v", first)
}

// 單點訂單不該多出一個空 stops 陣列——前端據此決定要不要顯示停靠點區塊。
single := &model.Ride{
CustomerID: cust.ID,
Status: constants.RideStatusRequested,
PickupPoint: model.GeoPoint{Lat: 25.03, Lng: 121.56},
PickupAddress: "單點訂單",
RequestedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(single).Error; err != nil {
t.Fatalf("建立單點行程失敗:%v", err)
}
if _, ok := do(itoa(single.ID))["stops"]; ok {
t.Fatal("單點訂單不該帶 stops 鍵")
}
}

// TestAdminRideDetail_未注入停靠點repo仍可用 停靠點是加值資訊,不該讓訂單詳情整頁掛掉。
func TestAdminRideDetail_未注入停靠點repo仍可用(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newMigratedTestDB(t)

customers := repository.NewCustomerRepository(db)
cust, err := customers.FindOrCreateByLineUserID("U_admin_nostops", "乘客")
if err != nil {
t.Fatalf("建立乘客失敗:%v", err)
}
now := time.Now()
ride := &model.Ride{
CustomerID: cust.ID, Status: constants.RideStatusRequested,
PickupPoint: model.GeoPoint{Lat: 25.03, Lng: 121.56}, PickupAddress: "台北車站",
RequestedAt: now, CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(ride).Error; err != nil {
t.Fatalf("建立行程失敗:%v", err)
}

// 刻意不呼叫 SetRideStops
h := &AdminHandler{rides: repository.NewRideRepository(db), tracks: repository.NewTrackRepository(db)}
r := gin.New()
r.GET("/api/admin/rides/:id", h.RideDetail)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/admin/rides/"+itoa(ride.ID), nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("未注入停靠點 repo 仍應回 200,得到 %d:%s", w.Code, w.Body.String())
}
}

func itoa(id int64) string {
return strconv.FormatInt(id, 10)
}

func keysOf(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
32 changes: 32 additions & 0 deletions internal/handler/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,46 @@ func (h *DriverHandler) Me(c *gin.Context) {
c.JSON(http.StatusOK, driverPublic(d))
}

// UpdateProfile PUT /api/driver/profile — 司機自己的聯絡資料(目前只有電話,O7)。
// driver_id 一律取自 token,司機只能改自己的。
// **與 /driver/vehicle 分開**:電話不是車輛屬性,改電話不該讓車輛回到待審核(O5)。
func (h *DriverHandler) UpdateProfile(c *gin.Context) {
driverID := middleware.DriverIDFromCtx(c)
var req struct {
Phone string `json:"phone" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "參數錯誤"})
return
}
d, err := h.drivers.SetPhone(driverID, req.Phone)
if err != nil {
switch {
case errors.Is(err, service.ErrInvalidPhone):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "找不到司機"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, driverPublic(d))
}

// driverVehicle 車輛資訊的回應形狀(O2/O5)。
// `has_vehicle`=填了沒(App 決定是否顯示強制設定頁);
// `review_status`=審核狀態(App 四態路由:pending 審核中/rejected 已退回+原因);
// `can_accept`=能不能接單(O5 gate =已核准),App 用它就不必自行推導審核邏輯。
//
// `phone` 是**唯讀便利欄位**(O7):司機設定頁同時要填車輛與聯絡電話,
// 一次讀完省一支往返。寫入仍走各自的端點——改電話不該讓車輛回到待審核(O5),
// 所以 PUT /driver/profile 與 PUT /driver/vehicle 是分開的。
func driverVehicle(d *model.Driver) gin.H {
return gin.H{
"vehicle_type": d.VehicleType,
"plate_number": d.PlateNumber,
"phone": d.Phone,
"has_vehicle": d.HasVehicle(),
"review_status": d.VehicleReviewStatus,
"review_note": d.VehicleReviewNote,
Expand Down
Loading
Loading