From eea13a2a587ae07483a5d11ab622f7611a16d0b0 Mon Sep 17 00:00:00 2001 From: mzl Date: Fri, 12 Dec 2025 17:52:20 +0800 Subject: [PATCH] Merge branch 'develop' # Conflicts: # app/command/InquiryImportFbCommand.php # app/inquiry/utils/FbLeadHeaderMapper.php --- app/command/InquiryImportFbCommand.php | 3 + app/inquiry/service/FbFileImportService.php | 201 ++++++++++++++++ app/inquiry/service/FbLeadSyncService.php | 240 +++++++++++++++++++ app/inquiry/service/InquiryAssignService.php | 167 +++++++++++++ app/inquiry/utils/FbLeadHeaderMapper.php | 128 +++++++--- app/process/InquiryAutoProcess.php | 97 ++++++++ app/queue/redis/PermAgt.php | 4 + config/inquiry_channel.php | 69 ++++++ config/process.php | 8 + 9 files changed, 882 insertions(+), 35 deletions(-) create mode 100644 app/inquiry/service/FbFileImportService.php create mode 100644 app/inquiry/service/FbLeadSyncService.php create mode 100644 app/inquiry/service/InquiryAssignService.php create mode 100644 app/process/InquiryAutoProcess.php create mode 100644 config/inquiry_channel.php diff --git a/app/command/InquiryImportFbCommand.php b/app/command/InquiryImportFbCommand.php index 3c9a3c0..e90884c 100644 --- a/app/command/InquiryImportFbCommand.php +++ b/app/command/InquiryImportFbCommand.php @@ -11,6 +11,9 @@ use PhpOffice\PhpSpreadsheet\IOFactory; use app\inquiry\utils\FbLeadHeaderMapper; use support\exception\BusinessException; +/** + * 从Excel文件导入fb_leads + */ class InquiryImportFbCommand extends Command { protected static $defaultName = 'iq-fb'; diff --git a/app/inquiry/service/FbFileImportService.php b/app/inquiry/service/FbFileImportService.php new file mode 100644 index 0000000..c598158 --- /dev/null +++ b/app/inquiry/service/FbFileImportService.php @@ -0,0 +1,201 @@ +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; + } +} diff --git a/app/inquiry/service/FbLeadSyncService.php b/app/inquiry/service/FbLeadSyncService.php new file mode 100644 index 0000000..3f7432d --- /dev/null +++ b/app/inquiry/service/FbLeadSyncService.php @@ -0,0 +1,240 @@ +where('sync_status', 0) + ->where('channel', $channelId) + ->get(); + + if ($leads->isEmpty()) { + return 0; + } + + $now = time(); + + // 转数组,后面循环更快 + $leadArrList = []; + foreach ($leads as $lead) { + $leadArrList[] = (array)$lead; + } + + // ① 收集本批手机号(去重) + $phoneList = $this->collectPhonesFromLeads($leadArrList); + + // ② 批量查出 inquiry_log 已存在手机号集合(索引加持) + $existingTelSet = $this->getExistingTelSet($phoneList); + + $insert = []; + $idsToMarkProcessed = []; // 插入成功 or 重复跳过,统一标记已处理 + $insertCount = 0; + + Db::transaction(function () use ( + $leadArrList, $existingTelSet, $enterUserId, $channelId, $now, + &$insert, &$idsToMarkProcessed, &$insertCount + ) { + foreach ($leadArrList as $lead) { + $leadId = (int)($lead['id'] ?? 0); + + $tel = trim((string)($lead['phone'] ?? '')); + + // 如果手机号存在于 inquiry_log,跳过同步(但要标记 fb_leads 已处理) + if ($tel !== '' && isset($existingTelSet[$tel])) { + if ($leadId > 0) { + $idsToMarkProcessed[] = $leadId; + } + continue; + } + + $insert[] = $this->mapLeadToInquiry($lead, $now, $enterUserId, $channelId); + + if ($leadId > 0) { + $idsToMarkProcessed[] = $leadId; + } + } + + if (!empty($insert)) { + Db::table('inquiry_log')->insert($insert); + $insertCount = count($insert); + } + + if (!empty($idsToMarkProcessed)) { + Db::table('fb_leads') + ->whereIn('id', $idsToMarkProcessed) + ->update([ + 'sync_status' => 1, + 'updated_at' => $now, + ]); + } + }); + + return $insertCount; + } + + /** + * 从本批 leads 中收集手机号(tel),去重后返回 + */ + protected function collectPhonesFromLeads(array $leadArrList): array + { + $phones = []; + foreach ($leadArrList as $lead) { + $tel = trim((string)($lead['phone'] ?? '')); + if ($tel !== '') { + // 先用原值精确匹配(你后续要做归一化再改这里即可) + $phones[$tel] = true; + } + } + return array_keys($phones); + } + + /** + * 批量查出 inquiry_log 已存在手机号集合(返回 set:tel => true) + * - 自动分块 whereIn,避免 SQL 过长 + * - 建议给 inquiry_log.tel 加索引 idx_tel + */ + protected function getExistingTelSet(array $phoneList, int $chunkSize = 800): array + { + $phoneList = array_values(array_unique(array_filter(array_map('trim', $phoneList)))); + if (empty($phoneList)) { + return []; + } + + $existingTelSet = []; + + foreach (array_chunk($phoneList, $chunkSize) as $chunk) { + $rows = Db::table('inquiry_log') + ->select('tel') + ->where('tel', '<>', '') + ->whereIn('tel', $chunk) + ->get(); + + foreach ($rows as $r) { + $t = trim((string)($r->tel ?? '')); + if ($t !== '') { + $existingTelSet[$t] = true; + } + } + } + + return $existingTelSet; + } + + /** + * 映射一条 fb_leads -> inquiry_log + */ + protected function mapLeadToInquiry(array $lead, int $now, int $enterUserId, int $channelId): array + { + $email = (string)($lead['email'] ?? ''); + $tel = (string)($lead['phone'] ?? ''); + $wa = $tel; + + $countryId = PhoneCountryHelper::detectCountryId($tel ?: $wa); + + return [ + 'order_no' => $this->orderNum(), + 'enter_user_id' => $enterUserId, + 'recipient' => 0, + 'quality' => 2, + 'channel' => $channelId, // 👈 inquiry_log 里同样用这个渠道ID + + 'email' => $email, + 'tel' => $tel, + 'whatsapp' => $wa, + 'country' => $countryId ?: 0, + 'contacts' => (string)($lead['full_name'] ?? ''), + + 'content' => $this->buildContentHtml($lead), + + 'status' => 0, + 'r_time' => 0, + 't_time' => 0, + 'sync_crm' => 0, + + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + protected function orderNum(): string + { + return 'SN' . date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT); + } + + /** + * 富文本内容拼装 —— 基于你之前的版本 + */ + protected function buildContentHtml(array $lead): string + { + $e = function ($v) { + return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + }; + + $lines = []; + + if (!empty($lead['car_model'])) { + $lines[] = '

意向车型:' . $e($lead['car_model']) . '

'; + } + if (!empty($lead['car_quantity']) || !empty($lead['car_quantity_text'])) { + $qty = $lead['car_quantity'] ?? $lead['car_quantity_text'] ?? ''; + $lines[] = '

意向数量:' . $e($qty) . '

'; + } + if (!empty($lead['favorite_color'])) { + $lines[] = '

意向颜色:' . $e($lead['favorite_color']) . '

'; + } + if (!empty($lead['usage_type'])) { + $lines[] = '

用途:' . $e($lead['usage_type']) . '

'; + } + if (!empty($lead['seat_preference'])) { + $lines[] = '

座位数偏好:' . $e($lead['seat_preference']) . '

'; + } + if (!empty($lead['drive_type'])) { + $lines[] = '

驱动形式偏好:' . $e($lead['drive_type']) . '

'; + } + if (!empty($lead['extra_data'])) { + $extra = json_decode((string)$lead['extra_data'], true); + if (is_array($extra) && $extra) { + $lines[] = '
'; + $lines[] = '

补充问答信息:

'; + foreach ($extra as $k => $v) { + $lines[] = '

' . $e($k) . ':' . $e($v) . '

'; + } + } + } + + + $lines[] = '
'; + $lines[] = '

广告与表单信息:

'; + $lines[] = '

平台:' . $e($lead['platform'] ?? '') . '

'; + $lines[] = '

Campaign:' . $e($lead['campaign_name'] ?? '') . ' (#' . $e($lead['campaign_id'] ?? '') . ')

'; + $lines[] = '

Adset:' . $e($lead['adset_name'] ?? '') . ' (#' . $e($lead['adset_id'] ?? '') . ')

'; + $lines[] = '

Ad:' . $e($lead['ad_name'] ?? '') . ' (#' . $e($lead['ad_id'] ?? '') . ')

'; + $lines[] = '

Form:' . $e($lead['form_name'] ?? '') . ' (#' . $e($lead['form_id'] ?? '') . ')

'; + + $lines[] = '
'; + $lines[] = '

Meta 信息:

'; + $lines[] = '

Lead ID:' . $e($lead['fb_lead_id'] ?? '') . '

'; + $lines[] = '

FB 创建时间原始值:' . $e($lead['fb_created_time'] ?? '') . '

'; + + if (!empty($lead['inbox_url'])) { + $url = $e($lead['inbox_url']); + $lines[] = '

Inbox 链接:' . $url . '

'; + } + + return implode("\n", $lines); + } +} diff --git a/app/inquiry/service/InquiryAssignService.php b/app/inquiry/service/InquiryAssignService.php new file mode 100644 index 0000000..7fcdf10 --- /dev/null +++ b/app/inquiry/service/InquiryAssignService.php @@ -0,0 +1,167 @@ +normalizeUserIds($salesUserIds); + if (empty($defaultUserIds)) { + throw new BusinessException('分配失败:业务员列表为空'); + } + + $inquiries = Db::table('inquiry_log') + ->where('status', 0) + ->where('recipient', 0) + ->where('channel', $channelId) + ->orderBy('id') + ->limit($limit) + ->get(); + + if ($inquiries->isEmpty()) { + return 0; + } + + $now = time(); + $count = 0; + + foreach ($inquiries as $row) { + $inquiryId = (int)$row->id; + + // ✅ 关键:按规则选业务员池(命中规则就用规则池,否则用默认池) + [$poolUserIds, $rrKey] = $this->pickSalesPoolByRules( + (int)($row->country ?? 0), + $defaultUserIds, + $roundRobinKey, + $assignRules + ); + + // ✅ 用“对应池子自己的 rrKey”轮询拿业务员 + $recipientUserId = $this->nextRecipientId($poolUserIds, $rrKey); + + Db::transaction(function () use ($inquiryId, $recipientUserId, $now, &$count) { + $affected = Db::table('inquiry_log') + ->where('id', $inquiryId) + ->update([ + 'recipient' => $recipientUserId, + 'status' => 2, + 'r_time' => $now, + 't_time' => $now, + 'updated_at' => $now, + ]); + + if ($affected === 0) { + // 可能被其他进程抢先处理了,直接跳过 + return; + } + + $res = [ + 'id' => $inquiryId, + 'recipient' => $recipientUserId, + 'dispatched_at' => $now, + 'source' => 'fb_leads_inquiry', + ]; + + RedisQueueRedis::send('inquiry-to-crm-new', $res); + + $count++; + }); + } + + return $count; + } + + /** + * 轮询获取下一个业务员ID + */ + protected function nextRecipientId(array $userIds, string $groupKey): int + { + $userIds = $this->normalizeUserIds($userIds); + if (empty($userIds)) { + throw new BusinessException('轮询分配失败:业务员列表为空'); + } + + $count = count($userIds); + $key = 'inquiry:assign:round_robin:' . $groupKey; + + $val = Redis::incr($key); // 原子自增 + $index = ($val - 1) % $count; + + return $userIds[$index]; + } + + /** + * 按国家规则挑选“业务员池 + 轮询key” + * 命中 rules 则返回规则池,否则返回默认池 + * + * rules 示例: + * [ + * [ + * 'countries' => [1,2,3], + * 'sales_user_ids' => [63,64], + * 'round_robin_key' => 'fb_xxx_ru', + * ], + * ] + * + * @return array{0:int[], 1:string} [userIds, rrKey] + */ + protected function pickSalesPoolByRules( + int $countryId, + array $defaultUserIds, + string $defaultRrKey, + array $rules + ): array { + foreach ($rules as $rule) { + if (!is_array($rule)) continue; + + $countries = $rule['countries'] ?? []; + if (!is_array($countries) || empty($countries)) continue; + + if (!in_array($countryId, array_map('intval', $countries), true)) { + continue; + } + + $userIds = $this->normalizeUserIds($rule['sales_user_ids'] ?? []); + if (!empty($userIds)) { + // 规则池必须使用自己的 rrKey,否则会和默认池“抢指针” + $rrKey = (string)($rule['round_robin_key'] ?? ($defaultRrKey . '_rule_' . md5(json_encode($countries)))); + return [$userIds, $rrKey]; + } + } + + return [$this->normalizeUserIds($defaultUserIds), $defaultRrKey]; + } + + /** + * 统一清洗业务员ID数组:转 int、去重、过滤 <=0 + */ + protected function normalizeUserIds(array $ids): array + { + $ids = array_values(array_filter(array_map('intval', $ids), fn($v) => $v > 0)); + $ids = array_values(array_unique($ids)); + return $ids; + } +} diff --git a/app/inquiry/utils/FbLeadHeaderMapper.php b/app/inquiry/utils/FbLeadHeaderMapper.php index ed048ed..60b17dc 100644 --- a/app/inquiry/utils/FbLeadHeaderMapper.php +++ b/app/inquiry/utils/FbLeadHeaderMapper.php @@ -8,7 +8,7 @@ class FbLeadHeaderMapper { /** * Excel 原始表头 => DB 字段名 - * 注意:key 用的是“原始表头字符串”(包括下划线) + * 注意:这里的 key 会经过 normalizeHeader() 处理后再匹配 */ protected array $headerMap = [ // ===== 基础公共字段 ===== @@ -33,37 +33,53 @@ class FbLeadHeaderMapper 'inbox_url' => 'inbox_url', // ===== 车型相关:全部归到 car_model ===== - 'which_model_of_this_car_would_you_like_to_purchase?_' => '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', // 这里也归到车型/版本字段 + '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 ===== - 'how_many_cars_would_you_like_to_buy?_' => 'car_quantity', - 'how_many_cars_would_you_like_to_buy?' => 'car_quantity', - '_how_many_cars_would_you_like_to_buy?' => 'car_quantity', - 'how_many_cars_do_you_buy?' => 'car_quantity', - 'how_many_cars_do_you_want_to_purchase?' => 'car_quantity', - 'how_many_cars_do_you_want_to_buy?' => 'car_quantity', - 'what_quantity_do_you_want_to_buy?' => 'car_quantity', + // ===== 数量相关:建议归到 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_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', + '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', + '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', ]; /** - * 返回所有支持的 Excel 表头 + * 宽松模式下仍然强制要求存在的“核心 DB 字段” + * 你说的:联系人、电话、邮箱、国家、渠道 + * - country / channel 不是从 Excel 映射,而是你代码里根据电话识别/配置注入,所以这里不用要求 + */ + protected array $requiredDbFields = [ + 'full_name', + 'phone', + 'email', + ]; + + /** + * 返回所有支持的 Excel 表头(标准化后的 key) */ public function getSupportedHeaders(): array { @@ -76,12 +92,11 @@ class FbLeadHeaderMapper public function mapHeader(string $rawHeader): ?string { $key = $this->normalizeHeader($rawHeader); - return $this->headerMap[$key] ?? null; } /** - * 校验一整行表头: + * 旧的严格校验:保留(有时你可能还想用来排查某个表单的字段) * - 有任何不认识的表头,就抛 BusinessException */ public function validateHeaders(array $rawHeaders): void @@ -104,42 +119,74 @@ class FbLeadHeaderMapper if (!empty($unsupported)) { $msg = 'Excel 表头包含未兼容字段:' . implode('、', $unsupported) - . '。请检查这些字段是否需要加入映射配置。'; + . '。请检查这些字段是否需要加入映射配置。'; throw new BusinessException($msg); } } /** - * 根据第一行表头,构建 “列索引 => DB 字段” 映射 + * ✅ 宽松模式:根据第一行表头,构建映射 + * + * 返回结构: + * [ + * 'colToField' => [ 'A' => 'email', 'B' => 'phone', ... ], + * 'unknownCols' => [ 'C' => 'some_question?', ... ], // 未映射字段(后续可进富文本/extra_data) + * ] */ public function buildColumnFieldMap(array $rawHeaders): array { - // 先做一遍校验(发现问题直接抛异常) - $this->validateHeaders($rawHeaders); - - $colToField = []; + $colToField = []; + $unknownCols = []; foreach ($rawHeaders as $index => $header) { - $key = $this->normalizeHeader((string)$header); + $raw = (string)$header; + $key = $this->normalizeHeader($raw); + if ($key === '') { continue; } $field = $this->headerMap[$key] ?? null; + if ($field === null) { - continue; // 理论上不会到这儿,双保险 + // 宽松:记录未知列(保留原始表头用于展示) + $unknownCols[$index] = trim($raw) !== '' ? trim($raw) : $key; + continue; } $colToField[$index] = $field; } - return $colToField; + // 核心字段校验:缺少就终止(保证你说的“联系人/电话/邮箱没问题”) + $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,保持和 headerMap 中 key 完全一致 - * 如果后面再出现“有空格/大小写”的版本,我们可以在这里做 strtolower / 替换空格 为 _ + * - 去 BOM + * - trim + * - ✅ 统一处理尾部多余的 ? / _(解决 xxx?_ 与 xxx?) + * + * 注意:为了避免误伤中文表头,这里默认不做 strtolower / 空格替换。 + * 若你后续想增强,可在注释处开启。 */ protected function normalizeHeader(string $header): string { @@ -149,6 +196,17 @@ class FbLeadHeaderMapper // 去掉前后空白 $header = trim($header); + if ($header === '') { + return ''; + } + + // 可选增强(默认不开启,避免影响中文): + // $header = mb_strtolower($header); + // $header = preg_replace('/\s+/', '_', $header); + + // ✅ 去掉末尾连续的 "_" / "?" + $header = rtrim($header, "_?"); + return $header; } } diff --git a/app/process/InquiryAutoProcess.php b/app/process/InquiryAutoProcess.php new file mode 100644 index 0000000..3b8eec1 --- /dev/null +++ b/app/process/InquiryAutoProcess.php @@ -0,0 +1,97 @@ +interval = $interval; + } + + public function onWorkerStart() + { + // 每 interval 秒执行一次 + Timer::add($this->interval, function () { + $this->runOnceForAllChannels(); + }); + } + + /** + * 一轮执行:扫描 config/inquiry_channel.php 中所有渠道,逐个跑一遍流水线 + */ + protected function runOnceForAllChannels() + { + $configAll = config('inquiry_channel'); + if (empty($configAll) || !is_array($configAll)) { + Log::error('InquiryAutoProcess: config("inquiry_channel") 为空或格式不正确'); + return; + } + + foreach ($configAll as $channelKey => $cfg) { + if (!is_array($cfg)) { + Log::error("InquiryAutoProcess: 渠道 {$channelKey} 配置不是数组,已跳过"); + continue; + } + + $this->runForChannel($channelKey, $cfg); + } + } + + /** + * 对单个渠道执行:导入 Excel -> fb_leads -> inquiry_log -> 分配 + 推队列 + */ + protected function runForChannel(string $channelKey, array $cfg) + { + $watchDir = public_path($cfg['watch_dir'] ?? 'excel'); + $enterUserId = (int)($cfg['enter_user_id'] ?? 0); + $channelId = (int)($cfg['channel_id'] ?? 0); + $salesUserIds = $cfg['sales_user_ids'] ?? []; + $roundRobinKey = $cfg['round_robin_key'] ?? $channelKey; + + if ($enterUserId <= 0 || $channelId <= 0) { + Log::error("InquiryAutoProcess: 渠道 {$channelKey} 配置缺少正确的 enter_user_id 或 channel_id,已跳过"); + return; + } + + $importService = new FbFileImportService(); + $syncService = new FbLeadSyncService(); + $assignService = new InquiryAssignService(); + + try { + // 1) 导入该渠道目录下的 Excel 到 fb_leads(fb_leads.channel = 渠道ID) + $filesImported = $importService->importDir($watchDir, $channelId); + + // 2) 只同步该渠道的 fb_leads -> inquiry_log(channel 过滤) + $synced = $syncService->syncToInquiryLog($enterUserId, $channelId); + + // 3) 只分配该渠道的 inquiry_log(channel 过滤) + // $assigned = $assignService->assignBatch($salesUserIds, $roundRobinKey, $channelId, 100); + $assigned = $assignService->assignBatch( + $salesUserIds, + $roundRobinKey, + $channelId, + $cfg['assign_rules'] ?? [], + 100 + ); + + Log::info("InquiryAutoProcess[{$channelKey}] files={$filesImported}, synced={$synced}, assigned={$assigned}"); + } catch (\Throwable $e) { + Log::error( + 'InquiryAutoProcess[' . $channelKey . '] error: ' . + $e->getMessage() . "\n" . $e->getTraceAsString() + ); + } + } +} diff --git a/app/queue/redis/PermAgt.php b/app/queue/redis/PermAgt.php index 42a4898..7c570cb 100644 --- a/app/queue/redis/PermAgt.php +++ b/app/queue/redis/PermAgt.php @@ -53,6 +53,10 @@ class PermAgt implements Consumer 'role_id' => 2 ]; $systems = [ + [ + 'user_id' => $userId, + 'system_id' => 1 + ], [ 'user_id' => $userId, 'system_id' => 2 diff --git a/config/inquiry_channel.php b/config/inquiry_channel.php new file mode 100644 index 0000000..35e7af4 --- /dev/null +++ b/config/inquiry_channel.php @@ -0,0 +1,69 @@ + [ + // 监听目录(相对 public_path) + 'watch_dir' => 'excel/fb_paika_shemei', + + // 清洗到 inquiry_log 时使用的录入人 & 渠道 + 'enter_user_id' => 186, + 'channel_id' => 10, // 👈 这里就是你系统里的渠道ID + + // 参与轮询分配的业务员 ID 列表 + 'sales_user_ids' => [ + 63, + 67, + 93, + 108, + 132, + 143, + 145, + 157, + 160, + 168, + 172, + 173, + 191, + 193, + 194, + 195, + 196, + 197, + 200 + ], + + // 轮询用的分组 key(Redis 记指针用) + 'round_robin_key'=> 'fb_paika_shemei_inquiry', + + // ✅ 渠道内分配规则(按顺序匹配,先命中先用) + 'assign_rules' => [ + [ + 'name' => '加纳池', + 'countries' => [82], + 'sales_user_ids' => [93], + 'round_robin_key' => 'fb_paika_shemei_gh', + ] + ], + ], + + // 合塑社媒 + 'fb_hesu_shemei' => [ + 'watch_dir' => 'excel/fb_hesu_shemei', + 'enter_user_id' => 186, + 'channel_id' => 6, + 'sales_user_ids' => [ + 22, 23, 34, 44, 49, 71, 72, 73, 96, 104, 125, 135, 155 + ], + 'round_robin_key'=> 'fb_hesu_shemei_inquiry', + // ✅ 渠道内分配规则(按顺序匹配,先命中先用) + 'assign_rules' => [ + [ + 'name' => '加纳池', + 'countries' => [82], + 'sales_user_ids' => [44], + 'round_robin_key' => 'fb_hesu_shemei_gh', + ] + ], + ], +]; diff --git a/config/process.php b/config/process.php index 8022ef3..e04f409 100644 --- a/config/process.php +++ b/config/process.php @@ -69,4 +69,12 @@ return [ 'checkweb' => [ 'handler' => app\process\CheckWeb::class ], + 'inquiry_auto_all' => [ + 'handler' => app\process\InquiryAutoProcess::class, + 'count' => 1, + 'reloadable' => true, + 'constructor' => [ + 'interval' => 30, // 每 30 秒跑一轮所有渠道 + ], + ], ];