|
|
<?php
|
|
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
|
|
use App\Models\Calendar;
|
|
|
use App\Models\Course;
|
|
|
use Illuminate\Console\Command;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
use Maatwebsite\Excel\Facades\Excel;
|
|
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
|
|
|
|
class UpdateCalendar extends Command
|
|
|
{
|
|
|
/**
|
|
|
* The name and signature of the console command.
|
|
|
*
|
|
|
* @var string
|
|
|
*/
|
|
|
protected $signature = 'update:course-urls {file=课程台账.xlsx}';
|
|
|
|
|
|
/**
|
|
|
* The console command description.
|
|
|
*
|
|
|
* @var string
|
|
|
*/
|
|
|
protected $description = '从Excel文件读取课程信息,匹配courses表,获取新闻链接,并创建calendar记录';
|
|
|
|
|
|
/**
|
|
|
* Create a new command instance.
|
|
|
*
|
|
|
* @return void
|
|
|
*/
|
|
|
public function __construct()
|
|
|
{
|
|
|
parent::__construct();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Execute the console command.
|
|
|
*
|
|
|
* @return mixed
|
|
|
*/
|
|
|
public function handle()
|
|
|
{
|
|
|
$fileName = $this->argument('file');
|
|
|
$filePath = base_path($fileName);
|
|
|
|
|
|
if (!file_exists($filePath)) {
|
|
|
$this->error("文件不存在: {$filePath}");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
$this->info("开始处理文件: {$fileName}");
|
|
|
|
|
|
try {
|
|
|
// 读取Excel文件
|
|
|
$spreadsheet = IOFactory::load($filePath);
|
|
|
$sheetCount = $spreadsheet->getSheetCount();
|
|
|
|
|
|
$this->info("Excel文件包含 {$sheetCount} 个工作表");
|
|
|
|
|
|
$totalCreated = 0;
|
|
|
$failedCourses = [];
|
|
|
$failedNews = [];
|
|
|
|
|
|
// 处理每个工作表
|
|
|
for ($sheetIndex = 0; $sheetIndex < $sheetCount; $sheetIndex++) {
|
|
|
$worksheet = $spreadsheet->getSheet($sheetIndex);
|
|
|
$sheetName = $worksheet->getTitle();
|
|
|
|
|
|
$this->info("正在处理工作表: {$sheetName}");
|
|
|
|
|
|
list($created, $sheetFailedCourses, $sheetFailedNews) = $this->processWorksheet($worksheet, $sheetName);
|
|
|
$totalCreated += $created;
|
|
|
$failedCourses = array_merge($failedCourses, $sheetFailedCourses);
|
|
|
$failedNews = array_merge($failedNews, $sheetFailedNews);
|
|
|
}
|
|
|
|
|
|
$this->info("处理完成,总共创建了 {$totalCreated} 条日历记录");
|
|
|
|
|
|
// 显示匹配失败的课程
|
|
|
if (!empty($failedCourses)) {
|
|
|
$this->warn("匹配失败的课程:");
|
|
|
foreach (array_unique($failedCourses) as $failedCourse) {
|
|
|
$this->warn(" - {$failedCourse}");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 显示匹配失败的新闻
|
|
|
if (!empty($failedNews)) {
|
|
|
$this->warn("匹配失败的新闻:");
|
|
|
foreach (array_unique($failedNews) as $failedNewsItem) {
|
|
|
$this->warn(" - {$failedNewsItem}");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
$this->error("处理Excel文件时发生错误: " . $e->getMessage());
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 处理单个工作表
|
|
|
*/
|
|
|
private function processWorksheet($worksheet, $sheetName)
|
|
|
{
|
|
|
$highestRow = $worksheet->getHighestRow();
|
|
|
$highestColumn = $worksheet->getHighestColumn();
|
|
|
|
|
|
$this->info("工作表 {$sheetName} 有 {$highestRow} 行,最高列为 {$highestColumn}");
|
|
|
|
|
|
// 读取第一行作为表头
|
|
|
$headers = [];
|
|
|
$highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
|
|
|
|
|
|
for ($col = 1; $col <= $highestColumnIndex; $col++) {
|
|
|
$cellValue = $worksheet->getCellByColumnAndRow($col, 1)->getCalculatedValue();
|
|
|
$headers[$col] = trim($cellValue);
|
|
|
}
|
|
|
|
|
|
// 找到"课程"、"开始时间"、"结束时间"、"跳转链接"列的位置
|
|
|
$courseColumn = null;
|
|
|
$startTimeColumn = null;
|
|
|
$endTimeColumn = null;
|
|
|
$linkColumn = null;
|
|
|
|
|
|
foreach ($headers as $colIndex => $header) {
|
|
|
if (strpos($header, '课程') !== false) {
|
|
|
$courseColumn = $colIndex;
|
|
|
}
|
|
|
if (strpos($header, '开始时间') !== false) {
|
|
|
$startTimeColumn = $colIndex;
|
|
|
}
|
|
|
if (strpos($header, '结束时间') !== false) {
|
|
|
$endTimeColumn = $colIndex;
|
|
|
}
|
|
|
if (strpos($header, '跳转链接') !== false) {
|
|
|
$linkColumn = $colIndex;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!$courseColumn || !$startTimeColumn || !$endTimeColumn || !$linkColumn) {
|
|
|
$this->warn("工作表 {$sheetName} 中未找到必要的列(课程、开始时间、结束时间、跳转链接)");
|
|
|
return [0, [], []];
|
|
|
}
|
|
|
|
|
|
$this->info("找到课程列: {$courseColumn},开始时间列: {$startTimeColumn},结束时间列: {$endTimeColumn},跳转链接列: {$linkColumn}");
|
|
|
$created = 0;
|
|
|
$failedCourses = [];
|
|
|
$failedNews = [];
|
|
|
|
|
|
// 处理数据行
|
|
|
for ($row = 2; $row <= $highestRow; $row++) {
|
|
|
$courseName = trim($worksheet->getCellByColumnAndRow($courseColumn, $row)->getCalculatedValue());
|
|
|
|
|
|
// 获取开始时间和结束时间的原始值,避免格式化问题
|
|
|
$startTimeCell = $worksheet->getCellByColumnAndRow($startTimeColumn, $row);
|
|
|
$endTimeCell = $worksheet->getCellByColumnAndRow($endTimeColumn, $row);
|
|
|
|
|
|
// 优先使用原始值,如果没有则使用计算值
|
|
|
$startTime = $startTimeCell->getValue();
|
|
|
if ($startTime === null) {
|
|
|
$startTime = trim($startTimeCell->getCalculatedValue());
|
|
|
}
|
|
|
|
|
|
$endTime = $endTimeCell->getValue();
|
|
|
if ($endTime === null) {
|
|
|
$endTime = trim($endTimeCell->getCalculatedValue());
|
|
|
}
|
|
|
|
|
|
$jumpLink = trim($worksheet->getCellByColumnAndRow($linkColumn, $row)->getCalculatedValue());
|
|
|
|
|
|
if (empty($courseName) || empty($startTime) || empty($endTime)) {
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
$this->info("处理行 {$row}: 课程='{$courseName}', 开始时间='{$startTime}' (类型: " . gettype($startTime) . "), 结束时间='{$endTime}' (类型: " . gettype($endTime) . "), 跳转链接='{$jumpLink}'");
|
|
|
|
|
|
// 1. 匹配courses表
|
|
|
$courseId = $this->matchCourse($courseName);
|
|
|
if (!$courseId) {
|
|
|
$this->warn("✗ 未找到匹配的课程: '{$courseName}'");
|
|
|
$failedCourses[] = $courseName;
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
// 2. 匹配phome_ecms_news表获取url和title
|
|
|
$url = null;
|
|
|
$title = null;
|
|
|
if (!empty($jumpLink)) {
|
|
|
list($title, $url) = $this->getTitleUrlFromNews($jumpLink);
|
|
|
if (!$url) {
|
|
|
$this->warn("✗ 未找到匹配的新闻标题: '{$jumpLink}'");
|
|
|
$failedNews[] = $jumpLink;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 3. 更新courses表的url字段
|
|
|
if ($url && $title) {
|
|
|
$this->updateCourseUrl($courseId, $url, $title);
|
|
|
}
|
|
|
|
|
|
// 4. 创建calendar记录
|
|
|
$calendarCreated = $this->createCalendarRecord($courseId, $courseName, $startTime, $endTime, $url, $title, $courseName);
|
|
|
if ($calendarCreated) {
|
|
|
$created++;
|
|
|
$this->info("✓ 成功创建日历记录: '{$courseName}'");
|
|
|
} else {
|
|
|
$this->warn("✗ 创建日历记录失败: '{$courseName}'");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return [$created, $failedCourses, $failedNews];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 匹配courses表
|
|
|
*/
|
|
|
private function matchCourse($courseName)
|
|
|
{
|
|
|
try {
|
|
|
// 直接匹配
|
|
|
$course = Course::where('name', $courseName)->first();
|
|
|
|
|
|
if ($course) {
|
|
|
$this->info("通过直接匹配找到课程: '{$course->name}' (ID: {$course->id})");
|
|
|
return $course->id;
|
|
|
}
|
|
|
|
|
|
// 模糊匹配
|
|
|
$course = Course::where('name', 'like', "%{$courseName}%")
|
|
|
->whereNull('deleted_at')
|
|
|
->first();
|
|
|
|
|
|
if ($course) {
|
|
|
$this->info("通过模糊匹配找到课程: '{$course->name}' (ID: {$course->id})");
|
|
|
return $course->id;
|
|
|
}
|
|
|
|
|
|
// 使用相似度匹配
|
|
|
$courses = Course::whereNotNull('name')->where('name', '!=', '')->get();
|
|
|
|
|
|
$bestMatch = null;
|
|
|
$highestSimilarity = 0;
|
|
|
|
|
|
foreach ($courses as $course) {
|
|
|
$similarity = $this->calculateSimilarity($courseName, $course->name);
|
|
|
if ($similarity > $highestSimilarity) {
|
|
|
$highestSimilarity = $similarity;
|
|
|
$bestMatch = $course;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 取相似度最高的作为结果,不设置阈值限制
|
|
|
if ($bestMatch && $highestSimilarity > 0.3) {
|
|
|
$this->info("通过相似度匹配找到课程 (相似度: " . round($highestSimilarity * 100, 2) . "%): '{$bestMatch->name}' (ID: {$bestMatch->id})");
|
|
|
return $bestMatch->id;
|
|
|
}
|
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
$this->error("查询courses表时发生错误: " . $e->getMessage());
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 从phome_ecms_news表获取titleurl
|
|
|
*/
|
|
|
private function getTitleUrlFromNews($title)
|
|
|
{
|
|
|
try {
|
|
|
// 直接匹配
|
|
|
$news = DB::table('phome_ecms_news')
|
|
|
->where('title', $title)
|
|
|
->first();
|
|
|
|
|
|
if ($news && !empty($news->titleurl)) {
|
|
|
return [$news->title, $news->titleurl];
|
|
|
}
|
|
|
|
|
|
// 模糊匹配
|
|
|
$news = DB::table('phome_ecms_news')
|
|
|
->where('title', 'like', "%{$title}%")
|
|
|
->first();
|
|
|
|
|
|
if ($news && !empty($news->titleurl)) {
|
|
|
$this->info("通过模糊匹配找到: '{$news->title}' -> '{$news->titleurl}'");
|
|
|
return [$news->title, $news->titleurl];
|
|
|
}
|
|
|
|
|
|
// 使用相似度匹配
|
|
|
$allNews = DB::table('phome_ecms_news')
|
|
|
->whereNotNull('title')
|
|
|
->whereNotNull('titleurl')
|
|
|
->where('title', '!=', '')
|
|
|
->where('titleurl', '!=', '')
|
|
|
->get();
|
|
|
|
|
|
$bestMatch = null;
|
|
|
$highestSimilarity = 0;
|
|
|
|
|
|
foreach ($allNews as $news) {
|
|
|
$similarity = $this->calculateSimilarity($title, $news->title);
|
|
|
if ($similarity > $highestSimilarity) {
|
|
|
$highestSimilarity = $similarity;
|
|
|
$bestMatch = $news;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 取相似度最高的作为结果,不设置阈值限制
|
|
|
if ($bestMatch && $highestSimilarity > 0.3) {
|
|
|
$this->info("通过相似度匹配找到新闻 (相似度: " . round($highestSimilarity * 100, 2) . "%): '{$bestMatch->title}' -> '{$bestMatch->titleurl}'");
|
|
|
return [$bestMatch->title, $bestMatch->titleurl];
|
|
|
}
|
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
$this->error("查询phome_ecms_news表时发生错误: " . $e->getMessage());
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 更新courses表的url字段
|
|
|
*/
|
|
|
private function updateCourseUrl($courseId, $titleUrl, $title)
|
|
|
{
|
|
|
try {
|
|
|
$course = Course::find($courseId);
|
|
|
if ($course) {
|
|
|
$course->url = $titleUrl;
|
|
|
$course->url_title = $title;
|
|
|
$course->save();
|
|
|
$this->info("✓ 成功更新课程URL: '{$course->name}' -> '{$titleUrl}'");
|
|
|
return true;
|
|
|
}
|
|
|
} catch (\Exception $e) {
|
|
|
$this->error("更新courses表时发生错误: " . $e->getMessage());
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 创建calendar记录
|
|
|
*/
|
|
|
private function createCalendarRecord($courseId, $courseName, $startTime, $endTime, $url = null, $title = null, $calendarTitle = null)
|
|
|
{
|
|
|
try {
|
|
|
// 转换时间格式
|
|
|
$startDateTime = $this->parseDateTime($startTime);
|
|
|
$endDateTime = $this->parseDateTime($endTime);
|
|
|
|
|
|
if (!$startDateTime || !$endDateTime) {
|
|
|
$this->warn("时间格式解析失败: 开始时间='{$startTime}', 结束时间='{$endTime}'");
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
// 检查是否已存在相同的日历记录
|
|
|
$existingCalendar = Calendar::where('course_id', $courseId)
|
|
|
->where('start_time', $startDateTime)
|
|
|
->where('end_time', $endDateTime)
|
|
|
->first();
|
|
|
|
|
|
if ($existingCalendar) {
|
|
|
$this->info("日历记录已存在,跳过创建: '{$courseName}'");
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
// 创建新的日历记录
|
|
|
$calendar = new Calendar();
|
|
|
$calendar->type = 1; // 课程类型
|
|
|
$calendar->course_id = $courseId;
|
|
|
$calendar->title = $calendarTitle ?: $courseName; // 使用Excel中的课程名字作为title
|
|
|
$calendar->start_time = $startDateTime;
|
|
|
$calendar->end_time = $endDateTime;
|
|
|
$calendar->date = $startDateTime->format('Y-m-d');
|
|
|
$calendar->url = $url;
|
|
|
$calendar->is_publish = 1; // 默认发布
|
|
|
$calendar->save();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
$this->error("创建calendar记录时发生错误: " . $e->getMessage());
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 解析日期时间格式
|
|
|
*/
|
|
|
private function parseDateTime($dateTimeString)
|
|
|
{
|
|
|
try {
|
|
|
// 处理Excel数字格式的日期时间
|
|
|
if (is_numeric($dateTimeString)) {
|
|
|
$excelDate = (float)$dateTimeString;
|
|
|
|
|
|
// Excel日期从1900年1月1日开始计算天数
|
|
|
// 需要减去2是因为Excel错误地认为1900年是闰年
|
|
|
$unixTimestamp = ($excelDate - 25569) * 86400;
|
|
|
|
|
|
$dateTime = new \DateTime();
|
|
|
$dateTime->setTimestamp($unixTimestamp);
|
|
|
|
|
|
$this->info("Excel数字日期转换: {$dateTimeString} -> " . $dateTime->format('Y-m-d H:i:s'));
|
|
|
return $dateTime;
|
|
|
}
|
|
|
|
|
|
// 尝试多种日期时间格式
|
|
|
$formats = [
|
|
|
'Y-m-d H:i:s',
|
|
|
'Y-m-d H:i',
|
|
|
'Y/m/d H:i:s',
|
|
|
'Y/m/d H:i',
|
|
|
'Y-m-d',
|
|
|
'Y/m/d',
|
|
|
'd/m/Y H:i:s',
|
|
|
'd/m/Y H:i',
|
|
|
'd-m-Y H:i:s',
|
|
|
'd-m-Y H:i'
|
|
|
];
|
|
|
|
|
|
foreach ($formats as $format) {
|
|
|
$dateTime = \DateTime::createFromFormat($format, $dateTimeString);
|
|
|
if ($dateTime !== false) {
|
|
|
// 如果只有日期没有时间,设置默认时间
|
|
|
if (strpos($format, 'H:i') === false) {
|
|
|
$dateTime->setTime(9, 0, 0); // 默认上午9点
|
|
|
}
|
|
|
return $dateTime;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 尝试使用strtotime
|
|
|
$timestamp = strtotime($dateTimeString);
|
|
|
if ($timestamp !== false) {
|
|
|
$dateTime = new \DateTime();
|
|
|
$dateTime->setTimestamp($timestamp);
|
|
|
return $dateTime;
|
|
|
}
|
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
$this->error("解析日期时间时发生错误: " . $e->getMessage());
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 计算字符串相似度
|
|
|
*/
|
|
|
private function calculateSimilarity($str1, $str2)
|
|
|
{
|
|
|
// 移除空格并转换为小写
|
|
|
$str1 = strtolower(preg_replace('/\s+/', '', $str1));
|
|
|
$str2 = strtolower(preg_replace('/\s+/', '', $str2));
|
|
|
|
|
|
if ($str1 === $str2) {
|
|
|
return 1.0;
|
|
|
}
|
|
|
|
|
|
if (empty($str1) || empty($str2)) {
|
|
|
return 0.0;
|
|
|
}
|
|
|
|
|
|
// 使用Levenshtein距离计算相似度
|
|
|
$maxLen = max(strlen($str1), strlen($str2));
|
|
|
if ($maxLen == 0) {
|
|
|
return 1.0;
|
|
|
}
|
|
|
|
|
|
$distance = levenshtein($str1, $str2);
|
|
|
$similarity = 1 - ($distance / $maxLen);
|
|
|
|
|
|
// 如果其中一个字符串包含另一个,提高相似度
|
|
|
if (strpos($str1, $str2) !== false || strpos($str2, $str1) !== false) {
|
|
|
$containsSimilarity = min(strlen($str1), strlen($str2)) / $maxLen;
|
|
|
$similarity = max($similarity, $containsSimilarity);
|
|
|
}
|
|
|
|
|
|
return max(0, $similarity);
|
|
|
}
|
|
|
}
|