bool, 'conflicts' => [...]] */ public function validate(int $patientId, array $newItemCodes = [], ?string $targetDate = null): array { // 1. 获取患者的所有已预约项目 $bookedItems = $this->getPatientBookedItems($patientId); // 2. 获取新项目信息 $newItems = empty($newItemCodes) ? [] : $this->getItemsByCodes($newItemCodes); $newItemIds = array_column($newItems, 'id'); // 如果传入的项目编码在数据库中不存在,返回错误 if (!empty($newItemCodes)) { $foundCodes = array_column($newItems, 'code'); $notFoundCodes = array_diff($newItemCodes, $foundCodes); if (!empty($notFoundCodes)) { return [ 'valid' => false, 'conflicts' => [], 'error' => '以下项目编码不存在:' . implode(', ', $notFoundCodes), ]; } } // 3. 合并所有需要校验的项目ID $allItemIds = array_merge( array_column($bookedItems, 'item_id'), $newItemIds ); $allItemIds = array_unique($allItemIds); // 过滤掉 null 值 $allItemIds = array_filter($allItemIds, function ($id) { return $id !== null && $id !== ''; }); if (empty($allItemIds)) { return ['valid' => true, 'conflicts' => []]; } // 4. 构建项目ID映射(O(1)查找) $itemIdMap = []; foreach ($bookedItems as $item) { if ($item['item_id'] !== null) { $itemIdMap[$item['item_id']] = [ 'code' => $item['item_code'], 'name' => $item['item_name'], 'appointment_date' => $item['appointment_date'], 'appointment_datetime' => $item['appointment_datetime'] ?? null, 'is_new' => false, ]; } } foreach ($newItems as $item) { if ($item['id'] !== null && !isset($itemIdMap[$item['id']])) { $itemIdMap[$item['id']] = [ 'code' => $item['code'], 'name' => $item['name'] ?? '', 'appointment_date' => $targetDate, 'appointment_datetime' => $targetDate ? $targetDate . ' 00:00:00' : null, 'is_new' => true, ]; } } // 5. 构建快速查找集合(O(1)判断是否存在) $selectedSet = array_flip($allItemIds); // 6. 获取所有涉及的互斥规则(只查 source 在已选列表中的) $rules = DB::table('exam_item_exclusion as eie') ->whereIn('eie.source_item_id', $allItemIds) ->where('eie.status', 1) ->where('eie.deleted', 0) ->get() ->toArray(); // 7. 预加载检查组的成员ID(避免循环中重复查询) $groupMemberCache = []; foreach ($rules as $rule) { if ($rule->target_type === 'GROUP' && !isset($groupMemberCache[$rule->target_id])) { $members = DB::table('exam_group_item') ->where('group_id', $rule->target_id) ->pluck('item_id') ->toArray(); $groupMemberCache[$rule->target_id] = $members; } } // 8. 校验互斥规则 $conflicts = []; $conflictSet = []; // 避免重复记录冲突 foreach ($rules as $rule) { $sourceItem = $itemIdMap[$rule->source_item_id]; $targetIds = []; if ($rule->target_type === 'ITEM') { $targetIds = [$rule->target_id]; } elseif ($rule->target_type === 'GROUP') { // 方案C:获取检查组成员,排除源项目本身 $groupMembers = $groupMemberCache[$rule->target_id] ?? []; $targetIds = array_diff($groupMembers, [$rule->source_item_id]); } // 检查目标是否在已选列表中 foreach ($targetIds as $targetId) { if (!isset($selectedSet[$targetId])) { continue; } $targetItem = $itemIdMap[$targetId]; // 创建唯一冲突标识,避免重复 $conflictKey = min($rule->source_item_id, $targetId) . '-' . max($rule->source_item_id, $targetId); if (isset($conflictSet[$conflictKey])) { continue; } $conflictSet[$conflictKey] = true; // 同部位互斥特殊处理 if ($rule->exclusion_type === 'SAME_BODY_PART') { $bodyPartConflict = $this->checkBodyPartConflict( $rule->source_item_id, $targetId, $rule->exclusion_value ); if (!$bodyPartConflict) { continue; // 部位不同,不互斥 } // 部位冲突,继续检查日期条件 } // 检查日期/时间条件 $dateConditionPassed = true; $appointmentDate = null; $sourceDate = $sourceItem['appointment_date']; $targetDateItem = $targetItem['appointment_date']; if ($sourceDate && $targetDateItem) { // 两个都有日期:直接比较 $appointmentDate = $sourceDate; $dateConditionPassed = $this->checkDateCondition( $rule->exclusion_type, $rule->exclusion_value, $sourceDate, $sourceItem['appointment_datetime'], $targetDateItem, $targetItem['appointment_datetime'] ); } elseif ($sourceDate) { // source 有日期(已预约),target 无日期(不应再出现,新项目已传入 targetDate) $appointmentDate = $sourceDate; $dateConditionPassed = $this->checkDateCondition( $rule->exclusion_type, $rule->exclusion_value, $sourceDate, $sourceItem['appointment_datetime'], $targetDateItem ?? date('Y-m-d'), $targetItem['appointment_datetime'] ); } elseif ($targetDateItem) { // target 有日期(已预约),source 无日期 $appointmentDate = $targetDateItem; $dateConditionPassed = $this->checkDateCondition( $rule->exclusion_type, $rule->exclusion_value, $targetDateItem, $targetItem['appointment_datetime'], $sourceDate ?? date('Y-m-d'), $sourceItem['appointment_datetime'] ); } else { // 两个都没有日期(都在同一批次新预约中),视为同一天 $dateConditionPassed = true; } if (!$dateConditionPassed) { continue; } $conflicts[] = [ 'source_code' => $sourceItem['code'], 'target_code' => $targetItem['code'], 'exclusion_type' => $rule->exclusion_type, 'message' => $this->formatConflictMessage( $rule, $sourceItem['name'] ?? $sourceItem['code'], $sourceItem['appointment_date'], $targetItem['name'] ?? $targetItem['code'], $targetItem['appointment_date'] ), 'appointment_date' => $appointmentDate, ]; } } return [ 'valid' => empty($conflicts), 'conflicts' => $conflicts, ]; } /** * 获取患者已预约的项目 * * @param int $patientId 患者ID * @return array [['item_id' => 1, 'item_code' => 'ITEM001', 'appointment_date' => '2026-01-01'], ...] */ private function getPatientBookedItems(int $patientId): array { $appointments = DB::table('exam_appointment as ea') ->join('exam_application as eapp', 'ea.exam_application_id', '=', 'eapp.id') ->leftJoin('exam_item as ei', 'eapp.examination_item_code', '=', 'ei.code') ->where('ea.patient_id', $patientId) ->whereIn('ea.status', [1, 2]) // 1:预约成功, 2:已经到检 ->whereIn('eapp.status', [1, 2]) // 1:已预约, 2:已到检 ->select( 'ei.id as item_id', 'ei.code as item_code', 'ei.name as item_name', DB::raw('DATE(ea.appointment_time) as appointment_date'), DB::raw('ea.appointment_time as appointment_datetime') ) ->get() ->toArray(); // 过滤掉没有关联到 exam_item 的记录(item_id 为 null) $filteredAppointments = array_filter($appointments, function ($item) { return !is_null($item->item_id); }); return array_map(function ($item) { return [ 'item_id' => $item->item_id, 'item_code' => $item->item_code, 'item_name' => $item->item_name, 'appointment_date' => $item->appointment_date, 'appointment_datetime' => $item->appointment_datetime, ]; }, $filteredAppointments); } /** * 根据项目编码获取项目信息 */ private function getItemsByCodes(array $codes): array { if (empty($codes)) { return []; } $items = DB::table('exam_item') ->whereIn('code', $codes) ->where('deleted', 0) ->get() ->toArray(); return array_map(function ($item) { return (array) $item; }, $items); } /** * 检查日期/时间条件 * @param string $exclusionType 互斥类型 * @param string|null $exclusionValue 规则参数JSON * @param string $bookedDate 已有预约日期(Y-m-d) * @param string|null $bookedDatetime 已有预约完整时间(Y-m-d H:i:s) * @param string $newDate 待预约日期(Y-m-d) * @param string|null $newDatetime 待预约完整时间(Y-m-d H:i:s) */ private function checkDateCondition( string $exclusionType, ?string $exclusionValue, string $bookedDate, ?string $bookedDatetime, string $newDate, ?string $newDatetime ): bool { // 同日互斥:两个日期相同 if ($exclusionType === 'SAME_DAY') { return $bookedDate === $newDate; } // N天内互斥 if ($exclusionType === 'WITHIN_DAYS') { $params = json_decode($exclusionValue, true); $value = $params['value'] ?? ($params['days'] ?? 1); // 兼容旧数据 days 字段 $unit = $params['unit'] ?? 'day'; return $this->checkTimeOverlap($bookedDate, $bookedDatetime, $newDate, $newDatetime, $value, $unit); } // 同部位互斥:使用其自身的 scope 和时间参数 if ($exclusionType === 'SAME_BODY_PART') { $params = json_decode($exclusionValue, true); $scope = $params['scope'] ?? 'SAME_DAY'; if ($scope === 'SAME_DAY') { return $bookedDate === $newDate; } if ($scope === 'WITHIN_PERIOD') { $value = $params['value'] ?? 1; $unit = $params['unit'] ?? 'day'; return $this->checkTimeOverlap($bookedDate, $bookedDatetime, $newDate, $newDatetime, $value, $unit); } } return true; } /** * 检查两个时间点是否在指定范围内 */ private function checkTimeOverlap( string $bookedDate, ?string $bookedDatetime, string $newDate, ?string $newDatetime, int $value, string $unit ): bool { // 单位是天:用日期比较 if ($unit === 'day') { $bookedDateObj = new \DateTime($bookedDate); $newDateObj = new \DateTime($newDate); $dateDiff = $bookedDateObj->diff($newDateObj); return $dateDiff->days <= $value; } // 单位是小时或分钟:用完整时间比较 // 已有预约用实际时间,待约项目无时间则用当前时刻 $bookedTime = new \DateTime($bookedDatetime ?: ($bookedDate . ' 00:00:00')); $newTime = new \DateTime($newDatetime ?: date('Y-m-d H:i:s')); $diffSeconds = abs($bookedTime->getTimestamp() - $newTime->getTimestamp()); if ($unit === 'hour') { return $diffSeconds <= $value * 3600; } if ($unit === 'minute') { return $diffSeconds <= $value * 60; } return true; } /** * 格式化冲突消息(含项目名称和日期) * * @param object $exclusion 互斥规则 * @param string $sourceName 源项目名称 * @param string|null $sourceDate 源项目预约日期 * @param string $targetName 目标项目名称 * @param string|null $targetDate 目标项目预约日期 */ private function formatConflictMessage(object $exclusion, string $sourceName, ?string $sourceDate, string $targetName, ?string $targetDate): string { $sourceLabel = ($sourceName ?: '项目A') . ($sourceDate ? "({$sourceDate})" : ''); $targetLabel = ($targetName ?: '项目B') . ($targetDate ? "({$targetDate})" : ''); if ($exclusion->exclusion_type === 'SAME_DAY') { return "{$sourceLabel}与{$targetLabel}同日互斥,不可同一天预约"; } if ($exclusion->exclusion_type === 'WITHIN_DAYS') { $params = json_decode($exclusion->exclusion_value, true); $value = $params['value'] ?? ($params['days'] ?? 1); // 兼容旧数据 days 字段 $unit = $params['unit'] ?? 'day'; $unitText = ['day' => '天', 'hour' => '小时', 'minute' => '分钟'][$unit] ?? '天'; return "{$sourceLabel}与{$targetLabel}{$value}{$unitText}内互斥,两次检查需间隔超过{$value}{$unitText}"; } if ($exclusion->exclusion_type === 'SAME_BODY_PART') { $params = json_decode($exclusion->exclusion_value, true); $level = $params['body_part_level'] ?? 1; $levelText = $level === 0 ? '不限部位' : ($level === 1 ? '一级部位' : '二级部位'); $scope = $params['scope'] ?? 'SAME_DAY'; // 获取部位名称用于提示 $bodyPartNames = $this->getConflictBodyPartNames( $exclusion->source_item_id, $level, $params ); $partText = $bodyPartNames ? "(冲突部位:{$bodyPartNames})" : ''; if ($scope === 'SAME_DAY') { return "{$sourceLabel}与{$targetLabel}存在同部位互斥,检查部位相同({$levelText})不可同天预约{$partText}"; } $scopeValue = $params['value'] ?? 1; $scopeUnit = $params['unit'] ?? 'day'; $scopeUnitText = ['day' => '天', 'hour' => '小时', 'minute' => '分钟'][$scopeUnit] ?? '天'; return "{$sourceLabel}与{$targetLabel}存在同部位互斥,检查部位相同({$levelText}){$scopeValue}{$scopeUnitText}内不可重复预约{$partText}"; } return "{$sourceLabel}与{$targetLabel}存在互斥规则"; } /** * 获取冲突部位名称(用于消息提示) */ private function getConflictBodyPartNames(int $sourceItemId, int $level, array $params): string { $sourceParts = DB::table('exam_item_body_part as eibp') ->join('exam_body_part as ebp', 'eibp.body_part_id', '=', 'ebp.id') ->where('eibp.item_id', $sourceItemId) ->where('ebp.deleted', 0) ->pluck('ebp.name') ->toArray(); if (empty($sourceParts)) { return ''; } if ($level === 1) { // 取一级部位名称 $parentNames = []; foreach ($sourceParts as $partName) { $part = DB::table('exam_body_part') ->where('name', $partName) ->where('deleted', 0) ->first(); if ($part && $part->parent_id !== null) { $parent = DB::table('exam_body_part') ->where('id', $part->parent_id) ->where('deleted', 0) ->first(); if ($parent && !in_array($parent->name, $parentNames)) { $parentNames[] = $parent->name; } } elseif ($part && !in_array($partName, $parentNames)) { $parentNames[] = $partName; } } return implode('、', $parentNames); } return implode('、', $sourceParts); } /** * 检查两个项目的部位是否冲突 */ private function checkBodyPartConflict(int $sourceItemId, int $targetItemId, ?string $exclusionValue): bool { $params = json_decode($exclusionValue, true); if (!$params) { return true; } $level = $params['body_part_level'] ?? 1; // level=0 表示不限部位,直接返回 true(所有都互斥) if ($level === 0) { return true; } // 获取两个项目的部位集合 $sourceParts = DB::table('exam_item_body_part') ->where('item_id', $sourceItemId) ->pluck('body_part_id') ->toArray(); $targetParts = DB::table('exam_item_body_part') ->where('item_id', $targetItemId) ->pluck('body_part_id') ->toArray(); if (empty($sourceParts) || empty($targetParts)) { return false; // 没配部位就不判断 } // 按指定层级向上归并 $sourceGrouped = array_map(fn($id) => $this->getPartAtLevel($id, $level), $sourceParts); $targetGrouped = array_map(fn($id) => $this->getPartAtLevel($id, $level), $targetParts); // 过滤掉 null 值 $sourceGrouped = array_filter($sourceGrouped, fn($v) => $v !== null); $targetGrouped = array_filter($targetGrouped, fn($v) => $v !== null); // 两个集合有交集 = 有共同部位 = 触发部位互斥 return !empty(array_intersect($sourceGrouped, $targetGrouped)); } /** * 获取指定层级的部位ID(向上追溯父级) * level=1 表示一级部位(顶级) * level=2 表示二级部位(自身,不追溯) */ private function getPartAtLevel(int $partId, int $level): ?int { $current = DB::table('exam_body_part') ->where('id', $partId) ->where('deleted', 0) ->first(); if (!$current) { return null; } // level=1:向上找到 parent_id 为 null 的一级部位 if ($level === 1) { while ($current->parent_id !== null) { $current = DB::table('exam_body_part') ->where('id', $current->parent_id) ->where('deleted', 0) ->first(); if (!$current) { break; } } return $current->id ?? null; } // 其他层级:直接返回自身 return $current->id; } }