Merge branch 'develop'
# Conflicts: # app/command/InquiryImportFbCommand.php # app/inquiry/utils/FbLeadHeaderMapper.php
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace app\inquiry\service;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use support\Db;
|
||||
use app\inquiry\utils\FbLeadHeaderMapper;
|
||||
use support\exception\BusinessException;
|
||||
use support\Log;
|
||||
|
||||
class FbFileImportService
|
||||
{
|
||||
/**
|
||||
* 导入指定目录下所有 .xlsx 文件
|
||||
*/
|
||||
public function importDir(string $dir, int $channelId): int
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
throw new BusinessException("目录不存在:{$dir}");
|
||||
}
|
||||
|
||||
// 只扫描 .xlsx,之前处理过的 .xlsx.done 不会被匹配到
|
||||
$files = glob($dir . DIRECTORY_SEPARATOR . '*.xlsx');
|
||||
if (!$files) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($files as $filePath) {
|
||||
if (!is_file($filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$doneFlag = $filePath . '.done';
|
||||
if (is_file($doneFlag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = $this->importOneFile($filePath, $channelId);
|
||||
$count++;
|
||||
|
||||
// 导入成功后,把文件重命名为 .xlsx.done,避免下次重复导入
|
||||
$newPath = $filePath . '.done';
|
||||
if (!@rename($filePath, $newPath)) {
|
||||
Log::warning("FbFileImportService: 文件导入成功,但重命名失败:{$filePath} -> {$newPath}");
|
||||
} else {
|
||||
Log::info("FbFileImportService: 文件导入并标记完成:{$newPath}(行数:{$rows})");
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入单个 Excel 到 fb_leads
|
||||
*/
|
||||
public function importOneFile(string $filePath, int $channelId): int
|
||||
{
|
||||
if (!is_file($filePath)) {
|
||||
throw new BusinessException("Excel 文件不存在:{$filePath}");
|
||||
}
|
||||
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $sheet->toArray(null, true, true, true);
|
||||
|
||||
if (empty($rows)) {
|
||||
throw new BusinessException('Excel 内容为空:' . $filePath);
|
||||
}
|
||||
|
||||
// 表头
|
||||
$headerRow = array_shift($rows);
|
||||
|
||||
$mapper = new FbLeadHeaderMapper();
|
||||
$res = $mapper->buildColumnFieldMap($headerRow);
|
||||
$colToField = $res['colToField'];
|
||||
$unknownCols = $res['unknownCols'];
|
||||
|
||||
$insertData = [];
|
||||
$now = time();
|
||||
$sourceFile = basename($filePath);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$item = [];
|
||||
|
||||
foreach ($colToField as $colIndex => $field) {
|
||||
$raw = $row[$colIndex] ?? null;
|
||||
|
||||
switch ($field) {
|
||||
case 'is_organic':
|
||||
$item['is_organic'] = $this->normalizeIsOrganic($raw);
|
||||
break;
|
||||
case 'fb_created_time':
|
||||
// ✅ 方案B:写入 DATETIME 字符串
|
||||
$item['fb_created_time'] = $this->normalizeCreatedTime($raw);
|
||||
break;
|
||||
default:
|
||||
// 小增强:字符串统一 trim(避免 " xxx ")
|
||||
if (is_string($raw)) {
|
||||
$raw = trim($raw);
|
||||
}
|
||||
$item[$field] = $raw;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 未知列收集到 extra_data(宽松模式)
|
||||
$extra = [];
|
||||
foreach ($unknownCols as $colIndex => $rawHeader) {
|
||||
$val = $row[$colIndex] ?? null;
|
||||
if ($val === null || $val === '') continue;
|
||||
$extra[$rawHeader] = is_string($val) ? trim($val) : $val;
|
||||
}
|
||||
$item['extra_data'] = $extra ? json_encode($extra, JSON_UNESCAPED_UNICODE) : null;
|
||||
|
||||
$item['source_file'] = $sourceFile;
|
||||
$item['channel'] = $channelId;
|
||||
|
||||
$item['created_at'] = $now;
|
||||
$item['updated_at'] = $now;
|
||||
$item['sync_status'] = 0;
|
||||
|
||||
$insertData[] = $item;
|
||||
}
|
||||
|
||||
if ($insertData) {
|
||||
Db::table('fb_leads')->insert($insertData);
|
||||
}
|
||||
|
||||
return count($insertData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 is_organic 字段为 0/1
|
||||
*/
|
||||
protected function normalizeIsOrganic($raw): int
|
||||
{
|
||||
if (is_bool($raw)) {
|
||||
return $raw ? 1 : 0;
|
||||
}
|
||||
$str = strtolower(trim((string)$raw));
|
||||
if ($str === '') {
|
||||
return 0;
|
||||
}
|
||||
return in_array($str, ['1', 'true', 'yes', 'organic'], true) ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ 方案B:把 FB 创建时间统一转成 MySQL DATETIME(Y-m-d H:i:s)
|
||||
*
|
||||
* 支持:
|
||||
* - "10/13/25" / "12/8/25"
|
||||
* - "2025-12-08 13:22:11"
|
||||
* - Excel 日期序列(数字)
|
||||
*
|
||||
* 解析失败:返回 null(让 DB 用默认值/允许为空)
|
||||
* 注意:如果你的 fb_created_time 列是 NOT NULL 且无默认值,请把 null 改成 '1970-01-01 00:00:00'
|
||||
*/
|
||||
protected function normalizeCreatedTime($raw): ?string
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Excel 日期序列:数字
|
||||
if (is_numeric($raw)) {
|
||||
$n = (float)$raw;
|
||||
// Excel serial date -> unix timestamp
|
||||
$ts = (int)round(($n - 25569) * 86400);
|
||||
if ($ts > 0) {
|
||||
return date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$s = trim((string)$raw);
|
||||
if ($s === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1) strtotime 能解析很多格式
|
||||
$ts = strtotime($s);
|
||||
if ($ts !== false) {
|
||||
return date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
|
||||
// 2) 兼容 m/d/yy(如 10/13/25)
|
||||
if (preg_match('#^(\d{1,2})/(\d{1,2})/(\d{2})$#', $s, $m)) {
|
||||
$mm = (int)$m[1];
|
||||
$dd = (int)$m[2];
|
||||
$yy = (int)$m[3];
|
||||
$year = 2000 + $yy; // FB 导出通常是 20xx
|
||||
$ts2 = mktime(0, 0, 0, $mm, $dd, $year);
|
||||
return date('Y-m-d H:i:s', $ts2);
|
||||
}
|
||||
|
||||
// 3) 兜底:无法解析
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user