CoachService.php 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889
  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. ($latitude && $longitude) && $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. if ($latitude && $longitude) {
  346. $coach->distance = $this->calculateDistanceToCoach(Auth::id(), $coachId, $latitude, $longitude);
  347. } else {
  348. // 获取技师位置信息
  349. $homeLocation = $this->getCoachLocation($coachId . '_' . TechnicianLocationType::COMMON->value);
  350. // 优先返回常驻地位置
  351. if ($homeLocation && !empty($homeLocation[0]) && is_array($homeLocation[0])) {
  352. $coach->location = [
  353. 'latitude' => $homeLocation[0][1] ?? null, // geopos 返回 [longitude, latitude]
  354. 'longitude' => $homeLocation[0][0] ?? null
  355. ];
  356. } else {
  357. $coach->location = null;
  358. }
  359. }
  360. return $coach;
  361. }
  362. /**
  363. * 计算用户到技师的距离
  364. *
  365. * @param int $userId 用户ID
  366. * @param int $coachId 技师ID
  367. * @param float $latitude 用户纬度
  368. * @param float $longitude 用户经度
  369. * @return float|null 最近距离(公里)
  370. */
  371. protected function calculateDistanceToCoach(int $userId, int $coachId, float $latitude, float $longitude): ?float
  372. {
  373. // 临时存储用户位置
  374. $tempKey = 'user_temp_' . $userId;
  375. Redis::geoadd('coach_locations', $longitude, $latitude, $tempKey);
  376. try {
  377. // 获取技师位置
  378. $homeLocation = $this->getCoachLocation($coachId . '_' . TechnicianLocationType::COMMON->value);
  379. $workLocation = $this->getCoachLocation($coachId . '_' . TechnicianLocationType::CURRENT->value);
  380. $distances = [];
  381. // 计算到常驻地的距离
  382. if ($homeLocation && !empty($homeLocation[0])) {
  383. $distances[] = $this->calculateDistance($tempKey, $coachId . '_' . TechnicianLocationType::COMMON->value);
  384. }
  385. // 计算到当前位置的距离
  386. if ($workLocation && !empty($workLocation[0])) {
  387. $distances[] = $this->calculateDistance($tempKey, $coachId . '_' . TechnicianLocationType::CURRENT->value);
  388. }
  389. // 返回最近的距离
  390. return !empty($distances) ? round(min($distances), 2) : null;
  391. } finally {
  392. // 确保清理临时位置数据
  393. Redis::zrem('coach_locations', $tempKey);
  394. }
  395. }
  396. /**
  397. * 设置技师位置信息
  398. *
  399. * 业务流程:
  400. * 1. 验证位置信息的有效性
  401. * 2. 根据类型更新技师位置
  402. * 3. 验证更新结果
  403. *
  404. * 技术实现:
  405. * - 使用Redis GEOADD命令存储位置
  406. * - 支持多种位置类型(常驻/当前)
  407. * - 位置更新验证
  408. *
  409. * 数据验证:
  410. * - 坐标范围检查
  411. * - 位置类型验证
  412. * - 技师ID验证
  413. *
  414. * 日志记录:
  415. * - 位置更新操作
  416. * - 错误信息
  417. * - 验证结果
  418. *
  419. * @param int $coachId 技师ID
  420. * @param float $latitude 纬度坐标
  421. * @param float $longitude 经度坐标
  422. * @param int $type 位置类型 (TechnicianLocationType 举值)
  423. * @return bool 更新是否成功
  424. * @throws \Exception 当参数无效或Redis操作失败时
  425. */
  426. public function setCoachLocation($coachId, $latitude, $longitude, $type = TechnicianLocationType::COMMON->value)
  427. {
  428. // 验证坐标
  429. $this->validateCoordinates($latitude, $longitude);
  430. abort_if(
  431. !in_array($type, [TechnicianLocationType::CURRENT->value, TechnicianLocationType::COMMON->value]),
  432. 422,
  433. '无效的位置类型,必须是 current 或 common'
  434. );
  435. $key = $coachId . '_' . $type;
  436. return Redis::geoadd('coach_locations', $longitude, $latitude, $key);
  437. }
  438. /**
  439. * 获取技师可预约时间段列表
  440. *
  441. * 业务流程:
  442. * 1. 验证技师信息和状态
  443. * 2. 获取技师排班设置
  444. * 3. 获取已有预约订单
  445. * 4. 计算用时间段
  446. * 5. 缓存处结果
  447. *
  448. * 技术实现:
  449. * - 使用Carbon处理日期时间
  450. * - 缓存机制优化查询性能
  451. * - 订单冲突检测
  452. * - 时间段生成算法
  453. *
  454. * 数据处理:
  455. * - 排班数据解析
  456. * - 订单时间过滤
  457. * - 时间段计算
  458. * - 数据格式化
  459. *
  460. * 缓存策略:
  461. * - 15分钟缓存时间
  462. * - 按技师和日期缓存
  463. * - 支持缓存更新
  464. *
  465. * @param int $coachId 技师ID
  466. * @param string|null $date 日期,默认当天
  467. * @return array 格式化的时间段表
  468. * @throws \Exception 当验证失败或理异常时
  469. */
  470. public function getSchedule(int $coachId, ?string $date = null)
  471. {
  472. try {
  473. // 默认使用当天日期
  474. $date = $date ? Carbon::parse($date)->format('Y-m-d') : now()->toDateString();
  475. $targetDate = Carbon::parse($date);
  476. // 验证技师信息
  477. $coach = CoachUser::find($coachId);
  478. abort_if(! $coach, 404, '技师不存在');
  479. abort_if(
  480. (int)$coach->state != TechnicianStatus::ACTIVE->value,
  481. 400,
  482. '技师状态异常'
  483. );
  484. // 验证日期
  485. abort_if(
  486. $targetDate->startOfDay()->lt(now()->startOfDay()),
  487. 400,
  488. '不能询过去的日期'
  489. );
  490. abort_if(
  491. $targetDate->diffInDays(now()) > 30,
  492. 400,
  493. '只能查询未来30天内的时间段'
  494. );
  495. $cacheKey = "coach:timeslots:{$coachId}:{$date}";
  496. Cache::forget($cacheKey);
  497. // 使用缓存减少数据库查询
  498. return Cache::remember(
  499. "coach:timeslots:{$coachId}:{$date}",
  500. now()->addMinutes(15), // 缓存15分钟
  501. function () use ($coachId, $date, $targetDate) {
  502. // 获取技师排班信息
  503. $schedule = CoachSchedule::where('coach_id', $coachId)
  504. ->where('state', 1)
  505. ->first();
  506. if (! $schedule || empty($schedule->time_ranges)) {
  507. return $this->formatResponse($date, []);
  508. }
  509. $timeRanges = is_array($schedule->time_ranges)
  510. ? $schedule->time_ranges
  511. : json_decode($schedule->time_ranges, true);
  512. // 获取当天所有订单
  513. $dayOrders = $this->getDayOrders($coachId, $date);
  514. // 生成时间段列表
  515. $timeSlots = $this->generateAvailableTimeSlots(
  516. $date,
  517. $timeRanges,
  518. $dayOrders,
  519. $targetDate->isToday()
  520. );
  521. return $this->formatResponse($date, $timeSlots);
  522. }
  523. );
  524. } catch (\Exception $e) {
  525. Log::error('获取可预约时间段失败', [
  526. 'coach_id' => $coachId,
  527. 'date' => $date,
  528. 'error' => $e->getMessage(),
  529. 'trace' => $e->getTraceAsString(),
  530. ]);
  531. throw $e;
  532. }
  533. }
  534. /**
  535. * 获取当天所有订单
  536. *
  537. * @param int $coachId 技师ID
  538. * @param string $date 日期
  539. * @return array 订单列表
  540. */
  541. private function getDayOrders(int $coachId, string $date): array
  542. {
  543. $date = Carbon::parse($data['date'] ?? $date);
  544. $startOfDay = $date->startOfDay()->format('Y-m-d H:i:s');
  545. $endOfDay = $date->endOfDay()->format('Y-m-d H:i:s');
  546. return Order::where('coach_id', $coachId)
  547. ->whereBetween('service_time', [$startOfDay, $endOfDay])
  548. ->whereIn('state', [
  549. OrderStatus::ACCEPTED->value,
  550. OrderStatus::DEPARTED->value,
  551. OrderStatus::ARRIVED->value,
  552. OrderStatus::SERVICING->value,
  553. ])
  554. ->select(['id', 'service_start_time', 'service_end_time', 'state'])
  555. ->get()
  556. ->toArray();
  557. }
  558. /**
  559. * 生成可用时间段列表
  560. *
  561. * @param string $date 日期
  562. * @param array $timeRanges 班时间段
  563. * @param array $dayOrders 当天订单
  564. * @param bool $isToday 是否是当天
  565. * @return array 可用时间段列表
  566. */
  567. private function generateAvailableTimeSlots(
  568. string $date,
  569. array $timeRanges,
  570. array $dayOrders,
  571. bool $isToday
  572. ): array {
  573. $timeSlots = [];
  574. $currentTime = now();
  575. foreach ($timeRanges as $range) {
  576. $start = Carbon::parse($date . ' ' . $range['start_time']);
  577. $end = Carbon::parse($date . ' ' . $range['end_time']);
  578. // 果是当天且开始时间已过,从下一个30分钟时间点开始
  579. if ($isToday && $start->lt($currentTime)) {
  580. $start = $currentTime->copy()->addMinutes(30)->floorMinutes(30);
  581. // 如果调整后的开始时间已超过结束时间,跳过此时间段
  582. if ($start->gt($end)) {
  583. continue;
  584. }
  585. }
  586. // 生成30分钟间隔的时间段
  587. while ($start->lt($end)) {
  588. $slotStart = $start->format('H:i');
  589. $slotEnd = $start->copy()->addMinutes(30)->format('H:i');
  590. // 检查时间段是否被订单占用
  591. if (! $this->hasConflictingOrder($date, $slotStart, $slotEnd, $dayOrders)) {
  592. $timeSlots[] = [
  593. 'start_time' => $slotStart,
  594. 'end_time' => $slotEnd,
  595. 'is_available' => true,
  596. 'duration' => 30,
  597. ];
  598. }
  599. $start->addMinutes(30);
  600. }
  601. }
  602. return $timeSlots;
  603. }
  604. /**
  605. * 格式化返回数据
  606. *
  607. * @param string $date 日期
  608. * @param array $timeSlots 时间段列表
  609. * @return array 格式化后的数据
  610. */
  611. private function formatResponse(string $date, array $timeSlots): array
  612. {
  613. $targetDate = Carbon::parse($date);
  614. return [
  615. 'date' => $date,
  616. 'day_of_week' => $targetDate->isoFormat('dddd'), // 星期几
  617. 'is_today' => $targetDate->isToday(),
  618. 'time_slots' => $timeSlots,
  619. 'total_slots' => count($timeSlots),
  620. 'updated_at' => now()->toDateTimeString(),
  621. ];
  622. }
  623. /**
  624. * 检查是否与已有订单冲突
  625. *
  626. * @param string $date 日期
  627. * @param string $startTime 开始时间
  628. * @param string $endTime 结束时间
  629. * @param array $dayOrders 当天订单
  630. * @return bool 是否冲突
  631. */
  632. private function hasConflictingOrder(
  633. string $date,
  634. string $startTime,
  635. string $endTime,
  636. array $dayOrders
  637. ): bool {
  638. $slotStart = Carbon::parse("$date $startTime");
  639. $slotEnd = Carbon::parse("$date $endTime");
  640. foreach ($dayOrders as $order) {
  641. $orderStart = Carbon::parse($order['service_start_time']);
  642. $orderEnd = Carbon::parse($order['service_end_time']);
  643. // 检查时间段是否重叠
  644. if (($slotStart >= $orderStart && $slotStart < $orderEnd) ||
  645. ($slotEnd > $orderStart && $slotEnd <= $orderEnd) ||
  646. ($slotStart <= $orderStart && $slotEnd >= $orderEnd)
  647. ) {
  648. return true;
  649. }
  650. }
  651. return false;
  652. }
  653. /**
  654. * 验证技师服务时间是否在可用时间段内
  655. */
  656. public function validateServiceTimeWithinCoachAvailability(int $coachId, string $serviceTime): bool
  657. {
  658. $serviceDateTime = Carbon::parse($serviceTime);
  659. $coachSchedule = $this->getSchedule($coachId, $serviceDateTime->format('Y-m-d'));
  660. foreach ($coachSchedule['time_slots'] as $slot) {
  661. $slotStart = Carbon::parse($serviceDateTime->format('Y-m-d') . ' ' . $slot['start_time']);
  662. $slotEnd = Carbon::parse($serviceDateTime->format('Y-m-d') . ' ' . $slot['end_time']);
  663. if ($serviceDateTime->between($slotStart, $slotEnd)) {
  664. return true;
  665. }
  666. }
  667. return false;
  668. }
  669. /**
  670. * 获取技师的地理位置信
  671. *
  672. * @param string $key Redis中的位置键名
  673. * @return array|null 位置信息
  674. */
  675. protected function getCoachLocation(string $key): ?array
  676. {
  677. return Redis::geopos('coach_locations', $key);
  678. }
  679. /**
  680. * 计算两点之间的距离
  681. *
  682. * @param string $from 起点位置键名
  683. * @param string $to 终点位置键名
  684. * @return float|null 距离(km)
  685. */
  686. protected function calculateDistance(string $from, string $to): ?float
  687. {
  688. return Redis::geodist('coach_locations', $from, $to, 'km');
  689. }
  690. /**
  691. * 获取技师已开通的服务项目列表
  692. *
  693. * @param int $coachId 技师ID
  694. * @return array{
  695. * total: int,
  696. * list: array{
  697. * id: int,
  698. * project_id: int,
  699. * project_name: string,
  700. * category_id: int,
  701. * category_name: string,
  702. * duration: int,
  703. * price: float,
  704. * discount_amount: float,
  705. * final_price: float,
  706. * service_gender: string,
  707. * service_distance: int,
  708. * traffic_fee_type: string,
  709. * traffic_fee: float,
  710. * created_at: string,
  711. * updated_at: string
  712. * }[]
  713. * }
  714. */
  715. public function getEnabledProjects(int $coachId): array
  716. {
  717. // 获取技师已开通的项目列表
  718. $projects = CoachProject::query()
  719. ->where('coach_id', $coachId)
  720. ->where('state', 'enable')
  721. ->with(['project' => function ($query) {
  722. $query->select('id', 'name', 'category_id', 'duration', 'price')
  723. ->with(['category:id,name']);
  724. }])
  725. ->get();
  726. // 格式化返回数据
  727. $formattedProjects = $projects->map(function ($project) {
  728. $finalPrice = $project->project->price - $project->discount_amount;
  729. return [
  730. 'id' => $project->id,
  731. 'project_id' => $project->project_id,
  732. 'project_name' => $project->project->name,
  733. 'category_id' => $project->project->category_id,
  734. 'category_name' => $project->project->category->name,
  735. 'duration' => $project->project->duration,
  736. 'price' => $project->project->price,
  737. 'discount_amount' => $project->discount_amount,
  738. 'final_price' => max(0, $finalPrice),
  739. 'service_gender' => $project->service_gender,
  740. 'service_distance' => $project->service_distance,
  741. 'traffic_fee_type' => $project->traffic_fee_type,
  742. 'traffic_fee' => $project->traffic_fee,
  743. 'created_at' => $project->created_at->format('Y-m-d H:i:s'),
  744. 'updated_at' => $project->updated_at->format('Y-m-d H:i:s')
  745. ];
  746. })->values()->all();
  747. return [
  748. 'total' => count($formattedProjects),
  749. 'list' => $formattedProjects
  750. ];
  751. }
  752. /**
  753. * 获取技师订单评价列表
  754. *
  755. * @param int $coachId 技师ID
  756. * @param int $perPage 每页数量
  757. * @return array{
  758. * items: array,
  759. * total: int
  760. * }
  761. */
  762. public function getCoachComments(int $coachId, int $perPage = 10): array
  763. {
  764. $comments = CoachOrderComment::with(['order', 'user'])
  765. ->where('coach_id', $coachId)
  766. ->orderBy('created_at', 'desc')
  767. ->paginate($perPage);
  768. return [
  769. 'items' => $comments->map(function ($comment) {
  770. return [
  771. 'id' => $comment->id,
  772. 'rating' => $comment->rating,
  773. 'content' => $comment->content,
  774. 'images' => $comment->images,
  775. 'user_name' => $comment->user->nickname ?? '',
  776. 'user_avatar' => $comment->user->avatar ?? '',
  777. 'created_at' => $comment->created_at->format('Y-m-d H:i:s'),
  778. ];
  779. }),
  780. 'total' => $comments->total()
  781. ];
  782. }
  783. /**
  784. * 格式化技师列表数据
  785. */
  786. private function formatCoachList($coaches, array $coachDistances, array $coachLocations): array
  787. {
  788. return $coaches->map(function ($coach) use ($coachDistances, $coachLocations) {
  789. $data = [
  790. 'id' => $coach->id,
  791. 'info' => $coach->info,
  792. 'distance' => round($coachDistances[$coach->id] ?? 0, 2), // 添加距离信息并保留2位小数
  793. 'level' => $coach->level,
  794. 'formal_photo' => $coach->formal_photo,
  795. 'work_status' => $coach->work_status,
  796. 'work_status_text' => $coach->work_status_text, // 添加工作状态文字说明
  797. ];
  798. // 添加位置信息
  799. if (isset($coachLocations[$coach->id])) {
  800. $data['location'] = $coachLocations[$coach->id];
  801. }
  802. return $data;
  803. })->values()->all();
  804. }
  805. }