278 lines
10 KiB
PHP
278 lines
10 KiB
PHP
<?php
|
||
|
||
namespace app\inquiry\utils;
|
||
|
||
use support\exception\BusinessException;
|
||
|
||
class FbLeadHeaderMapper
|
||
{
|
||
/**
|
||
* Excel 原始表头 => DB 字段名
|
||
* 注意:这里的 key 会经过 normalizeHeader() 处理后再匹配
|
||
*/
|
||
protected array $headerMap = [
|
||
// ===== 基础公共字段 =====
|
||
'id' => 'fb_lead_id',
|
||
'created_time' => 'fb_created_time',
|
||
'ad_id' => 'ad_id',
|
||
'ad_name' => 'ad_name',
|
||
'adset_id' => 'adset_id',
|
||
'adset_name' => 'adset_name',
|
||
'campaign_id' => 'campaign_id',
|
||
'campaign_name' => 'campaign_name',
|
||
'form_id' => 'form_id',
|
||
'form_name' => 'form_name',
|
||
'is_organic' => 'is_organic',
|
||
'platform' => 'platform',
|
||
|
||
'full_name' => 'full_name',
|
||
'phone' => 'phone',
|
||
'phone_number' => 'phone', // 兼容写法
|
||
'email' => 'email',
|
||
'lead_status' => 'lead_status',
|
||
'inbox_url' => 'inbox_url',
|
||
|
||
// ===== 车型相关:全部归到 car_model =====
|
||
'which_model_of_this_car_would_you_like_to_purchase' => 'car_model',
|
||
'which_car_model_would_you_like_to_purchase' => 'car_model',
|
||
'which_model_do_you_want_to_purchase' => 'car_model',
|
||
'which_model_would_you_like_to_purchase' => 'car_model',
|
||
'which_version_do_you_want_to_buy' => 'car_model', // 这里也归到车型/版本字段
|
||
|
||
// ===== 数量相关:建议归到 car_quantity_text(避免 1-2/3-10/10+)=====
|
||
// 你之前 car_quantity 用 int 时会截断,这里建议后续统一改成 car_quantity_text
|
||
'how_many_cars_would_you_like_to_buy' => 'car_quantity_text',
|
||
'_how_many_cars_would_you_like_to_buy' => 'car_quantity_text',
|
||
'how_many_cars_do_you_buy' => 'car_quantity_text',
|
||
'how_many_cars_do_you_want_to_purchase' => 'car_quantity_text',
|
||
'how_many_cars_do_you_want_to_buy' => 'car_quantity_text',
|
||
'what_quantity_do_you_want_to_buy' => 'car_quantity_text',
|
||
'what_quantity_do_you_wantv_to_buy' => 'car_quantity_text', // wantv 拼写兼容
|
||
|
||
// ===== 颜色相关:归到 favorite_color =====
|
||
'what_color_do_you_like' => 'favorite_color',
|
||
'which_color_would_you_like_to_purchase' => 'favorite_color',
|
||
'what_color_car_do_you_plan_to_buy' => 'favorite_color',
|
||
|
||
// ===== 用途相关:归到 usage_type =====
|
||
'are_you_using_it_for_business_or_personal_use' => 'usage_type',
|
||
'business_use_or_personal_use' => 'usage_type',
|
||
'personal_use_or_business_use' => 'usage_type',
|
||
|
||
// ===== 座位数相关 =====
|
||
'do_you_want_to_buy_six_seats_or_seven_seats' => 'seat_preference',
|
||
|
||
// ===== AVATR 11 =====
|
||
'which_model_of_the_avatr_11_do_you_want_to_purchase' => 'car_model',
|
||
'do_you_want_to_buy_a_four-wheel_drive_or_rear-wheel_drive_car' => 'drive_type',
|
||
|
||
// ===== 中文表头兼容 =====
|
||
'创建时间' => 'fb_created_time', // 或者 'created_time' -> 再转 fb_created_time
|
||
'姓名' => 'full_name',
|
||
'邮箱' => 'email',
|
||
'电话号码' => 'phone',
|
||
'WhatsApp 电话号码' => 'phone', // 或者你表有 whatsapp 字段就映射到 whatsapp
|
||
// '备用电话号码' => 'phone_backup', // 如果表没有这个字段,建议放 extra_data
|
||
// '来源' => 'platform', // 不确定含义也可以放 extra_data
|
||
// '表单' => 'form_name', // 或 extra_data
|
||
// '渠道' => 'lead_channel', // 或 extra_data(因为你 fb_leads.channel 已由配置写入)
|
||
// '阶段' => 'lead_status', // 或 extra_data
|
||
// '所有者' => 'owner', // 建议 extra_data
|
||
// '标签' => 'tags', // 建议 extra_data
|
||
];
|
||
|
||
/**
|
||
* 宽松模式下仍然强制要求存在的“核心 DB 字段”
|
||
* 你说的:联系人、电话、邮箱、国家、渠道
|
||
* - country / channel 不是从 Excel 映射,而是你代码里根据电话识别/配置注入,所以这里不用要求
|
||
*/
|
||
protected array $requiredDbFields = [
|
||
'full_name',
|
||
'phone',
|
||
'email',
|
||
];
|
||
|
||
/**
|
||
* 表头别名:标准化后表头 => headerMap里的key
|
||
*/
|
||
protected array $aliases = [
|
||
// ===== full_name =====
|
||
'name' => 'full_name',
|
||
'full_name' => 'full_name',
|
||
'full_name_(full_name)' => 'full_name',
|
||
'full_name_(full_name_)' => 'full_name',
|
||
'full_name_full_name' => 'full_name',
|
||
'full name' => 'full_name', // 万一没被替换成 _
|
||
'full_name(full_name)' => 'full_name',
|
||
'姓名' => 'full_name',
|
||
'姓名_full_name' => 'full_name',
|
||
'姓名(full_name)' => 'full_name',
|
||
|
||
// ===== phone =====
|
||
'phone' => 'phone',
|
||
'phone_number' => 'phone_number',
|
||
'phone number' => 'phone_number',
|
||
'telephone' => 'phone',
|
||
'tel' => 'phone',
|
||
'mobile' => 'phone',
|
||
'mobile_number' => 'phone',
|
||
'手机号' => 'phone',
|
||
'电话' => 'phone',
|
||
'电话号码' => 'phone',
|
||
'whatsapp' => 'phone', // 有的表单把 whatsapp 当电话
|
||
|
||
// ===== email =====
|
||
'email' => 'email',
|
||
'e-mail' => 'email',
|
||
'mail' => 'email',
|
||
'邮箱' => 'email',
|
||
'电子邮箱' => 'email',
|
||
];
|
||
|
||
|
||
/**
|
||
* 返回所有支持的 Excel 表头(标准化后的 key)
|
||
*/
|
||
public function getSupportedHeaders(): array
|
||
{
|
||
return array_keys($this->headerMap);
|
||
}
|
||
|
||
/**
|
||
* 单个表头映射:原始表头 -> DB 字段名
|
||
*/
|
||
public function mapHeader(string $rawHeader): ?string
|
||
{
|
||
$key = $this->normalizeHeader($rawHeader);
|
||
$key = $this->aliases[$key] ?? $key;
|
||
return $this->headerMap[$key] ?? null;
|
||
}
|
||
|
||
/**
|
||
* 旧的严格校验:保留(有时你可能还想用来排查某个表单的字段)
|
||
* - 有任何不认识的表头,就抛 BusinessException
|
||
*/
|
||
public function validateHeaders(array $rawHeaders): void
|
||
{
|
||
$unsupported = [];
|
||
|
||
foreach ($rawHeaders as $header) {
|
||
$header = (string)$header;
|
||
$key = $this->normalizeHeader($header);
|
||
|
||
// 允许空表头(多余空列)
|
||
if ($key === '') {
|
||
continue;
|
||
}
|
||
|
||
if (!isset($this->headerMap[$key])) {
|
||
$unsupported[] = $header;
|
||
}
|
||
}
|
||
|
||
if (!empty($unsupported)) {
|
||
$msg = 'Excel 表头包含未兼容字段:' . implode('、', $unsupported)
|
||
. '。请检查这些字段是否需要加入映射配置。';
|
||
throw new BusinessException($msg);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ✅ 宽松模式:根据第一行表头,构建映射
|
||
*
|
||
* 返回结构:
|
||
* [
|
||
* 'colToField' => [ 'A' => 'email', 'B' => 'phone', ... ],
|
||
* 'unknownCols' => [ 'C' => 'some_question?', ... ], // 未映射字段(后续可进富文本/extra_data)
|
||
* ]
|
||
*/
|
||
public function buildColumnFieldMap(array $rawHeaders): array
|
||
{
|
||
$colToField = [];
|
||
$unknownCols = [];
|
||
|
||
foreach ($rawHeaders as $index => $header) {
|
||
$raw = (string)$header;
|
||
$key = $this->normalizeHeader($raw);
|
||
|
||
if ($key === '') {
|
||
continue;
|
||
}
|
||
|
||
// $field = $this->headerMap[$key] ?? null;
|
||
$aliasKey = $this->aliases[$key] ?? $key;
|
||
$field = $this->headerMap[$aliasKey] ?? null;
|
||
|
||
if ($field === null) {
|
||
// 宽松:记录未知列(保留原始表头用于展示)
|
||
$unknownCols[$index] = trim($raw) !== '' ? trim($raw) : $key;
|
||
continue;
|
||
}
|
||
|
||
$colToField[$index] = $field;
|
||
}
|
||
|
||
// 核心字段校验:缺少就终止(保证你说的“联系人/电话/邮箱没问题”)
|
||
$this->assertRequiredFields($colToField);
|
||
|
||
return [
|
||
'colToField' => $colToField,
|
||
'unknownCols' => $unknownCols,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 核心字段校验:要求必须能映射出 requiredDbFields
|
||
*/
|
||
protected function assertRequiredFields(array $colToField): void
|
||
{
|
||
$fields = array_values($colToField);
|
||
foreach ($this->requiredDbFields as $req) {
|
||
if (!in_array($req, $fields, true)) {
|
||
throw new BusinessException("Excel 表头缺少必要字段映射:{$req}(请补充映射或别名)");
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 表头标准化:
|
||
* - 去 BOM
|
||
* - trim
|
||
* - ✅ 统一处理尾部多余的 ? / _(解决 xxx?_ 与 xxx?)
|
||
*
|
||
* 注意:为了避免误伤中文表头,这里默认不做 strtolower / 空格替换。
|
||
* 若你后续想增强,可在注释处开启。
|
||
*/
|
||
protected function normalizeHeader(string $header): string
|
||
{
|
||
// 去掉 UTF-8 BOM
|
||
$header = preg_replace('/^\xEF\xBB\xBF/', '', $header);
|
||
$header = trim($header);
|
||
if ($header === '') return '';
|
||
|
||
// 统一全角括号
|
||
$header = str_replace(['(',')'], ['(',')'], $header);
|
||
|
||
// 小写化(对英文有效,中文不受影响)
|
||
$header = mb_strtolower($header);
|
||
|
||
// 把括号内容保留下来,但用下划线连接: "姓名(Full name)" -> "姓名_full_name"
|
||
$header = preg_replace('/\((.*?)\)/', '_$1', $header);
|
||
|
||
// 空白转下划线
|
||
$header = preg_replace('/\s+/', '_', $header);
|
||
|
||
// 连续下划线压缩
|
||
$header = preg_replace('/_+/', '_', $header);
|
||
|
||
// 去掉首尾下划线
|
||
$header = trim($header, '_');
|
||
|
||
// ✅ 去掉末尾连续的 "_" / "?"
|
||
$header = rtrim($header, "_?");
|
||
|
||
return $header;
|
||
}
|
||
|
||
}
|