2025-12-11 11:04:41 +00:00
|
|
|
|
<?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();
|
2025-12-12 09:47:34 +00:00
|
|
|
|
$res = $mapper->buildColumnFieldMap($headerRow);
|
|
|
|
|
|
$colToField = $res['colToField'];
|
|
|
|
|
|
$unknownCols = $res['unknownCols'];
|
2025-12-11 11:04:41 +00:00
|
|
|
|
|
|
|
|
|
|
$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;
|
2025-12-12 18:12:13 +08:00
|
|
|
|
|
2025-12-11 11:04:41 +00:00
|
|
|
|
case 'fb_created_time':
|
2025-12-12 18:12:13 +08:00
|
|
|
|
$item['fb_created_time'] = $this->normalizeCreatedTime($raw); // 你方案B的datetime
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ✅ 这些是数值ID字段:可能出现 ag:/as:/c:/f:/l: 前缀
|
|
|
|
|
|
case 'ad_id':
|
|
|
|
|
|
case 'adset_id':
|
|
|
|
|
|
case 'campaign_id':
|
|
|
|
|
|
case 'form_id':
|
|
|
|
|
|
$item[$field] = $this->normalizeNumericId($raw);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ✅ lead id 也经常是 l:xxxx
|
|
|
|
|
|
case 'fb_lead_id':
|
|
|
|
|
|
$item['fb_lead_id'] = $this->normalizeNumericId($raw);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
// ✅ 电话可能是 p:+233xxxx
|
|
|
|
|
|
case 'phone':
|
|
|
|
|
|
$item['phone'] = $this->normalizePhone($raw);
|
2025-12-11 11:04:41 +00:00
|
|
|
|
break;
|
2025-12-12 18:12:13 +08:00
|
|
|
|
|
2025-12-11 11:04:41 +00:00
|
|
|
|
default:
|
2025-12-12 18:12:13 +08:00
|
|
|
|
if (is_string($raw)) $raw = trim($raw);
|
2025-12-11 11:04:41 +00:00
|
|
|
|
$item[$field] = $raw;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2025-12-12 18:12:13 +08:00
|
|
|
|
|
2025-12-11 11:04:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-12 09:47:34 +00:00
|
|
|
|
// 未知列收集到 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;
|
|
|
|
|
|
|
2025-12-11 11:04:41 +00:00
|
|
|
|
$item['source_file'] = $sourceFile;
|
2025-12-12 09:47:34 +00:00
|
|
|
|
$item['channel'] = $channelId;
|
2025-12-11 11:04:41 +00:00
|
|
|
|
|
|
|
|
|
|
$item['created_at'] = $now;
|
|
|
|
|
|
$item['updated_at'] = $now;
|
2025-12-12 09:47:34 +00:00
|
|
|
|
$item['sync_status'] = 0;
|
2025-12-11 11:04:41 +00:00
|
|
|
|
|
|
|
|
|
|
$insertData[] = $item;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($insertData) {
|
|
|
|
|
|
Db::table('fb_leads')->insert($insertData);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return count($insertData);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-12 18:12:13 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 把 "ag:120..." / "l:831..." / "120..." 这种值清洗成纯数字(int 或 string数字)
|
|
|
|
|
|
* - 如果你的 DB 字段是 BIGINT UNSIGNED,PHP int 在 64 位环境下是够用的
|
|
|
|
|
|
* - 解析失败返回 0(避免 insert 失败)
|
|
|
|
|
|
*/
|
|
|
|
|
|
protected function normalizeNumericId($raw)
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($raw === null || $raw === '') return 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 数字直接返回
|
|
|
|
|
|
if (is_int($raw)) return $raw;
|
|
|
|
|
|
if (is_float($raw)) return (int)$raw;
|
|
|
|
|
|
|
|
|
|
|
|
$s = trim((string)$raw);
|
|
|
|
|
|
if ($s === '') return 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 取出所有数字拼成一段(ag:120... -> 120..., l:xxx -> xxx)
|
|
|
|
|
|
$digits = preg_replace('/\D+/', '', $s);
|
|
|
|
|
|
if ($digits === '') return 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 这里返回字符串数字也可以,MySQL 会转;返回 int 在 64 位一般没问题
|
|
|
|
|
|
// 若你担心超出 int,可直接 return $digits;
|
|
|
|
|
|
return $digits;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 电话清洗:去掉 "p:" 前缀,保留 + 和数字
|
|
|
|
|
|
*/
|
|
|
|
|
|
protected function normalizePhone($raw): string
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($raw === null) return '';
|
|
|
|
|
|
|
|
|
|
|
|
$s = trim((string)$raw);
|
|
|
|
|
|
if ($s === '') return '';
|
|
|
|
|
|
|
|
|
|
|
|
// 去掉类似 "p:" "tel:" 等前缀
|
|
|
|
|
|
$s = preg_replace('/^[a-zA-Z]+:\s*/', '', $s);
|
|
|
|
|
|
|
|
|
|
|
|
// 保留 + 和数字
|
|
|
|
|
|
$s = preg_replace('/[^\d\+]+/', '', $s);
|
|
|
|
|
|
|
|
|
|
|
|
return $s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-11 11:04:41 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 规范化 is_organic 字段为 0/1
|
|
|
|
|
|
*/
|
|
|
|
|
|
protected function normalizeIsOrganic($raw): int
|
|
|
|
|
|
{
|
|
|
|
|
|
if (is_bool($raw)) {
|
|
|
|
|
|
return $raw ? 1 : 0;
|
|
|
|
|
|
}
|
2025-12-12 09:47:34 +00:00
|
|
|
|
$str = strtolower(trim((string)$raw));
|
2025-12-11 11:04:41 +00:00
|
|
|
|
if ($str === '') {
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
return in_array($str, ['1', 'true', 'yes', 'organic'], true) ? 1 : 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-22 16:23:05 +08:00
|
|
|
|
* 方案B:写 DATETIME,且永不为 null(因为 fb_created_time NOT NULL)
|
|
|
|
|
|
* 兼容:
|
|
|
|
|
|
* - Excel 数字日期
|
|
|
|
|
|
* - 10/13/25
|
|
|
|
|
|
* - 2025-12-10 04:25:09
|
|
|
|
|
|
* - 25年12月21日 / 2025年12月21日
|
|
|
|
|
|
* - 25年12月21日 13:05 / 25年12月21日13:05:09
|
|
|
|
|
|
* 为空/解析失败:用当前时间兜底
|
2025-12-11 11:04:41 +00:00
|
|
|
|
*/
|
2025-12-22 16:23:05 +08:00
|
|
|
|
protected function normalizeCreatedTime($raw): string
|
2025-12-11 11:04:41 +00:00
|
|
|
|
{
|
2025-12-22 16:23:05 +08:00
|
|
|
|
$fallback = date('Y-m-d H:i:s');
|
|
|
|
|
|
|
|
|
|
|
|
if ($raw === null) return $fallback;
|
2025-12-12 09:47:34 +00:00
|
|
|
|
|
|
|
|
|
|
// Excel 日期序列:数字
|
|
|
|
|
|
if (is_numeric($raw)) {
|
2025-12-22 16:23:05 +08:00
|
|
|
|
$n = (float)$raw;
|
2025-12-12 09:47:34 +00:00
|
|
|
|
$ts = (int)round(($n - 25569) * 86400);
|
2025-12-22 16:23:05 +08:00
|
|
|
|
if ($ts > 0) return date('Y-m-d H:i:s', $ts);
|
|
|
|
|
|
return $fallback;
|
2025-12-12 09:47:34 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$s = trim((string)$raw);
|
2025-12-22 16:23:05 +08:00
|
|
|
|
if ($s === '') return $fallback;
|
2025-12-12 09:47:34 +00:00
|
|
|
|
|
2025-12-22 16:23:05 +08:00
|
|
|
|
// 先尝试 strtotime(能覆盖很多格式:2025-12-10 04:25:09 / 2025/12/10 等)
|
2025-12-12 09:47:34 +00:00
|
|
|
|
$ts = strtotime($s);
|
|
|
|
|
|
if ($ts !== false) {
|
|
|
|
|
|
return date('Y-m-d H:i:s', $ts);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 16:23:05 +08:00
|
|
|
|
// m/d/yy:10/13/25
|
2025-12-12 09:47:34 +00:00
|
|
|
|
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];
|
2025-12-22 16:23:05 +08:00
|
|
|
|
$year = 2000 + $yy;
|
2025-12-12 09:47:34 +00:00
|
|
|
|
$ts2 = mktime(0, 0, 0, $mm, $dd, $year);
|
|
|
|
|
|
return date('Y-m-d H:i:s', $ts2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 16:23:05 +08:00
|
|
|
|
// ✅ 中文日期:25年12月21日 / 2025年12月21日(可带时间)
|
|
|
|
|
|
// 支持:
|
|
|
|
|
|
// - 25年12月21日
|
|
|
|
|
|
// - 2025年12月21日
|
|
|
|
|
|
// - 25年12月21日 13:05
|
|
|
|
|
|
// - 25年12月21日13:05:09
|
|
|
|
|
|
$cn = preg_replace('/\s+/', '', $s); // 去空格方便匹配
|
|
|
|
|
|
if (preg_match('/^(\d{2,4})年(\d{1,2})月(\d{1,2})日(?:(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/u', $cn, $m)) {
|
|
|
|
|
|
$y = (int)$m[1];
|
|
|
|
|
|
$month = (int)$m[2];
|
|
|
|
|
|
$day = (int)$m[3];
|
|
|
|
|
|
|
|
|
|
|
|
// 2位年 -> 2000+(你这里是 25年 = 2025)
|
|
|
|
|
|
if ($y < 100) $y += 2000;
|
|
|
|
|
|
|
|
|
|
|
|
$hh = isset($m[4]) && $m[4] !== '' ? (int)$m[4] : 0;
|
|
|
|
|
|
$ii = isset($m[5]) && $m[5] !== '' ? (int)$m[5] : 0;
|
|
|
|
|
|
$ss = isset($m[6]) && $m[6] !== '' ? (int)$m[6] : 0;
|
|
|
|
|
|
|
|
|
|
|
|
$ts3 = mktime($hh, $ii, $ss, $month, $day, $y);
|
|
|
|
|
|
return date('Y-m-d H:i:s', $ts3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析失败兜底:当前时间
|
|
|
|
|
|
return $fallback;
|
2025-12-11 11:04:41 +00:00
|
|
|
|
}
|
2025-12-22 16:23:05 +08:00
|
|
|
|
|
2025-12-11 11:04:41 +00:00
|
|
|
|
}
|