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', ]; /** * 宽松模式下仍然强制要求存在的“核心 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; } }