CoachService.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758
  1. <?php
  2. namespace App\Services\Client;
  3. use App\Models\Order;
  4. use App\Enums\UserStatus;
  5. use App\Models\CoachUser;
  6. use App\Enums\OrderStatus;
  7. use App\Models\MemberUser;
  8. use App\Models\CoachSchedule;
  9. use Illuminate\Support\Carbon;
  10. use App\Enums\TechnicianStatus;
  11. use App\Enums\TechnicianAuthStatus;
  12. use Illuminate\Support\Facades\Log;
  13. use Illuminate\Support\Facades\Auth;
  14. use App\Enums\TechnicianLocationType;
  15. use Illuminate\Support\Facades\Cache;
  16. use Illuminate\Support\Facades\Redis;
  17. use App\Models\SettingItem;
  18. /**
  19. * 用户端技师服务类
  20. *
  21. * 该服务类处理与技师相关的所有业务逻辑,包括:
  22. * - 获取附近技师列表
  23. * - 获取技师详细信息
  24. * - 管理技师位置信息
  25. * - 处理技师排班和预约时间段
  26. *
  27. * 主要功能:
  28. * 1. 基于地理位置的技师查询和排序
  29. * 2. 技师认证状态验证
  30. * 3. 技师位置信息的缓存管理
  31. * 4. 技师预约时间段的计算和管理
  32. */
  33. class CoachService
  34. {
  35. /**
  36. * 获取附近技师列表
  37. *
  38. * 业务流程:
  39. * 1. 验证用户身份和状态
  40. * 2. 获取系统设置的最大搜索半径
  41. * 3. 使用Redis GEO功能查询范围内的技师
  42. * 4. 过滤并验证技师认证状态
  43. * 5. 根据技师接单距离设置筛选
  44. * 6. 计算并排序技师距离
  45. * 7. 返回分页后的技师列表
  46. *
  47. * 技术实现:
  48. * - 使用Redis GEORADIUS命令进行地理位置查询
  49. * - 使用Eloquent关联加载技师信息
  50. * - 实现基于距离的排序
  51. * - 支持分页功能
  52. *
  53. * 数据处理:
  54. * - 技师位置数据存储在Redis中
  55. * - 技师基本信息从数据库获取
  56. * - 认证状态多表关联验证
  57. * - 接单距离设置验证
  58. * - 距离数据格式化(保留2位小数)
  59. *
  60. * 筛选条件:
  61. * 1. 技师认证状态检查
  62. * - 基本信息认证通过
  63. * - 实名认证通过
  64. * - 资质认证通过
  65. * 2. 距离筛选
  66. * - 有设置接单距离:用户距离 <= 设置值
  67. * - 无设置接单距离:在系统搜索半径内即可
  68. *
  69. * 排序规则:
  70. * - 按照技师到用户的实际距离升序排序
  71. * - 未获取到距离的技师排在最后
  72. *
  73. * 异常处理:
  74. * - 用户未登录/状态异常
  75. * - Redis连接异常
  76. * - 无效的地理坐标
  77. * - 设置项未找到
  78. *
  79. * @param int $userId 当前用户ID
  80. * @param float $latitude 纬度坐标 (-90 到 90)
  81. * @param float $longitude 经度坐标 (-180 到 180)
  82. * @return array{
  83. * items: array{
  84. * id: int,
  85. * info: array{
  86. * id: int,
  87. * nickname: string,
  88. * avatar: string,
  89. * gender: int
  90. * },
  91. * distance: float
  92. * }[],
  93. * total: int
  94. * } 技师列表和总数
  95. * @throws \Exception 当用户验证失败或Redis操作异常时
  96. */
  97. public function getNearCoachList(int $userId, float $latitude, float $longitude)
  98. {
  99. // 初始化分页参数
  100. $perPage = request()->get('per_page', 15);
  101. // 基础验证(用户状态和Redis连接)
  102. $this->validateBasicRequirements($userId);
  103. // 获取搜索半径设置
  104. $distanceSettingItem = $this->getDistanceSetting();
  105. $maxSearchRadius = $distanceSettingItem?->max_value ?? 40; // 默认40公里
  106. // 获取附近的技师(使用Redis GEO功能)
  107. $nearbyCoaches = $this->getNearbyCoaches($longitude, $latitude, $maxSearchRadius);
  108. if (empty($nearbyCoaches)) {
  109. return ['items' => [], 'total' => 0];
  110. }
  111. // 处理技师距离数据(提取ID和距离信息)
  112. [$coachDistances, $coachIds] = $this->processCoachDistances($nearbyCoaches);
  113. // 构建技师查询(包含认证状态和距离筛选)
  114. $query = $this->buildCoachQuery($coachIds, $distanceSettingItem, $coachDistances);
  115. // 获取分页结果(带基本信息)
  116. $coaches = $query->with(['info:id,nickname,avatar,gender'])
  117. ->paginate($perPage);
  118. // 处理返回结果(添加距离信息)
  119. $items = $this->processCoachResults($coaches, $coachDistances);
  120. return [
  121. 'items' => $items,
  122. 'total' => $coaches->total()
  123. ];
  124. }
  125. /**
  126. * 验证基本要求
  127. *
  128. * @param int $userId 用户ID
  129. * @param bool $checkRedis 是否检查Redis连接
  130. * @throws \Illuminate\Database\Eloquent\ModelNotFoundException 当用户不存在时
  131. * @throws \Symfony\Component\HttpKernel\Exception\HttpException 当验证失败时
  132. */
  133. protected function validateBasicRequirements(int $userId, bool $checkRedis = true): void
  134. {
  135. // 验证用户状态
  136. $user = MemberUser::findOrFail($userId);
  137. abort_if($user->state !== UserStatus::OPEN->value, 400, '用户状态异常');
  138. // 检查Redis连接
  139. if ($checkRedis) {
  140. abort_if(!Redis::ping(), 500, 'Redis服务不可用');
  141. }
  142. }
  143. /**
  144. * 获取距离设置项
  145. *
  146. * @return \App\Models\SettingItem|null 距离设置项
  147. */
  148. protected function getDistanceSetting(): ?SettingItem
  149. {
  150. return SettingItem::query()
  151. ->whereHas('group', function ($query) {
  152. $query->where('code', 'order');
  153. })
  154. ->where('code', 'distance')
  155. ->first();
  156. }
  157. /**
  158. * 获取附近的技师
  159. *
  160. * @param float $longitude 经度
  161. * @param float $latitude 纬度
  162. * @param int $maxSearchRadius 最大搜索半径(km)
  163. * @return array 附近技师的位置和距离信息
  164. */
  165. protected function getNearbyCoaches(float $longitude, float $latitude, int $maxSearchRadius): array
  166. {
  167. return Redis::georadius(
  168. 'coach_locations',
  169. $longitude,
  170. $latitude,
  171. $maxSearchRadius,
  172. 'km',
  173. ['WITHDIST']
  174. );
  175. }
  176. /**
  177. * 处理技师距离数据
  178. *
  179. * @param array $nearbyCoaches Redis返回的原始数据
  180. * @return array{0: array<string, float>, 1: array<int>} [距离映射, 技师ID列表]
  181. */
  182. protected function processCoachDistances(array $nearbyCoaches): array
  183. {
  184. $coachDistances = [];
  185. $coachIds = [];
  186. foreach ($nearbyCoaches as $coach) {
  187. [$id, $type] = explode('_', $coach[0]);
  188. // 只保留最近的距离
  189. if (!isset($coachDistances[$id]) || $coach[1] < $coachDistances[$id]) {
  190. $coachDistances[$id] = (float)$coach[1];
  191. $coachIds[] = $id;
  192. }
  193. }
  194. return [$coachDistances, array_unique($coachIds)];
  195. }
  196. /**
  197. * 添加技师认证状态检查条件
  198. *
  199. * @param \Illuminate\Database\Eloquent\Builder $query
  200. * @return \Illuminate\Database\Eloquent\Builder
  201. */
  202. protected function addAuthStatusChecks($query)
  203. {
  204. return $query->whereHas('info', fn($q) => $q->where('state', TechnicianAuthStatus::PASSED->value))
  205. ->whereHas('real', fn($q) => $q->where('state', TechnicianAuthStatus::PASSED->value))
  206. ->whereHas('qual', fn($q) => $q->where('state', TechnicianAuthStatus::PASSED->value));
  207. }
  208. /**
  209. * 构建技师查询
  210. *
  211. * @param array $coachIds 技师ID列表
  212. * @param SettingItem|null $distanceSettingItem 距离设置项
  213. * @param array $coachDistances 技师距离映射
  214. * @return \Illuminate\Database\Eloquent\Builder
  215. */
  216. protected function buildCoachQuery(array $coachIds, ?SettingItem $distanceSettingItem, array $coachDistances): \Illuminate\Database\Eloquent\Builder
  217. {
  218. $query = CoachUser::query()
  219. ->whereIn('id', $coachIds);
  220. // 添加认证状态检查
  221. $this->addAuthStatusChecks($query);
  222. // 添加接单距离筛选条件
  223. if ($distanceSettingItem) {
  224. $query->where(function ($q) use ($distanceSettingItem, $coachDistances) {
  225. $q->whereHas('settings', function ($sq) use ($distanceSettingItem, $coachDistances) {
  226. // 检查用户距离是否在技师设置的接单范围内
  227. $sq->where('item_id', $distanceSettingItem->id)
  228. ->whereRaw('? <= CAST(value AS DECIMAL)', [
  229. $coachDistances[$sq->getModel()->coach_id] ?? 0
  230. ]);
  231. })
  232. // 未设置接单距离的技师直接显示
  233. ->orWhereDoesntHave('settings', function ($sq) use ($distanceSettingItem) {
  234. $sq->where('item_id', $distanceSettingItem->id);
  235. });
  236. });
  237. }
  238. // 按实际距离排序
  239. if (!empty($coachDistances)) {
  240. $query->orderByRaw('CASE coach_users.id ' .
  241. collect($coachDistances)->map(function ($distance, $id) {
  242. return "WHEN $id THEN CAST($distance AS DECIMAL(10,2))";
  243. })->implode(' ') .
  244. ' ELSE ' . ($distanceSettingItem->default_value ?? PHP_FLOAT_MAX) .
  245. ' END ASC');
  246. }
  247. return $query;
  248. }
  249. /**
  250. * 处理技师查询结果
  251. *
  252. * @param \Illuminate\Pagination\LengthAwarePaginator $coaches 分页后的技师数据
  253. * @param array $coachDistances 技师距离映射
  254. * @return \Illuminate\Support\Collection
  255. */
  256. protected function processCoachResults($coaches, array $coachDistances): \Illuminate\Support\Collection
  257. {
  258. return $coaches->getCollection()
  259. ->map(function ($coach) use ($coachDistances) {
  260. // 添加距离信息(保留2位小数)
  261. $coach->distance = round($coachDistances[$coach->id] ?? 0, 2);
  262. return $coach;
  263. })
  264. ->values();
  265. }
  266. /**
  267. * 获取技师详情
  268. *
  269. * 业务流程:
  270. * 1. 验证Redis连接状态
  271. * 2. 验证用户身份和状态
  272. * 3. 获取技师基本信息和认证状态
  273. * 4. 获取技师位置信息
  274. * 5. 计算用户与技师之间的距离
  275. *
  276. * 技术实现:
  277. * - Redis连接状态检查
  278. * - 多重认证状态验证
  279. * - 地理位置距离计算
  280. * - 数据关联查询
  281. *
  282. * 数据验证:
  283. * - 技师ID有效性
  284. * - 用户状态检查
  285. * - 地理坐标有效性
  286. * - 认证状态验证
  287. *
  288. * 异常处理:
  289. * - Redis连接异常
  290. * - 技师不存在
  291. * - 无效坐标
  292. * - 认证状态异常
  293. *
  294. * @param int $coachId 技师ID
  295. * @param float|null $latitude 用户当前纬度
  296. * @param float|null $longitude 用户当前经度
  297. * @return CoachUser 技师详细信息
  298. * @throws \Exception 当验证失败或查询异常时
  299. */
  300. public function getCoachDetail($coachId, $latitude, $longitude)
  301. {
  302. // 验证基本要求
  303. $this->validateBasicRequirements(Auth::id());
  304. // 检查Redis连接
  305. try {
  306. $pingResult = Redis::connection()->ping();
  307. Log::info('Redis connection test:', ['ping_result' => $pingResult]);
  308. } catch (\Exception $e) {
  309. Log::error('Redis connection error:', ['error' => $e->getMessage()]);
  310. throw new \Exception('Redis连接失败:' . $e->getMessage());
  311. }
  312. // 获取当前用户
  313. $user = Auth::user();
  314. Log::info('Current user and coordinates:', [
  315. 'user' => $user ? $user->id : null,
  316. 'latitude' => $latitude,
  317. 'longitude' => $longitude,
  318. 'coach_id' => $coachId,
  319. ]);
  320. // 检查用户状态
  321. if (! $user) {
  322. throw new \Exception('用户未登录');
  323. }
  324. if ($user->state !== UserStatus::OPEN->value) {
  325. throw new \Exception('用户状态异常');
  326. }
  327. // 修改查询以处理可能的 null 值
  328. $query = CoachUser::where('state', TechnicianStatus::ACTIVE->value);
  329. $this->addAuthStatusChecks($query);
  330. $coach = $query->with(['info:id,nickname,avatar,gender'])
  331. ->find($coachId);
  332. if (! $coach) {
  333. throw new \Exception('技师不存在');
  334. }
  335. // 从 Redis 获取技师的 id_home 和 id_work 的经纬度
  336. $homeLocation = Redis::geopos('coach_locations', $coachId . '_' . TechnicianLocationType::COMMON->value);
  337. $workLocation = Redis::geopos('coach_locations', $coachId . '_' . TechnicianLocationType::CURRENT->value);
  338. // 检输入的经纬度否效
  339. if (! is_numeric($latitude) || ! is_numeric($longitude)) {
  340. Log::error('Invalid coordinates:', ['latitude' => $latitude, 'longitude' => $longitude]);
  341. throw new \Exception('无效的经纬度坐标');
  342. }
  343. // 临时存储用户当前位置用于计算距离
  344. $tempKey = 'user_temp_' . $user->id;
  345. Redis::geoadd('coach_locations', $longitude, $latitude, $tempKey);
  346. // 计算距离(单位:km)
  347. $distanceHome = null;
  348. $distanceWork = null;
  349. if ($homeLocation && ! empty($homeLocation[0])) {
  350. $distanceHome = Redis::geodist('coach_locations', $tempKey, $coachId . '_' . TechnicianLocationType::COMMON->value, 'km');
  351. Log::info('Home distance calculation:', [
  352. 'from' => $tempKey,
  353. 'to' => $coachId . '_' . TechnicianLocationType::COMMON->value,
  354. 'distance' => $distanceHome,
  355. 'home_location' => $homeLocation[0],
  356. ]);
  357. }
  358. if ($workLocation && ! empty($workLocation[0])) {
  359. $distanceWork = Redis::geodist('coach_locations', $tempKey, $coachId . '_' . TechnicianLocationType::CURRENT->value, 'km');
  360. }
  361. // 删除临位置点
  362. Redis::zrem('coach_locations', $tempKey);
  363. // 选择最近的距离
  364. $distances = array_filter([$distanceHome, $distanceWork]);
  365. $coach->distance = ! empty($distances) ? round(min($distances), 2) : null;
  366. return $coach;
  367. }
  368. /**
  369. * 设置技师位置信息
  370. *
  371. * 业务流程:
  372. * 1. 验证位置信息的有效性
  373. * 2. 根据类型更新技师位置
  374. * 3. 验证更新结果
  375. *
  376. * 技术实现:
  377. * - 使用Redis GEOADD命令存储位置
  378. * - 支持多种位置类型(常驻/当前)
  379. * - 位置更新验证
  380. *
  381. * 数据验证:
  382. * - 标范围检查
  383. * - 位置类型验证
  384. * - 技师ID验证
  385. *
  386. * 日志记录:
  387. * - 位置更新操作
  388. * - 错误信息
  389. * - 验证结果
  390. *
  391. * @param int $coachId 技师ID
  392. * @param float $latitude 纬度坐标
  393. * @param float $longitude 经度坐标
  394. * @param int $type 位置类型 (TechnicianLocationType 举值)
  395. * @return bool 更新是否成功
  396. * @throws \Exception 当参数无效或Redis操作失败时
  397. */
  398. public function setCoachLocation($coachId, $latitude, $longitude, $type = TechnicianLocationType::COMMON->value)
  399. {
  400. if (! is_numeric($latitude) || ! is_numeric($longitude)) {
  401. Log::error('Invalid coordinates in setCoachLocation:', [
  402. 'coach_id' => $coachId,
  403. 'latitude' => $latitude,
  404. 'longitude' => $longitude,
  405. ]);
  406. throw new \Exception('无效的经纬度坐标');
  407. }
  408. if (! in_array($type, [TechnicianLocationType::CURRENT->value, TechnicianLocationType::COMMON->value])) {
  409. throw new \Exception('无效的位置类型,必须是 current 或 common');
  410. }
  411. $key = $coachId . '_' . $type;
  412. $result = Redis::geoadd('coach_locations', $longitude, $latitude, $key);
  413. Log::info('Coach location set:', [
  414. 'coach_id' => $coachId,
  415. 'type' => $type,
  416. 'key' => $key,
  417. 'latitude' => $latitude,
  418. 'longitude' => $longitude,
  419. 'result' => $result,
  420. ]);
  421. // 验证数据是否成功写入
  422. $location = Redis::geopos('coach_locations', $key);
  423. Log::info('Verify location after set:', [
  424. 'key' => $key,
  425. 'location' => $location,
  426. ]);
  427. return $result;
  428. }
  429. /**
  430. * 获取技师可预约时间段列表
  431. *
  432. * 业务流程:
  433. * 1. 验证技师信息和状态
  434. * 2. 获取技师排班设置
  435. * 3. 获取已有预约订单
  436. * 4. 计算用时间段
  437. * 5. 缓存处结果
  438. *
  439. * 技术实现:
  440. * - 使用Carbon处理日期时间
  441. * - 缓存机制优化查询性能
  442. * - 订单冲突检测
  443. * - 时间段生成算法
  444. *
  445. * 数据处理:
  446. * - 排班数据解析
  447. * - 订单时间过滤
  448. * - 时间段计算
  449. * - 数据格式化
  450. *
  451. * 缓存策略:
  452. * - 15分钟缓存时间
  453. * - 按技师和日期缓存
  454. * - 支持缓存更新
  455. *
  456. * @param int $coachId 技师ID
  457. * @param string|null $date 日期,默认当天
  458. * @return array 格式化的时间段表
  459. * @throws \Exception 当验证失败或理异常时
  460. */
  461. public function getSchedule(int $coachId, ?string $date = null)
  462. {
  463. try {
  464. // 默认使用当天日期
  465. $date = $date ? Carbon::parse($date)->format('Y-m-d') : now()->toDateString();
  466. $targetDate = Carbon::parse($date);
  467. // 验证技师信息
  468. $coach = CoachUser::with('info')->find($coachId);
  469. abort_if(! $coach, 404, '技师不存在');
  470. abort_if(
  471. $coach->info->state != TechnicianStatus::ACTIVE->value,
  472. 400,
  473. '技师状态异常'
  474. );
  475. // 验证日期
  476. abort_if(
  477. $targetDate->startOfDay()->lt(now()->startOfDay()),
  478. 400,
  479. '不能询过去的日期'
  480. );
  481. abort_if(
  482. $targetDate->diffInDays(now()) > 30,
  483. 400,
  484. '只能查询未来30天内的时间段'
  485. );
  486. $cacheKey = "coach:timeslots:{$coachId}:{$date}";
  487. Cache::forget($cacheKey);
  488. // 使用缓存减少数据库查询
  489. return Cache::remember(
  490. "coach:timeslots:{$coachId}:{$date}",
  491. now()->addMinutes(15), // 缓存15分钟
  492. function () use ($coachId, $date, $targetDate) {
  493. // 获取技师排班信息
  494. $schedule = CoachSchedule::where('coach_id', $coachId)
  495. ->where('state', 1)
  496. ->first();
  497. if (! $schedule || empty($schedule->time_ranges)) {
  498. return $this->formatResponse($date, []);
  499. }
  500. $timeRanges = json_decode($schedule->time_ranges, true);
  501. // 获取当天所有订单
  502. $dayOrders = $this->getDayOrders($coachId, $date);
  503. // 生成时间段列表
  504. $timeSlots = $this->generateAvailableTimeSlots(
  505. $date,
  506. $timeRanges,
  507. $dayOrders,
  508. $targetDate->isToday()
  509. );
  510. return $this->formatResponse($date, $timeSlots);
  511. }
  512. );
  513. } catch (\Exception $e) {
  514. Log::error('获取可预约时间段失败', [
  515. 'coach_id' => $coachId,
  516. 'date' => $date,
  517. 'error' => $e->getMessage(),
  518. 'trace' => $e->getTraceAsString(),
  519. ]);
  520. throw $e;
  521. }
  522. }
  523. /**
  524. * 获取当天所有订单
  525. *
  526. * @param int $coachId 技师ID
  527. * @param string $date 日期
  528. * @return array 订单列表
  529. */
  530. private function getDayOrders(int $coachId, string $date): array
  531. {
  532. $date = Carbon::parse($data['date'] ?? $date);
  533. $startOfDay = $date->startOfDay()->format('Y-m-d H:i:s');
  534. $endOfDay = $date->endOfDay()->format('Y-m-d H:i:s');
  535. return Order::where('coach_id', $coachId)
  536. ->whereBetween('service_time', [$startOfDay, $endOfDay])
  537. ->whereIn('state', [
  538. OrderStatus::ACCEPTED->value,
  539. OrderStatus::DEPARTED->value,
  540. OrderStatus::ARRIVED->value,
  541. OrderStatus::SERVICING->value,
  542. ])
  543. ->select(['id', 'service_start_time', 'service_end_time', 'state'])
  544. ->get()
  545. ->toArray();
  546. }
  547. /**
  548. * 生成可用时间段列表
  549. *
  550. * @param string $date 日期
  551. * @param array $timeRanges 班时间段
  552. * @param array $dayOrders 当天订单
  553. * @param bool $isToday 是否是当天
  554. * @return array 可用时间段列表
  555. */
  556. private function generateAvailableTimeSlots(
  557. string $date,
  558. array $timeRanges,
  559. array $dayOrders,
  560. bool $isToday
  561. ): array {
  562. $timeSlots = [];
  563. $currentTime = now();
  564. foreach ($timeRanges as $range) {
  565. $start = Carbon::parse($date . ' ' . $range['start_time']);
  566. $end = Carbon::parse($date . ' ' . $range['end_time']);
  567. // 果是当天且开始时间已过,从下一个30分钟时间点开始
  568. if ($isToday && $start->lt($currentTime)) {
  569. $start = $currentTime->copy()->addMinutes(30)->floorMinutes(30);
  570. // 如果调整后的开始时间已超过结束时间,跳过此时间段
  571. if ($start->gt($end)) {
  572. continue;
  573. }
  574. }
  575. // 生成30分钟间隔的时间段
  576. while ($start->lt($end)) {
  577. $slotStart = $start->format('H:i');
  578. $slotEnd = $start->copy()->addMinutes(30)->format('H:i');
  579. // 检查时间段是否被订单占用
  580. if (! $this->hasConflictingOrder($date, $slotStart, $slotEnd, $dayOrders)) {
  581. $timeSlots[] = [
  582. 'start_time' => $slotStart,
  583. 'end_time' => $slotEnd,
  584. 'is_available' => true,
  585. 'duration' => 30,
  586. ];
  587. }
  588. $start->addMinutes(30);
  589. }
  590. }
  591. return $timeSlots;
  592. }
  593. /**
  594. * 格式化返回数据
  595. *
  596. * @param string $date 日期
  597. * @param array $timeSlots 时间段列表
  598. * @return array 格式化后的数据
  599. */
  600. private function formatResponse(string $date, array $timeSlots): array
  601. {
  602. $targetDate = Carbon::parse($date);
  603. return [
  604. 'date' => $date,
  605. 'day_of_week' => $targetDate->isoFormat('dddd'), // 星期几
  606. 'is_today' => $targetDate->isToday(),
  607. 'time_slots' => $timeSlots,
  608. 'total_slots' => count($timeSlots),
  609. 'updated_at' => now()->toDateTimeString(),
  610. ];
  611. }
  612. /**
  613. * 检查是否与已有订单冲突
  614. *
  615. * @param string $date 日期
  616. * @param string $startTime 开始时间
  617. * @param string $endTime 结束时间
  618. * @param array $dayOrders 当天订单
  619. * @return bool 是否冲突
  620. */
  621. private function hasConflictingOrder(
  622. string $date,
  623. string $startTime,
  624. string $endTime,
  625. array $dayOrders
  626. ): bool {
  627. $slotStart = Carbon::parse("$date $startTime");
  628. $slotEnd = Carbon::parse("$date $endTime");
  629. foreach ($dayOrders as $order) {
  630. $orderStart = Carbon::parse($order['service_start_time']);
  631. $orderEnd = Carbon::parse($order['service_end_time']);
  632. // 检查时间段是否重叠
  633. if (($slotStart >= $orderStart && $slotStart < $orderEnd) ||
  634. ($slotEnd > $orderStart && $slotEnd <= $orderEnd) ||
  635. ($slotStart <= $orderStart && $slotEnd >= $orderEnd)
  636. ) {
  637. return true;
  638. }
  639. }
  640. return false;
  641. }
  642. /**
  643. * 验证技师服务时间是否在可用时间段内
  644. */
  645. public function validateServiceTimeWithinCoachAvailability(int $coachId, string $serviceTime): bool
  646. {
  647. $serviceDateTime = Carbon::parse($serviceTime);
  648. $coachSchedule = $this->getSchedule($coachId, $serviceDateTime->format('Y-m-d'));
  649. foreach ($coachSchedule['time_slots'] as $slot) {
  650. $slotStart = Carbon::parse($serviceDateTime->format('Y-m-d') . ' ' . $slot['start_time']);
  651. $slotEnd = Carbon::parse($serviceDateTime->format('Y-m-d') . ' ' . $slot['end_time']);
  652. if ($serviceDateTime->between($slotStart, $slotEnd)) {
  653. return true;
  654. }
  655. }
  656. return false;
  657. }
  658. /**
  659. * 获取技师的地理位置信
  660. *
  661. * @param string $key Redis中的位置键名
  662. * @return array|null 位置信息
  663. */
  664. protected function getCoachLocation(string $key): ?array
  665. {
  666. return Redis::geopos('coach_locations', $key);
  667. }
  668. /**
  669. * 计算两点之间的距离
  670. *
  671. * @param string $from 起点位置键名
  672. * @param string $to 终点位置键名
  673. * @return float|null 距离(km)
  674. */
  675. protected function calculateDistance(string $from, string $to): ?float
  676. {
  677. return Redis::geodist('coach_locations', $from, $to, 'km');
  678. }
  679. }