123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889 |
- <?php
- namespace App\Services\Client;
- use App\Models\Order;
- use App\Enums\UserStatus;
- use App\Models\CoachUser;
- use App\Enums\OrderStatus;
- use App\Models\MemberUser;
- use App\Models\SettingItem;
- use App\Models\CoachProject;
- use App\Models\CoachSchedule;
- use Illuminate\Support\Carbon;
- use App\Enums\TechnicianStatus;
- use App\Enums\TechnicianAuthStatus;
- use Illuminate\Support\Facades\Log;
- use Illuminate\Support\Facades\Auth;
- use App\Enums\TechnicianLocationType;
- use Illuminate\Support\Facades\Cache;
- use Illuminate\Support\Facades\Redis;
- use App\Models\CoachOrderComment;
- /**
- * 用户端技师服务类
- *
- * 该服务类处理与技师相关的所有业务逻辑,包括:
- * - 获取附近技师列表
- * - 获取技师详细信息
- * - 管理技师位置信息
- * - 处理技师排班和预约时间段
- *
- * 主要功能:
- * 1. 基于地理位置的技师查询和排序
- * 2. 技师认证状态验证
- * 3. 技师位置信息的缓存管理
- * 4. 技师预约时间段的计算和管理
- */
- class CoachService
- {
- /**
- * 获取附近技师列表
- *
- * 业务流程:
- * 1. 验证用户身份和状态
- * 2. 获取系统设置的最大搜索半径
- * 3. 使用Redis GEO功能查询范围内的技师
- * 4. 过滤并验证技师认证状态
- * 5. 根据技师接单距离设置筛选
- * 6. 计算并排序技师距离
- * 7. 返回分页后的技师列表
- *
- * 技术实现:
- * - 使用Redis GEORADIUS命令进行地理位置查询
- * - 使用Eloquent关联加载技师信息
- * - 实现基于距离的排序
- * - 支持分页功能
- *
- * 数据处理:
- * - 技师位置数据存储在Redis中
- * - 技师基本信息从数据库获取
- * - 认证状态多表关联验证
- * - 接单距离设置验证
- * - 距离数据格式化(保留2位小数)
- *
- * 筛选条件:
- * 1. 技师认证状态检查
- * - 基本信息认证通过
- * - 实名认证通过
- * - 资质认证通过
- * 2. 距离筛选
- * - 有设置接单距离:用户距离 <= 设置值
- * - 无设置接单距离:在系统搜索半径内即可
- *
- * 排序规则:
- * - 按照技师到用户的实际距离升序排序
- * - 未获取到距离的技师排在最后
- *
- * 异常处理:
- * - 用户未登录/状态异常
- * - Redis连接异常
- * - 无效的地理坐标
- * - 设置项未找到
- *
- * @param int $userId 当前用户ID
- * @param float $latitude 纬度坐标 (-90 到 90)
- * @param float $longitude 经度坐标 (-180 到 180)
- * @return array{
- * items: array{
- * id: int,
- * info: array{
- * id: int,
- * nickname: string,
- * avatar: string,
- * gender: int
- * },
- * distance: float
- * }[],
- * total: int
- * } 技师列表和总数
- * @throws \Exception 当用户验证失败或Redis操作异常时
- */
- public function getNearCoachList(int $userId, float $latitude, float $longitude)
- {
- // 初始化分页参数
- $perPage = request()->get('per_page', 15);
- // 基础验证(用户状态和Redis连接)
- $this->validateBasicRequirements($userId);
- // 获取搜索半径设置
- $distanceSettingItem = $this->getDistanceSetting();
- $maxSearchRadius = $distanceSettingItem?->max_value ?? 40; // 默认40公里
- // 获取附近的技师(使用Redis GEO功能)
- $nearbyCoaches = $this->getNearbyCoaches($longitude, $latitude, $maxSearchRadius);
- if (empty($nearbyCoaches)) {
- return ['items' => [], 'total' => 0];
- }
- // 处理技师距离数据(提取ID和距离信息)
- [$coachDistances, $coachIds, $coachLocations] = $this->processCoachDistances($nearbyCoaches);
- // 构建技师查询(包含认证状态和距离筛选)
- $query = $this->buildCoachQuery($coachIds, $distanceSettingItem, $coachDistances);
- // 获取分页结果(带基本信息)
- $coaches = $query->with(['info:id,nickname,avatar,gender,introduction'])
- ->paginate($perPage);
- // 格式化技师列表数据(包含距离和位置信息)
- $formattedItems = $this->formatCoachList($coaches, $coachDistances, $coachLocations);
- return [
- 'items' => $formattedItems,
- 'total' => $coaches->total()
- ];
- }
- /**
- * 验证基本要求
- *
- * @param int $userId 用户ID
- * @param bool $checkRedis 是否检查Redis连接
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException 当用户不存在时
- * @throws \Symfony\Component\HttpKernel\Exception\HttpException 当验证失败时
- */
- protected function validateBasicRequirements(int $userId, bool $checkRedis = true): void
- {
- // 验证用户状态
- $user = MemberUser::findOrFail($userId);
- abort_if(!$user, 401, '用户未登录');
- abort_if($user->state !== UserStatus::OPEN->value, 400, '用户状态异常');
- // 检查Redis连接
- if ($checkRedis) {
- abort_if(!Redis::ping(), 500, 'Redis服务不可用');
- }
- }
- /**
- * 获取距离设置项
- *
- * @return \App\Models\SettingItem|null 距离设置项
- */
- protected function getDistanceSetting(): ?SettingItem
- {
- return SettingItem::query()
- ->whereHas('group', function ($query) {
- $query->where('code', 'order');
- })
- ->where('code', 'distance')
- ->first();
- }
- /**
- * 获取附近的技师
- *
- * @param float $longitude 经度
- * @param float $latitude 纬度
- * @param int $maxSearchRadius 最大搜索半径(km)
- * @return array 附近技师的位置和距离信息
- */
- protected function getNearbyCoaches(float $longitude, float $latitude, int $maxSearchRadius): array
- {
- return Redis::georadius(
- 'coach_locations',
- $longitude,
- $latitude,
- $maxSearchRadius,
- 'km',
- ['WITHDIST', 'WITHCOORD']
- );
- }
- /**
- * 处理技师距离数据
- *
- * @param array $nearbyCoaches Redis返回的原始数据
- * @return array{0: array<string, float>, 1: array<int>} [距离映射, 技师ID列表]
- */
- protected function processCoachDistances(array $nearbyCoaches): array
- {
- $coachDistances = [];
- $coachIds = [];
- $coachLocations = [];
- foreach ($nearbyCoaches as $coach) {
- [$coachId, $distance, $coordinates] = $coach;
- $coachId = (int)explode('_', $coachId)[0];
- if (!isset($coachDistances[$coachId]) || $distance < $coachDistances[$coachId]) {
- $coachDistances[$coachId] = $distance;
- $coachLocations[$coachId] = [
- 'longitude' => (float)$coordinates[0],
- 'latitude' => (float)$coordinates[1]
- ];
- }
- $coachIds[] = $coachId;
- }
- return [$coachDistances, array_unique($coachIds), $coachLocations];
- }
- /**
- * 添加技师认证状态检查条件
- *
- * @param \Illuminate\Database\Eloquent\Builder $query
- * @return \Illuminate\Database\Eloquent\Builder
- */
- protected function addAuthStatusChecks($query)
- {
- return $query->whereHas('info', fn($q) => $q->where('state', TechnicianAuthStatus::PASSED->value))
- ->whereHas('real', fn($q) => $q->where('state', TechnicianAuthStatus::PASSED->value))
- ->whereHas('qual', fn($q) => $q->where('state', TechnicianAuthStatus::PASSED->value));
- }
- /**
- * 构建技师查询
- *
- * @param array $coachIds 技师ID列表
- * @param SettingItem|null $distanceSettingItem 距离设置项
- * @param array $coachDistances 技师距离映射
- * @return \Illuminate\Database\Eloquent\Builder
- */
- protected function buildCoachQuery(array $coachIds, ?SettingItem $distanceSettingItem, array $coachDistances): \Illuminate\Database\Eloquent\Builder
- {
- $query = CoachUser::query()
- ->whereIn('id', $coachIds);
- // 添加认证状态检查
- $this->addAuthStatusChecks($query);
- // 添加接单距离筛选条件
- if ($distanceSettingItem) {
- $query->where(function ($q) use ($distanceSettingItem, $coachDistances) {
- $q->whereHas('settings', function ($sq) use ($distanceSettingItem, $coachDistances) {
- // 检查用户距离是否在技师设置的接单范围内
- $sq->where('item_id', $distanceSettingItem->id)
- ->whereRaw('? <= CAST(value AS DECIMAL)', [
- $coachDistances[$sq->getModel()->coach_id] ?? 0
- ]);
- })
- // 未设置接单距离的技师直接显示
- ->orWhereDoesntHave('settings', function ($sq) use ($distanceSettingItem) {
- $sq->where('item_id', $distanceSettingItem->id);
- });
- });
- }
- // 实际距离排序
- if (!empty($coachDistances)) {
- $query->orderByRaw('CASE coach_users.id ' .
- collect($coachDistances)->map(function ($distance, $id) {
- return "WHEN $id THEN CAST($distance AS DECIMAL(10,2))";
- })->implode(' ') .
- ' ELSE ' . ($distanceSettingItem->default_value ?? PHP_FLOAT_MAX) .
- ' END ASC');
- }
- return $query;
- }
- /**
- * 处理技师查询结果
- *
- * @param \Illuminate\Pagination\LengthAwarePaginator $coaches 分页后的技师数据
- * @param array $coachDistances 技师距离映射
- * @return \Illuminate\Support\Collection
- */
- protected function processCoachResults($coaches, array $coachDistances): \Illuminate\Support\Collection
- {
- return $coaches->getCollection()
- ->map(function ($coach) use ($coachDistances) {
- // 添加距离信息(保留2位小数)
- $coach->distance = round($coachDistances[$coach->id] ?? 0, 2);
- return $coach;
- })
- ->values();
- }
- /**
- * 验证地理坐标
- *
- * @param float|null $latitude 纬度
- * @param float|null $longitude 经度
- * @throws \Exception 当坐标无效时
- */
- protected function validateCoordinates(?float $latitude, ?float $longitude): void
- {
- $isInvalid = !is_numeric($latitude) || !is_numeric($longitude);
- if ($isInvalid) {
- Log::error('Invalid coordinates:', ['latitude' => $latitude, 'longitude' => $longitude]);
- }
- abort_if($isInvalid, 422, '无效的经纬度坐标');
- }
- /**
- * 获取技师详情
- *
- * 业务流程:
- * 1. 验证用户身份和Redis连接状态
- * 2. 验证技师存在性和认证状态
- * 3. 获取技师位置信息(常驻地和当前位置)
- * 4. 计算用户与技师的距离(如果提供了用户坐标)
- * 5. 返回完整的技师信息
- *
- * 技术实现:
- * - 使用Redis GEO功能计算距离
- * - 多重认证状态验证
- * - 关联查询技师信息
- * - 临时存储用户位置
- *
- * 数据处理:
- * - 技师位置数据从Redis获取
- * - 距离计算保留2位小数
- * - 选择最近的距离(常驻地和当前位置)
- * - 自动清理临时位置数据
- *
- * 数据验证:
- * - 技师ID有效性
- * - 用户状态检查
- * - 地理坐标有效性
- * - 认证状态验证
- *
- * 异常处理:
- * - Redis连接异常
- * - 技师不存在
- * - 无效坐标
- * - 认证状态异常
- *
- * @param int $coachId 技师ID
- * @param float|null $latitude 用户当前纬度
- * @param float|null $longitude 用户当前经度
- * @return CoachUser 技师详细信息,包含距离计算结果
- * @throws \Exception 当验证失败或查询异常时
- */
- public function getCoachDetail($coachId, $latitude, $longitude)
- {
- // 验证基本要求(用户状态和Redis连接)
- $this->validateBasicRequirements(Auth::id());
- // 验证坐标
- ($latitude && $longitude) && $this->validateCoordinates($latitude, $longitude);
- // 获取技师信息(包含认证状态检查)
- $query = CoachUser::where('state', TechnicianStatus::ACTIVE->value);
- $this->addAuthStatusChecks($query);
- // 获取技师详细信息
- $coach = $query->with(['info:id,nickname,avatar,gender,introduction,life_photos'])
- ->find($coachId);
- abort_if(!$coach, 404, '技师不存在');
- // 获取技师订单评价总数
- $coach->comment_count = CoachOrderComment::where('coach_id', $coachId)->count();
- // 计算并设置距离
- if ($latitude && $longitude) {
- $coach->distance = $this->calculateDistanceToCoach(Auth::id(), $coachId, $latitude, $longitude);
- } else {
- // 获取技师位置信息
- $homeLocation = $this->getCoachLocation($coachId . '_' . TechnicianLocationType::COMMON->value);
- // 优先返回常驻地位置
- if ($homeLocation && !empty($homeLocation[0]) && is_array($homeLocation[0])) {
- $coach->location = [
- 'latitude' => $homeLocation[0][1] ?? null, // geopos 返回 [longitude, latitude]
- 'longitude' => $homeLocation[0][0] ?? null
- ];
- } else {
- $coach->location = null;
- }
- }
- return $coach;
- }
- /**
- * 计算用户到技师的距离
- *
- * @param int $userId 用户ID
- * @param int $coachId 技师ID
- * @param float $latitude 用户纬度
- * @param float $longitude 用户经度
- * @return float|null 最近距离(公里)
- */
- protected function calculateDistanceToCoach(int $userId, int $coachId, float $latitude, float $longitude): ?float
- {
- // 临时存储用户位置
- $tempKey = 'user_temp_' . $userId;
- Redis::geoadd('coach_locations', $longitude, $latitude, $tempKey);
- try {
- // 获取技师位置
- $homeLocation = $this->getCoachLocation($coachId . '_' . TechnicianLocationType::COMMON->value);
- $workLocation = $this->getCoachLocation($coachId . '_' . TechnicianLocationType::CURRENT->value);
- $distances = [];
- // 计算到常驻地的距离
- if ($homeLocation && !empty($homeLocation[0])) {
- $distances[] = $this->calculateDistance($tempKey, $coachId . '_' . TechnicianLocationType::COMMON->value);
- }
- // 计算到当前位置的距离
- if ($workLocation && !empty($workLocation[0])) {
- $distances[] = $this->calculateDistance($tempKey, $coachId . '_' . TechnicianLocationType::CURRENT->value);
- }
- // 返回最近的距离
- return !empty($distances) ? round(min($distances), 2) : null;
- } finally {
- // 确保清理临时位置数据
- Redis::zrem('coach_locations', $tempKey);
- }
- }
- /**
- * 设置技师位置信息
- *
- * 业务流程:
- * 1. 验证位置信息的有效性
- * 2. 根据类型更新技师位置
- * 3. 验证更新结果
- *
- * 技术实现:
- * - 使用Redis GEOADD命令存储位置
- * - 支持多种位置类型(常驻/当前)
- * - 位置更新验证
- *
- * 数据验证:
- * - 坐标范围检查
- * - 位置类型验证
- * - 技师ID验证
- *
- * 日志记录:
- * - 位置更新操作
- * - 错误信息
- * - 验证结果
- *
- * @param int $coachId 技师ID
- * @param float $latitude 纬度坐标
- * @param float $longitude 经度坐标
- * @param int $type 位置类型 (TechnicianLocationType 举值)
- * @return bool 更新是否成功
- * @throws \Exception 当参数无效或Redis操作失败时
- */
- public function setCoachLocation($coachId, $latitude, $longitude, $type = TechnicianLocationType::COMMON->value)
- {
- // 验证坐标
- $this->validateCoordinates($latitude, $longitude);
- abort_if(
- !in_array($type, [TechnicianLocationType::CURRENT->value, TechnicianLocationType::COMMON->value]),
- 422,
- '无效的位置类型,必须是 current 或 common'
- );
- $key = $coachId . '_' . $type;
- return Redis::geoadd('coach_locations', $longitude, $latitude, $key);
- }
- /**
- * 获取技师可预约时间段列表
- *
- * 业务流程:
- * 1. 验证技师信息和状态
- * 2. 获取技师排班设置
- * 3. 获取已有预约订单
- * 4. 计算用时间段
- * 5. 缓存处结果
- *
- * 技术实现:
- * - 使用Carbon处理日期时间
- * - 缓存机制优化查询性能
- * - 订单冲突检测
- * - 时间段生成算法
- *
- * 数据处理:
- * - 排班数据解析
- * - 订单时间过滤
- * - 时间段计算
- * - 数据格式化
- *
- * 缓存策略:
- * - 15分钟缓存时间
- * - 按技师和日期缓存
- * - 支持缓存更新
- *
- * @param int $coachId 技师ID
- * @param string|null $date 日期,默认当天
- * @return array 格式化的时间段表
- * @throws \Exception 当验证失败或理异常时
- */
- public function getSchedule(int $coachId, ?string $date = null)
- {
- try {
- // 默认使用当天日期
- $date = $date ? Carbon::parse($date)->format('Y-m-d') : now()->toDateString();
- $targetDate = Carbon::parse($date);
- // 验证技师信息
- $coach = CoachUser::find($coachId);
- abort_if(! $coach, 404, '技师不存在');
- abort_if(
- (int)$coach->state != TechnicianStatus::ACTIVE->value,
- 400,
- '技师状态异常'
- );
- // 验证日期
- abort_if(
- $targetDate->startOfDay()->lt(now()->startOfDay()),
- 400,
- '不能询过去的日期'
- );
- abort_if(
- $targetDate->diffInDays(now()) > 30,
- 400,
- '只能查询未来30天内的时间段'
- );
- $cacheKey = "coach:timeslots:{$coachId}:{$date}";
- Cache::forget($cacheKey);
- // 使用缓存减少数据库查询
- return Cache::remember(
- "coach:timeslots:{$coachId}:{$date}",
- now()->addMinutes(15), // 缓存15分钟
- function () use ($coachId, $date, $targetDate) {
- // 获取技师排班信息
- $schedule = CoachSchedule::where('coach_id', $coachId)
- ->where('state', 1)
- ->first();
- if (! $schedule || empty($schedule->time_ranges)) {
- return $this->formatResponse($date, []);
- }
- $timeRanges = is_array($schedule->time_ranges)
- ? $schedule->time_ranges
- : json_decode($schedule->time_ranges, true);
- // 获取当天所有订单
- $dayOrders = $this->getDayOrders($coachId, $date);
- // 生成时间段列表
- $timeSlots = $this->generateAvailableTimeSlots(
- $date,
- $timeRanges,
- $dayOrders,
- $targetDate->isToday()
- );
- return $this->formatResponse($date, $timeSlots);
- }
- );
- } catch (\Exception $e) {
- Log::error('获取可预约时间段失败', [
- 'coach_id' => $coachId,
- 'date' => $date,
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString(),
- ]);
- throw $e;
- }
- }
- /**
- * 获取当天所有订单
- *
- * @param int $coachId 技师ID
- * @param string $date 日期
- * @return array 订单列表
- */
- private function getDayOrders(int $coachId, string $date): array
- {
- $date = Carbon::parse($data['date'] ?? $date);
- $startOfDay = $date->startOfDay()->format('Y-m-d H:i:s');
- $endOfDay = $date->endOfDay()->format('Y-m-d H:i:s');
- return Order::where('coach_id', $coachId)
- ->whereBetween('service_time', [$startOfDay, $endOfDay])
- ->whereIn('state', [
- OrderStatus::ACCEPTED->value,
- OrderStatus::DEPARTED->value,
- OrderStatus::ARRIVED->value,
- OrderStatus::SERVICING->value,
- ])
- ->select(['id', 'service_start_time', 'service_end_time', 'state'])
- ->get()
- ->toArray();
- }
- /**
- * 生成可用时间段列表
- *
- * @param string $date 日期
- * @param array $timeRanges 班时间段
- * @param array $dayOrders 当天订单
- * @param bool $isToday 是否是当天
- * @return array 可用时间段列表
- */
- private function generateAvailableTimeSlots(
- string $date,
- array $timeRanges,
- array $dayOrders,
- bool $isToday
- ): array {
- $timeSlots = [];
- $currentTime = now();
- foreach ($timeRanges as $range) {
- $start = Carbon::parse($date . ' ' . $range['start_time']);
- $end = Carbon::parse($date . ' ' . $range['end_time']);
- // 果是当天且开始时间已过,从下一个30分钟时间点开始
- if ($isToday && $start->lt($currentTime)) {
- $start = $currentTime->copy()->addMinutes(30)->floorMinutes(30);
- // 如果调整后的开始时间已超过结束时间,跳过此时间段
- if ($start->gt($end)) {
- continue;
- }
- }
- // 生成30分钟间隔的时间段
- while ($start->lt($end)) {
- $slotStart = $start->format('H:i');
- $slotEnd = $start->copy()->addMinutes(30)->format('H:i');
- // 检查时间段是否被订单占用
- if (! $this->hasConflictingOrder($date, $slotStart, $slotEnd, $dayOrders)) {
- $timeSlots[] = [
- 'start_time' => $slotStart,
- 'end_time' => $slotEnd,
- 'is_available' => true,
- 'duration' => 30,
- ];
- }
- $start->addMinutes(30);
- }
- }
- return $timeSlots;
- }
- /**
- * 格式化返回数据
- *
- * @param string $date 日期
- * @param array $timeSlots 时间段列表
- * @return array 格式化后的数据
- */
- private function formatResponse(string $date, array $timeSlots): array
- {
- $targetDate = Carbon::parse($date);
- return [
- 'date' => $date,
- 'day_of_week' => $targetDate->isoFormat('dddd'), // 星期几
- 'is_today' => $targetDate->isToday(),
- 'time_slots' => $timeSlots,
- 'total_slots' => count($timeSlots),
- 'updated_at' => now()->toDateTimeString(),
- ];
- }
- /**
- * 检查是否与已有订单冲突
- *
- * @param string $date 日期
- * @param string $startTime 开始时间
- * @param string $endTime 结束时间
- * @param array $dayOrders 当天订单
- * @return bool 是否冲突
- */
- private function hasConflictingOrder(
- string $date,
- string $startTime,
- string $endTime,
- array $dayOrders
- ): bool {
- $slotStart = Carbon::parse("$date $startTime");
- $slotEnd = Carbon::parse("$date $endTime");
- foreach ($dayOrders as $order) {
- $orderStart = Carbon::parse($order['service_start_time']);
- $orderEnd = Carbon::parse($order['service_end_time']);
- // 检查时间段是否重叠
- if (($slotStart >= $orderStart && $slotStart < $orderEnd) ||
- ($slotEnd > $orderStart && $slotEnd <= $orderEnd) ||
- ($slotStart <= $orderStart && $slotEnd >= $orderEnd)
- ) {
- return true;
- }
- }
- return false;
- }
- /**
- * 验证技师服务时间是否在可用时间段内
- */
- public function validateServiceTimeWithinCoachAvailability(int $coachId, string $serviceTime): bool
- {
- $serviceDateTime = Carbon::parse($serviceTime);
- $coachSchedule = $this->getSchedule($coachId, $serviceDateTime->format('Y-m-d'));
- foreach ($coachSchedule['time_slots'] as $slot) {
- $slotStart = Carbon::parse($serviceDateTime->format('Y-m-d') . ' ' . $slot['start_time']);
- $slotEnd = Carbon::parse($serviceDateTime->format('Y-m-d') . ' ' . $slot['end_time']);
- if ($serviceDateTime->between($slotStart, $slotEnd)) {
- return true;
- }
- }
- return false;
- }
- /**
- * 获取技师的地理位置信
- *
- * @param string $key Redis中的位置键名
- * @return array|null 位置信息
- */
- protected function getCoachLocation(string $key): ?array
- {
- return Redis::geopos('coach_locations', $key);
- }
- /**
- * 计算两点之间的距离
- *
- * @param string $from 起点位置键名
- * @param string $to 终点位置键名
- * @return float|null 距离(km)
- */
- protected function calculateDistance(string $from, string $to): ?float
- {
- return Redis::geodist('coach_locations', $from, $to, 'km');
- }
- /**
- * 获取技师已开通的服务项目列表
- *
- * @param int $coachId 技师ID
- * @return array{
- * total: int,
- * list: array{
- * id: int,
- * project_id: int,
- * project_name: string,
- * category_id: int,
- * category_name: string,
- * duration: int,
- * price: float,
- * discount_amount: float,
- * final_price: float,
- * service_gender: string,
- * service_distance: int,
- * traffic_fee_type: string,
- * traffic_fee: float,
- * created_at: string,
- * updated_at: string
- * }[]
- * }
- */
- public function getEnabledProjects(int $coachId): array
- {
- // 获取技师已开通的项目列表
- $projects = CoachProject::query()
- ->where('coach_id', $coachId)
- ->where('state', 'enable')
- ->with(['project' => function ($query) {
- $query->select('id', 'name', 'category_id', 'duration', 'price')
- ->with(['category:id,name']);
- }])
- ->get();
- // 格式化返回数据
- $formattedProjects = $projects->map(function ($project) {
- $finalPrice = $project->project->price - $project->discount_amount;
- return [
- 'id' => $project->id,
- 'project_id' => $project->project_id,
- 'project_name' => $project->project->name,
- 'category_id' => $project->project->category_id,
- 'category_name' => $project->project->category->name,
- 'duration' => $project->project->duration,
- 'price' => $project->project->price,
- 'discount_amount' => $project->discount_amount,
- 'final_price' => max(0, $finalPrice),
- 'service_gender' => $project->service_gender,
- 'service_distance' => $project->service_distance,
- 'traffic_fee_type' => $project->traffic_fee_type,
- 'traffic_fee' => $project->traffic_fee,
- 'created_at' => $project->created_at->format('Y-m-d H:i:s'),
- 'updated_at' => $project->updated_at->format('Y-m-d H:i:s')
- ];
- })->values()->all();
- return [
- 'total' => count($formattedProjects),
- 'list' => $formattedProjects
- ];
- }
- /**
- * 获取技师订单评价列表
- *
- * @param int $coachId 技师ID
- * @param int $perPage 每页数量
- * @return array{
- * items: array,
- * total: int
- * }
- */
- public function getCoachComments(int $coachId, int $perPage = 10): array
- {
- $comments = CoachOrderComment::with(['order', 'user'])
- ->where('coach_id', $coachId)
- ->orderBy('created_at', 'desc')
- ->paginate($perPage);
- return [
- 'items' => $comments->map(function ($comment) {
- return [
- 'id' => $comment->id,
- 'rating' => $comment->rating,
- 'content' => $comment->content,
- 'images' => $comment->images,
- 'user_name' => $comment->user->nickname ?? '',
- 'user_avatar' => $comment->user->avatar ?? '',
- 'created_at' => $comment->created_at->format('Y-m-d H:i:s'),
- ];
- }),
- 'total' => $comments->total()
- ];
- }
- /**
- * 格式化技师列表数据
- */
- private function formatCoachList($coaches, array $coachDistances, array $coachLocations): array
- {
- return $coaches->map(function ($coach) use ($coachDistances, $coachLocations) {
- $data = [
- 'id' => $coach->id,
- 'info' => $coach->info,
- 'distance' => round($coachDistances[$coach->id] ?? 0, 2), // 添加距离信息并保留2位小数
- 'level' => $coach->level,
- 'formal_photo' => $coach->formal_photo,
- 'work_status' => $coach->work_status,
- 'work_status_text' => $coach->work_status_text, // 添加工作状态文字说明
- ];
- // 添加位置信息
- if (isset($coachLocations[$coach->id])) {
- $data['location'] = $coachLocations[$coach->id];
- }
- return $data;
- })->values()->all();
- }
- }
|