467 lines
16 KiB
PHP
467 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Models\Patient;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Support\Facades\Response;
|
||
|
||
class PatientController extends Controller
|
||
{
|
||
/**
|
||
* 获取当前用户的患者查询构建器
|
||
*/
|
||
private function userPatients()
|
||
{
|
||
return Patient::where('user_id', Auth::id());
|
||
}
|
||
|
||
/**
|
||
* 显示患者列表页面
|
||
*/
|
||
public function index(Request $request)
|
||
{
|
||
$query = $this->userPatients();
|
||
|
||
// 搜索功能
|
||
if ($request->filled('search')) {
|
||
$search = $request->input('search');
|
||
$query->where(function ($q) use ($search) {
|
||
$q->where('name', 'like', "%{$search}%")
|
||
->orWhere('phone', 'like', "%{$search}%")
|
||
->orWhere('address', 'like', "%{$search}%");
|
||
});
|
||
}
|
||
|
||
// 诊断筛选
|
||
if ($request->filled('diagnosis')) {
|
||
$query->where('diagnosis', 'like', "%{$request->input('diagnosis')}%");
|
||
}
|
||
|
||
$patients = $query->orderBy('discharge_date', 'desc')->paginate(20);
|
||
|
||
return view('patients.index', compact('patients'));
|
||
}
|
||
|
||
/**
|
||
* 显示需要随访提醒的患者(只显示当月需要随访的)
|
||
*/
|
||
public function reminders(Request $request)
|
||
{
|
||
$patients = $this->userPatients()->get();
|
||
|
||
// 只筛选当月需要随访的患者
|
||
$reminders = $patients->filter(function ($patient) {
|
||
return $patient->needsFollowUpThisMonth();
|
||
})->map(function ($patient) {
|
||
return [
|
||
'patient' => $patient,
|
||
'follow_up_date' => $patient->getCurrentMonthFollowUpDate(),
|
||
'follow_up_number' => $patient->getCurrentMonthFollowUpNumber(),
|
||
];
|
||
})->sortBy(function ($r) {
|
||
return $r['follow_up_date'];
|
||
});
|
||
|
||
// 统计数据
|
||
$stats = [
|
||
'count' => $reminders->count(),
|
||
'completed' => $patients->filter(fn($p) => $p->isCompleted())->count(),
|
||
'total' => $patients->count(),
|
||
];
|
||
|
||
return view('patients.reminders', compact('reminders', 'stats'));
|
||
}
|
||
|
||
/**
|
||
* 显示导入页面
|
||
*/
|
||
public function showImport()
|
||
{
|
||
return view('patients.import');
|
||
}
|
||
|
||
/**
|
||
* 处理Excel导入
|
||
*/
|
||
public function import(Request $request)
|
||
{
|
||
$request->validate([
|
||
'file' => 'required|file|mimes:xlsx,xls,csv|max:10240',
|
||
]);
|
||
|
||
$file = $request->file('file');
|
||
$path = $file->getRealPath();
|
||
|
||
try {
|
||
$data = $this->parseExcel($path, $file->getClientOriginalExtension());
|
||
|
||
$imported = 0;
|
||
$errors = [];
|
||
$headerSkipped = false;
|
||
|
||
foreach ($data as $index => $row) {
|
||
try {
|
||
// 跳过空行
|
||
if (empty($row) || !is_array($row)) {
|
||
continue;
|
||
}
|
||
|
||
$firstCell = trim((string)($row[0] ?? ''));
|
||
|
||
// 跳过表头行(第一行或包含"姓名"的行)
|
||
if (!$headerSkipped && ($index === 0 || mb_strpos($firstCell, '姓名') !== false)) {
|
||
$headerSkipped = true;
|
||
continue;
|
||
}
|
||
|
||
// 跳过空行
|
||
if (empty($firstCell)) {
|
||
continue;
|
||
}
|
||
|
||
// 解析数据
|
||
$patientData = $this->parseRow($row);
|
||
|
||
if ($patientData) {
|
||
// 关联到当前用户
|
||
$patientData['user_id'] = Auth::id();
|
||
Patient::create($patientData);
|
||
$imported++;
|
||
}
|
||
} catch (\Exception $e) {
|
||
$errors[] = "第 " . ($index + 1) . " 行导入失败: " . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
$message = "成功导入 {$imported} 条记录。";
|
||
if (count($errors) > 0) {
|
||
$message .= " " . count($errors) . " 条记录导入失败。";
|
||
}
|
||
|
||
return redirect()->route('patients.index')
|
||
->with('success', $message)
|
||
->with('errors', $errors);
|
||
|
||
} catch (\Exception $e) {
|
||
return back()->withErrors(['file' => '文件解析失败: ' . $e->getMessage()]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析Excel/CSV文件 (使用 xlswriter 扩展)
|
||
*/
|
||
private function parseExcel(string $path, string $extension): array
|
||
{
|
||
$data = [];
|
||
|
||
if ($extension === 'csv') {
|
||
// 检测文件编码并转换
|
||
$content = file_get_contents($path);
|
||
$encoding = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312', 'BIG5'], true);
|
||
|
||
if ($encoding && $encoding !== 'UTF-8') {
|
||
$content = mb_convert_encoding($content, 'UTF-8', $encoding);
|
||
$tempPath = sys_get_temp_dir() . '/converted_' . time() . '.csv';
|
||
file_put_contents($tempPath, $content);
|
||
$path = $tempPath;
|
||
}
|
||
|
||
$handle = fopen($path, 'r');
|
||
// 跳过 BOM
|
||
$bom = fread($handle, 3);
|
||
if ($bom !== "\xEF\xBB\xBF") {
|
||
rewind($handle);
|
||
}
|
||
|
||
while (($row = fgetcsv($handle)) !== false) {
|
||
$data[] = $row;
|
||
}
|
||
fclose($handle);
|
||
|
||
// 清理临时文件
|
||
if (isset($tempPath) && file_exists($tempPath)) {
|
||
@unlink($tempPath);
|
||
}
|
||
} elseif ($extension === 'xls') {
|
||
// .xls 是旧版 Excel 格式,xlswriter 不支持读取
|
||
// 尝试使用 PhpSpreadsheet 或提示用户转换格式
|
||
if (class_exists(\PhpOffice\PhpSpreadsheet\IOFactory::class)) {
|
||
$spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($path);
|
||
$worksheet = $spreadsheet->getActiveSheet();
|
||
foreach ($worksheet->getRowIterator() as $row) {
|
||
$rowData = [];
|
||
$cellIterator = $row->getCellIterator();
|
||
$cellIterator->setIterateOnlyExistingCells(false);
|
||
foreach ($cellIterator as $cell) {
|
||
$rowData[] = $cell->getValue();
|
||
}
|
||
$data[] = $rowData;
|
||
}
|
||
} else {
|
||
throw new \Exception('不支持 .xls 格式(Excel 97-2003)。请将文件另存为 .xlsx 格式(Excel 2007+)或 .csv 格式后重新上传。操作方法:在 Excel 中打开文件 → 文件 → 另存为 → 选择"Excel 工作簿 (*.xlsx)"或"CSV UTF-8"');
|
||
}
|
||
} else {
|
||
// 使用 xlswriter 扩展解析 xlsx
|
||
if (!class_exists(\Vtiful\Kernel\Excel::class)) {
|
||
throw new \Exception('xlswriter 扩展未安装,请安装后重试');
|
||
}
|
||
|
||
$config = ['path' => sys_get_temp_dir()];
|
||
$excel = new \Vtiful\Kernel\Excel($config);
|
||
|
||
// 复制文件到临时目录
|
||
$tempFile = sys_get_temp_dir() . '/' . uniqid('import_') . '.' . $extension;
|
||
copy($path, $tempFile);
|
||
|
||
// 读取Excel数据
|
||
$data = $excel->openFile(basename($tempFile))
|
||
->openSheet()
|
||
->getSheetData();
|
||
|
||
// 如果没有读取到数据,可能是格式问题
|
||
if (empty($data)) {
|
||
throw new \Exception('无法读取 Excel 文件内容。请确保文件是有效的 .xlsx 格式,或尝试另存为 .csv 格式后重新上传。');
|
||
}
|
||
|
||
// 释放 Excel 对象
|
||
unset($excel);
|
||
|
||
// 清理临时文件 (使用 @ 抑制 Windows 上的文件锁定警告)
|
||
if (file_exists($tempFile)) {
|
||
@unlink($tempFile);
|
||
}
|
||
}
|
||
|
||
return $data;
|
||
}
|
||
|
||
/**
|
||
* 解析单行数据
|
||
*/
|
||
private function parseRow(array $row): ?array
|
||
{
|
||
// 期望的列顺序: 姓名, 性别, 年龄, 出院诊断, 转诊时间, 户籍地址, 联系方式, 备注
|
||
if (count($row) < 5) {
|
||
return null;
|
||
}
|
||
|
||
$name = trim((string)($row[0] ?? ''));
|
||
if (empty($name) || mb_strpos($name, '姓名') !== false) {
|
||
return null;
|
||
}
|
||
|
||
// 解析日期
|
||
$dateValue = $row[4] ?? '';
|
||
$dischargeDate = $this->parseDate($dateValue);
|
||
|
||
if (!$dischargeDate) {
|
||
throw new \Exception("日期格式无效: {$dateValue}");
|
||
}
|
||
|
||
// 处理年龄(可能是数字或字符串)
|
||
$age = $row[2] ?? 0;
|
||
if (is_string($age)) {
|
||
$age = (int)preg_replace('/[^0-9]/', '', $age);
|
||
}
|
||
|
||
// 处理电话号码(可能是科学计数法或数字)
|
||
$phone = $row[6] ?? '';
|
||
if (is_numeric($phone)) {
|
||
$phone = number_format($phone, 0, '', '');
|
||
}
|
||
|
||
return [
|
||
'name' => $name,
|
||
'gender' => trim((string)($row[1] ?? '未知')),
|
||
'age' => (int)$age,
|
||
'diagnosis' => trim((string)($row[3] ?? '')),
|
||
'discharge_date' => $dischargeDate,
|
||
'address' => trim((string)($row[5] ?? '')),
|
||
'phone' => trim((string)$phone),
|
||
'remark' => trim((string)($row[7] ?? '')),
|
||
'follow_up_count' => 0,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 解析日期
|
||
*/
|
||
private function parseDate($value): ?string
|
||
{
|
||
if (empty($value)) {
|
||
return null;
|
||
}
|
||
|
||
$value = trim((string)$value);
|
||
|
||
// Excel 日期序列号(纯数字且大于25569)
|
||
if (is_numeric($value) && (float)$value > 25569 && (float)$value < 50000) {
|
||
// Excel日期是从1900年1月1日开始的天数
|
||
// 25569 是 1970-01-01 的Excel序列号
|
||
$unixTimestamp = ((float)$value - 25569) * 86400;
|
||
return date('Y-m-d', (int)$unixTimestamp);
|
||
}
|
||
|
||
// 字符串格式日期 - 各种格式
|
||
$formats = [
|
||
'Y.m.d', // 2025.12.01
|
||
'Y-m-d', // 2025-12-01
|
||
'Y/m/d', // 2025/12/01
|
||
'Y.n.j', // 2025.1.1 (无前导零)
|
||
'Y-n-j', // 2025-1-1
|
||
'Y/n/j', // 2025/1/1
|
||
'd/m/Y', // 01/12/2025
|
||
'm/d/Y', // 12/01/2025
|
||
];
|
||
|
||
foreach ($formats as $format) {
|
||
$date = \DateTime::createFromFormat($format, $value);
|
||
if ($date) {
|
||
// 验证日期是否有效
|
||
$errors = \DateTime::getLastErrors();
|
||
if (empty($errors['warning_count']) && empty($errors['error_count'])) {
|
||
return $date->format('Y-m-d');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 尝试通过 strtotime(处理更多格式)
|
||
$timestamp = strtotime(str_replace('.', '-', $value));
|
||
if ($timestamp && $timestamp > 0) {
|
||
return date('Y-m-d', $timestamp);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 导出本月随访列表 (使用 xlswriter 生成 Excel)
|
||
*/
|
||
public function export(Request $request)
|
||
{
|
||
$patients = $this->userPatients()->get();
|
||
|
||
// 只筛选本月需要随访的患者
|
||
$reminders = $patients->filter(function ($patient) {
|
||
return $patient->needsFollowUpThisMonth();
|
||
})->map(function ($patient) {
|
||
return [
|
||
'patient' => $patient,
|
||
'follow_up_date' => $patient->getCurrentMonthFollowUpDate(),
|
||
'follow_up_number' => $patient->getCurrentMonthFollowUpNumber(),
|
||
];
|
||
})->sortBy(function ($r) {
|
||
return $r['follow_up_date'];
|
||
});
|
||
|
||
// 使用 xlswriter 生成 Excel 文件
|
||
$config = ['path' => sys_get_temp_dir()];
|
||
$filename = '本月随访_' . date('Y-m') . '.xlsx';
|
||
|
||
$excel = new \Vtiful\Kernel\Excel($config);
|
||
|
||
// 创建文件并设置表头
|
||
$fileHandle = $excel->fileName($filename)
|
||
->header(['姓名', '性别', '年龄', '出院诊断', '转诊时间', '随访日期', '第几次随访', '户籍地址', '联系方式', '备注']);
|
||
|
||
// 设置表头样式
|
||
$headerStyle = $fileHandle->getHandle();
|
||
$format = new \Vtiful\Kernel\Format($headerStyle);
|
||
$boldFormat = $format->bold()->toResource();
|
||
|
||
// 添加数据行
|
||
$rows = [];
|
||
foreach ($reminders as $reminder) {
|
||
$p = $reminder['patient'];
|
||
$rows[] = [
|
||
$p->name,
|
||
$p->gender,
|
||
$p->age,
|
||
$p->diagnosis,
|
||
$p->discharge_date->format('Y-m-d'),
|
||
$reminder['follow_up_date']?->format('Y-m-d') ?? '',
|
||
'第' . $reminder['follow_up_number'] . '次',
|
||
$p->address ?? '',
|
||
$p->phone ?? '',
|
||
$p->remark ?? '',
|
||
];
|
||
}
|
||
|
||
$fileHandle->data($rows)->output();
|
||
|
||
$filePath = $config['path'] . '/' . $filename;
|
||
|
||
return Response::download($filePath, $filename, [
|
||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
])->deleteFileAfterSend(true);
|
||
}
|
||
|
||
/**
|
||
* 标记已随访
|
||
*/
|
||
public function markFollowedUp(Patient $patient)
|
||
{
|
||
// 验证患者属于当前用户
|
||
if ($patient->user_id !== Auth::id()) {
|
||
abort(403, '无权操作此患者');
|
||
}
|
||
|
||
if (!$patient->isCompleted()) {
|
||
$patient->follow_up_count += 1;
|
||
$patient->last_follow_up_date = Carbon::today();
|
||
$patient->save();
|
||
}
|
||
|
||
return back()->with('success', "{$patient->name} 已标记为完成第 {$patient->follow_up_count} 次随访");
|
||
}
|
||
|
||
/**
|
||
* 删除患者
|
||
*/
|
||
public function destroy(Patient $patient)
|
||
{
|
||
// 验证患者属于当前用户
|
||
if ($patient->user_id !== Auth::id()) {
|
||
abort(403, '无权操作此患者');
|
||
}
|
||
|
||
$name = $patient->name;
|
||
$patient->delete();
|
||
|
||
return back()->with('success', "已删除患者: {$name}");
|
||
}
|
||
|
||
/**
|
||
* 下载导入模板 (使用 xlswriter 生成 Excel)
|
||
*/
|
||
public function downloadTemplate()
|
||
{
|
||
$config = ['path' => sys_get_temp_dir()];
|
||
$filename = '导入模板.xlsx';
|
||
|
||
$excel = new \Vtiful\Kernel\Excel($config);
|
||
|
||
// 创建文件
|
||
$fileHandle = $excel->fileName($filename)
|
||
->header(['姓名', '性别', '年龄', '出院诊断', '转诊时间', '户籍地址', '联系方式', '备注']);
|
||
|
||
// 添加示例数据
|
||
$data = [
|
||
['张三', '男', 65, '脑卒中', '2025.12.01', '北京市朝阳区', '13800138000', '常住'],
|
||
['李四', '女', 70, '慢性肾脏病', '2025.11.15', '上海市浦东新区', '13900139000', ''],
|
||
['王五', '男', 58, '心肌梗塞', '2025.10.20', '广州市天河区', '13700137000', ''],
|
||
];
|
||
|
||
$fileHandle->data($data)->output();
|
||
|
||
$filePath = $config['path'] . '/' . $filename;
|
||
|
||
return Response::download($filePath, $filename, [
|
||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
])->deleteFileAfterSend(true);
|
||
}
|
||
}
|