Files
CFDivePlatform/database/seeders/DemoSeeder.php
T
a620906209 43720482c4
Run Tests / test (pull_request) Successful in 2m3s
feat(verification): 教練審核流程完整版——狀態機/證照送審/Admin 裁決/通知
實作 openspec change provider-verification-workflow(25/25 tasks):
- is_verified 升級為 verification_status 四狀態機(unsubmitted/pending/
  approved/rejected),migration 自動轉換既有資料,API 以 accessor 保留
  is_verified 相容輸出
- 教練端:證照上傳(≤3 張、複用 CompressesImages)、送審、駁回原因
  顯示與重送(/coach/verification 新頁面)
- Admin 端:審核佇列、通過/駁回(原因必填)、撤銷 approved;移除
  toggle-verified 端點(防繞過狀態機);裁決後 flush 課程快取
- 通知:審核通過/駁回(站內+Email,駁回含原因)
- 可見性與預約入口改判 approved(行為等價)
- 測試 +18(ProviderVerificationTest 10、AdminVerificationTest 8),
  既有 4 個測試檔 helper 遷移;Swagger 同步
- 規格同步:provider-verification 全面改寫、admin-user-management
  toggle 段落改為移除聲明

容器內全套件 173 passed / 439 assertions。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:26:12 +08:00

408 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Database\Seeders;
use App\Enums\BookingStatus;
use App\Enums\ScheduleStatus;
use App\Models\AdminProfile;
use App\Models\Booking;
use App\Models\CourseSchedule;
use App\Models\DivingOffer;
use App\Models\MemberProfile;
use App\Models\ProviderProfile;
use App\Models\Review;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class DemoSeeder extends Seeder
{
public function run(): void
{
if (User::where('email', 'bluedive@cfdive.com')->exists()) {
$this->command->info('Demo 資料已存在,略過。');
return;
}
$this->createAdmin();
$providers = $this->createProviders();
$members = $this->createMembers();
$offers = $this->createOffers($providers);
[$pastSchedules, $futureSchedules] = $this->createSchedules($offers);
$completedBookings = $this->createCompletedBookings($pastSchedules, $members, $offers);
$this->createPendingBookings($futureSchedules, $members, $offers);
$this->createReviews($completedBookings);
$this->refreshOfferStats($offers);
$this->createNotifications($completedBookings);
$this->command->info('✅ Demo 資料建立完成');
$this->printSummary($providers, $members);
}
private function createAdmin(): void
{
$admin = User::firstOrCreate(['email' => 'admin@cfdive.com'], [
'name' => '平台管理員', 'password' => Hash::make('password123'),
'role' => 'admin', 'is_active' => true,
]);
AdminProfile::firstOrCreate(['user_id' => $admin->id], ['department' => '營運']);
}
private function createProviders(): array
{
$providerData = [
[
'email' => 'bluedive@cfdive.com', 'name' => '林志遠',
'business_name' => '墾丁藍海潛水',
'description' => '屹立墾丁十五年,PADI 五星級授權中心,專業教學團隊由 8 名持照教練組成,累計培訓超過三千名學員。',
'contact_phone' => '08-8881234', 'contact_email' => 'bluedive@cfdive.com',
'contact_person' => '林志遠', 'address' => '屏東縣恆春鎮南灣路 88 號',
'dive_sites' => '南灣,萬里桐,核三出水口,後壁湖',
'services' => '體驗潛水,OW認證課程,AOW認證課程,夜潛,水下攝影',
'certifications' => 'PADI 五星級潛水中心,PADI Course Director',
'facilities' => '空氣填充站,氧氣填充站,裝備租借,沖洗區,更衣室,停車場',
'business_hours' => '每日 07:3018:00', 'verification_status' => 'approved',
],
[
'email' => 'greendive@cfdive.com', 'name' => '陳美玲',
'business_name' => '綠島深潛體驗',
'description' => '深耕綠島潛水生態導覽,主打小班教學(每班最多 4 人),讓每位學員獲得充足的水下指導時間。',
'contact_phone' => '089-671234', 'contact_email' => 'greendive@cfdive.com',
'contact_person' => '陳美玲', 'address' => '台東縣綠島鄉南寮村 15 號',
'dive_sites' => '大香菇,石朗,柴口,哈巴狗,雞仔礁',
'services' => '體驗潛水,OW認證課程,AOW認證課程,水下生態導覽,浮潛',
'certifications' => 'PADI 潛水中心,SSI 認證教練',
'facilities' => '空氣填充站,裝備租借,防寒衣洗滌區,水下攝影記錄',
'business_hours' => '每日 07:0017:30', 'verification_status' => 'approved',
],
[
'email' => 'islet@cfdive.com', 'name' => '張大偉',
'business_name' => '小琉球海洋探索',
'description' => '小琉球在地老字號潛水店,最熟悉當地珊瑚礁地形與海龜族群,提供最道地的水下生態體驗。',
'contact_phone' => '08-8613456', 'contact_email' => 'islet@cfdive.com',
'contact_person' => '張大偉', 'address' => '屏東縣琉球鄉中山路 25 號',
'dive_sites' => '花瓶石,美人洞,厚石裙礁,杉福生態廊道,烏鬼洞',
'services' => '浮潛,體驗潛水,OW認證課程,海龜觀察導覽',
'certifications' => 'PADI 授權潛水中心',
'facilities' => '裝備租借,沖洗區,更衣室,代訂民宿服務',
'business_hours' => '每日 08:0017:00', 'verification_status' => 'approved',
],
[
'email' => 'northdive@cfdive.com', 'name' => '王建國',
'business_name' => '北海岸潛水俱樂部',
'description' => '以龍洞、和平島為主場地,北部最大的技術潛水訓練基地,提供 Tec 課程與洞穴潛水入門訓練。',
'contact_phone' => '02-24912345', 'contact_email' => 'northdive@cfdive.com',
'contact_person' => '王建國', 'address' => '新北市貢寮區龍洞街 56 號',
'dive_sites' => '龍洞灣,龍洞南口,鼻頭角,和平島,望海巷',
'services' => '體驗潛水,OW認證課程,進階課程,Tec潛水,洞穴入門',
'certifications' => 'PADI 課程總監,PADI TecRec 教練',
'facilities' => '空氣填充站,混氣填充站,裝備租借,技術潛水裝備維修',
'business_hours' => '週一公休,週二至週日 08:0018:00', 'verification_status' => 'pending',
],
];
$providers = [];
foreach ($providerData as $data) {
$user = User::firstOrCreate(['email' => $data['email']], [
'name' => $data['name'], 'password' => Hash::make('password123'),
'role' => 'provider', 'is_active' => true,
]);
ProviderProfile::firstOrCreate(['user_id' => $user->id], [
'business_name' => $data['business_name'],
'description' => $data['description'],
'contact_phone' => $data['contact_phone'],
'contact_email' => $data['contact_email'],
'contact_person' => $data['contact_person'],
'address' => $data['address'],
'dive_sites' => $data['dive_sites'],
'services' => $data['services'],
'certifications' => $data['certifications'],
'facilities' => $data['facilities'],
'business_hours' => $data['business_hours'],
'verification_status' => $data['verification_status'],
'rating' => 0,
]);
$providers[] = $user;
}
return $providers;
}
private function createMembers(): array
{
$memberData = [
['email' => 'alice@demo.com', 'name' => '李小雨', 'gender' => 'female', 'birthday' => '1995-03-12', 'address' => '台北市大安區仁愛路四段 100 號', 'emergency_contact' => '李大明', 'emergency_phone' => '0912001001'],
['email' => 'bob@demo.com', 'name' => '王柏翰', 'gender' => 'male', 'birthday' => '1990-07-24', 'address' => '新北市板橋區文化路二段 55 號', 'emergency_contact' => '王美惠', 'emergency_phone' => '0912001002'],
['email' => 'carol@demo.com', 'name' => '張雅婷', 'gender' => 'female', 'birthday' => '1998-11-05', 'address' => '台中市西屯區台灣大道三段 210 號', 'emergency_contact' => '張志明', 'emergency_phone' => '0912001003'],
['email' => 'david@demo.com', 'name' => '陳俊宏', 'gender' => 'male', 'birthday' => '1988-02-18', 'address' => '高雄市苓雅區中正一路 88 號', 'emergency_contact' => '陳淑芬', 'emergency_phone' => '0912001004'],
['email' => 'eva@demo.com', 'name' => '林芷萱', 'gender' => 'female', 'birthday' => '2000-06-30', 'address' => '台南市東區東門路一段 33 號', 'emergency_contact' => '林國雄', 'emergency_phone' => '0912001005'],
['email' => 'frank@demo.com', 'name' => '黃建豪', 'gender' => 'male', 'birthday' => '1993-09-15', 'address' => '桃園市中壢區中央路 120 號', 'emergency_contact' => '黃淑娟', 'emergency_phone' => '0912001006'],
['email' => 'grace@demo.com', 'name' => '吳怡君', 'gender' => 'female', 'birthday' => '1996-04-22', 'address' => '新竹市東區光復路二段 101 號', 'emergency_contact' => '吳明德', 'emergency_phone' => '0912001007'],
['email' => 'henry@demo.com', 'name' => '劉家豪', 'gender' => 'male', 'birthday' => '1985-12-08', 'address' => '基隆市中正區中正路 200 號', 'emergency_contact' => '劉淑英', 'emergency_phone' => '0912001008'],
];
$members = [];
foreach ($memberData as $data) {
$user = User::firstOrCreate(['email' => $data['email']], [
'name' => $data['name'], 'password' => Hash::make('password123'),
'role' => 'member', 'is_active' => true,
]);
MemberProfile::firstOrCreate(['user_id' => $user->id], [
'birthday' => $data['birthday'],
'gender' => $data['gender'],
'address' => $data['address'],
'emergency_contact' => $data['emergency_contact'],
'emergency_phone' => $data['emergency_phone'],
]);
$members[] = $user;
}
return $members;
}
private function createOffers(array $providers): array
{
$offersData = [
// 墾丁藍海潛水
['title' => '墾丁南灣體驗潛水', 'location' => '墾丁', 'spot' => '南灣', 'price' => 3800, 'region' => '南部', 'tag' => '初學者', 'badges' => ['含裝備', '教練全程陪同', '保險'], 'description' => '適合零基礎的海水體驗課程,由持照教練一對一陪同入水,南灣能見度佳,適合拍攝水下照片。課程含教學、裝備與保險,歡迎親友組團報名。'],
['title' => '墾丁 PADI OW 開放水域認證', 'location' => '墾丁', 'spot' => '核三出水口', 'price' => 14800, 'region' => '南部', 'tag' => '認證課程', 'badges' => ['PADI認證', '含裝備', '教材費含', '保險'], 'description' => '4 天完整 OW 課程,含學科、泳池練習與 4 支開放水域訓練潛水。核三出水口水溫穩定,能見度超過 15 米,是取得人生第一張 PADI 證書的最佳地點。'],
['title' => '墾丁萬里桐夜潛探秘', 'location' => '墾丁', 'spot' => '萬里桐', 'price' => 5500, 'region' => '南部', 'tag' => '進階', 'badges' => ['AOW認證', '含裝備', '保險', '夜間燈光設備'], 'description' => '僅開放給持有 OW 以上證書的潛水員。夜晚的萬里桐章魚出沒、螢光珊瑚、夜行魚類——這是白天潛水感受不到的神秘體驗。'],
// 綠島深潛體驗
['title' => '綠島大香菇體驗潛水', 'location' => '綠島', 'spot' => '大香菇', 'price' => 4500, 'region' => '東部', 'tag' => '初學者', 'badges' => ['含裝備', '教練全程陪同', '保險', '水下攝影服務'], 'description' => '大香菇是綠島最著名的潛點,獨特的菇形珊瑚礁聳立在 20 米深處,四周魚群聚集。小班教學讓每位學員都獲得充分指導,視覺震撼無與倫比。'],
['title' => '綠島 PADI AOW 進階認證', 'location' => '綠島', 'spot' => '石朗', 'price' => 16800, 'region' => '東部', 'tag' => '認證課程', 'badges' => ['PADI認證', '含裝備', '教材費含', '保險'], 'description' => '5 支多元進階潛水(深潛、導航、水下攝影、夜潛、自然生態),全程在石朗保護區完成。石朗是台灣保育最完善的珊瑚礁區之一,生態豐富多樣。'],
['title' => '綠島柴口海龜共游體驗', 'location' => '綠島', 'spot' => '柴口', 'price' => 6800, 'region' => '東部', 'tag' => '生態', 'badges' => ['含裝備', '生態解說', '保險', '小班4人'], 'description' => '柴口是台灣最容易近距離觀察綠蠵龜的潛點,我們與海龜共存超過 20 年,熟知牠們的作息。每梯次限 4 人,確保對環境最低干擾。'],
// 小琉球海洋探索
['title' => '小琉球花瓶石浮潛', 'location' => '小琉球', 'spot' => '花瓶石', 'price' => 1800, 'region' => '南部', 'tag' => '浮潛', 'badges' => ['含裝備', '教練帶領', '適合全家', '保險'], 'description' => '不需要任何潛水經驗,只要會游泳或穿著救生衣就能參加!花瓶石淺灘珊瑚礁完整,是小朋友和第一次接觸海洋的旅客最佳選擇。'],
['title' => '小琉球美人洞海龜體驗潛水', 'location' => '小琉球', 'spot' => '美人洞', 'price' => 5200, 'region' => '南部', 'tag' => '初學者', 'badges' => ['含裝備', '教練全程陪同', '保險', '海龜保證'], 'description' => '小琉球海龜密度全台第一,美人洞水下可同時觀察到 3–8 隻海龜悠游。體驗課程不需事先考照,教練確保每位學員安全觀察到海龜。'],
['title' => '小琉球 PADI OW 認證課程', 'location' => '小琉球', 'spot' => '厚石裙礁', 'price' => 12800, 'region' => '南部', 'tag' => '認證課程', 'badges' => ['PADI認證', '含裝備', '教材費含', '保險', '海龜同行'], 'description' => '結合 PADI OW 課程與小琉球生態,4 天課程讓你在取得認證的同時深度認識台灣海龜生態。厚石裙礁水況穩定,是絕佳的訓練環境。'],
// 北海岸潛水俱樂部
['title' => '龍洞灣體驗潛水', 'location' => '龍洞', 'spot' => '龍洞灣', 'price' => 3200, 'region' => '北部', 'tag' => '初學者', 'badges' => ['含裝備', '教練全程陪同', '保險'], 'description' => '龍洞是北台灣能見度最穩定的潛點,清澈海水與豐富的溫帶魚種。北部居民不用遠赴南部,週末就能享受高品質的潛水體驗。'],
['title' => '東北角鼻頭角生態導覽潛水', 'location' => '東北角', 'spot' => '鼻頭角', 'price' => 4800, 'region' => '北部', 'tag' => '生態', 'badges' => ['含裝備', '生態解說', '保險', 'OW以上'], 'description' => '東北角海域受黑潮支流影響,生物多樣性極高,鼻頭角是章魚、河豚、獅子魚的熱區。需持 OW 以上證書,適合進階潛水員。'],
['title' => '龍洞技術潛水入門', 'location' => '龍洞', 'spot' => '龍洞南口', 'price' => 9800, 'region' => '北部', 'tag' => '進階', 'badges' => ['AOW認證', '含裝備', '保險', 'Tec入門', '小班2人'], 'description' => '學習雙瓶氣瓶配置、Backplate 裝具與減壓程序基礎。龍洞南口地形複雜,是訓練水下導航與定位的絕佳環境,每班限 2 人。'],
];
$offers = [];
foreach ($offersData as $index => $data) {
$providerIndex = (int) floor($index / 3);
$offer = DivingOffer::firstOrCreate(
['title' => $data['title'], 'provider_id' => $providers[$providerIndex]->id],
array_merge($data, [
'provider_id' => $providers[$providerIndex]->id,
'rating' => 0,
'reviews' => 0,
])
);
$offers[] = $offer;
}
return $offers;
}
private function createSchedules(array $offers): array
{
$pastSchedules = [];
$futureSchedules = [];
foreach ($offers as $index => $offer) {
$pastDate = now()->subDays(90 + $index * 5)->toDateString();
$futureDate = now()->addDays(30 + $index * 7)->toDateString();
$pastSchedules[] = CourseSchedule::firstOrCreate(
['diving_offer_id' => $offer->id, 'scheduled_date' => $pastDate],
[
'provider_id' => $offer->provider_id,
'start_time' => '09:00',
'max_participants' => 6,
'current_participants' => 4,
'status' => ScheduleStatus::Open,
]
);
$futureSchedules[] = CourseSchedule::firstOrCreate(
['diving_offer_id' => $offer->id, 'scheduled_date' => $futureDate],
[
'provider_id' => $offer->provider_id,
'start_time' => '09:00',
'max_participants' => 6,
'current_participants' => 1,
'status' => ScheduleStatus::Open,
]
);
}
return [$pastSchedules, $futureSchedules];
}
private function createCompletedBookings(array $pastSchedules, array $members, array $offers): array
{
$completedBookings = [];
foreach ($pastSchedules as $index => $schedule) {
$member = $members[$index % count($members)];
$offer = $offers[$index];
$booking = Booking::firstOrCreate(
['schedule_id' => $schedule->id, 'member_id' => $member->id],
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Completed]
);
$completedBookings[] = [
'booking' => $booking,
'member' => $member,
'offer' => $offer,
'schedule' => $schedule,
];
}
return $completedBookings;
}
private function createPendingBookings(array $futureSchedules, array $members, array $offers): void
{
$statusCycle = [
BookingStatus::Pending, BookingStatus::Confirmed,
BookingStatus::Pending, BookingStatus::Confirmed,
BookingStatus::Pending, BookingStatus::Confirmed,
];
foreach ($futureSchedules as $index => $schedule) {
$member = $members[($index + 4) % count($members)];
$offer = $offers[$index];
Booking::firstOrCreate(
['schedule_id' => $schedule->id, 'member_id' => $member->id],
[
'participants' => 1,
'total_price' => $offer->price,
'status' => $statusCycle[$index % count($statusCycle)],
]
);
}
}
private function createReviews(array $completedBookings): void
{
$reviewTexts = [
5 => [
'教練超級專業!水下說明非常清楚,全程讓我感到安心。第一次潛水就看到這麼多魚,感動到想哭,絕對會再來!',
'課程安排超完善,從學科到實際下水都循序漸進。教練英文也很好,外國朋友一起來完全沒問題。強力推薦!',
'海龜真的出現了!三隻海龜就在我旁邊游過,那一刻我整個人都愣住了,這輩子難忘的體驗。',
'認證課程物超所值,教練非常有耐心,每個步驟都解釋得很清楚,讓我順利考到 OW 證書!',
'夜潛太刺激了!螢光珊瑚、章魚、獅子魚,白天完全看不到的生物都出來了,裝備品質很好,非常安全。',
],
4 => [
'整體體驗很棒,珊瑚保育做得很完善,能見度超過 20 米。等待入水時間稍長,希望未來能改善行程安排。',
'教練很專業,課程內容扎實。裝備保養良好,建議加入更多生態解說,讓學員更了解看到的生物。',
'潛點選擇很棒,教練熟悉地形。課程說明文件清楚,行前準備資訊充足,讓第一次潛水的我很放心。',
],
3 => [
'潛水本身體驗還不錯,但課程安排感覺有點趕,希望每個環節能多一點時間。教練很專業,人手稍嫌不足。',
'裝備稍舊但堪用,整體體驗中規中矩。適合想嘗試的初學者,進階潛水員可能會覺得行程太簡單。',
],
];
$skipIndexes = [3, 9];
foreach ($completedBookings as $index => $data) {
if (in_array($index, $skipIndexes)) {
continue;
}
$rating = match (true) {
$index < 5 => 5,
$index < 8 => 4,
default => 3,
};
$textPool = $reviewTexts[$rating];
Review::firstOrCreate(
['diving_offer_id' => $data['offer']->id, 'member_id' => $data['member']->id],
[
'rating' => $rating,
'comment' => $textPool[$index % count($textPool)],
'helpful_count' => rand(0, 20),
'is_edited' => false,
]
);
}
}
private function refreshOfferStats(array $offers): void
{
foreach ($offers as $offer) {
$reviews = Review::where('diving_offer_id', $offer->id)->get();
if ($reviews->isEmpty()) {
continue;
}
$offer->update([
'rating' => round($reviews->avg('rating'), 1),
'reviews' => $reviews->count(),
]);
}
}
private function createNotifications(array $completedBookings): void
{
$rows = [];
foreach (array_slice($completedBookings, 0, 6) as $data) {
['booking' => $booking, 'offer' => $offer, 'member' => $member, 'schedule' => $schedule] = $data;
$date = $schedule->scheduled_date->toDateString();
$baseUrl = config('app.frontend_url');
$rows[] = [
'id' => Str::uuid()->toString(),
'type' => 'App\Notifications\BookingConfirmedNotification',
'notifiable_type' => 'App\Models\User',
'notifiable_id' => $member->id,
'data' => json_encode([
'type' => 'booking_confirmed',
'title' => '預約已確認',
'body' => "你的《{$offer->title}》課程預約已由教練確認(時段:{$date}",
'action_url' => "{$baseUrl}/my-bookings",
'related_id' => $booking->id,
'related_type' => 'booking',
]),
'read_at' => now()->subDays(rand(20, 60))->toDateTimeString(),
'created_at' => now()->subDays(rand(61, 90))->toDateTimeString(),
'updated_at' => now()->subDays(rand(20, 60))->toDateTimeString(),
];
$rows[] = [
'id' => Str::uuid()->toString(),
'type' => 'App\Notifications\BookingCompletedNotification',
'notifiable_type' => 'App\Models\User',
'notifiable_id' => $member->id,
'data' => json_encode([
'type' => 'booking_completed',
'title' => '課程已完成,歡迎留下評價',
'body' => "你的《{$offer->title}》課程已完成,分享你的潛水心得讓更多人看到!",
'action_url' => "{$baseUrl}/my-bookings",
'related_id' => $booking->id,
'related_type' => 'booking',
]),
'read_at' => null,
'created_at' => now()->subDays(rand(5, 19))->toDateTimeString(),
'updated_at' => now()->subDays(rand(5, 19))->toDateTimeString(),
];
}
if (!empty($rows)) {
DB::table('notifications')->insert($rows);
}
}
private function printSummary(array $providers, array $members): void
{
$this->command->info('');
$this->command->info(' Admin: admin@cfdive.com / password123');
$this->command->info('');
$this->command->info(' 教練帳號(密碼均為 password123):');
foreach ($providers as $provider) {
$profile = ProviderProfile::where('user_id', $provider->id)->value('business_name');
$this->command->info(" {$provider->email}{$profile}");
}
$this->command->info('');
$this->command->info(' 會員帳號(密碼均為 password123):');
foreach ($members as $member) {
$this->command->info(" {$member->email} ({$member->name})");
}
}
}