Finish mzl_inquery
This commit is contained in:
@@ -12,3 +12,5 @@
|
||||
/windows.php
|
||||
/bootstrap.php
|
||||
/composer.lock
|
||||
.env_copy
|
||||
/public/excel
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
use support\Redis;
|
||||
use support\exception\BusinessException;
|
||||
use Webman\RedisQueue\Redis as RedisQueueRedis;
|
||||
|
||||
/**
|
||||
* 轮询分配 inquiry_log 里的询盘给业务员,并推送到 CRM 队列
|
||||
*/
|
||||
class InquiryAssignInquiryCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'iq-assign';
|
||||
protected static $defaultDescription = '轮询分配询盘给业务员,并推送到 CRM 队列';
|
||||
|
||||
/**
|
||||
* 参与轮询的业务员 ID 列表(请按你想要的顺序填写)
|
||||
* 比如:销售1=186,销售2=233...
|
||||
*/
|
||||
protected const SALES_USER_IDS = [
|
||||
63,
|
||||
64,
|
||||
67,
|
||||
93,
|
||||
108,
|
||||
132,
|
||||
143,
|
||||
145,
|
||||
157,
|
||||
160,
|
||||
168,
|
||||
172,
|
||||
173,
|
||||
191,
|
||||
193,
|
||||
194,
|
||||
195,
|
||||
196,
|
||||
197
|
||||
];
|
||||
|
||||
/**
|
||||
* 轮询分配的分组 key(同一分组共用一个轮询指针)
|
||||
*/
|
||||
protected const ROUND_ROBIN_GROUP_KEY = 'fb_inquiry';
|
||||
|
||||
/**
|
||||
* 每次脚本最多分配多少条
|
||||
*/
|
||||
protected const BATCH_LIMIT = 10;
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
// 业务员列表检查
|
||||
$userIds = array_values(array_filter(
|
||||
array_map('intval', static::SALES_USER_IDS),
|
||||
fn($v) => $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('<comment>没有需要分配的询盘(status=0 且 recipient=0)</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$count = 0;
|
||||
|
||||
foreach ($inquiries as $row) {
|
||||
$inquiryId = (int)$row->id;
|
||||
sleep(1);
|
||||
// 轮询取出一个业务员 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("<comment>询盘 {$inquiryId} 未更新(可能已被处理)</comment>");
|
||||
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("<info>已分配询盘 {$inquiryId} 给业务员 {$recipientUserId},并推送到队列</info>");
|
||||
});
|
||||
}
|
||||
|
||||
$output->writeln("<info>本次共分配成功 {$count} 条询盘</info>");
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\inquiry\utils\PhoneCountryHelper;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 将 fb_leads 数据清洗后导入 inquiry_log
|
||||
*/
|
||||
class InquiryFbLeadCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'iq-fb-to';
|
||||
protected static $defaultDescription = '将 fb_leads 数据清洗后导入 inquiry_log';
|
||||
|
||||
// 这些常量根据你实际业务改
|
||||
protected const DEFAULT_ENTER_USER_ID = 186;
|
||||
protected const DEFAULT_CHANNEL = 10;
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
// 只取未同步的记录
|
||||
$leads = Db::table('fb_leads')
|
||||
->where('sync_status', 0)
|
||||
->get();
|
||||
|
||||
if ($leads->isEmpty()) {
|
||||
$output->writeln('<comment>没有需要同步的 fb_leads(sync_status = 0)</comment>');
|
||||
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('<info>已导入 ' . count($insert) . ' 条数据到 inquiry_log,并更新 fb_leads.sync_status = 1</info>');
|
||||
}
|
||||
});
|
||||
|
||||
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[] = '<p><strong>意向车型:</strong>' . $e($lead['car_model']) . '</p>';
|
||||
}
|
||||
if (!empty($lead['car_quantity']) || !empty($lead['car_quantity_text'])) {
|
||||
$qty = $lead['car_quantity'] ?? $lead['car_quantity_text'] ?? '';
|
||||
$lines[] = '<p><strong>意向数量:</strong>' . $e($qty) . '</p>';
|
||||
}
|
||||
if (!empty($lead['favorite_color'])) {
|
||||
$lines[] = '<p><strong>意向颜色:</strong>' . $e($lead['favorite_color']) . '</p>';
|
||||
}
|
||||
if (!empty($lead['usage_type'])) {
|
||||
$lines[] = '<p><strong>用途:</strong>' . $e($lead['usage_type']) . '</p>';
|
||||
}
|
||||
if (!empty($lead['seat_preference'])) {
|
||||
$lines[] = '<p><strong>座位数偏好:</strong>' . $e($lead['seat_preference']) . '</p>';
|
||||
}
|
||||
|
||||
$lines[] = '<hr />';
|
||||
$lines[] = '<p><strong>广告与表单信息:</strong></p>';
|
||||
$lines[] = '<p>平台:' . $e($lead['platform'] ?? '') . '</p>';
|
||||
$lines[] = '<p>Campaign:' . $e($lead['campaign_name'] ?? '') . ' (#' . $e($lead['campaign_id'] ?? '') . ')</p>';
|
||||
$lines[] = '<p>Adset:' . $e($lead['adset_name'] ?? '') . ' (#' . $e($lead['adset_id'] ?? '') . ')</p>';
|
||||
$lines[] = '<p>Ad:' . $e($lead['ad_name'] ?? '') . ' (#' . $e($lead['ad_id'] ?? '') . ')</p>';
|
||||
$lines[] = '<p>Form:' . $e($lead['form_name'] ?? '') . ' (#' . $e($lead['form_id'] ?? '') . ')</p>';
|
||||
|
||||
$lines[] = '<hr />';
|
||||
$lines[] = '<p><strong>Meta 信息:</strong></p>';
|
||||
$lines[] = '<p>Lead ID:' . $e($lead['fb_lead_id'] ?? '') . '</p>';
|
||||
$lines[] = '<p>FB 创建时间原始值:' . $e($lead['fb_created_time'] ?? '') . '</p>';
|
||||
|
||||
if (!empty($lead['inbox_url'])) {
|
||||
$url = $e($lead['inbox_url']);
|
||||
$lines[] = '<p>Inbox 链接:<a href="' . $url . '" target="_blank">' . $url . '</a></p>';
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use app\inquiry\utils\FbLeadHeaderMapper;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
class InquiryImportFbCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'iq-fb';
|
||||
protected static $defaultDescription = '导入 Facebook Leads Excel 数据';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->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("<info>开始导入单个文件:</info> {$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("<comment>目录 {$baseDir} 下没有找到 .xlsx 文件</comment>");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$output->writeln("<info>开始批量导入目录:</info> {$baseDir}");
|
||||
foreach ($files as $filePath) {
|
||||
$output->writeln(" -> 文件:{$filePath}");
|
||||
try {
|
||||
$this->importOneFile($filePath, $output);
|
||||
$output->writeln(" <info>导入成功</info>");
|
||||
} catch (\Throwable $e) {
|
||||
// 某个文件失败,不影响后面的文件
|
||||
$output->writeln(" <error>导入失败:{$e->getMessage()}</error>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(" <comment>文件 {$filePath} 没有有效数据行</comment>");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 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"
|
||||
|
||||
*/
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace app\inquiry\utils;
|
||||
|
||||
use support\exception\BusinessException;
|
||||
|
||||
class FbLeadHeaderMapper
|
||||
{
|
||||
/**
|
||||
* Excel 原始表头 => 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace app\inquiry\utils;
|
||||
|
||||
use support\Db;
|
||||
|
||||
class PhoneCountryHelper
|
||||
{
|
||||
/**
|
||||
* 根据电话/WhatsApp 号码识别国家 ID
|
||||
* 号码格式可以是:+996557660304、996557660304、00996557660304 等
|
||||
*/
|
||||
public static function detectCountryId(?string $rawPhone): ?int
|
||||
{
|
||||
if (!$rawPhone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. 只保留数字和+
|
||||
$phone = preg_replace('/[^\d\+]/', '', $rawPhone);
|
||||
|
||||
if ($phone === '' || $phone === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 统一前缀:00 开头 -> +
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace app\inquiry\utils;
|
||||
|
||||
use support\Redis;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
class RoundRobinAssigner
|
||||
{
|
||||
/**
|
||||
* Redis key 前缀
|
||||
* 你可以按业务分组:比如 "fb_inquiry", "website_inquiry" 等
|
||||
*/
|
||||
protected const REDIS_KEY_PREFIX = 'inquiry:assign:round_robin:';
|
||||
|
||||
/**
|
||||
* 从一组业务员 ID 中按轮询规则返回“下一个业务员 ID”
|
||||
*
|
||||
* @param int[] $userIds 业务员 ID 列表(有顺序)
|
||||
* @param string $groupKey 分配分组标识(同一个分组共享一个轮询指针)
|
||||
*
|
||||
* @return int 下一位业务员 ID
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public static function nextUserId(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);
|
||||
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -48,7 +48,8 @@
|
||||
"webman/gateway-worker": "^1.0",
|
||||
"mpdf/mpdf": "^8.2",
|
||||
"jaeger/querylist": "^4.4",
|
||||
"zoujingli/ip2region": "^3.0"
|
||||
"zoujingli/ip2region": "^3.0",
|
||||
"webman/log": "^2.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-event": "For better performance. "
|
||||
|
||||
Generated
+403
-308
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
return [
|
||||
'enable' => true,
|
||||
'exception' => [
|
||||
// 是否记录异常到日志
|
||||
'enable' => true,
|
||||
// 不会记录到日志的异常类
|
||||
'dontReport' => [
|
||||
support\exception\BusinessException::class
|
||||
]
|
||||
],
|
||||
'dontReport' => [
|
||||
'app' => [],
|
||||
'controller' => [],
|
||||
'action' => [],
|
||||
'path' => []
|
||||
],
|
||||
'channel' => 'default' // 日志通道(在config/log.php里配置,默认是default)
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of webman.
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* For full copyright and license information, please see the MIT-LICENSE.txt
|
||||
* Redistributions of files must retain the above copyright notice.
|
||||
*
|
||||
* @author walkor<walkor@workerman.net>
|
||||
* @copyright walkor<walkor@workerman.net>
|
||||
* @link http://www.workerman.net/
|
||||
* @license http://www.opensource.org/licenses/mit-license.php MIT License
|
||||
*/
|
||||
|
||||
use Webman\Log\Middleware;
|
||||
|
||||
return [
|
||||
'@' => [
|
||||
Middleware::class
|
||||
]
|
||||
];
|
||||
Reference in New Issue
Block a user