You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

539 lines
20 KiB
PHP

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?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;
}
}