CoachService.php 29 KB

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