diff --git a/app/inquiry/command/AssignInquiryCommand.php b/app/inquiry/command/AssignInquiryCommand.php
new file mode 100644
index 0000000..f743335
--- /dev/null
+++ b/app/inquiry/command/AssignInquiryCommand.php
@@ -0,0 +1,145 @@
+ $v > 0
+ ));
+
+ if (empty($userIds)) {
+ throw new BusinessException('SALES_USER_IDS 业务员列表不能为空,请先在命令中配置.');
+ }
+
+ // 取出“待分配”的询盘(你可以根据自己系统调整条件)
+ // 当前策略:status = 0 且 recipient = 0 的视为未分配
+ $inquiries = Db::table('inquiry_log')
+ ->where('enter_user_id', 186)
+ ->where('channel', 10)
+ ->where('status', 0)
+ ->where('recipient', 0)
+ ->where('id', '>', 34426)
+ ->orderBy('id')
+ ->limit(static::BATCH_LIMIT)
+ ->get();
+
+ if ($inquiries->isEmpty()) {
+ $output->writeln('没有需要分配的询盘(status=0 且 recipient=0)');
+ return self::SUCCESS;
+ }
+
+ $now = time();
+ $count = 0;
+
+ foreach ($inquiries as $row) {
+ $inquiryId = (int)$row->id;
+
+ // 轮询取出一个业务员 ID
+ $recipientUserId = $this->nextRecipientId($userIds, static::ROUND_ROBIN_GROUP_KEY);
+
+ // 对每一条记录单独事务,避免单条异常拖垮全部
+ Db::transaction(function () use ($inquiryId, $recipientUserId, $now, $output, &$count) {
+ // 1)更新 inquiry_log
+ $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) {
+ // 被其他进程抢先更新也有可能,这里只打印提示不中断整个批次
+ $output->writeln("询盘 {$inquiryId} 未更新(可能已被处理)");
+ return;
+ }
+
+ // 2)构造队列 payload(后续 CRM 消费时至少知道 ID)
+ $res = [
+ 'id' => $inquiryId,
+ 'recipient' => $recipientUserId,
+ 'dispatched_at' => $now,
+ 'source' => 'fb_leads_inquiry',
+ ];
+
+ // 3)推送到 CRM 队列
+ RedisQueueRedis::send('inquiry-to-crm-new', $res);
+
+ $count++;
+ $output->writeln("已分配询盘 {$inquiryId} 给业务员 {$recipientUserId},并推送到队列");
+ });
+ }
+
+ $output->writeln("本次共分配成功 {$count} 条询盘");
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * 轮询获取“下一位业务员 ID”
+ *
+ * @param int[] $userIds
+ * @param string $groupKey
+ * @return int
+ * @throws BusinessException
+ */
+ protected function nextRecipientId(array $userIds, string $groupKey = 'default'): int
+ {
+ $userIds = array_values(array_filter(array_map('intval', $userIds), fn($v) => $v > 0));
+ if (empty($userIds)) {
+ throw new BusinessException('轮询分配失败:业务员列表为空');
+ }
+
+ $count = count($userIds);
+ $key = 'inquiry:assign:round_robin:' . $groupKey;
+
+ // Redis INCR 是原子操作,适合并发下使用
+ $val = Redis::incr($key); // 第一次会从 0 变成 1
+
+ // 换算为从 0 开始的下标
+ $index = ($val - 1) % $count;
+
+ return $userIds[$index];
+ }
+}
diff --git a/app/inquiry/command/FbLeadToInquiryCommand.php b/app/inquiry/command/FbLeadToInquiryCommand.php
new file mode 100644
index 0000000..424417a
--- /dev/null
+++ b/app/inquiry/command/FbLeadToInquiryCommand.php
@@ -0,0 +1,164 @@
+where('sync_status', 0)
+ ->get();
+
+ if ($leads->isEmpty()) {
+ $output->writeln('没有需要同步的 fb_leads(sync_status = 0)');
+ return self::SUCCESS;
+ }
+
+ $now = time();
+
+ Db::transaction(function () use ($leads, $now, $output) {
+ $insert = [];
+ $ids = [];
+
+ foreach ($leads as $lead) {
+ $leadArr = (array)$lead;
+ $insert[] = $this->mapLeadToInquiry($leadArr, $now);
+ // 假设 fb_leads 的主键字段名为 id
+ $ids[] = $leadArr['id'];
+ }
+
+ if ($insert) {
+ // 1) 写入 inquiry_log
+ Db::table('inquiry_log')->insert($insert);
+
+ // 2) 回写 fb_leads 的同步状态
+ Db::table('fb_leads')
+ ->whereIn('id', $ids)
+ ->update([
+ 'sync_status' => 1,
+ 'updated_at' => $now,
+ ]);
+
+ $output->writeln('已导入 ' . count($insert) . ' 条数据到 inquiry_log,并更新 fb_leads.sync_status = 1');
+ }
+ });
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * 将一条 fb_leads 记录映射为 inquiry_log 记录
+ */
+ protected function mapLeadToInquiry(array $lead, int $now): array
+ {
+ // 联系方式
+ $email = (string)($lead['email'] ?? '');
+ $tel = (string)($lead['phone'] ?? '');
+ $wa = $tel; // 先用电话号码当 WhatsApp
+
+ // 根据电话或 WhatsApp 区号识别国家
+ $countryId = PhoneCountryHelper::detectCountryId($tel ?: $wa);
+
+ // 所有默认值按你的要求设置
+ $data = [
+ 'order_no' => $this->orderNum(),
+ 'enter_user_id' => static::DEFAULT_ENTER_USER_ID,
+ 'recipient' => 0,
+ 'quality' => 2,
+ 'channel' => static::DEFAULT_CHANNEL,
+
+ '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,
+ ];
+
+ return $data;
+ }
+
+ /**
+ * 生成订单号
+ */
+ protected function orderNum(): string
+ {
+ return 'SN' . date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
+ }
+
+ /**
+ * 根据 fb_leads 的字段拼出富文本内容
+ */
+ 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']) . '
';
+ }
+
+ $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/command/ImportFbCommand.php b/app/inquiry/command/ImportFbCommand.php
new file mode 100644
index 0000000..1e11d34
--- /dev/null
+++ b/app/inquiry/command/ImportFbCommand.php
@@ -0,0 +1,256 @@
+addOption(
+ 'file',
+ null,
+ InputOption::VALUE_OPTIONAL,
+ '要导入的单个 Excel 文件名(相对 public/excel 的路径,或绝对路径)'
+ )
+ ->addOption(
+ 'dir',
+ null,
+ InputOption::VALUE_OPTIONAL,
+ 'Excel 目录(相对 public/ 的路径)',
+ 'excel' // 默认就是 public/excel
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $fileOption = $input->getOption('file');
+ $dirOption = $input->getOption('dir') ?? 'excel';
+
+ // 基准目录:public/{$dirOption}
+ $baseDir = rtrim(public_path($dirOption), DIRECTORY_SEPARATOR);
+
+ if ($fileOption) {
+ // 如果传的是绝对路径,直接用;否则认为是相对 baseDir 的文件名
+ if ($this->isAbsolutePath($fileOption)) {
+ $filePath = $fileOption;
+ } else {
+ $filePath = $baseDir . DIRECTORY_SEPARATOR . $fileOption;
+ }
+
+ $output->writeln("开始导入单个文件: {$filePath}");
+
+ $this->importOneFile($filePath, $output);
+ } else {
+ // 不指定 file 时,默认导入目录下所有 .xlsx
+ if (!is_dir($baseDir)) {
+ throw new BusinessException("目录不存在:{$baseDir}");
+ }
+
+ $pattern = $baseDir . DIRECTORY_SEPARATOR . '*.xlsx';
+ $files = glob($pattern);
+
+ if (empty($files)) {
+ $output->writeln("目录 {$baseDir} 下没有找到 .xlsx 文件");
+ return self::SUCCESS;
+ }
+
+ $output->writeln("开始批量导入目录: {$baseDir}");
+ foreach ($files as $filePath) {
+ $output->writeln(" -> 文件:{$filePath}");
+ try {
+ $this->importOneFile($filePath, $output);
+ $output->writeln(" 导入成功");
+ } catch (\Throwable $e) {
+ // 某个文件失败,不影响后面的文件
+ $output->writeln(" 导入失败:{$e->getMessage()}");
+ }
+ }
+ }
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * 导入单个 Excel 文件
+ */
+ protected function importOneFile(string $filePath, OutputInterface $output): void
+ {
+ if (!is_file($filePath)) {
+ throw new BusinessException("文件不存在:{$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();
+
+ // 如果有未知表头,这里会直接抛异常中断当前文件
+ $colToField = $mapper->buildColumnFieldMap($headerRow);
+
+ $insertData = [];
+ $now = time();
+
+ foreach ($rows as $row) {
+ $item = [];
+
+ foreach ($colToField as $colIndex => $field) {
+ $raw = $row[$colIndex] ?? null;
+
+ switch ($field) {
+ // 1) is_organic: Excel 里是 true/false,这里转成 0/1
+ case 'is_organic':
+ $item['is_organic'] = $this->normalizeIsOrganic($raw);
+ break;
+
+ // 2) fb_created_time: 统一转成 Y-m-d H:i:s
+ case 'fb_created_time':
+ $item['fb_created_time'] = $this->normalizeFbCreatedTime($raw);
+ break;
+
+ default:
+ if (is_string($raw)) {
+ $raw = trim($raw);
+ }
+ $item[$field] = $raw;
+ break;
+ }
+ }
+
+ // 补充公共字段
+ $item['source_file'] = basename($filePath);
+ $item['created_at'] = $now;
+ $item['updated_at'] = $now;
+
+ // 整行都空就跳过
+ $allNull = true;
+ foreach ($item as $v) {
+ if ($v !== null && $v !== '') {
+ $allNull = false;
+ break;
+ }
+ }
+ if ($allNull) {
+ continue;
+ }
+
+ $insertData[] = $item;
+ }
+
+ if ($insertData) {
+ Db::table('fb_leads')->insert($insertData);
+ } else {
+ $output->writeln(" 文件 {$filePath} 没有有效数据行");
+ }
+ }
+
+ /**
+ * 将 Excel 里的 is_organic 值转换为 0/1
+ */
+ protected function normalizeIsOrganic($value): int
+ {
+ if ($value === null) {
+ return 0;
+ }
+
+ $v = strtolower(trim((string)$value));
+
+ // 认为这些是 “true”
+ $trueValues = ['1', 'true', 'yes', 'y'];
+
+ return in_array($v, $trueValues, true) ? 1 : 0;
+ }
+
+ /**
+ * 统一处理 fb_created_time
+ * Excel 目前是类似 '12/8/25',这里尽量转成 'Y-m-d H:i:s'
+ * 解析失败时返回 null
+ */
+ protected function normalizeFbCreatedTime($value): ?string
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ if ($value instanceof \DateTimeInterface) {
+ return $value->format('Y-m-d H:i:s');
+ }
+
+ $v = trim((string)$value);
+
+ $formats = [
+ 'Y-m-d H:i:s',
+ 'Y-m-d',
+ 'n/j/y', // 12/8/25
+ 'n/j/Y',
+ 'd/m/Y',
+ 'd.m.Y',
+ ];
+
+ foreach ($formats as $fmt) {
+ $dt = \DateTime::createFromFormat($fmt, $v);
+ if ($dt !== false) {
+ return $dt->format('Y-m-d H:i:s');
+ }
+ }
+
+ // 纯数字当时间戳试一下
+ if (is_numeric($v) && strlen($v) >= 9 && strlen($v) <= 11) {
+ $ts = (int)$v;
+ if ($ts > 0) {
+ return date('Y-m-d H:i:s', $ts);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * 判断是否为绝对路径(简单兼容 Windows / Linux)
+ */
+ protected function isAbsolutePath(string $path): bool
+ {
+ // Windows: X:\ 或 \\server\share
+ if (preg_match('/^[a-zA-Z]:\\\\/', $path) || str_starts_with($path, '\\\\')) {
+ return true;
+ }
+
+ // *nix: / 开头
+ return str_starts_with($path, '/');
+ }
+}
+
+/*
+
+php webman tb-db
+# 等价于 php webman tb-db --dir=excel
+# 会遍历 public/excel/*.xlsx 逐个导入
+
+# 相对路径(相对 public/excel)
+php webman tb-db --file="BYD tang_Leads_2025-12-04_2025-12-09.xlsx"
+
+# 或给绝对路径
+php webman tb-db --file="/www/wwwroot/xxx/public/excel/BYD tang_Leads_2025-12-04_2025-12-09.xlsx"
+
+*/
\ No newline at end of file
diff --git a/app/inquiry/utils/FbLeadHeaderMapper.php b/app/inquiry/utils/FbLeadHeaderMapper.php
new file mode 100644
index 0000000..59b1cb5
--- /dev/null
+++ b/app/inquiry/utils/FbLeadHeaderMapper.php
@@ -0,0 +1,154 @@
+ DB 字段名
+ * 注意:key 用的是“原始表头字符串”(包括下划线)
+ */
+ 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_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',
+
+ // ===== 颜色相关:归到 favorite_color =====
+ 'what_color_do_you_like?' => 'favorite_color',
+ 'which_color_would_you_like_to_purchase?' => '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',
+ ];
+
+ /**
+ * 返回所有支持的 Excel 表头
+ */
+ public function getSupportedHeaders(): array
+ {
+ return array_keys($this->headerMap);
+ }
+
+ /**
+ * 单个表头映射:原始表头 -> DB 字段名
+ */
+ public function mapHeader(string $rawHeader): ?string
+ {
+ $key = $this->normalizeHeader($rawHeader);
+
+ 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);
+ }
+ }
+
+ /**
+ * 根据第一行表头,构建 “列索引 => DB 字段” 映射
+ */
+ public function buildColumnFieldMap(array $rawHeaders): array
+ {
+ // 先做一遍校验(发现问题直接抛异常)
+ $this->validateHeaders($rawHeaders);
+
+ $colToField = [];
+
+ foreach ($rawHeaders as $index => $header) {
+ $key = $this->normalizeHeader((string)$header);
+ if ($key === '') {
+ continue;
+ }
+
+ $field = $this->headerMap[$key] ?? null;
+ if ($field === null) {
+ continue; // 理论上不会到这儿,双保险
+ }
+
+ $colToField[$index] = $field;
+ }
+
+ return $colToField;
+ }
+
+ /**
+ * 表头标准化:
+ * 目前只去 BOM + trim,保持和 headerMap 中 key 完全一致
+ * 如果后面再出现“有空格/大小写”的版本,我们可以在这里做 strtolower / 替换空格 为 _
+ */
+ protected function normalizeHeader(string $header): string
+ {
+ // 去掉 UTF-8 BOM
+ $header = preg_replace('/^\xEF\xBB\xBF/', '', $header);
+
+ // 去掉前后空白
+ $header = trim($header);
+
+ return $header;
+ }
+}
diff --git a/app/inquiry/utils/PhoneCountryHelper.php b/app/inquiry/utils/PhoneCountryHelper.php
new file mode 100644
index 0000000..78ec4ab
--- /dev/null
+++ b/app/inquiry/utils/PhoneCountryHelper.php
@@ -0,0 +1,58 @@
+ +
+ if (str_starts_with($phone, '00')) {
+ $phone = '+' . substr($phone, 2);
+ } elseif ($phone[0] !== '+') {
+ // 没有 +,默认补一个(大多数线索都是国际号)
+ $phone = '+' . $phone;
+ }
+
+ $digits = ltrim($phone, '+');
+ if ($digits === '') {
+ return null;
+ }
+
+ // 3. 国家区号最长 3 位,从长到短匹配
+ for ($len = 3; $len >= 1; $len--) {
+ $code = substr($digits, 0, $len);
+ if ($code === '') {
+ continue;
+ }
+
+ $country = Db::table('country')
+ ->where('phone_code', $code)
+ ->first();
+
+ if ($country) {
+ return (int)$country->id;
+ }
+ }
+
+ // 没找到
+ return null;
+ }
+}
diff --git a/app/inquiry/utils/RoundRobinAssigner.php b/app/inquiry/utils/RoundRobinAssigner.php
new file mode 100644
index 0000000..5063884
--- /dev/null
+++ b/app/inquiry/utils/RoundRobinAssigner.php
@@ -0,0 +1,46 @@
+ $v > 0));
+ if (empty($userIds)) {
+ throw new BusinessException('轮询分配业务员失败:业务员列表不能为空');
+ }
+
+ $count = count($userIds);
+
+ // Redis key:同一个 groupKey 共用一条轮询链
+ $key = self::REDIS_KEY_PREFIX . $groupKey;
+
+ // 使用 INCR 做自增计数,天然是原子操作,适合并发环境
+ // 第一次调用 INCR 返回 1,我们用 (value - 1) 来做索引,从 0 开始
+ $val = Redis::incr($key); // 如果不存在,Redis 会先置为 0 再加 1
+
+ $index = ($val - 1) % $count;
+
+ return $userIds[$index];
+ }
+}