From 26685be0cd905db5c56349be32448bb9920e59a4 Mon Sep 17 00:00:00 2001 From: awei Date: Wed, 22 Jul 2026 16:03:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(Q):=20=E4=B8=8B=E6=B8=B8=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=E5=9B=9E=E8=A3=9C=E2=80=94=E2=80=94=E6=97=A5=E5=A0=B1=E8=A1=A8?= =?UTF-8?q?=E6=B8=85=E6=BD=94=E8=B2=BB=E5=88=86=E9=A0=85=E3=80=81admin=20?= =?UTF-8?q?=E8=A8=82=E5=96=AE=E8=A9=B3=E6=83=85=E5=B8=B6=20stops=E3=80=81?= =?UTF-8?q?=E5=8F=B8=E6=A9=9F=E9=9B=BB=E8=A9=B1=E5=AF=AB=E5=85=A5=E8=B7=AF?= =?UTF-8?q?=E5=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 盤點三端程式碼(不只看勾選)發現後端有三個洞:兩個讓 admin/App 顯示不出已完成的 能力,一個讓已拍板的功能**實質從未生效**。 **Q1 日報表補清潔費分項**(DailyDriverStats) 月報表 F6 與司機收入 F7 早就有 total_cleaning_fee_cents,日報表 F5 漏了。 少了它,「營業額 − 手續費」會莫名對不上 driver_net_cents——差額正是清潔費, 而 admin 只呈現不算錢,看到的就是一組兜不攏的數字。 反向確認:拿掉 SQL 那行,TestCleaningFeeReports/日報表分項與等式 FAIL(得到 0,預期 3700)。 **Q2 admin 訂單詳情帶 stops**(RideDetail + SetRideStops 注入) 多停靠點行程在後台只看得到由停靠點推導出的單一上車/下車點,中間的乘客完全消失, 客服無法回答「這趟載了誰、停了哪幾站、哪站被跳過」。 形狀共用 service.StopViews(=司機/乘客端同一份 stopView)——三端說法不一致就沒得對帳。 停靠點讀取失敗**不擋整個詳情頁**;單點訂單不帶 stops 鍵(不是空陣列)。 反向確認:拿掉接線後 TestAdminRideDetail_帶停靠點 FAIL(回應只有 ride/track/events) ——比照 N4 踩過的「SetStops 沒在 main.go 呼叫」漏接線坑。 **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(唯讀便利欄位,App 設定頁一次讀完省一支往返)。 驗收:gofmt 乾淨、go build/vet 綠;CI 的無 Docker 測試集全過; 新增測試 TestDriverProfile_*(授權邊界/參數驗證/改電話不重置審核,走真 DB)、 TestAdminRideDetail_帶停靠點/_未注入停靠點repo仍可用、TestNormalizePhone、 TestCleaningFeeReports/日報表分項與等式。 Co-Authored-By: Claude Opus 4.8 --- cmd/server/main.go | 2 + docs/TODO.md | 41 ++++- internal/constants/phone.go | 45 +++++ internal/handler/admin.go | 20 ++- .../handler/admin_ride_detail_stops_test.go | 161 ++++++++++++++++++ internal/handler/driver.go | 32 ++++ internal/handler/driver_profile_test.go | 142 +++++++++++++++ .../report_cleaning_fee_integration_test.go | 23 +++ internal/repository/repository.go | 18 +- internal/service/driver_registry.go | 23 +++ internal/service/errors.go | 3 + internal/service/ride_stops.go | 7 + 12 files changed, 512 insertions(+), 5 deletions(-) create mode 100644 internal/constants/phone.go create mode 100644 internal/handler/admin_ride_detail_stops_test.go create mode 100644 internal/handler/driver_profile_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 8d057bb..9bfff13 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) @@ -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) diff --git a/docs/TODO.md b/docs/TODO.md index 6b3ef62..3b12ef7 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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%) @@ -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 加入,尚未實作,皆需後端地基先行)**: diff --git a/internal/constants/phone.go b/internal/constants/phone.go new file mode 100644 index 0000000..7a613f0 --- /dev/null +++ b/internal/constants/phone.go @@ -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 +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 49b9aaa..35abf92 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -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 @@ -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, @@ -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 diff --git a/internal/handler/admin_ride_detail_stops_test.go b/internal/handler/admin_ride_detail_stops_test.go new file mode 100644 index 0000000..669ad7a --- /dev/null +++ b/internal/handler/admin_ride_detail_stops_test.go @@ -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 +} diff --git a/internal/handler/driver.go b/internal/handler/driver.go index cbb4292..d69466c 100644 --- a/internal/handler/driver.go +++ b/internal/handler/driver.go @@ -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, diff --git a/internal/handler/driver_profile_test.go b/internal/handler/driver_profile_test.go new file mode 100644 index 0000000..c104638 --- /dev/null +++ b/internal/handler/driver_profile_test.go @@ -0,0 +1,142 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "line-fleet-dispatch/internal/auth" + "line-fleet-dispatch/internal/constants" + "line-fleet-dispatch/internal/middleware" + "line-fleet-dispatch/internal/model" + "line-fleet-dispatch/internal/repository" + "line-fleet-dispatch/internal/service" +) + +// setupProfileRouter 與 setupVehicleRouter 同策略:格式驗證在碰 DB 前就回,故 repo 可為 nil。 +func setupProfileRouter(reg *service.DriverRegistry) *gin.Engine { + gin.SetMode(gin.TestMode) + h := NewDriverHandler(nil, reg, nil, "s", 1) + r := gin.New() + g := r.Group("/api") + g.Use(middleware.DriverAuth("s")) + g.PUT("/driver/profile", h.UpdateProfile) + return r +} + +func putProfile(t *testing.T, r *gin.Engine, token, body string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", "/api/driver/profile", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + r.ServeHTTP(w, req) + return w +} + +func TestDriverProfile_授權邊界(t *testing.T) { + r := setupProfileRouter(service.NewDriverRegistry(nil)) + + if w := putProfile(t, r, "", `{"phone":"0912345678"}`); w.Code != http.StatusUnauthorized { + t.Fatalf("無 token 預期 401,得到 %d", w.Code) + } + ctok, _ := auth.GenerateToken("customer", 9, "s", time.Hour) + if w := putProfile(t, r, ctok, `{"phone":"0912345678"}`); w.Code != http.StatusUnauthorized { + t.Fatalf("customer token 打 driver 端點預期 401,得到 %d", w.Code) + } +} + +func TestDriverProfile_參數驗證(t *testing.T) { + r := setupProfileRouter(service.NewDriverRegistry(nil)) + dtok, _ := auth.GenerateDriverToken(7, "s", time.Hour) + + for name, body := range map[string]string{ + "缺 phone": `{}`, + "空字串": `{"phone":""}`, + "太短": `{"phone":"1234567"}`, + "太長": `{"phone":"+1234567890123456"}`, + "含英文字母": `{"phone":"09abcdefgh"}`, + "分隔符去掉後仍短": `{"phone":"09-12"}`, + } { + t.Run(name, func(t *testing.T) { + if w := putProfile(t, r, dtok, body); w.Code != http.StatusBadRequest { + t.Fatalf("%s 預期 400,得到 %d:%s", name, w.Code, w.Body.String()) + } + }) + } +} + +func TestNormalizePhone(t *testing.T) { + cases := map[string]string{ + "0912-345-678": "0912345678", + "(02) 2345 6789": "0223456789", + " 0912345678 ": "0912345678", + "+886 912 345 678": "+886912345678", + } + for in, want := range cases { + if got := constants.NormalizePhone(in); got != want { + t.Fatalf("NormalizePhone(%q) = %q,預期 %q", in, got, want) + } + } + if constants.IsValidPhone(constants.NormalizePhone("09-12")) { + t.Fatal("去掉分隔符後只有 4 位數不該通過") + } + if !constants.IsValidPhone(constants.NormalizePhone("0912-345-678")) { + t.Fatal("正常台灣手機號應通過") + } +} + +// TestDriverProfile_改電話不重置車輛審核 這是 SetPhone 與 SetVehicle 分開的理由: +// 車輛審核(O5)通過後,司機只是更新聯絡電話卻被打回 pending,等於被鎖出派單池。 +func TestDriverProfile_改電話不重置車輛審核(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newMigratedTestDB(t) // Docker 不可用時內部 t.Skip + + driverRepo := repository.NewDriverRepository(db) + now := time.Now() + d := &model.Driver{ + LineUserID: "D_profile_phone", Name: "電話司機", Status: constants.DriverStatusIdle, + VehicleType: constants.VehicleTypeSedan, PlateNumber: "PHONE-01", + VehicleReviewStatus: constants.VehicleReviewApproved, + CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(d).Error; err != nil { + t.Fatalf("建立司機失敗:%v", err) + } + + r := setupProfileRouter(service.NewDriverRegistry(driverRepo)) + dtok, _ := auth.GenerateDriverToken(d.ID, "s", time.Hour) + w := putProfile(t, r, dtok, `{"phone":"0912-345-678"}`) + 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) + } + // 回應帶正規化後的號碼——乘客端會直接拿它組 tel:,分隔符留著部分機型撥不出去。 + if body["phone"] != "0912345678" { + t.Fatalf("回應電話應為正規化後的值,得到 %v", body["phone"]) + } + + saved, err := driverRepo.FindByID(d.ID) + if err != nil { + t.Fatalf("重讀司機失敗:%v", err) + } + if saved.Phone != "0912345678" { + t.Fatalf("DB 電話未更新:%q", saved.Phone) + } + if saved.VehicleReviewStatus != constants.VehicleReviewApproved { + t.Fatalf("改電話不該重置車輛審核,實際狀態:%q", saved.VehicleReviewStatus) + } + if saved.VehicleType != constants.VehicleTypeSedan || saved.PlateNumber != "PHONE-01" { + t.Fatalf("改電話不該動到車輛欄位:%+v", saved) + } +} diff --git a/internal/repository/report_cleaning_fee_integration_test.go b/internal/repository/report_cleaning_fee_integration_test.go index 0c5e2cd..f0ecf51 100644 --- a/internal/repository/report_cleaning_fee_integration_test.go +++ b/internal/repository/report_cleaning_fee_integration_test.go @@ -103,6 +103,29 @@ func TestCleaningFeeReports(t *testing.T) { } }) + t.Run("日報表分項與等式", func(t *testing.T) { + daily, err := reports.DailyDriverStats(billTestDate) + if err != nil { + t.Fatalf("日報表查詢失敗:%v", err) + } + if len(daily) != 1 { + t.Fatalf("預期 1 位司機,得到 %d", len(daily)) + } + d := daily[0] + if d.TotalRevenueCents != wantRevenue { + t.Fatalf("日報表營業額不該含清潔費:得到 %d,預期 %d", d.TotalRevenueCents, wantRevenue) + } + if d.TotalCleaningFeeCents != wantCleaning { + t.Fatalf("日報表清潔費分項不符:得到 %d,預期 %d", d.TotalCleaningFeeCents, wantCleaning) + } + // 日報表與月報表用同一條等式;沒有分項欄時「營業額 − 手續費」會比實得少一截。 + if got := d.TotalRevenueCents - d.TotalCommissionCents + d.TotalCleaningFeeCents; got != d.DriverNetCents { + t.Fatalf("等式失效:營業額 %d − 手續費 %d + 清潔費 %d = %d,但實得為 %d", + d.TotalRevenueCents, d.TotalCommissionCents, d.TotalCleaningFeeCents, + got, d.DriverNetCents) + } + }) + t.Run("司機收入分項與等式", func(t *testing.T) { earn, err := reports.DriverMonthlyEarnings(driver.ID, billTestMonth) if err != nil { diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 42699a9..859886d 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -212,6 +212,16 @@ func (r *DriverRepository) UpdateStatus(id int64, status int16) error { // ErrPlateTaken 車牌已掛在別的司機帳號上(uq_drivers_plate_number,O1)。 var ErrPlateTaken = errors.New("此車牌已被其他司機使用") +// UpdatePhone 更新司機聯絡電話(O7)。呼叫端負責正規化與格式驗證。 +// **刻意不碰 vehicle_review_status**——電話不是車輛屬性,改電話重新送審會讓 +// 司機為了更新一個號碼而被鎖出派單池。 +func (r *DriverRepository) UpdatePhone(id int64, phone string) error { + return r.db.Model(&model.Driver{}).Where("id = ?", id).Updates(map[string]interface{}{ + "phone": phone, + "updated_at": time.Now(), + }).Error +} + // UpdateVehicle 更新司機車輛資訊(O2)。呼叫端負責驗證車種白名單與車牌格式; // 值域仍由 DB CHECK 與 partial unique index 兜底。 // 撞到車牌唯一索引時翻成 ErrPlateTaken,讓 handler 能回 409 而非 500。 @@ -516,9 +526,14 @@ type DailyDriverReport struct { TotalDistanceM int64 `json:"total_distance_m"` // int64 防大量加總溢位(F9-2) AvgPickupSec float64 `json:"avg_pickup_sec"` // 金額欄位(分):營業額、手續費、司機實得(F5)。 + // TotalRevenueCents 營業額=車資合計,**不含清潔費**(O6)。 TotalRevenueCents int64 `json:"total_revenue_cents"` TotalCommissionCents int64 `json:"total_commission_cents"` - DriverNetCents int64 `json:"driver_net_cents"` + // TotalCleaningFeeCents 寵物車清潔費合計(O6):不計入營業額與抽成,全額歸司機。 + // DriverNetCents 已含它,少了這個分項,日報表的「營業額 − 手續費」會莫名對不上實得 + // (月報表 F6 與司機收入頁 F7 早已有此欄,日報表 F5 漏了)。 + TotalCleaningFeeCents int64 `json:"total_cleaning_fee_cents"` + DriverNetCents int64 `json:"driver_net_cents"` } func (r *ReportRepository) DailyDriverStats(date string) ([]DailyDriverReport, error) { @@ -534,6 +549,7 @@ func (r *ReportRepository) DailyDriverStats(date string) ([]DailyDriverReport, e COALESCE(AVG(EXTRACT(EPOCH FROM (r.accepted_at - r.requested_at))), 0) AS avg_pickup_sec, COALESCE(SUM(r.fare_amount_cents), 0)::bigint AS total_revenue_cents, COALESCE(SUM(r.commission_amount_cents), 0)::bigint AS total_commission_cents, + COALESCE(SUM(r.cleaning_fee_cents), 0)::bigint AS total_cleaning_fee_cents, COALESCE(SUM(r.driver_net_amount_cents), 0)::bigint AS driver_net_cents FROM rides r JOIN drivers d ON d.id = r.driver_id diff --git a/internal/service/driver_registry.go b/internal/service/driver_registry.go index fe8f4f1..34d5eb6 100644 --- a/internal/service/driver_registry.go +++ b/internal/service/driver_registry.go @@ -87,6 +87,29 @@ func (s *DriverRegistry) SetVehicle(driverID int64, vehicleType, plateNumber str return d, nil } +// SetPhone 設定司機聯絡電話(O7)。乘客在「司機前往上車點」階段看得到這個號碼並可直接撥打, +// 但在此之前 drivers.phone **沒有任何寫入路徑**——註冊不收、車輛設定也不收, +// 導致乘客端的撥號按鈕實質永遠不出現。 +// 刻意獨立於 SetVehicle:改電話不該觸發車輛重新審核(O5),否則司機換號碼就被鎖出派單池。 +func (s *DriverRegistry) SetPhone(driverID int64, phone string) (*model.Driver, error) { + normalized := constants.NormalizePhone(phone) + if !constants.IsValidPhone(normalized) { + return nil, ErrInvalidPhone + } + d, err := s.drivers.FindByID(driverID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + if err := s.drivers.UpdatePhone(driverID, normalized); err != nil { + return nil, err + } + d.Phone = normalized + return d, nil +} + // GoOnline 顯式上線:設為待命(Idle),重新進入派單池。 // 載客中(OnTrip)則維持原狀不降級;已停用(Disabled)回 ErrDriverDisabled。 func (s *DriverRegistry) GoOnline(driverID int64) (*model.Driver, error) { diff --git a/internal/service/errors.go b/internal/service/errors.go index 4cc39b2..4ea7c5e 100644 --- a/internal/service/errors.go +++ b/internal/service/errors.go @@ -16,6 +16,9 @@ var ( ErrLastSuperadmin = errors.New("不可移除最後一個 superadmin") ErrInvalidVehicleType = errors.New("車種無效") ErrInvalidPlateNumber = errors.New("車牌格式錯誤") + // ErrInvalidPhone O7:司機聯絡電話格式錯誤。乘客端會把它組成 tel: 撥出, + // 存進髒值等於乘客在路邊撥不通。 + ErrInvalidPhone = errors.New("電話格式錯誤") // ErrDriverNoVehicle O3 gate:未填車輛資訊者不得接單。訊息會原樣回給司機 // (API 走 statusForErr → 409;LINE 走 webhook 的 err.Error() 文字回覆)。 ErrDriverNoVehicle = errors.New("請先填寫車種與車牌才能接單") diff --git a/internal/service/ride_stops.go b/internal/service/ride_stops.go index 1dc81f6..185befb 100644 --- a/internal/service/ride_stops.go +++ b/internal/service/ride_stops.go @@ -57,6 +57,13 @@ func stopViews(stops []model.RideStop) []map[string]any { return out } +// StopViews 對外公開的停靠點序列化(admin 訂單詳情用)。 +// 刻意與司機/乘客端共用同一個 stopView——三端看到的停靠點形狀必須一模一樣, +// 否則客服在後台看到的站序/到達時間會與司機 App 說法不同。 +func StopViews(stops []model.RideStop) []map[string]any { + return stopViews(stops) +} + // DriverRideView 司機視角的進行中訂單:ride 全欄位 + 全程停靠點(N6)。 // 內嵌 *model.Ride 讓 JSON 攤平,既有欄位一個不少——App 讀到的形狀只多不變。 // 單點訂單的 Stops 為 nil(omitempty),不會多出一個空陣列。