CoachService.php 24 KB

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