|
|
<?php
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
|
/**
|
|
|
* 互斥校验服务
|
|
|
*/
|
|
|
class ExclusionValidationService
|
|
|
{
|
|
|
/**
|
|
|
* 统一互斥校验入口
|
|
|
*
|
|
|
* @param int $patientId 患者ID
|
|
|
* @param array $newItemCodes 新预约的项目编码(可选,用于新增场景)
|
|
|
* @param string|null $targetDate 新预约的目标日期(Y-m-d),用于日期条件判断
|
|
|
* @return array ['valid' => 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;
|
|
|
}
|
|
|
}
|