Compare commits
144 Commits
develop_bm
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| db0be8486f | |||
| d4deb8855e | |||
| 694056c8cc | |||
| 2de6342058 | |||
| 2e2188d6e5 | |||
| 6fa80336a6 | |||
| 89b9162f09 | |||
| d5f05a81a4 | |||
| 8bf0984a9c | |||
| 705f402c78 | |||
| 21c1d6afd9 | |||
| 09fdd7a5d5 | |||
| 37e63637a4 | |||
| 3a3ed12fd5 | |||
| e096157063 | |||
| 06b0647fc4 | |||
| 6e2e8e1cc6 | |||
| b2684ecbb2 | |||
| 61c34db99e | |||
| 3d3caddc7d | |||
| b89ac48dd8 | |||
| 71dca13119 | |||
| 35d98c16e9 | |||
| 929bae6f13 | |||
| a72b30a699 | |||
| 75a59a7197 | |||
| 30f3e211af | |||
| eee09316df | |||
| 2da3804953 | |||
| eae699cd27 | |||
| 4ea4204ff4 | |||
| 67a39917f0 | |||
| b6851cdef0 | |||
| 733996f346 | |||
| 607de6c70a | |||
| e7c5e17592 | |||
| 2ec89a880a | |||
| dffaac5b07 | |||
| 907be759a0 | |||
| f7b94da39f | |||
| c6fada47fc | |||
| b09e695ca7 | |||
| de39108efa | |||
| 7dceb4b828 | |||
| d056ba5f6f | |||
| 99551bba7f | |||
| 5e06b1737c | |||
| b43335c0d9 | |||
| ac6bfcf16c | |||
| 9f74934f37 | |||
| 9776df8289 | |||
| 6178084612 | |||
| d58c33e758 | |||
| 24e45f93db | |||
| 530d72071f | |||
| 8382a47a8c | |||
| f83e578bed | |||
| 2c00a35b6a | |||
| cd597b9605 | |||
| 259a7d2ae6 | |||
| 10d3701ab0 | |||
| 279139e18f | |||
| 4d957e8f43 | |||
| 57251718b4 | |||
| 0bcd9bf029 | |||
| 927f1d9c28 | |||
| 05096bc5ed | |||
| ece685fbe5 | |||
| 8072388da2 | |||
| f2b7d40b17 | |||
| e514129ff6 | |||
| a951fc39b3 | |||
| 7c12ab54e1 | |||
| d81fdae406 | |||
| 1e70bbe0ad | |||
| 861b8b216f | |||
| 5b2590bff8 | |||
| 7d1ea71e94 | |||
| d171ad58c7 | |||
| 31a610664f | |||
| 8f60c79b7c | |||
| 5aadcac7e3 | |||
| ca3455b219 | |||
| 56f8657886 | |||
| c29c4941fc | |||
| e960dd9b36 | |||
| cd622addec | |||
| da3794db50 | |||
| 8fab10b972 | |||
| eea13a2a58 | |||
| 1d0b51d598 | |||
| f0af3a0cdb | |||
| 45a77ba12e | |||
| 85a79193a6 | |||
| 38deea3b0d | |||
| b37cdc6834 | |||
| b60df7f5cf | |||
| 628429af66 | |||
| 2a6d517a74 | |||
| f58b0ee986 | |||
| 1207352e5a | |||
| 14967f28c2 | |||
| 4ff2c2493b | |||
| b391608d87 | |||
| 0ace4a7b0b | |||
| 7258d9890e | |||
| 8c3fbd4d9a | |||
| a33b109ddb | |||
| 4bcc6c53f7 | |||
| 32aa7170a5 | |||
| 59994d1f5a | |||
| a0a71161d3 | |||
| 06a264ff21 | |||
| 82673cdcfa | |||
| b4b4f6cd5f | |||
| 143dde08ce | |||
| 2ffd053723 | |||
| d238b47aa8 | |||
| 5c3f8679ce | |||
| 3520f10572 | |||
| 1858d3e586 | |||
| c0a5904c12 | |||
| e2f79598b2 | |||
| 75a333b756 | |||
| 0d979934be | |||
| 0153f40127 | |||
| fb5b523264 | |||
| 54c23a26b7 | |||
| bbb3f2f70e | |||
| 707fd275c1 | |||
| 80d458a8b0 | |||
| fe78500ef0 | |||
| db7c8a650b | |||
| bf74d9b917 | |||
| 05e39d2d65 | |||
| af8577064d | |||
| 22ba33125a | |||
| 6997947717 | |||
| 1e37560301 | |||
| 8a94de610d | |||
| 6bdcc73ee8 | |||
| fa22cfe132 | |||
| 6096f7ee01 | |||
| cb1b208190 |
+5
-1
@@ -10,4 +10,8 @@
|
||||
/webman
|
||||
/start.php
|
||||
/windows.php
|
||||
/bootstrap.php
|
||||
/bootstrap.php
|
||||
/composer.lock
|
||||
.env_copy
|
||||
/public/excel
|
||||
/docs/*.sql
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InquiryTransmitConfigService;
|
||||
|
||||
class InquiryTransmitConfigController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InquiryTransmitConfigService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
// protected function indexFormat($data)
|
||||
// {
|
||||
// if (isset($data['created_at']))
|
||||
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
// if (isset($data['updated_at']))
|
||||
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
// return $data;
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\model\InquiryTransmit;
|
||||
use app\common\model\InquiryTransmitConfig;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InquiryTransmitService;
|
||||
use support\Log;
|
||||
use support\Request;
|
||||
|
||||
class InquiryTransmitController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $noNeedLogin = ['form'];
|
||||
|
||||
protected $noNeedHeaderData = ['form'];
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InquiryTransmitService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
// protected function indexFormat($data)
|
||||
// {
|
||||
// if (isset($data['created_at']))
|
||||
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
// if (isset($data['updated_at']))
|
||||
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
// return $data;
|
||||
// }
|
||||
|
||||
|
||||
// }
|
||||
|
||||
public function form(Request $request)
|
||||
{
|
||||
try {
|
||||
$params = $request->all();
|
||||
$url = $request->header('referer');
|
||||
$domain = parse_url($url, PHP_URL_HOST);
|
||||
$transmitConfig = InquiryTransmitConfig::where('domain', $domain)->first();
|
||||
if ($transmitConfig) {
|
||||
$mailto = $transmitConfig->mailto;
|
||||
$fromDomain = $domain;
|
||||
} else {
|
||||
$mailto = '1696136552@qq.com';
|
||||
$fromDomain = 'referer Failed to obtain';
|
||||
}
|
||||
|
||||
if ($request->method() == 'GET') {
|
||||
$config = [
|
||||
'iLang' => 'en',
|
||||
'Act' => 'Inquiry',
|
||||
'post_url' => getenv('MAIN_DOMAIN').'/inquiry/transmit/form'.'?m=Data&a=save',
|
||||
'mailto' => $mailto,
|
||||
'fromDomain' => $fromDomain,
|
||||
'fromPath' => parse_url($params['fromlink'], PHP_URL_PATH),
|
||||
'm' => $params['m'],
|
||||
'a' => $params['a'],
|
||||
'tid' => $params['tid'],
|
||||
'cid' => $params['cid'],
|
||||
'version' => $params['version'],
|
||||
'fromlink' => $params['fromlink']
|
||||
];
|
||||
|
||||
$html = match ($params['tid']) {
|
||||
'en-5-LP-pnx' => $this->formHmtl($config),
|
||||
'en-3-P-vdx' => $this->formHmtl2($config)
|
||||
};
|
||||
|
||||
return $this->success(['html' => $html], 'success');
|
||||
// return json([
|
||||
// 'code' => 200,
|
||||
// 'data' => [
|
||||
// 'html' => $html
|
||||
// ],
|
||||
// 'message' => 'success'
|
||||
// ]);
|
||||
}
|
||||
|
||||
if ($request->method() == 'POST') {
|
||||
$ip = $request->getRealIp($safe_mode=true);
|
||||
$edata = [
|
||||
'mailto' => $params['mailto'],
|
||||
|
||||
'Date' => date('Y-m-d H:i:s', time()),
|
||||
'Email' => $params['userEMail'],
|
||||
'Name' => $params['userName'],
|
||||
'Tel' => $params['userTel'],
|
||||
'Company' => '',
|
||||
'Country' => $params['uCountry'],
|
||||
'Form Url' => $params['fromlink'],
|
||||
'Message' => $params['Message'],
|
||||
'Ip' => $ip,
|
||||
'IpLocation' => ip2region($ip).'(仅供参考)',
|
||||
|
||||
'iLang' => $params['iLang'],
|
||||
'mailto' => $params['mailto'],
|
||||
'navigator_language' => $params['navigator_language'],
|
||||
'navigator_platform' => $params['navigator_platform'],
|
||||
'screen_width' => $params['screen_width'],
|
||||
'screen_height' => $params['screen_height'],
|
||||
'uCountry' => $params['uCountry']
|
||||
];
|
||||
|
||||
$this->service->serviceStore($request, $edata, $domain);
|
||||
|
||||
$this->emailHtml($edata);
|
||||
|
||||
return $this->success([], 'Thank you for your message! We will contact with you soon.');
|
||||
// return json([
|
||||
// 'code' => 200,
|
||||
// 'data' => [],
|
||||
// 'message' => 'Thank you for your message! We will contact with you soon.'
|
||||
// ]);
|
||||
}
|
||||
} catch (\Throwable $th) {
|
||||
Log::info('Data:'.date('Y-m-d H:i:s').':'.$th->getMessage().'|'.$th->getLine().'|'.$th->getFile());
|
||||
}
|
||||
}
|
||||
|
||||
public function formHmtl($config)
|
||||
{
|
||||
// https://v1.xzgoogle.com/form.php?m=Temp&a=getTpl&tid=en-5-LP-pnx&cid=760&version=v4&fromlink=https://magicbridgeauto.com/contact-us/
|
||||
$html = '<form class="xz-form-x xz-form-pnx" action="'.$config['post_url'].'"> <style> .xz-form-pnx { --c-theme: #BB0010; --c-border: #f0f0f0; --c-bg: #fff; --c-label: #333; --c-font: #666; --c-green: #00B160; --size: 1.6rem; --gap: 1.25em; --h: 3em; --lh: 1.8em; --pd: .4em .625em; --br: 4px; --btn-w: 18.75em; --btn-jc: center; --lines: 4; } .xz-form-pnx { font-size: var(--size); } .xz-form-pnx .xz-form-infos { display: grid; grid-template-columns: 1fr 1fr; grid-gap: var(--gap); margin-bottom: var(--gap); } .xz-form-pnx .xz-form-info, .xz-form-pnx .xz-form-msg { border: solid 1px var(--c-border); background: var(--c-bg); padding: var(--pd); display: flex; align-items: center; border-radius: var(--br); box-sizing: border-box; } .xz-form-pnx .xz-form-info { height: var(--h); } .xz-form-pnx .xz-form-msg { align-items: flex-start; } .xz-form-pnx .xz-form-input, .xz-form-pnx .xz-form-textarea { width: 100%; border: none; font-size: var(--size); line-height: var(--lh); outline: 0; padding: 0; margin-left: 1em; color: var(--c-font); } .xz-form-pnx .xz-form-textarea { height: calc(var(--lh) * var(--lines)); min-height: var(--h); font-family: inherit; resize: none; } .xz-form-pnx .xz-form-label { display: block; color: var(--c-label); line-height: var(--lh); white-space: nowrap; } .xz-form-pnx .xz-form-label.required:after { content: "*"; margin-left: .625em; color: var(--c-theme); } .xz-form-pnx .xz-form-btns { margin-top: var(--gap); display: flex; justify-content: var(--btn-jc); } .xz-form-pnx .xz-form-button { font-size: var(--size); border-radius: var(--br); display: inline-flex; justify-content: center; align-items: center; width: var(--btn-w); height: var(--h); background: var(--c-theme); color: #fff; border: none; transition: all .3s; display: flex; justify-content: center; align-items: center; gap: 1em; } .xz-form-pnx .xz-form-button path { fill: #fff; } .xz-form-pnx .xz-form-button:hover { filter: brightness(1.1); } .xz-form-pnx .xz-form-button:active { transform: scale(.98); } @media screen and (max-width: 500px){ .xz-form-pnx { --gap: .8em; } .xz-form-pnx .xz-form-infos { grid-template-columns: 1fr; } .xz-form-pnx .xz-form-button { width: 100%; } } .xzform_o { position: relative; } .xz-form-pnx .xz-form-message.success { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); z-index: 9; max-width: 75%; display: flex; text-align: center; align-items: center; padding: 4em; background: rgba(255,255,255,.8); color: var(--c-green); font-size: 1.125em; line-height: 1.8em; font-weight: 500; box-shadow: 0 .5rem 3rem -.5rem rgba(0,0,0,.3); } </style> <input type="hidden" name="iLang" value="'.$config['iLang'].'"> <input type="hidden" name="Act" value="'.$config['Act'].'"> <input type="hidden" name="mailto" value="'.$config['mailto'].'"> <input type="hidden" name="customerId" value="'.$config['cid'].'"> <input type="hidden" name="fromDomain" value="'.$config['fromDomain'].'"> <input type="hidden" name="fromlink" value="'.$config['fromlink'].'"> <input type="hidden" name="fromPath" value="'.$config['fromPath'].'"> <input type="hidden" name="navigator.language"> <input type="hidden" name="navigator.platform"> <input type="hidden" name="screen.width"> <input type="hidden" name="screen.height"> <div class="xz-form-infos"> <div class="xz-form-info"> <label class="xz-form-label" for="userName-pnx">Name</label> <input type="text" name="userName" id="userName-pnx" maxlength="80" class="xz-form-input" required> </div> <div class="xz-form-info"> <label class="xz-form-label" for="uCountry-pnx">Country/Region</label> <input type="text" name="uCountry" id="uCountry-pnx" maxlength="80" class="xz-form-input" required> </div> <div class="xz-form-info"> <label class="xz-form-label" for="userEMail-pnx">Email</label> <input type="email" name="userEMail" id="userEMail-pnx" maxlength="80" class="xz-form-input"> </div> <div class="xz-form-info"> <label class="xz-form-label" for="userTel-pnx">Mobile</label> <input type="tel" name="userTel" id="userTel-pnx" maxlength="80" class="xz-form-input" required placeholder="Or Whatsapp/Skype"> </div> </div> <div class="xz-form-msg"> <label class="xz-form-label" for="Message-pnx">Message</label> <textarea name="Message" id="Message-pnx" class="xz-form-textarea" required></textarea> </div> <div class="xz-form-btns"> <button class="xz-form-button"> Submit <svg t="1664258923484" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3462" width="20" height="20"> <path d="M871.04 89.770667L120.064 380.16a51.2 51.2 0 0 0-1.792 94.762667l303.36 130.56 131.072 303.957333a51.2 51.2 0 0 0 94.805333-1.877333l289.792-751.573334a51.2 51.2 0 0 0-66.261333-66.133333z m-41.130667 107.392l-231.978666 601.642666-97.962667-227.114666-3.584-7.338667a85.333333 85.333333 0 0 0-41.045333-37.248l-226.56-97.536 601.173333-232.405333z" p-id="3463"></path> </svg> </button> </div></form>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function formHmtl2($config)
|
||||
{
|
||||
// https://v1.xzgoogle.com/form.php?m=Temp&a=getTpl&tid=en-3-P-vdx&cid=760&version=v4&fromlink=https://magicbridgeauto.com/news/
|
||||
$html = '<form class="xz-form-x xz-form-vdx" action="'.$config['post_url'].'"> <style> .xz-form-vdx { --c-theme: #2C9F1C; --c-border: #f8f8f8; --c-bg: #f8f8f8; --c-font: #333; --c-green: #2C9F1C; --size: 1.6rem; --gap: .625em; --h: 3.5em; --lh: 1.25em; --pd: 1em; --br: .625em; --btn-w: 100%; --lines: 1; font-size: var(--size); } .xz-form-vdx .xz-form-infos { display: grid; grid-template-columns: 1fr; grid-gap: var(--gap); margin-bottom: var(--gap); } .xz-form-vdx .xz-form-info, .xz-form-vdx .xz-form-msg { border: solid 1px var(--c-border); background: var(--c-bg); padding: var(--pd); display: flex; align-items: center; border-radius: var(--br); box-sizing: border-box; } .xz-form-vdx .xz-form-info { height: var(--h); } .xz-form-vdx .xz-form-input, .xz-form-vdx .xz-form-textarea { width: 100%; border: none; font-size: var(--size); line-height: var(--lh); outline: 0; padding: 0; color: var(--c-font); background: transparent; } .xz-form-vdx .xz-form-textarea { height: calc(var(--lh) * var(--lines)); font-family: inherit; resize: none; } .xz-form-vdx .xz-form-btns { margin-top: var(--gap); display: flex; justify-content: var(--btn-jc); } .xz-form-vdx .xz-form-button { font-size: var(--size); border-radius: var(--br); display: inline-flex; justify-content: center; align-items: center; width: var(--btn-w); height: var(--h); background: var(--c-theme); color: #fff; border: none; transition: all .3s; display: flex; justify-content: center; align-items: center; } .xz-form-vdx .xz-form-button:hover { filter: brightness(1.1); } .xz-form-vdx .xz-form-button:active { transform: scale(.98); } .xzform_o { position: relative; } .xz-form-vdx .xz-form-message.success { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 9; max-width: 90%; display: flex; text-align: center; align-items: center; padding: 2em; background: rgba(255, 255, 255, .8); color: var(--c-green); font-size: 1em; line-height: 1.5em; box-shadow: 0 .5rem 3rem -.5rem rgba(0, 0, 0, .3); } </style> <input type="hidden" name="iLang" value="'.$config['iLang'].'"> <input type="hidden" name="Act" value="'.$config['Act'].'"> <input type="hidden" name="mailto" value="'.$config['mailto'].'"> <input type="hidden" name="customerId" value="'.$config['cid'].'"> <input type="hidden" name="fromDomain" value="'.$config['fromDomain'].'"> <input type="hidden" name="fromlink" value="'.$config['fromlink'].'"> <input type="hidden" name="fromPath" value="'.$config['fromPath'].'"> <input type="hidden" name="navigator.language"> <input type="hidden" name="navigator.platform"> <input type="hidden" name="screen.width"> <input type="hidden" name="screen.height"> <div class="xz-form-infos"> <div class="xz-form-info"> <input type="text" name="userName" id="userName-vdx" maxlength="80" class="xz-form-input" placeholder="Name"> </div> <div class="xz-form-info"> <input type="email" name="userEMail" id="userEMail-vdx" maxlength="80" class="xz-form-input" placeholder="Email"> </div> </div> <div class="xz-form-msg"> <textarea name="Message" id="Message-vdx" class="xz-form-textarea" placeholder="What can we do for you? *" required></textarea> </div> <div class="xz-form-btns"> <button class="xz-form-button"> SUBMIT <svg t="1690535811615" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5056" width="1em" height="1em" fill="#fff"> <path d="M851.2 166.4c-12.8-6.4-25.6-6.4-32 0l-640 320C166.4 486.4 160 499.2 160 512s6.4 25.6 12.8 25.6l153.6 96c12.8 6.4 25.6 6.4 38.4-6.4L704 320l12.8 6.4-307.2 326.4c-6.4 6.4-6.4 12.8-6.4 19.2l0 140.8c0 12.8 6.4 25.6 19.2 32 12.8 6.4 25.6 0 32-6.4l76.8-76.8 153.6 102.4c6.4 6.4 12.8 6.4 19.2 6.4 6.4 0 6.4 0 12.8 0 12.8-6.4 19.2-12.8 19.2-25.6l128-640C864 185.6 864 172.8 851.2 166.4z" p-id="5057"></path> </svg> </button> </div></form>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
|
||||
public function emailHtml($data)
|
||||
{
|
||||
$html = '<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<title>Lead Preview</title>
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f6f7f9;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#f6f7f9;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px 12px;">
|
||||
<!-- container -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="width:600px; max-width:100%; background:#ffffff; border:1px solid #e5e7eb; border-radius:8px;">
|
||||
<!-- header -->
|
||||
<tr>
|
||||
<td style="padding:16px 20px; border-bottom:1px solid #e5e7eb; font:700 18px/1.4 Arial, Helvetica, sans-serif; color:#111;">
|
||||
Preview
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Preview section -->
|
||||
<tr>
|
||||
<td style="padding:18px 20px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<!-- 一行:左 label / 右 value -->
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Date:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['Date'].'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Email:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">
|
||||
<a href="mailto:muhseen2222@yaho.com" style="color:#2563eb; text-decoration:none;">'.$data['Email'].'</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Name:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;" dir="auto">'.$data['Name'].'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Tel:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['Tel'].'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Company:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#666;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Country:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;" dir="auto">'.$data['Country'].'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Form Url:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222; word-break:break-word;">
|
||||
<a href="'.$data['Form Url'].'" style="color:#2563eb; text-decoration:none;">'.$data['Form Url'].'</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Message:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;" dir="auto">'.$data['Message'].'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Ip:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['Ip'].'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">IpLocation:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['IpLocation'].'</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- divider + More -->
|
||||
<tr>
|
||||
<td style="padding:0 20px;">
|
||||
<div style="height:1px; background:#e5e7eb; line-height:1px; font-size:0;"> </div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:14px 20px 0; font:700 18px Arial, Helvetica, sans-serif; color:#111;">More</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 20px 18px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">iLang:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['iLang'].'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">mailto:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">
|
||||
<a href="mailto:info@borderlesscar.com" style="color:#2563eb; text-decoration:none;">'.$data['mailto'].'</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">navigator_language:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['navigator_language'].'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">navigator_platform:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['navigator_platform'].'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">screen_width:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['screen_width'].'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">screen_height:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['screen_height'].'</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">uCountry:</td>
|
||||
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;" dir="auto">'.$data['uCountry'].'</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<!-- /container -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>';
|
||||
|
||||
// $email = new Email();
|
||||
//return $email->send($data['mailto'], 'Inquiry_Of_'.$data['Name'], $html);
|
||||
return \app\common\utils\email\MailSend::send($data['mailto'], 'Inquiry_Of_'.$data['Name'], $html);
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,10 @@ class StockOrdersController extends BaseController
|
||||
];
|
||||
}
|
||||
unset($item['options']);
|
||||
// 附件 url 响应期重签(历史签名URL会过期)
|
||||
if (!empty($item['attachment']) && is_array($item['attachment'])) {
|
||||
$item['attachment'] = \app\common\service\OssService::resignAttachmentUrls($item['attachment']);
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\model\Resource;
|
||||
use app\common\model\Role;
|
||||
use app\common\model\User;
|
||||
use app\common\model\UserGroup;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\UserDashboardRuleService;
|
||||
use Couchbase\Group;
|
||||
|
||||
class UserDashboardRuleController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new UserDashboardRuleService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function indexFormat($data)
|
||||
{
|
||||
if (isset($data['created_at']))
|
||||
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
if (isset($data['updated_at']))
|
||||
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
if (isset($data['resources_id']))
|
||||
$data['resources'] = Resource::where(['id' => $data['resources_id']])->first();
|
||||
if (isset($data['rule_type']) && $data['rule_type'] == 'role'){
|
||||
$data['related_rt'] = Role::where(['id' => $data['related']])->value('name');
|
||||
}elseif (isset($data['rule_type']) && $data['rule_type'] == 'user'){
|
||||
$data['related_rt'] = User::where(['id' => $data['related']])->value('name');
|
||||
}elseif (isset($data['rule_type']) && $data['rule_type'] == 'group'){
|
||||
$data['related_rt'] = UserGroup::where(['id' => $data['related']])->value('title');
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -328,20 +328,22 @@ class BrandSeriesService
|
||||
// 执行抓取
|
||||
$crawlService = new CrawlService();
|
||||
|
||||
//懂车帝模糊搜索返回车系地址
|
||||
//懂车帝模糊搜索返回车系地址|单个车系id抓取
|
||||
$href = $crawlService->modelSearch($keyword);
|
||||
$seriesId = $this->extractSeriesId($href); //懂车帝车系id
|
||||
|
||||
if (empty($seriesId)) { //没有抓取到懂车帝系列id时,按关键词为品牌名抓取所有的系列名
|
||||
$crawlSeries = $crawlService->brandSeries($keyword);
|
||||
}
|
||||
// if (empty($seriesId)) { //没有抓取到懂车帝系列id时,按关键词为品牌名抓取所有的系列名
|
||||
// $crawlSeries = $crawlService->brandSeries($keyword);
|
||||
// }
|
||||
//按关键词为品牌名抓取所有的系列名
|
||||
$crawlSeries = $crawlService->brandSeries($keyword);
|
||||
|
||||
if (empty($seriesId) && empty($crawlSeries)) {
|
||||
throw new \Exception('抓取失败,未找到该系列车型');
|
||||
}
|
||||
|
||||
$brandSerieses = [];
|
||||
if(empty($seriesId) && !empty($crawlSeries)){ //关键词为品牌名时抓取所有车系的懂车帝id
|
||||
if(empty($seriesId) && !empty($crawlSeries) || count($crawlSeries)>1){ //关键词为品牌名时抓取所有车系的懂车帝id
|
||||
$href = $crawlService->modelSearch($crawlSeries[0]);
|
||||
$seriesId = $this->extractSeriesId($href); //懂车帝车系id
|
||||
//抓取品牌与系列
|
||||
@@ -396,21 +398,39 @@ class BrandSeriesService
|
||||
*/
|
||||
public function crawlKeywordCreate(Request $request)
|
||||
{
|
||||
$series_id = $request->input('series_id');
|
||||
$brand_id = $request->input('brand_id');
|
||||
$brand_name = $request->input('brand_name');
|
||||
$brand_code = $request->input('brand_code');
|
||||
$series_id = $request->input('series_id');
|
||||
$series_name = $request->input('series_name');
|
||||
$models = $request->input('models');
|
||||
|
||||
if(empty($brand_id)){
|
||||
if(empty($brand_id) && empty($brand_name)){
|
||||
throw new \Exception("请选择品牌");
|
||||
}
|
||||
if(empty($series_id) && empty($series_name)){
|
||||
throw new \Exception("请选择车系");
|
||||
}
|
||||
|
||||
// 获取品牌信息
|
||||
if (empty($brand_id)) {
|
||||
$brand = Brand::where('id', $brand_id)->where('brand_name', $brand_name)->first();
|
||||
if (empty($brand)) { //如果没有该车系则创建
|
||||
$brandService = new \app\admin\service\BrandService();
|
||||
$brandData = [
|
||||
'brand_code' => $brandService->generateUniqueCode(),
|
||||
'brand_name' => $brand_name,
|
||||
];
|
||||
$brand = CrudUtil::create(Brand::class, $brandData);
|
||||
}
|
||||
$brand_id = $brand->id;
|
||||
}else{
|
||||
$brand = Brand::where('id', $brand_id)->first();
|
||||
}
|
||||
|
||||
if (empty($series_id)) {
|
||||
$series = BrandSeries::where('brand_id', $brand_id)->where('series_name', $series_name)->first();
|
||||
if (empty($brandseries)) { //如果没有该车系则创建
|
||||
if (empty($series)) { //如果没有该车系则创建
|
||||
$seriesService = new \app\admin\service\BrandSeriesService();
|
||||
$seriesData = [
|
||||
'brand_id' => $brand_id,
|
||||
@@ -423,8 +443,6 @@ class BrandSeriesService
|
||||
}else{
|
||||
$series = BrandSeries::where('id', $series_id)->first();
|
||||
}
|
||||
// 获取品牌信息
|
||||
$brand = Brand::where('id', $series->brand_id)->first();
|
||||
|
||||
// 保存车型数据
|
||||
$createdCount = 0;
|
||||
@@ -479,6 +497,91 @@ class BrandSeriesService
|
||||
return $msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓取
|
||||
*/
|
||||
public function crawl(Request $request): array
|
||||
{
|
||||
$keyword = $request->get('keyword');
|
||||
if(filter_var($keyword, FILTER_VALIDATE_URL)){
|
||||
return $this->crawlUrl($request);
|
||||
}else{
|
||||
return $this->crawlKeyword($request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓取车型数据(通过接口)
|
||||
* @param Request $request
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function crawlUrl(Request $request): array
|
||||
{
|
||||
//$url = $request->get('url');
|
||||
$url = $request->get('keyword');
|
||||
|
||||
// 执行抓取
|
||||
$crawlService = new CrawlService();
|
||||
|
||||
//懂车帝提取车系id
|
||||
$seriesId = $this->get_series_from_url($url); //懂车帝车系id
|
||||
|
||||
if (empty($seriesId)) {
|
||||
throw new \Exception('抓取失败,未找到该系列车型');
|
||||
}
|
||||
|
||||
$brandSerieses = [];
|
||||
//抓取品牌与系列
|
||||
$brandSeries = $crawlService->crawlBrandSeries($seriesId);
|
||||
$brandSerieses[] = ['brand' => $brandSeries['brand'], 'series' => $brandSeries['series']];
|
||||
|
||||
$result = [];
|
||||
try {
|
||||
foreach ($brandSerieses as $brandSeries) {
|
||||
$item = [];
|
||||
// 验证品牌是否存在
|
||||
$brand = Brand::where('brand_name', $brandSeries['brand'])->first();
|
||||
// 获取品牌信息
|
||||
$series = BrandSeries::where('series_name', $brandSeries['series'])->first();
|
||||
$item['brand'] = ['brand_name' => $brandSeries['brand'], 'exists' => $brand ? 1 : 0];
|
||||
if ($brand) {
|
||||
$item['brand']['id'] = $brand['id'];
|
||||
}
|
||||
$item['series'] = ['series_name' => $brandSeries['series'], 'exists' => $series ? 1 : 0];
|
||||
if ($series) {
|
||||
$item['series']['id'] = $series['id'];
|
||||
if (empty($item['brand']['id'])) {
|
||||
$item['brand']['id'] = $series['brand_id'];
|
||||
}
|
||||
}
|
||||
|
||||
//获取车型数据
|
||||
$item['model'] = [];
|
||||
if (count($brandSerieses)<2) {
|
||||
$item['model'] = $this->crawlSeriesModel($seriesId);
|
||||
}
|
||||
$result[] = $item;
|
||||
}
|
||||
|
||||
return ['is_series' => (count($brandSerieses)<2)?1:0, 'data' => $result];
|
||||
} catch (\Exception $e) {
|
||||
throw new \Exception($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function get_series_from_url($url) {
|
||||
// 使用正则表达式匹配 "https://www.dongchedi.com/auto/series/{数字}"
|
||||
$pattern = '/^https:\/\/www\.dongchedi\.com\/auto\/series\/(\d+)$/';
|
||||
if (preg_match($pattern, $url, $matches)) {
|
||||
// 提取数字部分作为 series ID
|
||||
$series_id = $matches[1];
|
||||
return $series_id; // 返回系列ID
|
||||
} else {
|
||||
throw new \Exception("URL格式不匹配");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取车系下车型数据
|
||||
* @return array
|
||||
@@ -516,7 +619,11 @@ class BrandSeriesService
|
||||
}
|
||||
$official_price = 0;
|
||||
if(!isset($model["info"]['official_price'])){
|
||||
$official_price = (int)(str_replace("万", "", $model["info"]['official_price_str']) * 10000) ?? 0;
|
||||
if(str_contains($model["info"]['official_price_str'],'万')){
|
||||
$official_price = (int)(str_replace("万", "", $model["info"]['official_price_str']) * 10000) ?? 0;
|
||||
}else{
|
||||
$official_price = 0;
|
||||
}
|
||||
}else{
|
||||
$official_price = $model["info"]['official_price'] * 10000;
|
||||
}
|
||||
|
||||
@@ -151,4 +151,37 @@ class BrandService
|
||||
$msg .= (!empty($fail))?',失败' . $fail . '条':'';
|
||||
return $msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一的7位颜色编码
|
||||
* 格式:C + (O/I) + 5位字母数字组合
|
||||
*
|
||||
* @param int $type 类型
|
||||
* @return string 7位唯一编码
|
||||
*/
|
||||
public function generateUniqueCode()
|
||||
{
|
||||
$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
$maxAttempts = 10; // 防止无限循环
|
||||
|
||||
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
|
||||
// 生成后5位随机字符
|
||||
$suffix = '';
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$suffix .= $chars[rand(0, strlen($chars) - 1)];
|
||||
}
|
||||
|
||||
$code = $suffix;
|
||||
|
||||
// 检查是否已存在
|
||||
$existing = Brand::where('brand_code', $code)->first();
|
||||
if (!$existing) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果1000次尝试后仍然重复,使用时间戳确保唯一性
|
||||
$timestamp = substr(time(), -6);
|
||||
return $suffix . $timestamp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\CrmCustomer;
|
||||
use app\common\utils\InquiryFairAssigner;
|
||||
use app\common\utils\email\InquiryDigestMail;
|
||||
use app\common\utils\email\MailSend;
|
||||
use app\crm\model\Country;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 合塑谷歌AI客服询盘导入
|
||||
*
|
||||
* AI客服询盘 Excel(库外数据)-> 建 crm_customer 档案 -> 轮询分配 -> 汇总邮件。
|
||||
* 固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入)、magnitude=1(银牌);
|
||||
* contacts=第一个非空联系方式(WhatsApp->手机->邮箱);去重:手机/WhatsApp/邮箱任一重复即跳过。
|
||||
* 未对应库字段的内容分块拼入 desc_collect(询盘信息/意向车型/其他备注/AI评估/完整对话)。
|
||||
*/
|
||||
class HesuGoogleAiInquiryImportCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'inquiry:import-hesu-google-ai';
|
||||
protected static $defaultDescription = '合塑谷歌AI客服询盘导入:新询盘建档+轮询分配+汇总邮件';
|
||||
|
||||
private const CORP_ID = 1;
|
||||
private const CHANNEL_ID = 4; // 合塑谷歌
|
||||
private const CHANNEL_NAME = '合塑谷歌(AI客服)';
|
||||
private const TYPE = 2; // 导入数据
|
||||
private const GRADE = 0;
|
||||
private const MAGNITUDE = 1; // 银牌商机
|
||||
|
||||
/** @var array 国家名(小写)->country_id 缓存 */
|
||||
private $countryCache = [];
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/AI客服询盘_2026-08-20.xlsx')
|
||||
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔(必填)', '')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+去重预检+分配预演,不写库不发邮件')
|
||||
->addOption('no-notify', null, InputOption::VALUE_NONE, '只建档分配,不发邮件')
|
||||
->addOption('notify-only', null, InputOption::VALUE_NONE, '跳过写库,按 manifest 补发未成功的邮件')
|
||||
->addOption('only-user', null, InputOption::VALUE_REQUIRED, '仅给指定 user_id 发邮件(试发)', '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Excel(按表头名定位列,顺序无关)
|
||||
*/
|
||||
private function parseExcel(string $filePath): array
|
||||
{
|
||||
$sheet = IOFactory::load($filePath)->getActiveSheet();
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
|
||||
$colMap = [];
|
||||
foreach (range('A', 'Z') as $col) {
|
||||
$v = trim((string)($sheet->getCell($col . '1')->getValue() ?? ''));
|
||||
if ($v !== '') {
|
||||
$colMap[$v] = $col;
|
||||
}
|
||||
}
|
||||
foreach (['手机', 'WhatsApp', '邮箱'] as $need) {
|
||||
if (!isset($colMap[$need])) {
|
||||
throw new \RuntimeException('Excel 缺少必需列头: ' . $need . ';实际列头: ' . implode(',', array_keys($colMap)));
|
||||
}
|
||||
}
|
||||
|
||||
$get = function (string $header, int $r) use ($sheet, $colMap): string {
|
||||
if (!isset($colMap[$header])) {
|
||||
return '';
|
||||
}
|
||||
return trim((string)($sheet->getCell($colMap[$header] . $r)->getValue() ?? ''));
|
||||
};
|
||||
|
||||
$rows = [];
|
||||
for ($r = 2; $r <= $highestRow; $r++) {
|
||||
$row = [
|
||||
'excel_row' => $r,
|
||||
'phone' => $this->normalizeNumber($get('手机', $r)),
|
||||
'whatsapp' => $this->normalizeNumber($get('WhatsApp', $r)),
|
||||
'email' => $get('邮箱', $r),
|
||||
'wechat' => $get('微信', $r),
|
||||
'intent' => $get('意向', $r),
|
||||
'country' => $get('目标国家', $r),
|
||||
'budget' => $get('预算', $r),
|
||||
'brand' => $get('意向品牌', $r),
|
||||
'model' => $get('意向车型', $r),
|
||||
'qty' => $get('采购数量', $r),
|
||||
'usage' => $get('采购用途', $r),
|
||||
'note' => $get('其他备注', $r),
|
||||
'assess' => $get('评估理由', $r),
|
||||
'dialog' => $get('完整对话记录', $r),
|
||||
];
|
||||
if ($row['phone'] === '' && $row['whatsapp'] === '' && $row['email'] === ''
|
||||
&& $row['wechat'] === '' && $row['dialog'] === '') {
|
||||
continue; // 整行为空
|
||||
}
|
||||
// contacts:第一个非空联系方式
|
||||
$row['contacts'] = $row['whatsapp'] !== '' ? $row['whatsapp']
|
||||
: ($row['phone'] !== '' ? $row['phone'] : $row['email']);
|
||||
$row['intended'] = trim($row['brand'] . ' ' . $row['model']);
|
||||
$rows[] = $row;
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 号码归一化:仅保留数字与开头 +(去空格/横线等)
|
||||
*/
|
||||
private function normalizeNumber(string $raw): string
|
||||
{
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
$n = preg_replace('/[^0-9+]/', '', trim($raw));
|
||||
$n = preg_replace('/\++/', '+', $n);
|
||||
if ($n !== '' && $n[0] !== '+') {
|
||||
$n = '+' . $n;
|
||||
}
|
||||
return $n !== '+' ? $n : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 国家名(英文/中文,忽略大小写)匹配 country_id;匹配不上返回 0
|
||||
*/
|
||||
private function matchCountryId(string $name): int
|
||||
{
|
||||
if ($name === '') {
|
||||
return 0;
|
||||
}
|
||||
$key = strtolower($name);
|
||||
if (!array_key_exists($key, $this->countryCache)) {
|
||||
$lower = strtolower($name);
|
||||
$id = Country::where(function ($q) use ($lower) {
|
||||
$q->whereRaw('LOWER(en_addr) = ?', [$lower])
|
||||
->orWhereRaw('LOWER(cn_addr) = ?', [$lower]);
|
||||
})->value('id');
|
||||
$this->countryCache[$key] = (int)($id ?? 0);
|
||||
}
|
||||
return $this->countryCache[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 未对应库字段的内容 -> 分块 HTML(desc_collect 富文本)
|
||||
*/
|
||||
private function buildDescCollect(array $row): string
|
||||
{
|
||||
$e = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES);
|
||||
$blocks = [];
|
||||
|
||||
$info = [];
|
||||
if ($row['intent'] !== '') {
|
||||
$info[] = '意向:' . $e($row['intent']);
|
||||
}
|
||||
if ($row['budget'] !== '') {
|
||||
$info[] = '预算:' . $e($row['budget']);
|
||||
}
|
||||
if ($row['qty'] !== '') {
|
||||
$info[] = '采购数量:' . $e($row['qty']);
|
||||
}
|
||||
if ($row['usage'] !== '') {
|
||||
$info[] = '采购用途:' . $e($row['usage']);
|
||||
}
|
||||
if ($row['country'] !== '') {
|
||||
$info[] = '目标国家:' . $e($row['country']);
|
||||
}
|
||||
if ($row['wechat'] !== '') {
|
||||
$info[] = '微信:' . $e($row['wechat']);
|
||||
}
|
||||
if ($info) {
|
||||
$blocks[] = $this->block('询盘信息', implode('<br>', $info));
|
||||
}
|
||||
|
||||
if ($row['brand'] !== '' || $row['model'] !== '') {
|
||||
$blocks[] = $this->block('意向车型',
|
||||
($row['brand'] !== '' ? '品牌:' . $e($row['brand']) : '')
|
||||
. ($row['brand'] !== '' && $row['model'] !== '' ? '<br>' : '')
|
||||
. ($row['model'] !== '' ? '车型:' . $e($row['model']) : ''));
|
||||
}
|
||||
|
||||
if ($row['note'] !== '') {
|
||||
$blocks[] = $this->block('其他备注', $e($row['note']));
|
||||
}
|
||||
|
||||
$ai = [];
|
||||
if ($row['intent'] !== '') {
|
||||
$ai[] = '意向等级:' . $e($row['intent']);
|
||||
}
|
||||
if ($row['assess'] !== '') {
|
||||
$ai[] = '评估理由:' . $e($row['assess']);
|
||||
}
|
||||
if ($ai) {
|
||||
$blocks[] = $this->block('AI评估', implode('<br>', $ai));
|
||||
}
|
||||
|
||||
if ($row['dialog'] !== '') {
|
||||
$blocks[] = $this->block('完整对话',
|
||||
'<div style="white-space:pre-wrap;background:#f7f8fa;border:1px solid #eee;'
|
||||
. 'border-radius:4px;padding:8px;font-size:12px;line-height:1.6;">' . $e($row['dialog']) . '</div>');
|
||||
}
|
||||
|
||||
return implode('', $blocks);
|
||||
}
|
||||
|
||||
private function block(string $title, string $bodyHtml): string
|
||||
{
|
||||
return '<div style="margin:0 0 10px;">'
|
||||
. '<div style="font-weight:700;color:#BB0010;border-left:3px solid #BB0010;'
|
||||
. 'padding-left:6px;margin-bottom:4px;">【' . htmlspecialchars($title) . '】</div>'
|
||||
. '<div style="color:#333;">' . $bodyHtml . '</div></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 去重:手机/WhatsApp/邮箱任一与库内对应列重复(含文件内)即跳过
|
||||
*/
|
||||
private function dedup(array $rows, OutputInterface $output): array
|
||||
{
|
||||
$dbSets = [];
|
||||
foreach (['phone' => 'phone', 'whatsapp' => 'WhatsApp', 'email' => 'email'] as $field => $col) {
|
||||
$vals = array_values(array_unique(array_filter(array_column($rows, $field))));
|
||||
$set = [];
|
||||
foreach (array_chunk($vals, 500) as $chunk) {
|
||||
foreach (Db::table('crm_customer')->whereNull('deleted_at')->whereIn($col, $chunk)->pluck($col) as $v) {
|
||||
$v = trim((string)$v);
|
||||
if ($v !== '') {
|
||||
if ($col === 'email') {
|
||||
$set[strtolower($v)] = 1;
|
||||
} else {
|
||||
$set[$this->normalizeNumber($v)] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$dbSets[$field] = $set;
|
||||
}
|
||||
|
||||
$keep = []; $skipped = []; $noContact = [];
|
||||
$seen = ['phone' => [], 'whatsapp' => [], 'email' => []];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['phone'] === '' && $row['whatsapp'] === '' && $row['email'] === '') {
|
||||
$noContact[] = $row;
|
||||
continue;
|
||||
}
|
||||
$dupReason = '';
|
||||
foreach (['phone', 'whatsapp', 'email'] as $field) {
|
||||
if ($row[$field] === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $field === 'email' ? strtolower($row[$field]) : $row[$field];
|
||||
if (isset($dbSets[$field][$key]) || isset($seen[$field][$key])) {
|
||||
$dupReason = $field . '重复: ' . $row[$field];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($dupReason !== '') {
|
||||
$skipped[] = [$row['excel_row'], $dupReason];
|
||||
continue;
|
||||
}
|
||||
foreach (['phone', 'whatsapp', 'email'] as $field) {
|
||||
if ($row[$field] !== '') {
|
||||
$key = $field === 'email' ? strtolower($row[$field]) : $row[$field];
|
||||
$seen[$field][$key] = 1;
|
||||
}
|
||||
}
|
||||
$keep[] = $row;
|
||||
}
|
||||
return [$keep, $skipped, $noContact];
|
||||
}
|
||||
|
||||
private function manifestPath(string $date): string
|
||||
{
|
||||
return runtime_path() . '/inquiry_hesu_google_ai_' . $date . '.json';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$file = $input->getOption('file');
|
||||
$targetUserIds = array_values(array_filter(array_map('intval', explode(',', (string)$input->getOption('users')))));
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$notifyOnly = (bool)$input->getOption('notify-only');
|
||||
$noNotify = (bool)$input->getOption('no-notify');
|
||||
$onlyUser = (int)$input->getOption('only-user');
|
||||
$date = date('Y-m-d');
|
||||
|
||||
if (!$targetUserIds) {
|
||||
$output->writeln('<error>--users 不能为空(目标业务员 user_id 列表)</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (!is_file($file)) {
|
||||
$output->writeln('<error>文件不存在: ' . $file . '</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($notifyOnly && $noNotify) {
|
||||
$output->writeln('<error>--notify-only 与 --no-notify 不能同时使用</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln('<info>解析 Excel: ' . $file . '</info>');
|
||||
$rows = $this->parseExcel($file);
|
||||
$output->writeln('<info>数据行: ' . count($rows) . '</info>');
|
||||
|
||||
[$keep, $skipped, $noContact] = $this->dedup($rows, $output);
|
||||
$output->writeln('<info>去重后待建档: ' . count($keep) . ',重复跳过: ' . count($skipped) . ',无联系方式跳过: ' . count($noContact) . '</info>');
|
||||
foreach (array_slice($skipped, 0, 10) as $s) {
|
||||
$output->writeln('<comment> 跳过 行' . $s[0] . ' ' . $s[1] . '</comment>');
|
||||
}
|
||||
if (count($skipped) > 10) {
|
||||
$output->writeln('<comment> ...另 ' . (count($skipped) - 10) . ' 条</comment>');
|
||||
}
|
||||
foreach (array_slice($noContact, 0, 5) as $row) {
|
||||
$output->writeln('<comment> 无联系方式 行' . $row['excel_row'] . ' 备注:' . mb_substr($row['note'], 0, 30) . '</comment>');
|
||||
}
|
||||
|
||||
// 国家匹配预览
|
||||
$countryMiss = [];
|
||||
foreach ($keep as $row) {
|
||||
if ($row['country'] !== '' && $this->matchCountryId($row['country']) === 0) {
|
||||
$countryMiss[$row['country']] = 1;
|
||||
}
|
||||
}
|
||||
if ($countryMiss) {
|
||||
$output->writeln('<comment>未匹配到国家(将留空): ' . implode('、', array_keys($countryMiss)) . '</comment>');
|
||||
}
|
||||
|
||||
// 公平分配:按近10天窗口内实际分到数升序(少者优先、最久未分到优先),再按序轮询
|
||||
$fair = InquiryFairAssigner::fairOrder($targetUserIds);
|
||||
$orderedUsers = $fair['ordered'];
|
||||
$output->writeln('<info>分配顺序(近' . $fair['window_days'] . '天已有条数):'
|
||||
. implode('、', array_map(fn($u) => $u . '(' . ($fair['counts'][$u] ?? 0) . ')', $orderedUsers)) . '</info>');
|
||||
$n = count($orderedUsers);
|
||||
foreach ($keep as $i => &$row) {
|
||||
$row['new_user_id'] = $orderedUsers[$i % $n];
|
||||
}
|
||||
unset($row);
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
foreach ($keep as $row) {
|
||||
$groups[$row['new_user_id']][] = $row;
|
||||
}
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$output->writeln('<info>user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条</info>');
|
||||
}
|
||||
foreach (array_slice($keep, 0, 3) as $row) {
|
||||
$output->writeln('<comment>样例[行' . $row['excel_row'] . '] ' . $row['contacts'] . ' '
|
||||
. mb_substr($row['intended'], 0, 24) . ' -> user ' . $row['new_user_id'] . '</comment>');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<info>[dry-run] 不写库、不发邮件。确认后去掉 --dry-run 执行。</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$manifestPath = $this->manifestPath($date);
|
||||
|
||||
if ($notifyOnly) {
|
||||
// 建档后联系方式已入库,重新解析会被去重清空——补发必须用 manifest 保存的分配结果
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : [];
|
||||
if (empty($manifest['groups'])) {
|
||||
$output->writeln('<error>manifest 不存在或无分配数据: ' . $manifestPath . ';请先执行 --no-notify 或完整命令生成</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$digestGroups = $manifest['groups'];
|
||||
$output->writeln('<comment>[notify-only] 跳过写库,按 manifest 分配结果补发。</comment>');
|
||||
} else {
|
||||
$created = [];
|
||||
Db::connection()->transaction(function () use ($keep, $output, &$created) {
|
||||
foreach ($keep as $row) {
|
||||
$c = new CrmCustomer();
|
||||
$c->corp_id = self::CORP_ID;
|
||||
$c->user_id = $row['new_user_id'];
|
||||
$c->type = self::TYPE;
|
||||
$c->channel_id = self::CHANNEL_ID;
|
||||
$c->grade = self::GRADE;
|
||||
$c->magnitude = self::MAGNITUDE;
|
||||
$c->email = $row['email'];
|
||||
$c->phone = $row['phone'];
|
||||
$c->WhatsApp = $row['whatsapp'];
|
||||
$c->contacts = $row['contacts'];
|
||||
$c->country_id = $this->matchCountryId($row['country']) ?: null;
|
||||
$c->intended_vehicles = $row['intended'] !== '' ? $row['intended'] : null;
|
||||
$c->desc_collect = $this->buildDescCollect($row);
|
||||
$c->obtain = time();
|
||||
if (!$c->save()) {
|
||||
throw new \RuntimeException('行' . $row['excel_row'] . ' 建档失败');
|
||||
}
|
||||
$row['code'] = $c->code;
|
||||
$created[] = $row;
|
||||
}
|
||||
$output->writeln('<info>已建档并分配: ' . count($created) . ' 条(code 自动生成)</info>');
|
||||
});
|
||||
|
||||
if ($created) {
|
||||
$digestGroups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$digestGroups[(string)$uid] = [];
|
||||
}
|
||||
foreach ($created as $row) {
|
||||
$digestGroups[(string)$row['new_user_id']][] = $this->digestRow($row);
|
||||
}
|
||||
$this->saveManifest($manifestPath, $targetUserIds, $digestGroups);
|
||||
} elseif (is_file($manifestPath)) {
|
||||
$manifest = json_decode((string)file_get_contents($manifestPath), true) ?: [];
|
||||
if (!empty($manifest['groups'])) {
|
||||
$digestGroups = $manifest['groups'];
|
||||
$output->writeln('<comment>本次无新建档(可能已导入过),沿用已有 manifest 的分配结果。</comment>');
|
||||
} else {
|
||||
$output->writeln('<comment>没有可建档数据,结束。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
} else {
|
||||
$output->writeln('<comment>没有可建档数据,结束。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($noNotify) {
|
||||
$output->writeln('<comment>[no-notify] 已建档,跳过发邮件。核对库后用 --notify-only 补发。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendDigests($digestGroups, $targetUserIds, $manifestPath, $onlyUser, $output);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function saveManifest(string $path, array $targetUserIds, array $digestGroups): void
|
||||
{
|
||||
$perUser = []; $emailed = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$perUser[(string)$uid] = count($digestGroups[(string)$uid] ?? []);
|
||||
$emailed[(string)$uid] = false;
|
||||
}
|
||||
$data = [
|
||||
'date' => date('Y-m-d'),
|
||||
'imported_at' => time(),
|
||||
'target_users' => $targetUserIds,
|
||||
'per_user_counts' => $perUser,
|
||||
'groups' => $digestGroups,
|
||||
'emailed' => $emailed,
|
||||
];
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* 建档行 -> 邮件汇总行
|
||||
*/
|
||||
private function digestRow(array $row): array
|
||||
{
|
||||
$remark = trim($row['intended'] . ' ' . $row['budget']);
|
||||
return [
|
||||
'code' => $row['code'] ?? '',
|
||||
'contact' => $row['contacts'],
|
||||
'email' => $row['email'],
|
||||
'phone' => $row['whatsapp'] !== '' ? $row['whatsapp'] : $row['phone'],
|
||||
'country_cn' => $row['country'],
|
||||
'channel' => self::CHANNEL_NAME,
|
||||
'remark' => mb_substr($remark, 0, 100),
|
||||
];
|
||||
}
|
||||
|
||||
private function sendDigests(array $digestGroups, array $targetUserIds, string $manifestPath, int $onlyUser, OutputInterface $output): void
|
||||
{
|
||||
$users = Db::table('user')->whereIn('id', $targetUserIds)->get(['id', 'name', 'email']);
|
||||
$userMap = [];
|
||||
foreach ($users as $u) {
|
||||
$userMap[(int)$u->id] = $u;
|
||||
}
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)file_get_contents($manifestPath), true) ?: ['emailed' => []]) : ['emailed' => []];
|
||||
$date = date('Y-m-d');
|
||||
|
||||
foreach ($targetUserIds as $uid) {
|
||||
if (count($digestGroups[(string)$uid] ?? []) === 0) {
|
||||
$output->writeln('<comment>user ' . $uid . ' 未分到询盘,不发送</comment>');
|
||||
continue;
|
||||
}
|
||||
if ($onlyUser > 0 && $uid !== $onlyUser) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($manifest['emailed'][(string)$uid])) {
|
||||
$output->writeln('<comment>跳过已发送: user ' . $uid . '</comment>');
|
||||
continue;
|
||||
}
|
||||
$u = $userMap[$uid] ?? null;
|
||||
if (!$u || empty($u->email)) {
|
||||
$output->writeln('<error>user ' . $uid . ' 无邮箱,跳过</error>');
|
||||
continue;
|
||||
}
|
||||
$inquiries = array_values($digestGroups[(string)$uid] ?? []);
|
||||
$html = InquiryDigestMail::build((string)$u->name, $inquiries, $date);
|
||||
$subject = 'AI客服新询盘分配汇总(共 ' . count($inquiries) . ' 条)- ' . $u->name . ' - ' . $date;
|
||||
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
if (!$ok) {
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
}
|
||||
$manifest['emailed'][(string)$uid] = (bool)$ok;
|
||||
file_put_contents($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
$tag = $ok ? 'info' : 'error';
|
||||
$output->writeln('<' . $tag . '>user ' . $uid . ' (' . $u->email . '): '
|
||||
. ($ok ? '发送成功' : '发送失败') . ',共 ' . count($inquiries) . ' 条</' . $tag . '>');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\CrmCustomer;
|
||||
use app\common\utils\InquiryFairAssigner;
|
||||
use app\common\utils\email\InquiryDigestMail;
|
||||
use app\common\utils\email\MailSend;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 合塑谷歌网站询盘导入
|
||||
*
|
||||
* Excel 新询盘(库外数据)-> 建 crm_customer 档案 -> 按行序轮询分配业务员 -> 每人一封汇总邮件。
|
||||
* 固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入数据);
|
||||
* 去重:电话或邮箱与现有未删除客户重复(含本文件内重复)则跳过。
|
||||
*/
|
||||
class HesuGoogleInquiryImportCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'inquiry:import-hesu-google';
|
||||
protected static $defaultDescription = '合塑谷歌网站询盘导入:新询盘建档+轮询分配+汇总邮件';
|
||||
|
||||
private const CORP_ID = 1;
|
||||
private const CHANNEL_ID = 4; // 合塑谷歌
|
||||
private const CHANNEL_NAME = '合塑谷歌';
|
||||
private const TYPE = 2; // 导入数据
|
||||
private const GRADE = 0;
|
||||
private const MAGNITUDE = 2;
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/询盘明细_2026年6-8月_已清洗.xlsx')
|
||||
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔(必填)', '')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+去重预检+分配预演,不写库不发邮件')
|
||||
->addOption('no-notify', null, InputOption::VALUE_NONE, '只建档分配,不发邮件(先核对库再 --notify-only 补发)')
|
||||
->addOption('notify-only', null, InputOption::VALUE_NONE, '跳过写库,按 manifest 补发未成功的邮件')
|
||||
->addOption('only-user', null, InputOption::VALUE_REQUIRED, '仅给指定 user_id 发邮件(试发)', '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Excel(按表头名定位列,顺序无关)。返回行数组。
|
||||
*/
|
||||
private function parseExcel(string $filePath): array
|
||||
{
|
||||
$sheet = IOFactory::load($filePath)->getActiveSheet();
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
|
||||
$colMap = [];
|
||||
foreach (range('A', 'Z') as $col) {
|
||||
$v = trim((string)($sheet->getCell($col . '1')->getValue() ?? ''));
|
||||
if ($v !== '') {
|
||||
$colMap[$v] = $col;
|
||||
}
|
||||
}
|
||||
$required = ['客户姓名', '电话/WhatsApp', '邮箱', '留言内容', '产品页链接'];
|
||||
$missing = array_diff($required, array_keys($colMap));
|
||||
if ($missing) {
|
||||
throw new \RuntimeException('Excel 缺少必需列头: ' . implode(', ', $missing) . ';实际列头: ' . implode(',', array_keys($colMap)));
|
||||
}
|
||||
|
||||
$get = function (string $header, int $r) use ($sheet, $colMap): string {
|
||||
return trim((string)($sheet->getCell($colMap[$header] . $r)->getValue() ?? ''));
|
||||
};
|
||||
|
||||
$rows = [];
|
||||
for ($r = 2; $r <= $highestRow; $r++) {
|
||||
$row = [
|
||||
'contacts' => $get('客户姓名', $r),
|
||||
'phone' => $get('电话/WhatsApp', $r),
|
||||
'email' => $get('邮箱', $r),
|
||||
'message' => $get('留言内容', $r),
|
||||
'url' => $get('产品页链接', $r),
|
||||
'excel_row' => $r,
|
||||
];
|
||||
if ($row['contacts'] === '' && $row['phone'] === '' && $row['email'] === '') {
|
||||
continue; // 整行为空
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从产品页链接提取车型 slug,如 /product/rox-01/ -> rox-01
|
||||
*/
|
||||
private function productSlug(string $url): string
|
||||
{
|
||||
$path = parse_url($url, PHP_URL_PATH) ?: '';
|
||||
$segs = array_values(array_filter(explode('/', $path), fn($s) => $s !== ''));
|
||||
if (empty($segs)) {
|
||||
return '';
|
||||
}
|
||||
$i = array_search('product', $segs, true);
|
||||
if ($i !== false && isset($segs[$i + 1])) {
|
||||
return $segs[$i + 1];
|
||||
}
|
||||
return (string)end($segs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 库内 + 文件内去重。返回 [待建档行, 跳过信息列表]
|
||||
*/
|
||||
private function dedup(array $rows, OutputInterface $output): array
|
||||
{
|
||||
$dbPhones = [];
|
||||
foreach (array_chunk(array_filter(array_column($rows, 'phone')), 500) as $chunk) {
|
||||
$dbPhones = array_merge($dbPhones, Db::table('crm_customer')
|
||||
->whereNull('deleted_at')->whereIn('phone', $chunk)->pluck('phone')->map(fn($v) => trim((string)$v))->all());
|
||||
}
|
||||
$dbEmails = [];
|
||||
foreach (array_chunk(array_filter(array_column($rows, 'email')), 500) as $chunk) {
|
||||
$dbEmails = array_merge($dbEmails, Db::table('crm_customer')
|
||||
->whereNull('deleted_at')->whereIn('email', $chunk)->pluck('email')->map(fn($v) => trim((string)$v))->all());
|
||||
}
|
||||
$dbPhones = array_flip($dbPhones);
|
||||
$dbEmails = array_flip($dbEmails);
|
||||
|
||||
$keep = []; $skipped = []; $noContact = [];
|
||||
$seenPhone = []; $seenEmail = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['phone'] === '' && $row['email'] === '') {
|
||||
$noContact[] = $row;
|
||||
continue;
|
||||
}
|
||||
if ($row['phone'] !== '' && (isset($dbPhones[$row['phone']]) || isset($seenPhone[$row['phone']]))) {
|
||||
$skipped[] = [$row['excel_row'], '电话重复: ' . $row['phone']];
|
||||
$seenPhone[$row['phone']] = 1;
|
||||
continue;
|
||||
}
|
||||
if ($row['email'] !== '' && (isset($dbEmails[$row['email']]) || isset($seenEmail[$row['email']]))) {
|
||||
$skipped[] = [$row['excel_row'], '邮箱重复: ' . $row['email']];
|
||||
$seenEmail[$row['email']] = 1;
|
||||
continue;
|
||||
}
|
||||
if ($row['phone'] !== '') {
|
||||
$seenPhone[$row['phone']] = 1;
|
||||
}
|
||||
if ($row['email'] !== '') {
|
||||
$seenEmail[$row['email']] = 1;
|
||||
}
|
||||
$keep[] = $row;
|
||||
}
|
||||
return [$keep, $skipped, $noContact];
|
||||
}
|
||||
|
||||
private function manifestPath(string $date): string
|
||||
{
|
||||
return runtime_path() . '/inquiry_hesu_google_import_' . $date . '.json';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$file = $input->getOption('file');
|
||||
$targetUserIds = array_values(array_filter(array_map('intval', explode(',', (string)$input->getOption('users')))));
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$notifyOnly = (bool)$input->getOption('notify-only');
|
||||
$noNotify = (bool)$input->getOption('no-notify');
|
||||
$onlyUser = (int)$input->getOption('only-user');
|
||||
$date = date('Y-m-d');
|
||||
|
||||
if (!$targetUserIds) {
|
||||
$output->writeln('<error>--users 不能为空(目标业务员 user_id 列表)</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (!is_file($file)) {
|
||||
$output->writeln('<error>文件不存在: ' . $file . '</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($notifyOnly && $noNotify) {
|
||||
$output->writeln('<error>--notify-only 与 --no-notify 不能同时使用</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln('<info>解析 Excel: ' . $file . '</info>');
|
||||
$rows = $this->parseExcel($file);
|
||||
$output->writeln('<info>数据行: ' . count($rows) . '</info>');
|
||||
|
||||
[$keep, $skipped, $noContact] = $this->dedup($rows, $output);
|
||||
$output->writeln('<info>去重后待建档: ' . count($keep) . ',重复跳过: ' . count($skipped) . ',无联系方式跳过: ' . count($noContact) . '</info>');
|
||||
foreach (array_slice($skipped, 0, 10) as $s) {
|
||||
$output->writeln('<comment> 跳过 行' . $s[0] . ' ' . $s[1] . '</comment>');
|
||||
}
|
||||
if (count($skipped) > 10) {
|
||||
$output->writeln('<comment> ...另 ' . (count($skipped) - 10) . ' 条</comment>');
|
||||
}
|
||||
foreach ($noContact as $row) {
|
||||
$output->writeln('<comment> 无联系方式 行' . $row['excel_row'] . ' ' . $row['contacts'] . '</comment>');
|
||||
}
|
||||
|
||||
// 公平分配:按近10天窗口内实际分到数升序(少者优先、最久未分到优先),再按序轮询
|
||||
$fair = InquiryFairAssigner::fairOrder($targetUserIds);
|
||||
$orderedUsers = $fair['ordered'];
|
||||
$output->writeln('<info>分配顺序(近' . $fair['window_days'] . '天已有条数):'
|
||||
. implode('、', array_map(fn($u) => $u . '(' . ($fair['counts'][$u] ?? 0) . ')', $orderedUsers)) . '</info>');
|
||||
$n = count($orderedUsers);
|
||||
foreach ($keep as $i => &$row) {
|
||||
$row['new_user_id'] = $orderedUsers[$i % $n];
|
||||
}
|
||||
unset($row);
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
foreach ($keep as $row) {
|
||||
$groups[$row['new_user_id']][] = $row;
|
||||
}
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$output->writeln('<info>user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条</info>');
|
||||
}
|
||||
foreach ($keep as $i => $row) {
|
||||
if ($i < 3) {
|
||||
$output->writeln('<comment>样例[行' . $row['excel_row'] . '] ' . $row['contacts'] . ' ' . $row['phone'] . ' '
|
||||
. $this->productSlug($row['url']) . ' -> user ' . $row['new_user_id'] . '</comment>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<info>[dry-run] 不写库、不发邮件。确认后去掉 --dry-run 执行。</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$notifyOnly) {
|
||||
$created = [];
|
||||
Db::connection()->transaction(function () use ($keep, $targetUserIds, $output, &$created) {
|
||||
foreach ($keep as $row) {
|
||||
$desc = $row['message'];
|
||||
if ($row['url'] !== '') {
|
||||
$desc .= ($desc !== '' ? "\n" : '') . '产品页:' . $row['url'];
|
||||
}
|
||||
$c = new CrmCustomer();
|
||||
$c->corp_id = self::CORP_ID;
|
||||
$c->user_id = $row['new_user_id'];
|
||||
$c->type = self::TYPE;
|
||||
$c->channel_id = self::CHANNEL_ID;
|
||||
$c->grade = self::GRADE;
|
||||
$c->magnitude = self::MAGNITUDE;
|
||||
$c->email = $row['email'];
|
||||
$c->phone = $row['phone'];
|
||||
$c->WhatsApp = '';
|
||||
$c->contacts = $row['contacts'];
|
||||
$c->desc_collect = $desc;
|
||||
$slug = $this->productSlug($row['url']);
|
||||
$c->intended_vehicles = $slug !== '' ? $slug : null;
|
||||
$c->obtain = time();
|
||||
if (!$c->save()) {
|
||||
throw new \RuntimeException('行' . $row['excel_row'] . ' 建档失败');
|
||||
}
|
||||
$row['code'] = $c->code;
|
||||
$created[] = $row;
|
||||
}
|
||||
$output->writeln('<info>已建档并分配: ' . count($created) . ' 条(code 由模型自动生成)</info>');
|
||||
});
|
||||
$keep = $created;
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
foreach ($keep as $row) {
|
||||
$groups[$row['new_user_id']][] = $row;
|
||||
}
|
||||
} else {
|
||||
$output->writeln('<comment>[notify-only] 跳过写库。</comment>');
|
||||
}
|
||||
|
||||
$manifestPath = $this->manifestPath($date);
|
||||
|
||||
if ($notifyOnly) {
|
||||
// 建档后号码/邮箱已入库,重新解析会被去重清空——补发必须用 manifest 保存的分配结果
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : [];
|
||||
if (empty($manifest['groups'])) {
|
||||
$output->writeln('<error>manifest 不存在或无分配数据: ' . $manifestPath . ';请先执行 --no-notify 或完整命令生成</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$digestGroups = $manifest['groups'];
|
||||
} else {
|
||||
$totalRows = array_sum(array_map('count', $groups));
|
||||
if ($totalRows > 0) {
|
||||
$digestGroups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$digestGroups[(string)$uid] = [];
|
||||
}
|
||||
foreach ($groups as $uid => $rows) {
|
||||
foreach ($rows as $row) {
|
||||
$digestGroups[(string)$uid][] = $this->digestRow($row);
|
||||
}
|
||||
}
|
||||
$this->saveManifest($manifestPath, $targetUserIds, $digestGroups);
|
||||
} else {
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : [];
|
||||
if (!empty($manifest['groups'])) {
|
||||
$digestGroups = $manifest['groups'];
|
||||
$output->writeln('<comment>本次无新建档(可能已导入过),沿用已有 manifest 的分配结果。</comment>');
|
||||
} else {
|
||||
$output->writeln('<comment>没有可建档数据,结束。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
if ($noNotify) {
|
||||
$output->writeln('<comment>[no-notify] 已建档,跳过发邮件。核对库后用 --notify-only 补发。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendDigests($digestGroups, $targetUserIds, $manifestPath, $onlyUser, $output);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function saveManifest(string $path, array $targetUserIds, array $digestGroups): void
|
||||
{
|
||||
$perUser = []; $emailed = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$perUser[(string)$uid] = count($digestGroups[(string)$uid] ?? []);
|
||||
$emailed[(string)$uid] = false;
|
||||
}
|
||||
$data = [
|
||||
'date' => date('Y-m-d'),
|
||||
'imported_at' => time(),
|
||||
'target_users' => $targetUserIds,
|
||||
'per_user_counts' => $perUser,
|
||||
'groups' => $digestGroups,
|
||||
'emailed' => $emailed,
|
||||
];
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* 建档行 -> 邮件汇总行
|
||||
*/
|
||||
private function digestRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'code' => $row['code'] ?? '',
|
||||
'contact' => $row['contacts'],
|
||||
'email' => $row['email'],
|
||||
'phone' => $row['phone'],
|
||||
'country_cn' => '',
|
||||
'channel' => self::CHANNEL_NAME,
|
||||
'remark' => $row['message'],
|
||||
];
|
||||
}
|
||||
|
||||
private function sendDigests(array $digestGroups, array $targetUserIds, string $manifestPath, int $onlyUser, OutputInterface $output): void
|
||||
{
|
||||
$users = Db::table('user')->whereIn('id', $targetUserIds)->get(['id', 'name', 'email']);
|
||||
$userMap = [];
|
||||
foreach ($users as $u) {
|
||||
$userMap[(int)$u->id] = $u;
|
||||
}
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)file_get_contents($manifestPath), true) ?: ['emailed' => []]) : ['emailed' => []];
|
||||
$date = date('Y-m-d');
|
||||
|
||||
foreach ($targetUserIds as $uid) {
|
||||
if (count($digestGroups[(string)$uid] ?? []) === 0) {
|
||||
$output->writeln('<comment>user ' . $uid . ' 未分到询盘,不发送</comment>');
|
||||
continue;
|
||||
}
|
||||
if ($onlyUser > 0 && $uid !== $onlyUser) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($manifest['emailed'][(string)$uid])) {
|
||||
$output->writeln('<comment>跳过已发送: user ' . $uid . '</comment>');
|
||||
continue;
|
||||
}
|
||||
$u = $userMap[$uid] ?? null;
|
||||
if (!$u || empty($u->email)) {
|
||||
$output->writeln('<error>user ' . $uid . ' 无邮箱,跳过</error>');
|
||||
continue;
|
||||
}
|
||||
$inquiries = array_values($digestGroups[(string)$uid] ?? []);
|
||||
$html = InquiryDigestMail::build((string)$u->name, $inquiries, $date);
|
||||
$subject = '新询盘分配汇总(共 ' . count($inquiries) . ' 条)- ' . $u->name . ' - ' . $date;
|
||||
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
if (!$ok) {
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
}
|
||||
$manifest['emailed'][(string)$uid] = (bool)$ok;
|
||||
file_put_contents($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
$tag = $ok ? 'info' : 'error';
|
||||
$output->writeln('<' . $tag . '>user ' . $uid . ' (' . $u->email . '): '
|
||||
. ($ok ? '发送成功' : '发送失败') . ',共 ' . count($inquiries) . ' 条</' . $tag . '>');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\CrmCustomer;
|
||||
use app\common\service\OssService;
|
||||
use app\common\utils\InquiryFairAssigner;
|
||||
use app\common\utils\email\InquiryDigestMail;
|
||||
use app\common\utils\email\MailSend;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 合塑谷歌网站whatsapp询盘导入
|
||||
*
|
||||
* WhatsApp 询盘 Excel(库外数据)-> 截图传 OSS -> 建 crm_customer 档案 -> 轮询分配 -> 汇总邮件。
|
||||
* 固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入数据);
|
||||
* contacts=归一化号码、号码存 WhatsApp 字段;按号码去重(对比库内 WhatsApp/phone 两列 + 文件内)。
|
||||
*/
|
||||
class HesuGoogleWhatsAppImportCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'inquiry:import-hesu-google-wa';
|
||||
protected static $defaultDescription = '合塑谷歌网站whatsapp询盘导入:截图传OSS+建档+轮询分配+汇总邮件';
|
||||
|
||||
private const CORP_ID = 1;
|
||||
private const CHANNEL_ID = 4; // 合塑谷歌
|
||||
private const CHANNEL_NAME = '合塑谷歌(WhatsApp)';
|
||||
private const TYPE = 2; // 导入数据
|
||||
private const GRADE = 0;
|
||||
private const MAGNITUDE = 2;
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/WhatsApp询盘_6-8月_明细.xlsx')
|
||||
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔(必填)', '')
|
||||
->addOption('images-dir', null, InputOption::VALUE_REQUIRED, '截图所在目录', 'docs/whatsapp_screenshots')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+去重预检+分配预演(不上传截图/不写库/不发邮件)')
|
||||
->addOption('no-notify', null, InputOption::VALUE_NONE, '只上传+建档分配,不发邮件')
|
||||
->addOption('notify-only', null, InputOption::VALUE_NONE, '跳过上传与写库,按 manifest 补发未成功的邮件')
|
||||
->addOption('only-user', null, InputOption::VALUE_REQUIRED, '仅给指定 user_id 发邮件(试发)', '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Excel(按表头名定位列)。返回行数组。
|
||||
*/
|
||||
private function parseExcel(string $filePath): array
|
||||
{
|
||||
$sheet = IOFactory::load($filePath)->getActiveSheet();
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
|
||||
$colMap = [];
|
||||
foreach (range('A', 'Z') as $col) {
|
||||
$v = trim((string)($sheet->getCell($col . '1')->getValue() ?? ''));
|
||||
if ($v !== '') {
|
||||
$colMap[$v] = $col;
|
||||
}
|
||||
}
|
||||
if (!isset($colMap['WhatsApp号码'])) {
|
||||
throw new \RuntimeException('Excel 缺少必需列头: WhatsApp号码;实际列头: ' . implode(',', array_keys($colMap)));
|
||||
}
|
||||
|
||||
$get = function (string $header, int $r) use ($sheet, $colMap): string {
|
||||
if (!isset($colMap[$header])) {
|
||||
return '';
|
||||
}
|
||||
return trim((string)($sheet->getCell($colMap[$header] . $r)->getValue() ?? ''));
|
||||
};
|
||||
|
||||
$rows = [];
|
||||
for ($r = 2; $r <= $highestRow; $r++) {
|
||||
$number = $this->normalizeNumber($get('WhatsApp号码', $r));
|
||||
if ($number === '') {
|
||||
$blank = $get('最后留言', $r) === '' && $get('截图文件名', $r) === '';
|
||||
if ($blank) {
|
||||
continue; // 整行为空
|
||||
}
|
||||
$rows[] = ['excel_row' => $r, 'number' => '', 'message' => $get('最后留言', $r), 'shot' => $get('截图文件名', $r)];
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'excel_row' => $r,
|
||||
'number' => $number,
|
||||
'message' => $get('最后留言', $r),
|
||||
'shot' => $get('截图文件名', $r),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 号码归一化:仅保留数字与开头 +(去横线/空格等)
|
||||
*/
|
||||
private function normalizeNumber(string $raw): string
|
||||
{
|
||||
$n = preg_replace('/[^0-9+]/', '', trim($raw));
|
||||
$n = preg_replace('/\++/', '+', $n);
|
||||
if ($n !== '' && $n[0] !== '+') {
|
||||
$n = '+' . $n;
|
||||
}
|
||||
return $n !== '+' ? $n : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按号码去重:库内(WhatsApp 与 phone 两列)+ 文件内。返回 [待建档行, 跳过信息, 无号码行]
|
||||
*/
|
||||
private function dedup(array $rows, OutputInterface $output): array
|
||||
{
|
||||
$numbers = array_values(array_unique(array_filter(array_column($rows, 'number'))));
|
||||
$exists = [];
|
||||
foreach (array_chunk($numbers, 500) as $chunk) {
|
||||
foreach (['WhatsApp', 'phone'] as $col) {
|
||||
foreach (Db::table('crm_customer')->whereNull('deleted_at')->whereIn($col, $chunk)->pluck($col) as $v) {
|
||||
$n = $this->normalizeNumber((string)$v);
|
||||
if ($n !== '') {
|
||||
$exists[$n] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$keep = []; $skipped = []; $noNumber = []; $seen = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['number'] === '') {
|
||||
$noNumber[] = $row;
|
||||
continue;
|
||||
}
|
||||
if (isset($exists[$row['number']]) || isset($seen[$row['number']])) {
|
||||
$skipped[] = [$row['excel_row'], '号码重复: ' . $row['number']];
|
||||
continue;
|
||||
}
|
||||
$seen[$row['number']] = 1;
|
||||
$keep[] = $row;
|
||||
}
|
||||
return [$keep, $skipped, $noNumber];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传截图到 OSS。返回 [对象键或null, 是否成功];文件缺失返回 [null, false]。
|
||||
*/
|
||||
private function uploadShot(string $imagesDir, string $filename, OutputInterface $output): ?string
|
||||
{
|
||||
if ($filename === '') {
|
||||
return null;
|
||||
}
|
||||
$path = rtrim($imagesDir, '/') . '/' . ltrim($filename, '/');
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
$mainDir = rtrim((string)(getenv('OSS_MAIN_DIR') ?: 'x_server'), '/');
|
||||
$key = $mainDir . '/crm_whatsapp/' . basename($filename);
|
||||
try {
|
||||
(new OssService())->uploadImage($path, $key); // 返回带签名的临时URL,这里只关心落 OSS 的对象键
|
||||
return $key;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<comment> 截图上传失败 ' . $filename . ': ' . $e->getMessage() . '</comment>');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function manifestPath(string $date): string
|
||||
{
|
||||
return runtime_path() . '/inquiry_hesu_google_wa_' . $date . '.json';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$file = $input->getOption('file');
|
||||
$imagesDir = (string)$input->getOption('images-dir');
|
||||
$targetUserIds = array_values(array_filter(array_map('intval', explode(',', (string)$input->getOption('users')))));
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$notifyOnly = (bool)$input->getOption('notify-only');
|
||||
$noNotify = (bool)$input->getOption('no-notify');
|
||||
$onlyUser = (int)$input->getOption('only-user');
|
||||
$date = date('Y-m-d');
|
||||
|
||||
if (!$targetUserIds) {
|
||||
$output->writeln('<error>--users 不能为空(目标业务员 user_id 列表)</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (!is_file($file)) {
|
||||
$output->writeln('<error>文件不存在: ' . $file . '</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($notifyOnly && $noNotify) {
|
||||
$output->writeln('<error>--notify-only 与 --no-notify 不能同时使用</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln('<info>解析 Excel: ' . $file . '</info>');
|
||||
$rows = $this->parseExcel($file);
|
||||
$output->writeln('<info>数据行: ' . count($rows) . '</info>');
|
||||
|
||||
[$keep, $skipped, $noNumber] = $this->dedup($rows, $output);
|
||||
$output->writeln('<info>去重后待建档: ' . count($keep) . ',号码重复跳过: ' . count($skipped) . ',无号码跳过: ' . count($noNumber) . '</info>');
|
||||
foreach (array_slice($skipped, 0, 10) as $s) {
|
||||
$output->writeln('<comment> 跳过 行' . $s[0] . ' ' . $s[1] . '</comment>');
|
||||
}
|
||||
if (count($skipped) > 10) {
|
||||
$output->writeln('<comment> ...另 ' . (count($skipped) - 10) . ' 条</comment>');
|
||||
}
|
||||
foreach (array_slice($noNumber, 0, 5) as $row) {
|
||||
$output->writeln('<comment> 无号码 行' . $row['excel_row'] . ' 留言:' . mb_substr($row['message'], 0, 30) . '</comment>');
|
||||
}
|
||||
|
||||
// 截图可用性
|
||||
$shotFound = 0; $shotMissing = 0;
|
||||
foreach ($keep as $row) {
|
||||
if ($row['shot'] === '') {
|
||||
continue;
|
||||
}
|
||||
if (is_file(rtrim($imagesDir, '/') . '/' . ltrim($row['shot'], '/'))) {
|
||||
$shotFound++;
|
||||
} else {
|
||||
$shotMissing++;
|
||||
}
|
||||
}
|
||||
$output->writeln('<info>截图: 找到 ' . $shotFound . ' / 缺失 ' . $shotMissing . '(目录: ' . $imagesDir . ')</info>');
|
||||
|
||||
// 公平分配:按近10天窗口内实际分到数升序(少者优先、最久未分到优先),再按序轮询
|
||||
$fair = InquiryFairAssigner::fairOrder($targetUserIds);
|
||||
$orderedUsers = $fair['ordered'];
|
||||
$output->writeln('<info>分配顺序(近' . $fair['window_days'] . '天已有条数):'
|
||||
. implode('、', array_map(fn($u) => $u . '(' . ($fair['counts'][$u] ?? 0) . ')', $orderedUsers)) . '</info>');
|
||||
$n = count($orderedUsers);
|
||||
foreach ($keep as $i => &$row) {
|
||||
$row['new_user_id'] = $orderedUsers[$i % $n];
|
||||
}
|
||||
unset($row);
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
foreach ($keep as $row) {
|
||||
$groups[$row['new_user_id']][] = $row;
|
||||
}
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$output->writeln('<info>user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条</info>');
|
||||
}
|
||||
foreach (array_slice($keep, 0, 3) as $row) {
|
||||
$output->writeln('<comment>样例[行' . $row['excel_row'] . '] ' . $row['number'] . ' '
|
||||
. mb_substr($row['message'], 0, 20) . ' -> user ' . $row['new_user_id'] . '</comment>');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<info>[dry-run] 不上传截图、不写库、不发邮件。确认后去掉 --dry-run 执行。</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$notifyOnly) {
|
||||
// 先传截图(外部操作,不在事务内;失败不阻断,仅记录),再事务建档
|
||||
$uploaded = 0;
|
||||
foreach ($keep as &$row) {
|
||||
$key = $this->uploadShot($imagesDir, $row['shot'], $output);
|
||||
$row['shot_key'] = $key;
|
||||
if ($key !== null) {
|
||||
$uploaded++;
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
$output->writeln('<info>截图已上传 OSS: ' . $uploaded . '</info>');
|
||||
|
||||
$created = [];
|
||||
Db::connection()->transaction(function () use ($keep, $output, &$created) {
|
||||
foreach ($keep as $row) {
|
||||
$desc = $row['message'];
|
||||
if ($row['shot'] !== '') {
|
||||
$desc .= ($desc !== '' ? "\n" : '') . '截图:' . ($row['shot_key'] ?? $row['shot']);
|
||||
}
|
||||
$c = new CrmCustomer();
|
||||
$c->corp_id = self::CORP_ID;
|
||||
$c->user_id = $row['new_user_id'];
|
||||
$c->type = self::TYPE;
|
||||
$c->channel_id = self::CHANNEL_ID;
|
||||
$c->grade = self::GRADE;
|
||||
$c->magnitude = self::MAGNITUDE;
|
||||
$c->email = '';
|
||||
$c->phone = '';
|
||||
$c->WhatsApp = $row['number'];
|
||||
$c->contacts = $row['number'];
|
||||
$c->desc_collect = $desc;
|
||||
$c->obtain = time();
|
||||
if (!$c->save()) {
|
||||
throw new \RuntimeException('行' . $row['excel_row'] . ' 建档失败');
|
||||
}
|
||||
$row['code'] = $c->code;
|
||||
$created[] = $row;
|
||||
}
|
||||
$output->writeln('<info>已建档并分配: ' . count($created) . ' 条(code 自动生成)</info>');
|
||||
});
|
||||
$keep = $created;
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
foreach ($keep as $row) {
|
||||
$groups[$row['new_user_id']][] = $row;
|
||||
}
|
||||
} else {
|
||||
$output->writeln('<comment>[notify-only] 跳过上传与写库。</comment>');
|
||||
}
|
||||
|
||||
$manifestPath = $this->manifestPath($date);
|
||||
|
||||
if ($notifyOnly) {
|
||||
// 建档后号码已入库,重新解析会被去重清空——补发必须用 manifest 保存的分配结果
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : [];
|
||||
if (empty($manifest['groups'])) {
|
||||
$output->writeln('<error>manifest 不存在或无分配数据: ' . $manifestPath . ';请先执行 --no-notify 或完整命令生成</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$digestGroups = $manifest['groups'];
|
||||
} else {
|
||||
$totalRows = array_sum(array_map('count', $groups));
|
||||
if ($totalRows > 0) {
|
||||
$digestGroups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$digestGroups[(string)$uid] = [];
|
||||
}
|
||||
foreach ($groups as $uid => $rows) {
|
||||
foreach ($rows as $row) {
|
||||
$digestGroups[(string)$uid][] = $this->digestRow($row);
|
||||
}
|
||||
}
|
||||
$this->saveManifest($manifestPath, $targetUserIds, $digestGroups);
|
||||
} else {
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : [];
|
||||
if (!empty($manifest['groups'])) {
|
||||
$digestGroups = $manifest['groups'];
|
||||
$output->writeln('<comment>本次无新建档(可能已导入过),沿用已有 manifest 的分配结果。</comment>');
|
||||
} else {
|
||||
$output->writeln('<comment>没有可建档数据,结束。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
if ($noNotify) {
|
||||
$output->writeln('<comment>[no-notify] 已建档,跳过发邮件。核对库后用 --notify-only 补发。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendDigests($digestGroups, $targetUserIds, $manifestPath, $onlyUser, $output);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function saveManifest(string $path, array $targetUserIds, array $digestGroups): void
|
||||
{
|
||||
$perUser = []; $emailed = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$perUser[(string)$uid] = count($digestGroups[(string)$uid] ?? []);
|
||||
$emailed[(string)$uid] = false;
|
||||
}
|
||||
$data = [
|
||||
'date' => date('Y-m-d'),
|
||||
'imported_at' => time(),
|
||||
'target_users' => $targetUserIds,
|
||||
'per_user_counts' => $perUser,
|
||||
'groups' => $digestGroups,
|
||||
'emailed' => $emailed,
|
||||
];
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* 建档行 -> 邮件汇总行(联系人/电话列显示 WhatsApp 号)
|
||||
*/
|
||||
private function digestRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'code' => $row['code'] ?? '',
|
||||
'contact' => $row['number'],
|
||||
'email' => '',
|
||||
'phone' => $row['number'],
|
||||
'country_cn' => '',
|
||||
'channel' => self::CHANNEL_NAME,
|
||||
'remark' => $row['message'],
|
||||
];
|
||||
}
|
||||
|
||||
private function sendDigests(array $digestGroups, array $targetUserIds, string $manifestPath, int $onlyUser, OutputInterface $output): void
|
||||
{
|
||||
$users = Db::table('user')->whereIn('id', $targetUserIds)->get(['id', 'name', 'email']);
|
||||
$userMap = [];
|
||||
foreach ($users as $u) {
|
||||
$userMap[(int)$u->id] = $u;
|
||||
}
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)file_get_contents($manifestPath), true) ?: ['emailed' => []]) : ['emailed' => []];
|
||||
$date = date('Y-m-d');
|
||||
|
||||
foreach ($targetUserIds as $uid) {
|
||||
if (count($digestGroups[(string)$uid] ?? []) === 0) {
|
||||
$output->writeln('<comment>user ' . $uid . ' 未分到询盘,不发送</comment>');
|
||||
continue;
|
||||
}
|
||||
if ($onlyUser > 0 && $uid !== $onlyUser) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($manifest['emailed'][(string)$uid])) {
|
||||
$output->writeln('<comment>跳过已发送: user ' . $uid . '</comment>');
|
||||
continue;
|
||||
}
|
||||
$u = $userMap[$uid] ?? null;
|
||||
if (!$u || empty($u->email)) {
|
||||
$output->writeln('<error>user ' . $uid . ' 无邮箱,跳过</error>');
|
||||
continue;
|
||||
}
|
||||
$inquiries = array_values($digestGroups[(string)$uid] ?? []);
|
||||
$html = InquiryDigestMail::build((string)$u->name, $inquiries, $date);
|
||||
$subject = 'WhatsApp新询盘分配汇总(共 ' . count($inquiries) . ' 条)- ' . $u->name . ' - ' . $date;
|
||||
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
if (!$ok) {
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
}
|
||||
$manifest['emailed'][(string)$uid] = (bool)$ok;
|
||||
file_put_contents($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
$tag = $ok ? 'info' : 'error';
|
||||
$output->writeln('<' . $tag . '>user ' . $uid . ' (' . $u->email . '): '
|
||||
. ($ok ? '发送成功' : '发送失败') . ',共 ' . count($inquiries) . ' 条</' . $tag . '>');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = 300;
|
||||
|
||||
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', '>', 34847)
|
||||
->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,332 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\utils\email\InquiryDigestMail;
|
||||
use app\common\utils\email\MailSend;
|
||||
use app\crm\model\CustomerHis;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
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;
|
||||
|
||||
class InquiryBulkAssignCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'inquiry:bulk-assign';
|
||||
protected static $defaultDescription = '按 Excel 行序轮询分配询盘给业务员,更新 crm_customer.user_id 并发汇总邮件';
|
||||
|
||||
// Excel 列映射(A=编号/code)
|
||||
private const COL_CODE = 'A';
|
||||
private const COL_CHANNEL = 'D';
|
||||
private const COL_EMAIL = 'E';
|
||||
private const COL_PHONE = 'F';
|
||||
private const COL_WHATSAPP = 'G';
|
||||
private const COL_CONTACT = 'H';
|
||||
private const COL_REMARK = 'I';
|
||||
private const COL_COUNTRY_CN = 'J';
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/崔琪2.xlsx')
|
||||
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔', '34,73,72,155,64,211')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+匹配+分配,打印报告,不写库不发邮件')
|
||||
->addOption('only-user', null, InputOption::VALUE_REQUIRED, '仅给指定 user_id 发邮件(试发)', '0')
|
||||
->addOption('notify-only', null, InputOption::VALUE_NONE, '跳过写库,按 manifest 补发未成功的邮件')
|
||||
->addOption('no-notify', null, InputOption::VALUE_NONE, '只写库不发邮件(先核对库再用 --notify-only 补发)')
|
||||
->addOption('self-test', null, InputOption::VALUE_NONE, '运行纯逻辑自测');
|
||||
}
|
||||
|
||||
/**
|
||||
* 内存轮询分配(确定性 i % n)
|
||||
* 给 $matched 每项追加 new_user_id,返回 [user_id => [rows...]]
|
||||
*/
|
||||
public static function assign(array &$matched, array $targetUserIds): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
$n = count($targetUserIds);
|
||||
foreach ($matched as $i => &$row) {
|
||||
$uid = $targetUserIds[$i % $n];
|
||||
$row['new_user_id'] = $uid;
|
||||
$groups[$uid][] = $row;
|
||||
}
|
||||
unset($row);
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Excel,按行顺序返回询盘数组
|
||||
*/
|
||||
private function parseExcel(string $filePath): array
|
||||
{
|
||||
$sheet = IOFactory::load($filePath)->getActiveSheet();
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
$rows = [];
|
||||
$get = function (string $col, int $r) use ($sheet): string {
|
||||
return trim((string)($sheet->getCell($col . $r)->getValue() ?? ''));
|
||||
};
|
||||
for ($r = 2; $r <= $highestRow; $r++) {
|
||||
$code = $get(self::COL_CODE, $r);
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'code' => $code,
|
||||
'channel' => $get(self::COL_CHANNEL, $r),
|
||||
'email' => $get(self::COL_EMAIL, $r),
|
||||
'phone' => $get(self::COL_PHONE, $r),
|
||||
'whatsapp' => $get(self::COL_WHATSAPP, $r),
|
||||
'contact' => $get(self::COL_CONTACT, $r),
|
||||
'country_cn' => $get(self::COL_COUNTRY_CN, $r),
|
||||
'remark' => $get(self::COL_REMARK, $r),
|
||||
'excel_row' => $r,
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量匹配 crm_customer(code 唯一取 MIN(id),排除软删除)
|
||||
* 返回 ['matched' => [...], 'unmatched' => [...]];matched 每项追加 customer_id/old_user_id
|
||||
*/
|
||||
private function matchCustomers(array $rows): array
|
||||
{
|
||||
$codes = array_values(array_unique(array_column($rows, 'code')));
|
||||
$map = []; // code => ['id','old_user_id']
|
||||
foreach (array_chunk($codes, 500) as $chunk) {
|
||||
$recs = Db::table('crm_customer')
|
||||
->whereIn('code', $chunk)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id', 'asc')
|
||||
->get(['id', 'code', 'user_id']);
|
||||
foreach ($recs as $rec) {
|
||||
if (!isset($map[$rec->code])) {
|
||||
$map[$rec->code] = ['id' => (int)$rec->id, 'old_user_id' => $rec->user_id];
|
||||
}
|
||||
}
|
||||
}
|
||||
$matched = [];
|
||||
$unmatched = [];
|
||||
foreach ($rows as $row) {
|
||||
if (isset($map[$row['code']])) {
|
||||
$row['customer_id'] = $map[$row['code']]['id'];
|
||||
$row['old_user_id'] = $map[$row['code']]['old_user_id'];
|
||||
$matched[] = $row;
|
||||
} else {
|
||||
$unmatched[] = $row;
|
||||
}
|
||||
}
|
||||
return ['matched' => $matched, 'unmatched' => $unmatched];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单事务:更新 user_id + 写历史。用 Db::table() 不触发 model 事件。
|
||||
*/
|
||||
private function applyAssignments(array $matched): void
|
||||
{
|
||||
Db::connection()->transaction(function () use ($matched) {
|
||||
foreach ($matched as $row) {
|
||||
Db::table('crm_customer')
|
||||
->where('id', $row['customer_id'])
|
||||
->update(['user_id' => $row['new_user_id']]);
|
||||
CustomerHis::create([
|
||||
'customer_id' => $row['customer_id'],
|
||||
'user_his' => $row['old_user_id'],
|
||||
'user_id' => $row['new_user_id'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function manifestPath(string $date): string
|
||||
{
|
||||
return runtime_path() . '/inquiry_bulk_assign_' . $date . '.json';
|
||||
}
|
||||
|
||||
private function initManifest(string $path, array $targetUserIds, array $matched, array $groups): void
|
||||
{
|
||||
if (is_file($path)) {
|
||||
return; // 已存在则保留(含 emailed 标记),供补发使用
|
||||
}
|
||||
$perUser = [];
|
||||
$emailed = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$perUser[(string)$uid] = count($groups[$uid] ?? []);
|
||||
$emailed[(string)$uid] = false;
|
||||
}
|
||||
$data = [
|
||||
'date' => date('Y-m-d'),
|
||||
'assigned_at' => time(),
|
||||
'target_users' => $targetUserIds,
|
||||
'total_matched' => count($matched),
|
||||
'per_user_counts' => $perUser,
|
||||
'emailed' => $emailed,
|
||||
];
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
private function loadManifest(string $path): array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return ['emailed' => []];
|
||||
}
|
||||
return json_decode((string)file_get_contents($path), true) ?: ['emailed' => []];
|
||||
}
|
||||
|
||||
private function saveManifest(string $path, array $data): void
|
||||
{
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 targetUserIds 顺序发汇总邮件;跳过已标记成功与 --only-user 之外的人。
|
||||
*/
|
||||
private function sendDigests(array $groups, array $targetUserIds, string $manifestPath, int $onlyUser, OutputInterface $output): void
|
||||
{
|
||||
$users = Db::table('user')->whereIn('id', $targetUserIds)->get(['id', 'name', 'email']);
|
||||
$userMap = [];
|
||||
foreach ($users as $u) {
|
||||
$userMap[(int)$u->id] = $u;
|
||||
}
|
||||
$manifest = $this->loadManifest($manifestPath);
|
||||
$date = date('Y-m-d');
|
||||
|
||||
foreach ($targetUserIds as $uid) {
|
||||
if ($onlyUser > 0 && $uid !== $onlyUser) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($manifest['emailed'][(string)$uid])) {
|
||||
$output->writeln('<comment>跳过已发送: user ' . $uid . '</comment>');
|
||||
continue;
|
||||
}
|
||||
$u = $userMap[$uid] ?? null;
|
||||
if (!$u || empty($u->email)) {
|
||||
$output->writeln('<error>user ' . $uid . ' 无邮箱,跳过</error>');
|
||||
continue;
|
||||
}
|
||||
$inquiries = $groups[$uid] ?? [];
|
||||
$html = InquiryDigestMail::build((string)$u->name, $inquiries, $date);
|
||||
$subject = '询盘分配汇总(共 ' . count($inquiries) . ' 条)- ' . $u->name . ' - ' . $date;
|
||||
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
if (!$ok) {
|
||||
$ok = MailSend::send($u->email, $subject, $html); // 重试 1 次
|
||||
}
|
||||
$manifest['emailed'][(string)$uid] = (bool)$ok;
|
||||
$this->saveManifest($manifestPath, $manifest);
|
||||
|
||||
$tag = $ok ? 'info' : 'error';
|
||||
$output->writeln('<' . $tag . '>user ' . $uid . ' (' . $u->email . '): '
|
||||
. ($ok ? '发送成功' : '发送失败') . ',共 ' . count($inquiries) . ' 条</' . $tag . '>');
|
||||
}
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
if ($input->getOption('self-test')) {
|
||||
return $this->runSelfTest($output);
|
||||
}
|
||||
|
||||
$file = $input->getOption('file');
|
||||
$targetUserIds = array_values(array_filter(array_map('intval', explode(',', (string)$input->getOption('users')))));
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$notifyOnly = (bool)$input->getOption('notify-only');
|
||||
$noNotify = (bool)$input->getOption('no-notify');
|
||||
$onlyUser = (int)$input->getOption('only-user');
|
||||
$date = date('Y-m-d');
|
||||
|
||||
if (!$targetUserIds) {
|
||||
$output->writeln('<error>--users 不能为空</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (!is_file($file)) {
|
||||
$output->writeln('<error>文件不存在: ' . $file . '</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($notifyOnly && $noNotify) {
|
||||
$output->writeln('<error>--notify-only 与 --no-notify 不能同时使用</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln('<info>解析 Excel: ' . $file . '</info>');
|
||||
$rows = $this->parseExcel($file);
|
||||
$output->writeln('<info>Excel 询盘数: ' . count($rows) . '</info>');
|
||||
|
||||
$res = $this->matchCustomers($rows);
|
||||
$matched = $res['matched'];
|
||||
$unmatched = $res['unmatched'];
|
||||
$output->writeln('<info>匹配成功: ' . count($matched) . ',未匹配: ' . count($unmatched) . '</info>');
|
||||
if ($unmatched) {
|
||||
$sample = implode(', ', array_slice(array_column($unmatched, 'code'), 0, 10));
|
||||
$output->writeln('<comment>未匹配 code 样例: ' . $sample . '</comment>');
|
||||
}
|
||||
|
||||
$groups = self::assign($matched, $targetUserIds);
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$output->writeln('<info>user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条</info>');
|
||||
}
|
||||
foreach ($matched as $i => $row) {
|
||||
if ($i < 3) {
|
||||
$output->writeln('<comment>样例[' . $i . '] code=' . $row['code']
|
||||
. ' -> user ' . $row['new_user_id'] . ' (原 ' . $row['old_user_id'] . ')</comment>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<info>[dry-run] 不写库、不发邮件。确认无误后去掉 --dry-run 执行。</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$notifyOnly) {
|
||||
$this->applyAssignments($matched);
|
||||
$output->writeln('<info>已写库(user_id 更新 + crm_customer_his),共 ' . count($matched) . ' 条。</info>');
|
||||
} else {
|
||||
$output->writeln('<comment>[notify-only] 跳过写库。</comment>');
|
||||
}
|
||||
|
||||
if ($noNotify) {
|
||||
$output->writeln('<comment>[no-notify] 已写库,跳过发邮件。核对库后用 --notify-only 补发。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$manifestPath = $this->manifestPath($date);
|
||||
$this->initManifest($manifestPath, $targetUserIds, $matched, $groups);
|
||||
$this->sendDigests($groups, $targetUserIds, $manifestPath, $onlyUser, $output);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function runSelfTest(OutputInterface $output): int
|
||||
{
|
||||
$fail = 0;
|
||||
|
||||
// 1) assign: 7 条 / 3 人 -> 计数 [3,2,2]
|
||||
$items = array_fill(0, 7, []);
|
||||
$groups = self::assign($items, [10, 20, 30]);
|
||||
$counts = [count($groups[10]), count($groups[20]), count($groups[30])];
|
||||
if ($counts !== [3, 2, 2]) {
|
||||
$output->writeln('<error>assign 计数错误: ' . json_encode($counts) . '</error>');
|
||||
$fail++;
|
||||
}
|
||||
|
||||
// 2) InquiryDigestMail::build 内容校验
|
||||
$html = InquiryDigestMail::build('张三', [[
|
||||
'code' => 'CM1', 'contact' => 'A', 'email' => 'a@x.com',
|
||||
'phone' => '1', 'country_cn' => '中国', 'channel' => 'fb', 'remark' => '意向车型:X',
|
||||
]], '2026-07-17');
|
||||
if (strpos($html, '张三') === false || strpos($html, 'CM1') === false
|
||||
|| strpos($html, '共 <strong>1</strong> 条') === false) {
|
||||
$output->writeln('<error>InquiryDigestMail::build 内容校验失败</error>');
|
||||
$fail++;
|
||||
}
|
||||
|
||||
if ($fail === 0) {
|
||||
$output->writeln('<info>self-test 通过</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -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,259 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* 从Excel文件导入fb_leads
|
||||
*/
|
||||
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,43 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
use Webman\RedisQueue\Redis as RedisQueueRedis;
|
||||
|
||||
/**
|
||||
* 询盘补发
|
||||
*/
|
||||
class InquiryReissueCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'iq-bf';
|
||||
protected static $defaultDescription = '';
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$inquiries = Db::table('inquiry_log')
|
||||
->where('sync_crm', 0)
|
||||
->where('id', '>', 33785)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($inquiries as $row) {
|
||||
$inquiryId = (int)$row->id;
|
||||
|
||||
var_dump('补发ID:'.$inquiryId);
|
||||
|
||||
sleep(1);
|
||||
|
||||
$res = [
|
||||
'id' => $inquiryId
|
||||
];
|
||||
RedisQueueRedis::send('inquiry-to-crm-new', $res);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\User;
|
||||
use app\common\model\UserSn;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
use app\common\utils\email\Emails;
|
||||
|
||||
/**
|
||||
* 批量处理用户编号(也用于调试)
|
||||
*/
|
||||
class VehMoneyCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'veh-money';
|
||||
protected static $defaultDescription = '';
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
|
||||
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,42 @@ class BrandSeriesController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL抓取品牌、车系、车型
|
||||
* @param Request $request
|
||||
* @return \support\Response
|
||||
*/
|
||||
public function crawlUrl(Request $request){
|
||||
try {
|
||||
$result = $this->service->crawlUrl($request);
|
||||
if($result){
|
||||
return $this->success($result,'抓取成功');
|
||||
}else{
|
||||
return $this->error('抓取任务失败');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓取品牌、车系、车型
|
||||
* @param Request $request
|
||||
* @return \support\Response
|
||||
*/
|
||||
public function crawl(Request $request){
|
||||
try {
|
||||
$result = $this->service->crawl($request);
|
||||
if($result){
|
||||
return $this->success($result,'抓取成功');
|
||||
}else{
|
||||
return $this->error('抓取任务失败');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存抓取的系列车型
|
||||
* @param Request $request
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\model\StockOrders;
|
||||
use app\common\model\StockOrderItems;
|
||||
use app\common\model\User;
|
||||
use support\Request;
|
||||
|
||||
class PublicApiController extends BaseController
|
||||
{
|
||||
// 业务人员固定角色ID
|
||||
protected const STAFF_ROLE_ID = 2;
|
||||
|
||||
protected $noNeedLogin = ['piList', 'vinList', 'staffList'];
|
||||
protected $noNeedHeaderData = ['piList', 'vinList', 'staffList'];
|
||||
|
||||
/**
|
||||
* PI 号列表(含关联的 VIN 和车型名称)
|
||||
* GET /common/public/pi/list
|
||||
*/
|
||||
public function piList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$piNo = $request->input('pi_no', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrders::where('approval_status', 2)
|
||||
->whereNotNull('pi_no')
|
||||
->where('pi_no', '<>', '');
|
||||
|
||||
if ($piNo !== '') {
|
||||
$query->where('pi_no', 'like', '%' . addcslashes($piNo, '%_') . '%');
|
||||
}
|
||||
|
||||
$query->with(['items' => function ($q) {
|
||||
$q->select('id', 'order_id', 'vin', 'model_snapshot');
|
||||
}]);
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'pi_no'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($order) {
|
||||
return [
|
||||
'pi_no' => $order->pi_no,
|
||||
'vins' => $order->items->pluck('vin')->filter()->values()->toArray(),
|
||||
'model_names' => $order->items->pluck('model_snapshot')->filter()->values()->toArray(),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('查询失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VIN 号列表(含车型名称)
|
||||
* GET /common/public/vin/list
|
||||
*/
|
||||
public function vinList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$vin = $request->input('vin', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrderItems::whereHas('order', function ($q) {
|
||||
$q->where('approval_status', 2);
|
||||
})->whereNotNull('vin')
|
||||
->where('vin', '<>', '');
|
||||
|
||||
if ($vin !== '') {
|
||||
$query->where('vin', 'like', '%' . addcslashes($vin, '%_') . '%');
|
||||
}
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'vin', 'model_snapshot'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($item) {
|
||||
return [
|
||||
'vin' => $item->vin,
|
||||
'model_name' => $item->model_snapshot,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('查询失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务人员列表(role_id=2,在职,全量返回)
|
||||
* GET /common/public/staff/list
|
||||
*/
|
||||
public function staffList(Request $request)
|
||||
{
|
||||
try {
|
||||
$keyword = $request->input('keyword', '');
|
||||
|
||||
// 通过 user_roles 关联,筛选指定角色的用户
|
||||
$query = User::whereHas('roles', function ($q) {
|
||||
$q->where('roles.id', static::STAFF_ROLE_ID);
|
||||
})->where('status', 1);
|
||||
|
||||
if ($keyword !== '') {
|
||||
$kw = '%' . addcslashes($keyword, '%_') . '%';
|
||||
$query->where(function ($q) use ($kw) {
|
||||
$q->where('name', 'like', $kw)
|
||||
->orWhere('sn', 'like', $kw)
|
||||
->orWhere('phone', 'like', $kw)
|
||||
->orWhere('email', 'like', $kw);
|
||||
});
|
||||
}
|
||||
|
||||
$list = $query->orderBy('id', 'asc')
|
||||
->get(['id', 'sn', 'name', 'phone', 'email']);
|
||||
|
||||
$data = $list->map(function ($user) {
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'sn' => $user->sn,
|
||||
'phone' => $user->phone,
|
||||
'email' => $user->email,
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('查询失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ class UploadController
|
||||
|
||||
$result = Storage::adapter('oss')
|
||||
->path(config('oss.oss_main_dir') . $sceneUrl . date('Ymd'))
|
||||
->size(1024*1024*5)
|
||||
->size(1024*1024*50)
|
||||
->extYes($mimes)
|
||||
->upload($file);
|
||||
|
||||
@@ -133,7 +133,7 @@ class UploadController
|
||||
|
||||
$result = Storage::adapter('oss')
|
||||
->path(config('oss.oss_main_dir') . $sceneUrl . date('Ymd'))
|
||||
->size(1024*1024*5)
|
||||
->size(1024*1024*50)
|
||||
->extYes(['image/jpeg','image/png'])
|
||||
->extNo(['image/gif'])
|
||||
->upload($file);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\UsedcarCrawlResultsService;
|
||||
|
||||
class UsedcarCrawlResultsController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new UsedcarCrawlResultsService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
// protected function indexFormat($data)
|
||||
// {
|
||||
// if (isset($data['created_at']))
|
||||
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
// if (isset($data['updated_at']))
|
||||
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
// return $data;
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\UsedcarCrawlResultsImagesService;
|
||||
|
||||
class UsedcarCrawlResultsImagesController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new UsedcarCrawlResultsImagesService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
// protected function indexFormat($data)
|
||||
// {
|
||||
// if (isset($data['created_at']))
|
||||
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
// if (isset($data['updated_at']))
|
||||
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
// return $data;
|
||||
// }
|
||||
}
|
||||
@@ -539,23 +539,30 @@ class AuthLogic
|
||||
|
||||
public function getHome($systemId, $data)
|
||||
{
|
||||
// 2025.10.30 临时处理 ====== start
|
||||
if ($systemId == 1) {
|
||||
if (in_array(1, $data['role_ids'])) {
|
||||
return '/dashboard/console';
|
||||
}
|
||||
if (count($data['role_ids']) == 1 && in_array(5, $data['role_ids'])) {
|
||||
return '/dashboard/console/backoffice';
|
||||
}
|
||||
if (count($data['role_ids']) == 1 && in_array(6, $data['role_ids'])) {
|
||||
return '/dashboard/console/finance';
|
||||
}
|
||||
// // 2025.10.30 临时处理 ====== start
|
||||
// if ($systemId == 1) {
|
||||
// if (in_array(1, $data['role_ids'])) {
|
||||
// return '/dashboard/console';
|
||||
// }
|
||||
// if (count($data['role_ids']) == 1 && in_array(5, $data['role_ids'])) {
|
||||
// return '/dashboard/console/backoffice';
|
||||
// }
|
||||
// if (count($data['role_ids']) == 1 && in_array(6, $data['role_ids'])) {
|
||||
// return '/dashboard/console/finance';
|
||||
// }
|
||||
// }
|
||||
// // 2025.10.30 临时处理 ====== end
|
||||
//
|
||||
// $info = Resource::where('system_id', $systemId)
|
||||
// ->where('is_home', 1)
|
||||
// ->first();
|
||||
|
||||
$resources_id = (new UserDashboardRuleLogic)->getUserDashboard($data['uid']);
|
||||
if(!empty($resources_id)){
|
||||
$info = Resource::where('system_id', $systemId)
|
||||
->where('id', $resources_id)
|
||||
->first();
|
||||
}
|
||||
// 2025.10.30 临时处理 ====== end
|
||||
|
||||
$info = Resource::where('system_id', $systemId)
|
||||
->where('is_home', 1)
|
||||
->first();
|
||||
|
||||
if (!$info) {
|
||||
return '/dashboard/console';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\InquiryTransmitConfig;
|
||||
|
||||
class InquiryTransmitConfigLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InquiryTransmitConfig::class;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\InquiryTransmit;
|
||||
|
||||
class InquiryTransmitLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InquiryTransmit::class;
|
||||
|
||||
}
|
||||
@@ -23,6 +23,13 @@ class PermLogic
|
||||
}
|
||||
|
||||
$all = collect();
|
||||
//获取仪表盘
|
||||
$resources_id = (new UserDashboardRuleLogic)->getUserDashboard($userId);
|
||||
if(!empty($resources_id)){
|
||||
$resources = Resource::where('id', $resources_id)->get();
|
||||
$all = $all->merge($resources);
|
||||
}
|
||||
|
||||
// 遍历用户的所有角色,合并每个角色所拥有的资源
|
||||
foreach ($user->roles as $role) {
|
||||
$all = $all->merge(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\UsedcarCrawlResultsImages;
|
||||
|
||||
class UsedcarCrawlResultsImagesLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = UsedcarCrawlResultsImages::class;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\UsedcarCrawlResults;
|
||||
|
||||
class UsedcarCrawlResultsLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = UsedcarCrawlResults::class;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\model\User;
|
||||
use app\common\model\UserGroup;
|
||||
use app\common\model\UserGroupMembers;
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\UserDashboardRule;
|
||||
|
||||
class UserDashboardRuleLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = UserDashboardRule::class;
|
||||
|
||||
public function getUserDashboard($userId)
|
||||
{
|
||||
$user = User::with('reportToInfo')->find($userId);;
|
||||
//获取用户所在分组
|
||||
$ugm = UserGroupMembers::where('user_id', $userId)->where('status', 1)->get();
|
||||
$group_id = isset($ugm->group_id)?$ugm->group_id:0;
|
||||
|
||||
$roleRule = UserDashboardRule::orWhere(function ($query) use ($user) {
|
||||
$query->whereIn('related', $user->roles->pluck('id'))
|
||||
->where('rule_type', 'role');
|
||||
})
|
||||
->orWhere(function ($query) use ($userId) {
|
||||
$query->where('related', $userId)
|
||||
->where('rule_type', 'user');
|
||||
})
|
||||
->orWhere(function ($query) use ($group_id) {
|
||||
$query->where('related', $group_id)
|
||||
->where('rule_type', 'group');
|
||||
})
|
||||
->orderBy('weight','desc')
|
||||
->orderBy('rule_type','desc')
|
||||
->first();
|
||||
|
||||
$resources_id = $roleRule?$roleRule->resources_id:null;
|
||||
return $resources_id;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class EmailMailbox extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'email_mailbox';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = false;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'channel_id', 'corp_id', 'email', 'imap_host', 'imap_port', 'imap_flags', 'mailbox', 'username', 'password', 'status', 'last_uid', 'last_fetch_at', 'remark'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class EmailPull extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'email_pull';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'email', 'uid', 'subject', 'from', 'date', 'body', 'status', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class EmailPullParsed extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'email_pull_parsed';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
// protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'email_id', 'date', 'email', 'name', 'tel', 'company', 'country', 'form_url', 'message', 'ip', 'iplocation', 'ilang', 'mailto', 'navigator_language', 'navigator_platform', 'screen_width', 'screen_height', 'ucountry', 'status', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class EmailPullUids extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'email_pull_uids';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = false;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['uid', 'fetched_at', 'status', 'processed_at', 'email'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InquiryTransmit extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inquiry_transmit';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'date', 'email', 'name', 'tel', 'company', 'country', 'domain', 'form_url', 'message', 'ip', 'ip_location', 'ilang', 'mailto', 'navigator_language', 'navigator_platform', 'screen_width', 'screen_height', 'uCountry', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InquiryTransmitConfig extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inquiry_transmit_config';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'domain', 'mailto', 'lang', 'status', 'remark', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -45,7 +45,14 @@ class StockOrderItems extends Model
|
||||
'id', 'order_id', 'product_code', 'sku_id', 'sku_snapshot',
|
||||
'model_snapshot', 'quantity', 'color_interior_code', 'color_interior_snapshot',
|
||||
'color_exterior_code', 'color_exterior_snapshot', 'remark', 'vin', 'created_at', 'updated_at',
|
||||
'mileage', 'unique_id'
|
||||
'mileage', 'unique_id', 'attachment'
|
||||
];
|
||||
|
||||
/**
|
||||
* 字段类型转换:attachment 以 JSON 数组存取(写自动 json_encode、读自动 decode)
|
||||
*/
|
||||
protected $casts = [
|
||||
'attachment' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class UsedcarCrawlResults extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'usedcar_crawl_results';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'used_car_id', 'car_id', 'url', 'car_id_url', 'fuel_form', 'classify_energy', 'brand_name', 'series_name', 'car_name', 'year', 'title', 'price', 'new_price', 'price_difference', 'usage_mileage', 'condition_desc', 'images', 'used_detail', 'config_detail', 'format_shop_info', 'format_car_info', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class UsedcarCrawlResultsImages extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'usedcar_crawl_results_images';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'uc_id', 'used_car_id', 'original_name', 'file_path', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class UserDashboardRule extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'user_dashboard_rule';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = [];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\model\User;
|
||||
use app\common\model\UserGroupMembers;
|
||||
use support\Db;
|
||||
use app\common\validate\ApprovalRecordsValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\ApprovalRecords;
|
||||
@@ -56,20 +59,91 @@ class ApprovalRecordsService
|
||||
case 'todo':
|
||||
|
||||
if ($p['scene'] === 'todo') {
|
||||
if (!in_array(1, $request->user->role_ids)) {
|
||||
$config['where']['approver_id'] = $request->user->uid;
|
||||
// if (!in_array(1, $request->user->role_ids)) {
|
||||
// $config['where']['approver_id'] = $request->user->uid;
|
||||
// }
|
||||
$roles_id = array_intersect([1,6], $request->user->role_ids);
|
||||
if(count($roles_id) == 0){
|
||||
if(in_array(4, $request->user->role_ids)){
|
||||
$report = User::where('report_to',$request->user->uid)->pluck('id')->toArray();
|
||||
$report = array_merge($report, [$request->user->uid]);
|
||||
$config['where']['approver_id'] = ['in',$report];
|
||||
} elseif (in_array(5, $request->user->role_ids)){ //内勤
|
||||
$userGroupMember = UserGroupMembers::where('user_id',$request->user->uid)
|
||||
->where('is_leader',1)
|
||||
->first();
|
||||
if($userGroupMember){
|
||||
$report = UserGroupMembers::where('group_id',$userGroupMember->group_id)
|
||||
->pluck('user_id')
|
||||
->toArray();
|
||||
$report = array_merge($report, [$request->user->uid]);
|
||||
$config['where']['approver_id'] = ['in',$report];
|
||||
}else{ //内勤没有负责的组时
|
||||
$config['where']['approver_id'] = $request->user->uid;
|
||||
}
|
||||
} else {
|
||||
$config['where']['approver_id'] = $request->user->uid;
|
||||
}
|
||||
}
|
||||
$config['where']['status'] = 'pending';
|
||||
$config['where']['instance.status'] = 'processing';
|
||||
}
|
||||
|
||||
if ($p['scene'] === 'history') {
|
||||
if (!in_array(1, $request->user->role_ids)) {
|
||||
$config['where']['approver_id'] = $request->user->uid;
|
||||
// if (!in_array(1, $request->user->role_ids) && !in_array(5, $request->user->role_ids)) {
|
||||
// $config['where']['approver_id'] = $request->user->uid;
|
||||
// }
|
||||
$roles_id = array_intersect([1,6], $request->user->role_ids);
|
||||
if(count($roles_id) == 0){
|
||||
if(in_array(4, $request->user->role_ids)){
|
||||
$report = User::where('report_to',$request->user->uid)->pluck('id')->toArray();
|
||||
$report = array_merge($report, [$request->user->uid]);
|
||||
$config['where']['approver_id'] = ['in',$report];
|
||||
} elseif (in_array(5, $request->user->role_ids)){ //内勤
|
||||
$userGroupMember = UserGroupMembers::where('user_id',$request->user->uid)
|
||||
->where('is_leader',1)
|
||||
->first();
|
||||
if($userGroupMember){
|
||||
$report = UserGroupMembers::where('group_id',$userGroupMember->group_id)
|
||||
->pluck('user_id')
|
||||
->toArray();
|
||||
$report = array_merge($report, [$request->user->uid]);
|
||||
$config['where']['approver_id'] = ['in',$report];
|
||||
}else{ //内勤没有负责的组时
|
||||
$config['where']['approver_id'] = $request->user->uid;
|
||||
}
|
||||
} else {
|
||||
$config['where']['approver_id'] = $request->user->uid;
|
||||
}
|
||||
}
|
||||
$config['where']['status'] = ['!=', 'pending'];
|
||||
}
|
||||
|
||||
// 业务筛选:pi_no 精确到PI号;keyword 匹配 单号/PI号(历史页原有搜索框此前后端未实现)
|
||||
// 两个条件同时传时取交集
|
||||
$bizIdFilter = null;
|
||||
if (!empty($p['pi_no'])) {
|
||||
$ids = Db::table('stock_orders')
|
||||
->where('pi_no', 'like', '%' . trim((string)$p['pi_no']) . '%')
|
||||
->pluck('id')->toArray();
|
||||
$bizIdFilter = $ids ?: [0];
|
||||
}
|
||||
if (!empty($p['keyword'])) {
|
||||
$kw = '%' . trim((string)$p['keyword']) . '%';
|
||||
$ids = Db::table('stock_orders')
|
||||
->where(function ($q) use ($kw) {
|
||||
$q->where('order_no', 'like', $kw)
|
||||
->orWhere('pi_no', 'like', $kw)
|
||||
->orWhere('customer_snapshot', 'like', $kw);
|
||||
})
|
||||
->pluck('id')->toArray();
|
||||
$ids = $ids ?: [0];
|
||||
$bizIdFilter = $bizIdFilter === null ? $ids : array_values(array_intersect($bizIdFilter, $ids));
|
||||
}
|
||||
if ($bizIdFilter !== null) {
|
||||
$config['where']['instance.biz_id'] = ['in', $bizIdFilter];
|
||||
}
|
||||
|
||||
$config['order']['id'] = 'desc';
|
||||
|
||||
$config['with'] = [
|
||||
|
||||
@@ -9,7 +9,7 @@ class CrawlService
|
||||
{
|
||||
public function brandSeries($brand)
|
||||
{
|
||||
$url = "https://www.dongchedi.com/search?currTab=6&keyword=".$brand;
|
||||
$url = "https://www.dongchedi.com/search?currTab=6&keyword=".$brand.'&from=search_tab_er&api_param={"en_qc":+0}';
|
||||
$ql = QueryList::get($url );
|
||||
$series = [];
|
||||
$ql->find('div.card-name')->texts()->map(function($item) use(&$series) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\validate\InquiryTransmitConfigValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\InquiryTransmitConfig;
|
||||
use support\Request;
|
||||
|
||||
class InquiryTransmitConfigService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InquiryTransmitConfig::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = InquiryTransmitConfigValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 CrudUtil 查询配置
|
||||
*/
|
||||
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
|
||||
{
|
||||
$p = $request->all();
|
||||
|
||||
$config = [];
|
||||
$config['model'] = $this->modelClass;
|
||||
switch ($scene) {
|
||||
case 'default':
|
||||
$config['where']['status'] = 1;
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'index':
|
||||
$config['where']['status'] = 1;
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'show':
|
||||
$config['where']['id'] = $p['id'];
|
||||
$config['where']['status'] = 1;
|
||||
break;
|
||||
case 'store':
|
||||
break;
|
||||
case 'update':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'destroy':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\validate\InquiryTransmitValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\InquiryTransmit;
|
||||
use support\Request;
|
||||
|
||||
class InquiryTransmitService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InquiryTransmit::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = InquiryTransmitValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
|
||||
/** 创建 */
|
||||
public function serviceStore(Request $request, array $data = [], string $domain = '')
|
||||
{
|
||||
$data['email'] = $data['Email'];
|
||||
$data['name'] = $data['Name'];
|
||||
$data['tel'] = $data['Tel'];
|
||||
$data['company'] = $data['Company'];
|
||||
$data['country'] = $data['Country'];
|
||||
$data['form_Url'] = $data['Form Url'];
|
||||
$data['message'] = $data['Message'];
|
||||
$data['ip_location'] = $data['IpLocation'];
|
||||
$data['ip'] = $data['Ip'];
|
||||
$data['domain'] = $domain;
|
||||
|
||||
return InquiryTransmit::create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 CrudUtil 查询配置
|
||||
*/
|
||||
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
|
||||
{
|
||||
$p = $request->all();
|
||||
|
||||
$config = [];
|
||||
$config['model'] = $this->modelClass;
|
||||
switch ($scene) {
|
||||
case 'default':
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'index':
|
||||
// 域名
|
||||
if (isset($p['domain']) && !empty($p['domain'])) {
|
||||
$config['where']['domain'] = $p['domain'];
|
||||
}
|
||||
// 关键字
|
||||
if (isset($p['keyword']) && $p['keyword']) {
|
||||
$k = '%' . $p['keyword'] . '%';
|
||||
foreach (['email', 'name', 'tel', 'country', 'message', 'form_url'] as $col) {
|
||||
$config['orWhere'][] = [$col, 'like', $k];
|
||||
}
|
||||
}
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'show':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'store':
|
||||
break;
|
||||
case 'update':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'destroy':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,42 @@ class OssService
|
||||
return $this->ossClient->signUrl($this->bucket, $objectKey, $expireTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件数组的 url 重签为新的私有访问地址(响应期调用,防止历史签名过期)。
|
||||
* 兼容三种存量格式:完整签名URL / 完整未签名URL / 纯对象键。
|
||||
* @param array $attachments [{url, original_name}, ...]
|
||||
* @param int $expireTime
|
||||
* @return array
|
||||
*/
|
||||
public static function resignAttachmentUrls(array $attachments, int $expireTime = 604800): array
|
||||
{
|
||||
foreach ($attachments as &$a) {
|
||||
if (empty($a['url']) || !is_string($a['url'])) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$url = $a['url'];
|
||||
if (strpos($url, 'http') === 0) {
|
||||
$path = (string)parse_url($url, PHP_URL_PATH);
|
||||
if ($path === '') {
|
||||
continue;
|
||||
}
|
||||
$key = ltrim(urldecode($path), '/');
|
||||
} else {
|
||||
$key = ltrim($url, '/'); // 已是对象键
|
||||
}
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$a['url'] = (new self())->generatePrivateUrl($key, $expireTime);
|
||||
} catch (\Throwable $e) {
|
||||
// 重签失败保留原样
|
||||
}
|
||||
}
|
||||
unset($a);
|
||||
return $attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 OSS 上的文件
|
||||
* @param string $objectKey OSS上存储的对象名称
|
||||
|
||||
@@ -47,6 +47,9 @@ class ResourceService
|
||||
$config['order']['id'] = 'asc';
|
||||
break;
|
||||
case 'menuList':
|
||||
if(isset($p['is_home']) && $p['is_home']==1){
|
||||
$config['where']['is_home'] = 1;
|
||||
}
|
||||
$config['where']['status'] = 1;
|
||||
$config['where']['type'] = ['!=', 'permission'];
|
||||
$config['order']['sort'] = 'asc';
|
||||
|
||||
@@ -227,6 +227,8 @@ class StockOrdersService
|
||||
if (!$order->save()) {
|
||||
flg('备货单【'.$order->order_no.'】创建终版成功,但更新原单失败');
|
||||
}
|
||||
|
||||
StockOrders::where('object_id', $res->object_id)->where('is_final', 1)->where('approval_status', 3)->delete();
|
||||
}
|
||||
// 启动审批
|
||||
if ($res->approval_status == 1) {
|
||||
@@ -353,6 +355,10 @@ class StockOrdersService
|
||||
{
|
||||
$config = $this->buildQueryConfig($request, 'approval');
|
||||
$item = $this->logicShow($config);
|
||||
|
||||
if (!$item || $item->approval_status != 0) {
|
||||
throw new BusinessException('该单据已提交审批或单子不存在');
|
||||
}
|
||||
|
||||
// 检查备货单是否包含必需的文件
|
||||
$this->validateRequiredFilesForApproval($item);
|
||||
@@ -454,6 +460,12 @@ class StockOrdersService
|
||||
// 检查必填文件是否已上传
|
||||
// $this->validateRequiredFiles($data);
|
||||
|
||||
$param = $request->all();
|
||||
$info = StockOrders::find($param['id']);
|
||||
if (!$info || $info->approval_status != 0) {
|
||||
throw new BusinessException('该单不存在或状态异常,请关闭或刷新页面后重试~');
|
||||
}
|
||||
|
||||
$data['biz_date'] = strtotime($data['biz_date']);
|
||||
$data['eta_ship_date'] = strtotime($data['eta_ship_date']);
|
||||
$data['latest_arrival_date'] = strtotime($data['latest_arrival_date']);
|
||||
@@ -632,6 +644,8 @@ class StockOrdersService
|
||||
foreach ($vehList as $veh) {
|
||||
$veh['order_id'] = $order->id;
|
||||
$veh['unique_id'] = $this->generateVehicleUniqueId($veh);
|
||||
// 车辆附件:归一化数组(由模型 attachment=>'array' cast 自动序列化为 JSON 落库)
|
||||
$veh['attachment'] = is_array($veh['attachment'] ?? null) ? array_values($veh['attachment']) : [];
|
||||
|
||||
$item = StockOrderItems::create($veh);
|
||||
|
||||
@@ -655,7 +669,7 @@ class StockOrdersService
|
||||
*/
|
||||
private function generateVehicleUniqueId(array $veh): string
|
||||
{
|
||||
$keysToRemove = ['remark', 'modify_list', 'order_id'];
|
||||
$keysToRemove = ['remark', 'modify_list', 'order_id', 'attachment'];
|
||||
$array = array_diff_key($veh, array_flip($keysToRemove));
|
||||
$uniqueIdStr = json_encode($array);
|
||||
return md5($uniqueIdStr);
|
||||
@@ -870,6 +884,18 @@ class StockOrdersService
|
||||
$vehList[$v->unique_id]['remark'] = $v->remark;
|
||||
}
|
||||
}
|
||||
// 拼接附件(并集去重,按 url+文件名)
|
||||
$currAttach = is_array($v->attachment) ? $v->attachment : [];
|
||||
if (!empty($currAttach)) {
|
||||
$union = [];
|
||||
foreach (array_merge($vehList[$v->unique_id]['attachment'], $currAttach) as $a) {
|
||||
$key = ($a['url'] ?? '') . '|' . ($a['original_name'] ?? '');
|
||||
if (!isset($union[$key])) {
|
||||
$union[$key] = $a;
|
||||
}
|
||||
}
|
||||
$vehList[$v->unique_id]['attachment'] = array_values($union);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$index++;
|
||||
@@ -885,8 +911,16 @@ class StockOrdersService
|
||||
'mileage' => $v->mileage,
|
||||
'remark' => $v->remark,
|
||||
'vin' => $v->vin,
|
||||
'attachment' => is_array($v->attachment) ? $v->attachment : [],
|
||||
] + $moneyValues;
|
||||
}
|
||||
// 附件 url 响应期重签(历史存的是上传时的签名URL,会过期)
|
||||
foreach ($vehList as &$row) {
|
||||
if (!empty($row['attachment'])) {
|
||||
$row['attachment'] = \app\common\service\OssService::resignAttachmentUrls($row['attachment']);
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
return ['list' => $vehList];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\validate\UsedcarCrawlResultsImagesValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\UsedcarCrawlResultsImages;
|
||||
|
||||
class UsedcarCrawlResultsImagesService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = UsedcarCrawlResultsImages::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = UsedcarCrawlResultsImagesValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\validate\UsedcarCrawlResultsValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\UsedcarCrawlResults;
|
||||
|
||||
class UsedcarCrawlResultsService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = UsedcarCrawlResults::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = UsedcarCrawlResultsValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\validate\UserDashboardRuleValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\UserDashboardRule;
|
||||
use support\Request;
|
||||
|
||||
class UserDashboardRuleService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = UserDashboardRule::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = UserDashboardRuleValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 CrudUtil 查询配置
|
||||
*/
|
||||
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
|
||||
{
|
||||
$p = $request->all();
|
||||
|
||||
$config = [];
|
||||
$config['model'] = $this->modelClass;
|
||||
switch ($scene) {
|
||||
case 'default':
|
||||
$config['where']['status'] = 1;
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'index':
|
||||
$config['where']['status'] = 1;
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'show':
|
||||
$config['where']['id'] = $p['id'];
|
||||
$config['where']['status'] = 1;
|
||||
break;
|
||||
case 'store':
|
||||
break;
|
||||
case 'update':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'destroy':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加前置处理
|
||||
*/
|
||||
public function beforeServiceStore($data, Request $request)
|
||||
{
|
||||
if(!isset($data['weight']) || ($data['weight'] == null || $data['weight'] == '')){
|
||||
throw new \Exception("权重不能为空");
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,27 @@ class UserService
|
||||
// $menuTree = $permService->getMenuTree($uid, $systemCode);
|
||||
// $permissionCodes = $permService->getPermissionCodes($uid, $systemCode);
|
||||
$corp_name = Corp::where('abbr', $user->corp_id)->value('name');
|
||||
|
||||
// 查找直属上级 10.13 add
|
||||
$reportToInfo = [];
|
||||
if ($user->reportToInfo) {
|
||||
$reportToInfo = [
|
||||
'id' => $user->reportToInfo->id,
|
||||
'name' => $user->reportToInfo->name
|
||||
];
|
||||
}
|
||||
|
||||
// 查找绑定内勤 10.13 add
|
||||
$officeClerkInfo = [];
|
||||
$ugm = UserGroupMembers::where('user_id', $uid)->where('status', 1)->first();
|
||||
if ($ugm) {
|
||||
$lugm = UserGroupMembers::where('group_id', $ugm->group_id)->where('is_leader', 1)->first();
|
||||
if ($lugm) {
|
||||
$officeClerkInfo['id'] = $lugm->user_id;
|
||||
$officeClerkInfo['name'] = User::where('id', $lugm->user_id)->value('name');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
@@ -50,6 +71,7 @@ class UserService
|
||||
'email' => $user->email,
|
||||
'status' => $user->status,
|
||||
'roles' => $user->roles->pluck('name'),
|
||||
'roles_id' => $user->roles->pluck('id'),
|
||||
'sex' => $user->sex,
|
||||
'sn' => $user->sn,
|
||||
'corp_id' => $user->corp_id,
|
||||
@@ -57,6 +79,8 @@ class UserService
|
||||
'report_to' => $user->report_to,
|
||||
'sign_img' => $user->sign_img,
|
||||
'sign_img_url' => (!empty($user->sign_img))?(new \app\common\service\OssService)->generatePrivateUrl($user->sign_img):'',
|
||||
'report_to_info' => $reportToInfo,
|
||||
'office_clerk_info' => $officeClerkInfo
|
||||
],
|
||||
// 'menu' => $menuTree,
|
||||
// 'permissions' => $permissionCodes
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils;
|
||||
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 询盘公平分配排序器(合塑谷歌三个导入脚本共用)
|
||||
*
|
||||
* 按"最近窗口期内实际分到的条数"升序排用户:分得少的优先、最久未分到的优先、
|
||||
* 再按传入名单顺序。统计池:crm_customer 中 channel_id=4(合塑谷歌)、type=2(导入)、
|
||||
* 未删除、窗口期内新建的记录。
|
||||
*
|
||||
* 场景自适应:
|
||||
* - 名单增删(离职移除/新人加入):每次执行按当次名单统计,无残留状态;
|
||||
* - 暂停一段时间再恢复:暂停期间窗口计数自然衰减,回归后适度优先拉平,
|
||||
* 不会饿死也不会被灌顶。
|
||||
*/
|
||||
class InquiryFairAssigner
|
||||
{
|
||||
/** 默认窗口期(天) */
|
||||
public const DEFAULT_WINDOW_DAYS = 10;
|
||||
|
||||
/**
|
||||
* 计算公平分配顺序
|
||||
*
|
||||
* @param array $userIds 本次参与分配的 user_id 列表
|
||||
* @param int $windowDays 统计窗口(天)
|
||||
* @return array{ordered: array, counts: array<int,int>, window_days: int}
|
||||
* ordered=排好序的 user_id 列表;counts=窗口内每人已有条数
|
||||
*/
|
||||
public static function fairOrder(array $userIds, int $windowDays = self::DEFAULT_WINDOW_DAYS): array
|
||||
{
|
||||
$since = time() - $windowDays * 86400;
|
||||
$counts = []; $lastIds = [];
|
||||
foreach (Db::table('crm_customer')
|
||||
->where('channel_id', 4)
|
||||
->where('type', 2)
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('user_id', $userIds)
|
||||
->where('created_at', '>=', $since)
|
||||
->groupBy('user_id')
|
||||
->selectRaw('user_id, COUNT(*) AS cnt, MAX(id) AS last_id')
|
||||
->get() as $r) {
|
||||
$uid = (int)$r->user_id;
|
||||
$counts[$uid] = (int)$r->cnt;
|
||||
$lastIds[$uid] = (int)$r->last_id;
|
||||
}
|
||||
|
||||
$pos = array_flip(array_map('intval', $userIds));
|
||||
$ordered = array_map('intval', $userIds);
|
||||
usort($ordered, function ($a, $b) use ($counts, $lastIds, $pos) {
|
||||
$ca = $counts[$a] ?? 0; $cb = $counts[$b] ?? 0;
|
||||
if ($ca !== $cb) {
|
||||
return $ca <=> $cb; // 窗口内分到少的优先
|
||||
}
|
||||
$la = $lastIds[$a] ?? 0; $lb = $lastIds[$b] ?? 0;
|
||||
if ($la !== $lb) {
|
||||
return $la <=> $lb; // 最久未分到的优先
|
||||
}
|
||||
return $pos[$a] <=> $pos[$b]; // 名单顺序(稳定)
|
||||
});
|
||||
|
||||
return ['ordered' => $ordered, 'counts' => $counts, 'window_days' => $windowDays];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils\crawl\service;
|
||||
|
||||
use app\common\model\UsedcarCrawlResultsImages;
|
||||
use app\common\service\UploadService;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use QL\QueryList;
|
||||
use support\Redis;
|
||||
use support\Db;
|
||||
use support\exception\BusinessException;
|
||||
use Throwable;
|
||||
|
||||
class UsedCarCrawl
|
||||
{
|
||||
// 默认请求头
|
||||
private const DEFAULT_HEADERS = [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept-Language' => 'zh-CN,zh;q=0.9',
|
||||
'Referer' => 'https://www.dongchedi.com/',
|
||||
];
|
||||
|
||||
// Redis队列键名
|
||||
private const REDIS_QUEUE_KEY = 'usedcar:urls';
|
||||
|
||||
// 能源类型常量
|
||||
public const ENERGY_TYPE_RENEWABLE = 'RE';
|
||||
public const ENERGY_TYPE_CONVENTIONAL = 'CE';
|
||||
public const ENERGY_TYPE_UNKNOWN = 'UNKNOWN';
|
||||
|
||||
/**
|
||||
* 插入队列url
|
||||
*
|
||||
* @param string $url URL地址
|
||||
* @param string $scene 场景:usedcar(二手信息) config(配置信息)
|
||||
* @param int $id 记录ID
|
||||
* @return int
|
||||
*/
|
||||
public function pushUrl(string $url = '', string $scene = 'usedcar', int $id = 0): int
|
||||
{
|
||||
$data = [
|
||||
'scene' => $scene,
|
||||
'url' => $url,
|
||||
'id' => $id,
|
||||
];
|
||||
|
||||
return Redis::lpush(
|
||||
self::REDIS_QUEUE_KEY,
|
||||
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列url
|
||||
*
|
||||
* @return array|null
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function popUrl(): ?array
|
||||
{
|
||||
$data = Redis::rPop(self::REDIS_QUEUE_KEY);
|
||||
if (!$data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$task = json_decode($data, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($task)) {
|
||||
throw new BusinessException('任务数据解析失败:' . json_last_error_msg());
|
||||
}
|
||||
|
||||
// 基本字段校验
|
||||
if (empty($task['url']) || empty($task['scene'])) {
|
||||
throw new BusinessException('队列任务数据缺少必要字段:url 或 scene');
|
||||
}
|
||||
|
||||
return $task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行抓取任务
|
||||
*
|
||||
* @return bool
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function execute(): bool
|
||||
{
|
||||
try {
|
||||
$task = $this->popUrl();
|
||||
|
||||
if (!$task) {
|
||||
// 队列为空,任务结束
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = (string)$task['url'];
|
||||
$scene = (string)$task['scene'];
|
||||
$id = (int)($task['id'] ?? 0);
|
||||
|
||||
$html = $this->fetchHtml($url);
|
||||
if ($html === '') {
|
||||
throw new BusinessException("页面内容为空: {$url}");
|
||||
}
|
||||
|
||||
switch ($scene) {
|
||||
case 'usedcar':
|
||||
$info = $this->parseUsedCarDetail($html, $url);
|
||||
$insertId = $this->saveUsedCarBasicInfo($info);
|
||||
|
||||
// 下载图片 + 上传 + 入库(图片失败不影响主记录)
|
||||
$imagesArray = json_decode($info['images'] ?? '[]', true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$imagesArray = [];
|
||||
}
|
||||
$saveDir = public_path('uploads/usedcar/' . $insertId);
|
||||
$this->uploadAndRemove($imagesArray, $saveDir, $info['used_car_id'], $insertId);
|
||||
|
||||
// 继续派发配置页任务
|
||||
if (!empty($info['car_id_url'])) {
|
||||
$this->pushUrl($info['car_id_url'], 'config', $insertId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
if ($id <= 0) {
|
||||
throw new BusinessException("配置任务缺少主记录ID");
|
||||
}
|
||||
|
||||
$data = $this->parseConfigDetail($html);
|
||||
$this->saveUsedCarConfigInfo($data, $id);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new BusinessException("未知的任务场景: " . $scene);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (GuzzleException $e) {
|
||||
throw new BusinessException("网络请求失败: " . $e->getMessage());
|
||||
} catch (BusinessException $e) {
|
||||
// 业务异常原样抛出,方便上层知道是逻辑问题
|
||||
throw $e;
|
||||
} catch (Throwable $th) {
|
||||
// 兜底异常,避免 worker 直接挂掉
|
||||
throw new BusinessException(
|
||||
"执行任务出错: " . $th->getMessage()
|
||||
. ' | 行号: ' . $th->getLine()
|
||||
. ' | 文件: ' . $th->getFile()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网页HTML内容
|
||||
*
|
||||
* @param string $url
|
||||
* @return string
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function fetchHtml(string $url): string
|
||||
{
|
||||
$client = new Client();
|
||||
$response = $client->request('GET', $url, [
|
||||
'headers' => self::DEFAULT_HEADERS,
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
return (string)$response->getBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析二手车详情页信息
|
||||
*
|
||||
* @param string $html
|
||||
* @param string $url
|
||||
* @return array
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function parseUsedCarDetail(string $html = '', string $url = ''): array
|
||||
{
|
||||
try {
|
||||
$ql = QueryList::html($html);
|
||||
} catch (Throwable $e) {
|
||||
throw new BusinessException('解析 HTML 失败(详情页): ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$title = trim($ql->find('h1')->text());
|
||||
$price = trim($ql->find('.head-info_price-wrap__Y4bxi > p')->text());
|
||||
$pageText = $ql->find('body')->text();
|
||||
$jsonText = $ql->find('#__NEXT_DATA__')->text();
|
||||
|
||||
if ($jsonText === '') {
|
||||
throw new BusinessException('详情页未找到 __NEXT_DATA__ JSON');
|
||||
}
|
||||
|
||||
$jsonArray = $this->decodeJson($jsonText, '详情页 __NEXT_DATA__');
|
||||
|
||||
// 车辆相册
|
||||
$images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? [];
|
||||
|
||||
// 车辆ID / 年份
|
||||
$carInfo = $jsonArray['props']['pageProps']['skuDetail']['car_info'] ?? [];
|
||||
$carId = $carInfo['car_id'] ?? 0;
|
||||
$year = (int)($carInfo['year'] ?? 0);
|
||||
|
||||
// 车辆ID URL
|
||||
$carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : '';
|
||||
|
||||
// 能源类型(两个字段择一)
|
||||
$overview = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview'] ?? [];
|
||||
$fuelForm = $overview['power']['fuel_form'] ?? '';
|
||||
$newFuelForm = $overview['new_energy_power']['fuel_form'] ?? '';
|
||||
$resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm;
|
||||
|
||||
// 使用里程
|
||||
$importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? '';
|
||||
$importantTextArray = $importantText ? explode(' | ', $importantText) : [];
|
||||
$usageMileage = '';
|
||||
if (isset($importantTextArray[1])) {
|
||||
$usageMileage = str_replace('万公里', '', $importantTextArray[1]);
|
||||
}
|
||||
|
||||
// 车辆名称 品牌、车系、车型
|
||||
$brandName = $carInfo['brand_name'] ?? '';
|
||||
$seriesName = $carInfo['series_name'] ?? '';
|
||||
$carName = $carInfo['car_name'] ?? '';
|
||||
|
||||
// 商家信息
|
||||
$shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? [];
|
||||
$formatShopInfo = [
|
||||
'shop_id' => $shopInfo['shop_id'] ?? 0,
|
||||
'shop_name' => $shopInfo['shop_short_name'] ?? '',
|
||||
'shop_address' => $shopInfo['shop_address'] ?? '',
|
||||
];
|
||||
|
||||
// 简介参数
|
||||
$otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? [];
|
||||
$displacement = $this->getParamValue($otherParams, '排量');
|
||||
$gearbox = $this->getParamValue($otherParams, '变速箱');
|
||||
|
||||
$formatCarInfo = [
|
||||
'displacement' => $displacement,
|
||||
'gearbox' => $gearbox,
|
||||
];
|
||||
|
||||
// 提取新车指导价 / 比新车省金额
|
||||
$newPrice = $this->extractPatternValue($pageText, '/新车指导价[::]\s*([^\r\n万]*万)/u');
|
||||
$priceDifference = $this->extractPatternValue($pageText, '/比新车省[::]\s*([^\r\n万]*万)/u');
|
||||
|
||||
// 提取车况介绍
|
||||
$conditionDesc = '';
|
||||
if ($pageText && preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) {
|
||||
$conditionDesc = trim($m[1]);
|
||||
}
|
||||
|
||||
return [
|
||||
'used_car_id' => $this->extractIdFromUrl($url),
|
||||
'car_id' => $carId,
|
||||
'url' => $url,
|
||||
'title' => $title,
|
||||
'price' => str_replace('万', '', $this->decodePrice($price)),
|
||||
'new_price' => str_replace('万', '', $newPrice),
|
||||
'price_difference' => str_replace('万', '', $priceDifference),
|
||||
'condition_desc' => $conditionDesc,
|
||||
'images' => json_encode($images, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'car_id_url' => $carIdUrl,
|
||||
'used_detail' => $jsonText,
|
||||
'format_shop_info' => json_encode($formatShopInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'fuel_form' => $resultFuelForm,
|
||||
'classify_energy' => $this->detectEnergyType($resultFuelForm),
|
||||
'usage_mileage' => $usageMileage,
|
||||
'brand_name' => $brandName,
|
||||
'series_name' => $seriesName,
|
||||
'car_name' => $carName,
|
||||
'year' => $year,
|
||||
'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析配置详情页信息
|
||||
*
|
||||
* @param string $html
|
||||
* @return array
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function parseConfigDetail(string $html = ''): array
|
||||
{
|
||||
try {
|
||||
$ql = QueryList::html($html);
|
||||
} catch (Throwable $e) {
|
||||
throw new BusinessException('解析 HTML 失败(配置页): ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$jsonText = $ql->find('#__NEXT_DATA__')->text();
|
||||
if ($jsonText === '') {
|
||||
throw new BusinessException('配置页未找到 __NEXT_DATA__ JSON');
|
||||
}
|
||||
|
||||
$raw = $this->decodeJson($jsonText, '配置页 __NEXT_DATA__');
|
||||
|
||||
$properties = $raw['props']['pageProps']['rawData']['properties'] ?? [];
|
||||
$carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? [];
|
||||
|
||||
$valuesMap = $this->buildValuesMap($carInfo);
|
||||
$groups = $this->buildCarSpecGroups($properties, $valuesMap);
|
||||
|
||||
return [
|
||||
'format_car_info' => [
|
||||
'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间
|
||||
'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式
|
||||
'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数
|
||||
'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机
|
||||
'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率
|
||||
'electric_description' => $this->getFieldValue($groups, 'electric_description'), // 电动机
|
||||
'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), // 电池容量
|
||||
'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型
|
||||
'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage'), // 续航里程
|
||||
],
|
||||
'groups' => $groups,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从车辆配置中获取能源类型
|
||||
*
|
||||
* @param array $data JSON数据
|
||||
* @return string|null 能源类型
|
||||
*/
|
||||
public function getFuelFormByConfig(array $data): ?string
|
||||
{
|
||||
if (empty($data[0]['items']) || !is_array($data[0]['items'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($data[0]['items'] as $item) {
|
||||
if (isset($item['key']) && $item['key'] === 'fuel_form') {
|
||||
return $item['value'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据中根据字段key获取值
|
||||
*
|
||||
* @param mixed $data 数据
|
||||
* @param string $field 字段名
|
||||
* @return string|null
|
||||
*/
|
||||
public function getFieldValue($data, string $field): ?string
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($data as $group) {
|
||||
if (isset($group['items']) && is_array($group['items'])) {
|
||||
foreach ($group['items'] as $item) {
|
||||
if (($item['key'] ?? null) === $field) {
|
||||
return $item['value'] ?? null;
|
||||
}
|
||||
|
||||
if (!empty($item['children']) && is_array($item['children'])) {
|
||||
foreach ($item['children'] as $child) {
|
||||
if (($child['key'] ?? null) === $field) {
|
||||
return $child['value'] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从参数列表中获取指定名称的值
|
||||
*/
|
||||
public function getParamValue(array $params, string $name): ?string
|
||||
{
|
||||
foreach ($params as $item) {
|
||||
if (($item['name'] ?? null) === $name) {
|
||||
return $item['value'] ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断车辆能源类型
|
||||
*/
|
||||
public function detectEnergyType(string $text): string
|
||||
{
|
||||
if (function_exists('mb_internal_encoding')) {
|
||||
mb_internal_encoding('UTF-8');
|
||||
}
|
||||
|
||||
// 明确定义的新能源关键字
|
||||
$newEnergyKeywords = [
|
||||
'纯电', '纯电动', '电动车', 'BEV',
|
||||
'插电混动', '插电式混合', '插电式', '插电', 'PHEV',
|
||||
'增程式', '增程', 'REEV',
|
||||
'氢能', '氢燃料', '燃料电池', 'FCEV',
|
||||
'太阳能',
|
||||
];
|
||||
|
||||
// 传统能源关键字
|
||||
$traditionalKeywords = [
|
||||
'汽油',
|
||||
'柴油',
|
||||
'天然气', 'LNG', 'CNG',
|
||||
];
|
||||
|
||||
// 普通混动关键字(归为传统能源)
|
||||
$hybridKeywords = [
|
||||
'油电混合', '油电混动', '油电',
|
||||
'混动',
|
||||
'双擎',
|
||||
'HEV',
|
||||
];
|
||||
|
||||
$contains = function (string $haystack, string $needle): bool {
|
||||
if ($needle === '') {
|
||||
return false;
|
||||
}
|
||||
if (function_exists('mb_stripos')) {
|
||||
return mb_stripos($haystack, $needle) !== false;
|
||||
}
|
||||
return stripos($haystack, $needle) !== false;
|
||||
};
|
||||
|
||||
foreach ($newEnergyKeywords as $kw) {
|
||||
if ($contains($text, $kw)) {
|
||||
return self::ENERGY_TYPE_RENEWABLE;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($hybridKeywords as $kw) {
|
||||
if ($contains($text, $kw)) {
|
||||
return self::ENERGY_TYPE_CONVENTIONAL;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($traditionalKeywords as $kw) {
|
||||
if ($contains($text, $kw)) {
|
||||
return self::ENERGY_TYPE_CONVENTIONAL;
|
||||
}
|
||||
}
|
||||
|
||||
return self::ENERGY_TYPE_UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存二手车基本信息到数据库
|
||||
*
|
||||
* @param array $data 数据
|
||||
* @return int 插入记录ID
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function saveUsedCarBasicInfo(array $data): int
|
||||
{
|
||||
try {
|
||||
$time = time();
|
||||
$data['created_at'] = $data['created_at'] ?? $time;
|
||||
$data['updated_at'] = $time;
|
||||
|
||||
return (int)Db::table('usedcar_crawl_results')->insertGetId($data);
|
||||
} catch (Throwable $e) {
|
||||
throw new BusinessException('保存二手车基础信息失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置信息到数据库
|
||||
*
|
||||
* @param array $data 配置数据
|
||||
* @param int $id 记录ID
|
||||
* @return bool|int
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function saveUsedCarConfigInfo(array $data, int $id)
|
||||
{
|
||||
try {
|
||||
$record = Db::table('usedcar_crawl_results')->find($id);
|
||||
|
||||
if (!$record) {
|
||||
// 找不到记录就算了,不抛致命错误,视业务情况也可以抛异常
|
||||
return false;
|
||||
}
|
||||
|
||||
$groups = $data['groups'] ?? [];
|
||||
$oldFormat = [];
|
||||
|
||||
if (!empty($record->format_car_info)) {
|
||||
$decoded = json_decode($record->format_car_info, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$oldFormat = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$formatCarInfo = array_merge(
|
||||
$oldFormat,
|
||||
$data['format_car_info'] ?? []
|
||||
);
|
||||
|
||||
return Db::table('usedcar_crawl_results')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
throw new BusinessException('保存车辆配置信息失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建值映射表
|
||||
*/
|
||||
public function buildValuesMap(array $carInfo): array
|
||||
{
|
||||
$valuesMap = [];
|
||||
|
||||
// 顶层字段
|
||||
foreach ($carInfo as $k => $v) {
|
||||
if ($k === 'info' || is_array($v)) {
|
||||
continue;
|
||||
}
|
||||
$valuesMap[$k] = ['value' => $v];
|
||||
}
|
||||
|
||||
// info中的配置项
|
||||
if (!empty($carInfo['info']) && is_array($carInfo['info'])) {
|
||||
foreach ($carInfo['info'] as $k => $v) {
|
||||
if (is_array($v) && array_key_exists('value', $v)) {
|
||||
$valuesMap[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $valuesMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建车辆规格分组
|
||||
*/
|
||||
public function buildCarSpecGroups(array $properties, array $valuesMap): array
|
||||
{
|
||||
$groups = [];
|
||||
$currentKey = null;
|
||||
|
||||
foreach ($properties as $item) {
|
||||
$type = (int)($item['type'] ?? 1);
|
||||
$key = $item['key'] ?? '';
|
||||
$text = $item['text'] ?? '';
|
||||
$subList = $item['sub_list'] ?? null;
|
||||
|
||||
// 分类字段:type = 0 / 2
|
||||
if (in_array($type, [0, 2], true)) {
|
||||
$currentKey = $key ?: ($text ?: ('group_' . count($groups)));
|
||||
|
||||
if (!isset($groups[$currentKey])) {
|
||||
$groups[$currentKey] = [
|
||||
'group_key' => $currentKey,
|
||||
'group_text' => $text,
|
||||
'group_type' => $type,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 普通字段:挂到最近的分类下
|
||||
if ($currentKey === null) {
|
||||
$currentKey = '_default';
|
||||
$groups[$currentKey] = [
|
||||
'group_key' => '_default',
|
||||
'group_text' => '其他',
|
||||
'group_type' => -1,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$value = '';
|
||||
if ($key && isset($valuesMap[$key])) {
|
||||
$value = trim((string)($valuesMap[$key]['value'] ?? ''));
|
||||
}
|
||||
|
||||
$node = [
|
||||
'key' => $key,
|
||||
'text' => $text,
|
||||
'value' => $value,
|
||||
];
|
||||
|
||||
if (is_array($subList) && !empty($subList)) {
|
||||
$children = [];
|
||||
foreach ($subList as $sub) {
|
||||
$subKey = $sub['key'] ?? '';
|
||||
$subText = $sub['text'] ?? '';
|
||||
$subValue = '';
|
||||
|
||||
if ($subKey && isset($valuesMap[$subKey])) {
|
||||
$subValue = trim((string)($valuesMap[$subKey]['value'] ?? ''));
|
||||
}
|
||||
|
||||
$children[] = [
|
||||
'key' => $subKey,
|
||||
'text' => $subText,
|
||||
'value' => $subValue,
|
||||
];
|
||||
}
|
||||
|
||||
$node['children'] = $children;
|
||||
}
|
||||
|
||||
$groups[$currentKey]['items'][] = $node;
|
||||
}
|
||||
|
||||
return array_values($groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码懂车帝价格
|
||||
*/
|
||||
protected function decodePrice(string $str): string
|
||||
{
|
||||
$map = [
|
||||
"\u{E53D}" => '1',
|
||||
"\u{E3F0}" => '2',
|
||||
"\u{E422}" => '3',
|
||||
"\u{E42C}" => '4',
|
||||
"\u{E49C}" => '5',
|
||||
"\u{E42B}" => '6',
|
||||
"\u{E4FE}" => '7',
|
||||
"\u{E548}" => '8',
|
||||
"\u{E4C8}" => '9',
|
||||
"\u{E453}" => '0',
|
||||
"\u{E45F}" => '万',
|
||||
];
|
||||
|
||||
return strtr($str, $map);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL中提取ID
|
||||
*/
|
||||
protected function extractIdFromUrl(string $url)
|
||||
{
|
||||
if (preg_match('/\/(\d+)$/', $url, $matches)) {
|
||||
return (int)$matches[1];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取符合正则表达式的值(只保留数字和小数点)
|
||||
*/
|
||||
protected function extractPatternValue(string $text, string $pattern): string
|
||||
{
|
||||
$value = '';
|
||||
if ($text && preg_match($pattern, $text, $m)) {
|
||||
$value = trim(preg_replace('/[^0-9.]/', '', $m[1]));
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载单张图片
|
||||
*/
|
||||
public function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string
|
||||
{
|
||||
if (!is_dir($saveDir)) {
|
||||
if (!@mkdir($saveDir, 0777, true) && !is_dir($saveDir)) {
|
||||
// echo "[Image] 创建目录失败:{$saveDir}\n";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($filename === null) {
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
$ext = pathinfo($path ?? '', PATHINFO_EXTENSION);
|
||||
|
||||
if ($ext === '') {
|
||||
$ext = 'jpg';
|
||||
}
|
||||
|
||||
$filename = md5($url) . '.' . $ext;
|
||||
}
|
||||
|
||||
$savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename;
|
||||
|
||||
$defaultHeaders = [
|
||||
'User-Agent: ' . self::DEFAULT_HEADERS['User-Agent'],
|
||||
'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Referer: ' . self::DEFAULT_HEADERS['Referer'],
|
||||
];
|
||||
|
||||
foreach ($headers as $h) {
|
||||
$defaultHeaders[] = $h;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_HTTPHEADER => $defaultHeaders,
|
||||
]);
|
||||
|
||||
$data = curl_exec($ch);
|
||||
$errNo = curl_errno($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errNo !== 0 || $code !== 200 || $data === false || $data === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bytes = @file_put_contents($savePath, $data);
|
||||
if ($bytes === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $savePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载相册
|
||||
*
|
||||
* @param array $urls 图片URL数组
|
||||
* @param string $saveDir 保存目录
|
||||
* @return array [原始URL => 保存路径或null]
|
||||
*/
|
||||
public function downloadImageAlbum(array $urls, string $saveDir): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($urls as $url) {
|
||||
$url = trim((string)$url);
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strpos($url, '//') === 0) {
|
||||
$url = 'https:' . $url;
|
||||
}
|
||||
|
||||
try {
|
||||
$path = $this->downloadImage($url, $saveDir);
|
||||
} catch (Throwable $e) {
|
||||
$path = null;
|
||||
}
|
||||
|
||||
$result[$url] = $path;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 -> 上传 -> 删除本地
|
||||
*
|
||||
* @param array $images 原始相册URL列表
|
||||
* @param string $path 本地保存目录
|
||||
* @param int $usedCarId 懂车帝 used_car_id
|
||||
* @param int $id 本地 crawl 记录id
|
||||
* @return bool
|
||||
*/
|
||||
public function uploadAndRemove(array $images, string $path, $usedCarId, $id): bool
|
||||
{
|
||||
if (empty($images)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 下载图片
|
||||
$filePatch = $this->downloadImageAlbum($images, $path);
|
||||
if (empty($filePatch)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$uploadService = new UploadService();
|
||||
|
||||
foreach ($filePatch as $url => $filePath) {
|
||||
if ($filePath === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 上传云端
|
||||
$param = [
|
||||
'path' => $filePath,
|
||||
'scene' => 'usedcar_crawl',
|
||||
'category' => 'usedcar',
|
||||
];
|
||||
|
||||
$result = $uploadService->uploadLocalFile($param);
|
||||
if (empty($result['file_name'])) {
|
||||
// 上传失败则跳过,但不抛异常影响主流程
|
||||
continue;
|
||||
}
|
||||
|
||||
// 保存图片信息
|
||||
UsedcarCrawlResultsImages::create([
|
||||
'uc_id' => $id,
|
||||
'used_car_id' => $usedCarId,
|
||||
'original_name'=> $result['original_name'] ?? '',
|
||||
'file_path' => $result['file_name'],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
// 单张图失败不影响其他
|
||||
// 可以按需写日志
|
||||
} finally {
|
||||
// 删除本地文件
|
||||
if (is_file($filePath)) {
|
||||
@unlink($filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除目录
|
||||
rmdir($path);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解 JSON
|
||||
*
|
||||
* @param string $json
|
||||
* @param string $context
|
||||
* @return array
|
||||
* @throws BusinessException
|
||||
*/
|
||||
protected function decodeJson(string $json, string $context = 'JSON'): array
|
||||
{
|
||||
$data = json_decode($json, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
|
||||
throw new BusinessException($context . ' 解析失败:' . json_last_error_msg());
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils\crawl\service;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use QL\QueryList;
|
||||
use support\Redis;
|
||||
use support\Db;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
class UsedCarCrawl_bak
|
||||
{
|
||||
|
||||
/**
|
||||
* 插入队列url
|
||||
*
|
||||
* $scene 二手信息 usedcar 配置信息 config
|
||||
*/
|
||||
public function iUrl(string $url = '', string $scene = 'usedcar', int $id = 0)
|
||||
{
|
||||
return Redis::lpush('usedcar:urls', json_encode([
|
||||
'scene' => $scene,
|
||||
'url' => $url,
|
||||
'id' => $id
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列url
|
||||
*
|
||||
* $scene 二手信息 usedcar 配置信息 config
|
||||
*/
|
||||
public function rUrl()
|
||||
{
|
||||
return json_decode(Redis::rPop('usedcar:urls'), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行抓取
|
||||
*/
|
||||
public function doData()
|
||||
{
|
||||
try {
|
||||
$raw = $this->rUrl();
|
||||
|
||||
if (!$raw) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$html = $this->getHtml($raw['url']);
|
||||
|
||||
switch ($raw['scene']) {
|
||||
case 'usedcar':
|
||||
$info = $this->getUsedDetail($html, $raw['url']);
|
||||
$id = $this->saveUsedcarBasicToDb($info);
|
||||
$this->iUrl($info['car_id_url'], 'config', $id);
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
$data = $this->getConfigDetail($html);
|
||||
$this->saveUsedcarConfigToDb($data, $raw['id']);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $th) {
|
||||
throw new BusinessException($th->getMessage().'|'.$th->getLine().'|'.$th->getFile());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取html
|
||||
*/
|
||||
public function getHtml($url)
|
||||
{
|
||||
$client = new Client();
|
||||
$res = $client->request('GET', $url, [
|
||||
'headers' => [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
. 'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
. 'Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept-Language' => 'zh-CN,zh;q=0.9',
|
||||
'Referer' => 'https://www.dongchedi.com/'
|
||||
]
|
||||
]);
|
||||
return (string)$res->getBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取二手信息
|
||||
*/
|
||||
public function getUsedDetail(string $html = '', string $url = '')
|
||||
{
|
||||
$ql = QueryList::html($html);
|
||||
|
||||
$title = $ql->find('h1')->text();
|
||||
$price = $ql->find('.head-info_price-wrap__Y4bxi > p')->text();
|
||||
$pageText = $ql->find('body')->text();
|
||||
$jsonText = $ql->find('#__NEXT_DATA__')->text();
|
||||
|
||||
// 新车指导价
|
||||
$newPrice = '';
|
||||
if ($pageText) {
|
||||
if (preg_match('/新车指导价[::]\s*([^\r\n万]*万)/u', $pageText, $m)) {
|
||||
$newPrice = trim(preg_replace('/[^0-9.]/', '', $m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
// 比新车省
|
||||
$priceDifference = '';
|
||||
if ($pageText) {
|
||||
if (preg_match('/比新车省[::]\s*([^\r\n万]*万)/u', $pageText, $m)) {
|
||||
$priceDifference = trim(preg_replace('/[^0-9.]/', '', $m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
// 车况介绍
|
||||
$conditionDesc = '';
|
||||
if ($pageText) {
|
||||
if (preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) {
|
||||
$conditionDesc = trim($m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
$jsonArray = json_decode($jsonText, true);
|
||||
|
||||
// 车辆相册
|
||||
$images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? [];
|
||||
|
||||
// 车辆ID
|
||||
$carId = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_id'] ?? 0;
|
||||
|
||||
// 车辆年份
|
||||
$year = $jsonArray['props']['pageProps']['skuDetail']['car_info']['year'] ?? 0;
|
||||
|
||||
// 车辆ID URL
|
||||
$carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : '';
|
||||
|
||||
// 能源类型
|
||||
$fuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['power']['fuel_form'] ?? '';
|
||||
$newFuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['new_energy_power']['fuel_form'] ?? '';
|
||||
$resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm;
|
||||
|
||||
// 使用里程
|
||||
$importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? '';
|
||||
$importantTextArray = explode(' | ', $importantText);
|
||||
$usageMileage = str_replace('万公里', '', isset($importantTextArray[1]) ? $importantTextArray[1] : '');
|
||||
|
||||
// 车辆名称 品牌、车系、车型
|
||||
$brandName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['brand_name'] ?? '';
|
||||
$seriesName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['series_name'] ?? '';
|
||||
$carName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_name'] ?? '';
|
||||
|
||||
// 商家信息
|
||||
$shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? [];
|
||||
$formatShopInfo = [
|
||||
'shop_id' => $shopInfo['shop_id'] ?? 0,
|
||||
'shop_name' => $shopInfo['shop_short_name'] ?? '',
|
||||
'shop_address' => $shopInfo['shop_address'] ?? ''
|
||||
];
|
||||
|
||||
// 简介参数
|
||||
$otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? [];
|
||||
// 排量
|
||||
$displacement = $this->getOtherParamValue($otherParams, '排量');
|
||||
// 变速箱
|
||||
$gearbox = $this->getOtherParamValue($otherParams, '变速箱');
|
||||
// 格式化车辆信息
|
||||
$formatCarInfo = [
|
||||
'displacement' => $displacement,
|
||||
'gearbox' => $gearbox
|
||||
];
|
||||
|
||||
// 整理车辆信息
|
||||
$info['used_car_id'] = $this->getLastIdFromUrl($url);
|
||||
$info['car_id'] = $carId;
|
||||
$info['url'] = $url;
|
||||
$info['title'] = $title;
|
||||
$info['price'] = str_replace('万', '', $this->decodeDongchediPrice($price));
|
||||
$info['new_price'] = str_replace('万', '', $newPrice);
|
||||
$info['price_difference'] = str_replace('万', '', $priceDifference);
|
||||
$info['condition_desc'] = $conditionDesc;
|
||||
$info['images'] = json_encode($images);
|
||||
$info['car_id_url'] = $carIdUrl;
|
||||
$info['used_detail'] = $jsonText;
|
||||
$info['format_shop_info'] = json_encode($formatShopInfo);
|
||||
$info['fuel_form'] = $resultFuelForm;
|
||||
$info['classify_energy'] = $this->detectEnergyType($resultFuelForm);
|
||||
$info['usage_mileage'] = $usageMileage;
|
||||
$info['brand_name'] = $brandName;
|
||||
$info['series_name'] = $seriesName;
|
||||
$info['car_name'] = $carName;
|
||||
$info['year'] = $year;
|
||||
$info['format_car_info'] = json_encode($formatCarInfo);
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置信息
|
||||
*/
|
||||
public function getConfigDetail(string $html = '')
|
||||
{
|
||||
$ql = QueryList::html($html);
|
||||
|
||||
$jsonText = $ql->find('#__NEXT_DATA__')->text();
|
||||
|
||||
$raw = json_decode($jsonText, true);
|
||||
$properties = $raw['props']['pageProps']['rawData']['properties'] ?? [];
|
||||
$carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? [];
|
||||
|
||||
$valuesMap = $this->buildValuesMapFromCarInfo($carInfo);
|
||||
$groups = $this->buildCarSpecGroups($properties, $valuesMap);
|
||||
|
||||
$data['format_car_info'] = [
|
||||
'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间
|
||||
'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式
|
||||
'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数
|
||||
'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机
|
||||
'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率
|
||||
'electric_description' => $this->getFieldValue($groups, 'electric_description'), //电动机
|
||||
'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), //电池容量
|
||||
'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型
|
||||
'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage') // 续航里程
|
||||
];
|
||||
$data['groups'] = $groups;
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从车辆配置 JSON 数据中获取能源类型
|
||||
*
|
||||
* @param array $data JSON 数据
|
||||
* @return string|null 能源类型,如 '油电混合'、'纯电动'、
|
||||
*/
|
||||
public function getFuelFormByConfigInfo(array $data)
|
||||
{
|
||||
$fuelForm = null;
|
||||
foreach ($data[0]['items'] as $item) {
|
||||
if ($item['key'] === 'fuel_form') {
|
||||
$fuelForm = $item['value'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $fuelForm;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从车辆配置 JSON 数据中根据字段 key 获取对应的 value
|
||||
*
|
||||
* @param string|array $data JSON 字符串 或 已解析的数组
|
||||
* @param string $field 要查找的字段 key(如 'fuel_form')
|
||||
* @return string|null 找到则返回 value,否则返回 null
|
||||
*/
|
||||
public function getFieldValue($data, string $field)
|
||||
{
|
||||
// 如果传入的是 JSON 字符串,先解码
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return null; // JSON 格式错误
|
||||
}
|
||||
}
|
||||
|
||||
// 遍历第一层(假设结构是 [ { "items": [...] } ])
|
||||
foreach ($data as $group) {
|
||||
if (isset($group['items']) && is_array($group['items'])) {
|
||||
foreach ($group['items'] as $item) {
|
||||
if (isset($item['key']) && $item['key'] === $field) {
|
||||
return $item['value'] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null; // 未找到
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 other_params 数组中根据 name 获取对应的 value
|
||||
*
|
||||
* @param array $otherParams 格式为 [ ['name' => 'xxx', 'value' => 'yyy'], ... ]
|
||||
* @param string $name 要查找的字段名称(如 '上牌地')
|
||||
* @return string|null 找到则返回 value,否则返回 null
|
||||
*/
|
||||
public function getOtherParamValue(array $otherParams, string $name): ?string
|
||||
{
|
||||
foreach ($otherParams as $item) {
|
||||
if (isset($item['name']) && $item['name'] === $name) {
|
||||
return $item['value'] ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据给定字符串判断车辆能源类型
|
||||
*
|
||||
* 返回:
|
||||
* - RE 新能源(纯电、插电混动、增程、氢燃料等)
|
||||
* - CE 传统能源(汽油、柴油、天然气、非插电混动等)
|
||||
* - UNKNOWN 无法判断
|
||||
*
|
||||
* @param string $text 例如:'2022款 某某牌 2.0L 油电混合 双擎'
|
||||
* @return string
|
||||
*/
|
||||
public function detectEnergyType(string $text): string
|
||||
{
|
||||
if (function_exists('mb_internal_encoding')) {
|
||||
mb_internal_encoding('UTF-8');
|
||||
}
|
||||
|
||||
// 明确定义的新能源关键字(纯电 / 插电 / 增程 / 氢 / 太阳能 等)
|
||||
$newEnergyKeywords = [
|
||||
// 纯电
|
||||
'纯电', '纯电动', '电动车', 'BEV',
|
||||
// 插电式混合
|
||||
'插电混动', '插电式混合', '插电式', '插电', 'PHEV',
|
||||
// 增程式
|
||||
'增程式', '增程', 'REEV',
|
||||
// 氢能 / 燃料电池
|
||||
'氢能', '氢燃料', '燃料电池', 'FCEV',
|
||||
// 其它
|
||||
'太阳能',
|
||||
];
|
||||
|
||||
// 传统能源关键字(包括燃油/燃气)
|
||||
$traditionalKeywords = [
|
||||
'汽油',
|
||||
'柴油',
|
||||
'天然气', 'LNG', 'CNG',
|
||||
];
|
||||
|
||||
// 普通混动关键字(按你的要求,统一归为传统能源)
|
||||
$hybridKeywords = [
|
||||
'油电混合', '油电混动', '油电',
|
||||
'混动', // 不带“插电”的混动
|
||||
'双擎', // 丰田常见表达
|
||||
'HEV',
|
||||
];
|
||||
|
||||
// 辅助:不区分大小写的包含判断,优先 mb_stripos
|
||||
$contains = function (string $haystack, string $needle): bool {
|
||||
if ($needle === '') {
|
||||
return false;
|
||||
}
|
||||
if (function_exists('mb_stripos')) {
|
||||
return mb_stripos($haystack, $needle) !== false;
|
||||
}
|
||||
return stripos($haystack, $needle) !== false;
|
||||
};
|
||||
|
||||
// 1. 先判断“明确新能源”关键词(插电、增程、氢、纯电等)
|
||||
foreach ($newEnergyKeywords as $kw) {
|
||||
if ($contains($text, $kw)) {
|
||||
return 'RE';
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 再判断“普通油电混动”——按照你的业务,一律归为传统能源
|
||||
foreach ($hybridKeywords as $kw) {
|
||||
if ($contains($text, $kw)) {
|
||||
return 'CE';
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 再判断传统能源(纯燃油/气)
|
||||
foreach ($traditionalKeywords as $kw) {
|
||||
if ($contains($text, $kw)) {
|
||||
return 'CE';
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 都没匹配上
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* 二手信息入库
|
||||
*/
|
||||
public function saveUsedcarBasicToDb(array $data = [])
|
||||
{
|
||||
$data['created_at'] = time();
|
||||
$data['updated_at'] = time();
|
||||
return Db::table('usedcar_crawl_results')->insertGetId($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新信息入库
|
||||
*/
|
||||
public function saveUsedcarConfigToDb(array $data = [], int $id = 0)
|
||||
{
|
||||
$info = Db::table('usedcar_crawl_results')->find($id);
|
||||
|
||||
if (!$info) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$groups = $data['groups'];
|
||||
$formatCarInfo = array_merge($data['format_car_info'], json_decode($info->format_car_info, true));
|
||||
|
||||
return Db::table('usedcar_crawl_results')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE),
|
||||
'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => time()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 car_info 扁平成一个 [key => ['value' => xxx]] 的 map
|
||||
*
|
||||
* @param array $carInfo rawData['car_info'][0]
|
||||
* @return array
|
||||
*/
|
||||
public function buildValuesMapFromCarInfo(array $carInfo): array
|
||||
{
|
||||
$valuesMap = [];
|
||||
|
||||
// 1) car_info 顶层的一些字段(official_price、series_name 等)
|
||||
foreach ($carInfo as $k => $v) {
|
||||
if ($k === 'info' || is_array($v)) {
|
||||
continue;
|
||||
}
|
||||
$valuesMap[$k] = ['value' => $v];
|
||||
}
|
||||
|
||||
// 2) info 里的配置项
|
||||
if (!empty($carInfo['info']) && is_array($carInfo['info'])) {
|
||||
foreach ($carInfo['info'] as $k => $v) {
|
||||
if (is_array($v) && array_key_exists('value', $v)) {
|
||||
$valuesMap[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $valuesMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 type 重新分组:
|
||||
* - type=0,2 为分组(左侧分类)
|
||||
* - 其它 type 都视为该分组下的字段
|
||||
*
|
||||
* @param array $properties rawData['properties']
|
||||
* @param array $valuesMap 来自 buildValuesMapFromCarInfo()
|
||||
* @return array
|
||||
*/
|
||||
public function buildCarSpecGroups(array $properties, array $valuesMap): array
|
||||
{
|
||||
$groups = [];
|
||||
$currentKey = null; // 当前分组 key
|
||||
|
||||
foreach ($properties as $item) {
|
||||
$type = (int)($item['type'] ?? 1);
|
||||
$key = $item['key'] ?? '';
|
||||
$text = $item['text'] ?? '';
|
||||
$subList = $item['sub_list'] ?? null;
|
||||
|
||||
// ---------- 1) 分类字段:type = 0 / 2 ----------
|
||||
if (in_array($type, [0, 2], true)) {
|
||||
$currentKey = $key ?: ($text ?: ('group_' . count($groups)));
|
||||
|
||||
if (!isset($groups[$currentKey])) {
|
||||
$groups[$currentKey] = [
|
||||
'group_key' => $currentKey,
|
||||
'group_text' => $text,
|
||||
'group_type' => $type,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---------- 2) 普通字段:挂到最近的分类下 ----------
|
||||
if ($currentKey === null) {
|
||||
// 如果一开始就没有遇到分类,给一个默认分组
|
||||
$currentKey = '_default';
|
||||
$groups[$currentKey] = [
|
||||
'group_key' => '_default',
|
||||
'group_text' => '其他',
|
||||
'group_type' => -1,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// 取 value
|
||||
$value = '';
|
||||
if ($key && isset($valuesMap[$key])) {
|
||||
$value = trim((string)($valuesMap[$key]['value'] ?? ''));
|
||||
}
|
||||
|
||||
$node = [
|
||||
'key' => $key,
|
||||
'text' => $text,
|
||||
'value' => $value,
|
||||
];
|
||||
|
||||
// ---------- 3) sub_list 子项(有就当成 children) ----------
|
||||
if (is_array($subList) && !empty($subList)) {
|
||||
$children = [];
|
||||
foreach ($subList as $sub) {
|
||||
$subKey = $sub['key'] ?? '';
|
||||
$subText = $sub['text'] ?? '';
|
||||
$subValue = '';
|
||||
|
||||
if ($subKey && isset($valuesMap[$subKey])) {
|
||||
$subValue = trim((string)($valuesMap[$subKey]['value'] ?? ''));
|
||||
}
|
||||
|
||||
$children[] = [
|
||||
'key' => $subKey,
|
||||
'text' => $subText,
|
||||
'value' => $subValue,
|
||||
];
|
||||
}
|
||||
|
||||
$node['children'] = $children;
|
||||
}
|
||||
|
||||
$groups[$currentKey]['items'][] = $node;
|
||||
}
|
||||
|
||||
// 你可以按需返回关联数组或索引数组,这里转成索引数组方便前端遍历
|
||||
return array_values($groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义价格
|
||||
*/
|
||||
protected function decodeDongchediPrice(string $str): string
|
||||
{
|
||||
// 映射表:PUA 字符 => 数字 / 万
|
||||
$map = [
|
||||
"\u{E53D}" => '1',
|
||||
"\u{E3F0}" => '2',
|
||||
"\u{E422}" => '3',
|
||||
"\u{E42C}" => '4',
|
||||
"\u{E49C}" => '5',
|
||||
"\u{E42B}" => '6',
|
||||
"\u{E4FE}" => '7',
|
||||
"\u{E548}" => '8',
|
||||
"\u{E4C8}" => '9',
|
||||
"\u{E453}" => '0',
|
||||
"\u{E45F}" => '万',
|
||||
];
|
||||
|
||||
return strtr($str, $map);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取URL最后ID
|
||||
*/
|
||||
protected function getLastIdFromUrl($url)
|
||||
{
|
||||
// 方法1:使用正则表达式提取最后的数字ID
|
||||
if (preg_match('/\/(\d+)$/', $url, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载单张图片
|
||||
*
|
||||
* @param string $url 图片完整 URL
|
||||
* @param string $saveDir 保存目录(如 /www/usedcar_images/2025-11-20)
|
||||
* @param string|null $filename 指定文件名(可选,不含路径),为空则自动生成
|
||||
* @param array $headers 额外 HTTP 头(可选)
|
||||
*
|
||||
* @return string|null 成功返回保存路径,失败返回 null
|
||||
*/
|
||||
protected function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string
|
||||
{
|
||||
// 1. 目录检查 & 创建
|
||||
if (!is_dir($saveDir)) {
|
||||
if (!mkdir($saveDir, 0777, true) && !is_dir($saveDir)) {
|
||||
echo "[Image] 创建目录失败:{$saveDir}\n";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 自动生成文件名(基于 URL md5 + 原扩展名)
|
||||
if ($filename === null) {
|
||||
$path = parse_url($url, PHP_URL_PATH); // /xxx/xxx.jpg
|
||||
$ext = pathinfo($path, PATHINFO_EXTENSION); // jpg 或 webp 之类
|
||||
|
||||
if ($ext === '') {
|
||||
$ext = 'jpg'; // 没有扩展名时兜底
|
||||
}
|
||||
|
||||
$filename = md5($url) . '.' . $ext;
|
||||
}
|
||||
|
||||
$savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename;
|
||||
|
||||
// 3. 组装 HTTP 头
|
||||
$defaultHeaders = [
|
||||
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
. 'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
. 'Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Referer: https://www.dongchedi.com/', // 很多图床需要 Referer
|
||||
];
|
||||
|
||||
foreach ($headers as $h) {
|
||||
$defaultHeaders[] = $h;
|
||||
}
|
||||
|
||||
// 4. 使用 cURL 下载
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_HTTPHEADER => $defaultHeaders,
|
||||
]);
|
||||
|
||||
$data = curl_exec($ch);
|
||||
$errNo = curl_errno($ch);
|
||||
$errMsg = curl_error($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errNo !== 0) {
|
||||
echo "[Image] 下载失败(cURL):{$url} | {$errNo} {$errMsg}\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($code !== 200 || $data === false || $data === '') {
|
||||
echo "[Image] 下载失败(HTTP {$code}):{$url}\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5. 写入本地文件
|
||||
$bytes = file_put_contents($savePath, $data);
|
||||
if ($bytes === false) {
|
||||
echo "[Image] 保存失败:{$savePath}\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
echo "[Image] 下载成功:{$url} => {$savePath}\n";
|
||||
|
||||
return $savePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载相册
|
||||
*
|
||||
* @param array $urls 图片 URL 数组
|
||||
* @param string $saveDir 保存目录
|
||||
*
|
||||
* @return array [原始 URL => 保存路径或 null]
|
||||
*/
|
||||
protected function downloadImageAlbum(array $urls, string $saveDir): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($urls as $url) {
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果存在 // 开头的相对协议,补全
|
||||
if (strpos($url, '//') === 0) {
|
||||
$url = 'https:' . $url;
|
||||
}
|
||||
|
||||
$path = $this->downloadImage($url, $saveDir);
|
||||
$result[$url] = $path;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils\crawl\service;
|
||||
|
||||
use app\common\model\UsedcarCrawlResultsImages;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use QL\QueryList;
|
||||
use support\Redis;
|
||||
use support\Db;
|
||||
use support\exception\BusinessException;
|
||||
use Throwable;
|
||||
|
||||
class UsedCarCrawl_bak2
|
||||
{
|
||||
// 默认请求头
|
||||
private const DEFAULT_HEADERS = [
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept-Language' => 'zh-CN,zh;q=0.9',
|
||||
'Referer' => 'https://www.dongchedi.com/'
|
||||
];
|
||||
|
||||
// Redis队列键名
|
||||
private const REDIS_QUEUE_KEY = 'usedcar:urls';
|
||||
|
||||
// 能源类型常量
|
||||
public const ENERGY_TYPE_RENEWABLE = 'RE';
|
||||
public const ENERGY_TYPE_CONVENTIONAL = 'CE';
|
||||
public const ENERGY_TYPE_UNKNOWN = 'UNKNOWN';
|
||||
|
||||
/**
|
||||
* 插入队列url
|
||||
*
|
||||
* @param string $url URL地址
|
||||
* @param string $scene 场景:usedcar(二手信息) config(配置信息)
|
||||
* @param int $id 记录ID
|
||||
* @return int
|
||||
*/
|
||||
public function pushUrl(string $url = '', string $scene = 'usedcar', int $id = 0): int
|
||||
{
|
||||
$data = [
|
||||
'scene' => $scene,
|
||||
'url' => $url,
|
||||
'id' => $id
|
||||
];
|
||||
|
||||
return Redis::lpush(self::REDIS_QUEUE_KEY, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列url
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function popUrl(): ?array
|
||||
{
|
||||
$data = Redis::rPop(self::REDIS_QUEUE_KEY);
|
||||
if (!$data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($data, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行抓取任务
|
||||
*
|
||||
* @return bool
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function execute(): bool
|
||||
{
|
||||
try {
|
||||
$task = $this->popUrl();
|
||||
|
||||
if (!$task) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$html = $this->fetchHtml($task['url']);
|
||||
|
||||
switch ($task['scene']) {
|
||||
case 'usedcar':
|
||||
$info = $this->parseUsedCarDetail($html, $task['url']);
|
||||
$id = $this->saveUsedCarBasicInfo($info);
|
||||
$this->uploadAndRemove(json_decode($info['images'], true), public_path('uploads/usedcar/' . $id), $info['used_car_id'], $id);
|
||||
$this->pushUrl($info['car_id_url'], 'config', $id);
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
$data = $this->parseConfigDetail($html);
|
||||
$this->saveUsedCarConfigInfo($data, $task['id']);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new BusinessException("未知的任务场景: " . $task['scene']);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (GuzzleException $e) {
|
||||
throw new BusinessException("网络请求失败: " . $e->getMessage());
|
||||
} catch (Throwable $th) {
|
||||
throw new BusinessException("执行任务出错: " . $th->getMessage() . ' | 行号: ' . $th->getLine() . ' | 文件: ' . $th->getFile());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网页HTML内容
|
||||
*
|
||||
* @param string $url
|
||||
* @return string
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function fetchHtml(string $url): string
|
||||
{
|
||||
$client = new Client();
|
||||
$response = $client->request('GET', $url, [
|
||||
'headers' => self::DEFAULT_HEADERS
|
||||
]);
|
||||
return (string)$response->getBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析二手车详情页信息
|
||||
*
|
||||
* @param string $html
|
||||
* @param string $url
|
||||
* @return array
|
||||
*/
|
||||
public function parseUsedCarDetail(string $html = '', string $url = ''): array
|
||||
{
|
||||
$ql = QueryList::html($html);
|
||||
|
||||
$title = $ql->find('h1')->text();
|
||||
$price = $ql->find('.head-info_price-wrap__Y4bxi > p')->text();
|
||||
$pageText = $ql->find('body')->text();
|
||||
$jsonText = $ql->find('#__NEXT_DATA__')->text();
|
||||
|
||||
// 解析JSON数据
|
||||
$jsonArray = json_decode($jsonText, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new BusinessException("JSON解析失败: " . json_last_error_msg());
|
||||
}
|
||||
|
||||
// 车辆相册
|
||||
$images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? [];
|
||||
|
||||
// 车辆ID
|
||||
$carId = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_id'] ?? 0;
|
||||
|
||||
// 车辆年份
|
||||
$year = $jsonArray['props']['pageProps']['skuDetail']['car_info']['year'] ?? 0;
|
||||
|
||||
// 车辆ID URL
|
||||
$carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : '';
|
||||
|
||||
// 能源类型
|
||||
$fuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['power']['fuel_form'] ?? '';
|
||||
$newFuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['new_energy_power']['fuel_form'] ?? '';
|
||||
$resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm;
|
||||
|
||||
// 使用里程
|
||||
$importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? '';
|
||||
$importantTextArray = explode(' | ', $importantText);
|
||||
$usageMileage = str_replace('万公里', '', isset($importantTextArray[1]) ? $importantTextArray[1] : '');
|
||||
|
||||
// 车辆名称 品牌、车系、车型
|
||||
$brandName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['brand_name'] ?? '';
|
||||
$seriesName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['series_name'] ?? '';
|
||||
$carName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_name'] ?? '';
|
||||
|
||||
// 商家信息
|
||||
$shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? [];
|
||||
$formatShopInfo = [
|
||||
'shop_id' => $shopInfo['shop_id'] ?? 0,
|
||||
'shop_name' => $shopInfo['shop_short_name'] ?? '',
|
||||
'shop_address' => $shopInfo['shop_address'] ?? ''
|
||||
];
|
||||
|
||||
// 简介参数
|
||||
$otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? [];
|
||||
// 排量
|
||||
$displacement = $this->getParamValue($otherParams, '排量');
|
||||
// 变速箱
|
||||
$gearbox = $this->getParamValue($otherParams, '变速箱');
|
||||
// 格式化车辆信息
|
||||
$formatCarInfo = [
|
||||
'displacement' => $displacement,
|
||||
'gearbox' => $gearbox
|
||||
];
|
||||
|
||||
// 提取新车指导价
|
||||
$newPrice = $this->extractPatternValue($pageText, '/新车指导价[::]\s*([^\r\n万]*万)/u');
|
||||
|
||||
// 提取比新车省金额
|
||||
$priceDifference = $this->extractPatternValue($pageText, '/比新车省[::]\s*([^\r\n万]*万)/u');
|
||||
|
||||
// 提取车况介绍
|
||||
$conditionDesc = '';
|
||||
if ($pageText) {
|
||||
if (preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) {
|
||||
$conditionDesc = trim($m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// 整理车辆信息
|
||||
return [
|
||||
'used_car_id' => $this->extractIdFromUrl($url),
|
||||
'car_id' => $carId,
|
||||
'url' => $url,
|
||||
'title' => $title,
|
||||
'price' => str_replace('万', '', $this->decodePrice($price)),
|
||||
'new_price' => str_replace('万', '', $newPrice),
|
||||
'price_difference' => str_replace('万', '', $priceDifference),
|
||||
'condition_desc' => $conditionDesc,
|
||||
'images' => json_encode($images, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'car_id_url' => $carIdUrl,
|
||||
'used_detail' => $jsonText,
|
||||
'format_shop_info' => json_encode($formatShopInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'fuel_form' => $resultFuelForm,
|
||||
'classify_energy' => $this->detectEnergyType($resultFuelForm),
|
||||
'usage_mileage' => $usageMileage,
|
||||
'brand_name' => $brandName,
|
||||
'series_name' => $seriesName,
|
||||
'car_name' => $carName,
|
||||
'year' => $year,
|
||||
'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析配置详情页信息
|
||||
*
|
||||
* @param string $html
|
||||
* @return array
|
||||
*/
|
||||
public function parseConfigDetail(string $html = ''): array
|
||||
{
|
||||
$ql = QueryList::html($html);
|
||||
$jsonText = $ql->find('#__NEXT_DATA__')->text();
|
||||
|
||||
$raw = json_decode($jsonText, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new BusinessException("配置页面JSON解析失败: " . json_last_error_msg());
|
||||
}
|
||||
|
||||
$properties = $raw['props']['pageProps']['rawData']['properties'] ?? [];
|
||||
$carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? [];
|
||||
|
||||
$valuesMap = $this->buildValuesMap($carInfo);
|
||||
$groups = $this->buildCarSpecGroups($properties, $valuesMap);
|
||||
|
||||
return [
|
||||
'format_car_info' => [
|
||||
'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间
|
||||
'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式
|
||||
'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数
|
||||
'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机
|
||||
'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率
|
||||
'electric_description' => $this->getFieldValue($groups, 'electric_description'), //电动机
|
||||
'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), //电池容量
|
||||
'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型
|
||||
'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage') // 续航里程
|
||||
],
|
||||
'groups' => $groups
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从车辆配置中获取能源类型
|
||||
*
|
||||
* @param array $data JSON数据
|
||||
* @return string|null 能源类型
|
||||
*/
|
||||
public function getFuelFormByConfig(array $data): ?string
|
||||
{
|
||||
if (empty($data[0]['items'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($data[0]['items'] as $item) {
|
||||
if (isset($item['key']) && $item['key'] === 'fuel_form') {
|
||||
return $item['value'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据中根据字段key获取值
|
||||
*
|
||||
* @param mixed $data 数据
|
||||
* @param string $field 字段名
|
||||
* @return string|null
|
||||
*/
|
||||
public function getFieldValue($data, string $field): ?string
|
||||
{
|
||||
// 如果传入的是 JSON 字符串,先解码
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是数组,直接返回null
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 遍历寻找对应字段
|
||||
foreach ($data as $group) {
|
||||
if (isset($group['items']) && is_array($group['items'])) {
|
||||
foreach ($group['items'] as $item) {
|
||||
if (isset($item['key']) && $item['key'] === $field) {
|
||||
return $item['value'] ?? null;
|
||||
}
|
||||
|
||||
// 检查子元素
|
||||
if (isset($item['children']) && is_array($item['children'])) {
|
||||
foreach ($item['children'] as $child) {
|
||||
if (isset($child['key']) && $child['key'] === $field) {
|
||||
return $child['value'] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从参数列表中获取指定名称的值
|
||||
*
|
||||
* @param array $params 参数列表
|
||||
* @param string $name 名称
|
||||
* @return string|null
|
||||
*/
|
||||
public function getParamValue(array $params, string $name): ?string
|
||||
{
|
||||
foreach ($params as $item) {
|
||||
if (isset($item['name']) && $item['name'] === $name) {
|
||||
return $item['value'] ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断车辆能源类型
|
||||
*
|
||||
* @param string $text 描述文本
|
||||
* @return string 能源类型标识
|
||||
*/
|
||||
public function detectEnergyType(string $text): string
|
||||
{
|
||||
if (function_exists('mb_internal_encoding')) {
|
||||
mb_internal_encoding('UTF-8');
|
||||
}
|
||||
|
||||
// 明确定义的新能源关键字
|
||||
$newEnergyKeywords = [
|
||||
// 纯电
|
||||
'纯电', '纯电动', '电动车', 'BEV',
|
||||
// 插电式混合
|
||||
'插电混动', '插电式混合', '插电式', '插电', 'PHEV',
|
||||
// 增程式
|
||||
'增程式', '增程', 'REEV',
|
||||
// 氢能 / 燃料电池
|
||||
'氢能', '氢燃料', '燃料电池', 'FCEV',
|
||||
// 其它
|
||||
'太阳能',
|
||||
];
|
||||
|
||||
// 传统能源关键字
|
||||
$traditionalKeywords = [
|
||||
'汽油',
|
||||
'柴油',
|
||||
'天然气', 'LNG', 'CNG',
|
||||
];
|
||||
|
||||
// 普通混动关键字(归为传统能源)
|
||||
$hybridKeywords = [
|
||||
'油电混合', '油电混动', '油电',
|
||||
'混动', // 不带"插电"的混动
|
||||
'双擎', // 丰田常见表达
|
||||
'HEV',
|
||||
];
|
||||
|
||||
// 辅助函数:不区分大小写的包含判断
|
||||
$contains = function (string $haystack, string $needle): bool {
|
||||
if ($needle === '') {
|
||||
return false;
|
||||
}
|
||||
if (function_exists('mb_stripos')) {
|
||||
return mb_stripos($haystack, $needle) !== false;
|
||||
}
|
||||
return stripos($haystack, $needle) !== false;
|
||||
};
|
||||
|
||||
// 1. 先判断"明确新能源"关键词
|
||||
foreach ($newEnergyKeywords as $kw) {
|
||||
if ($contains($text, $kw)) {
|
||||
return self::ENERGY_TYPE_RENEWABLE;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 再判断"普通油电混动"
|
||||
foreach ($hybridKeywords as $kw) {
|
||||
if ($contains($text, $kw)) {
|
||||
return self::ENERGY_TYPE_CONVENTIONAL;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 再判断传统能源
|
||||
foreach ($traditionalKeywords as $kw) {
|
||||
if ($contains($text, $kw)) {
|
||||
return self::ENERGY_TYPE_CONVENTIONAL;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 都没匹配上
|
||||
return self::ENERGY_TYPE_UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存二手车基本信息到数据库
|
||||
*
|
||||
* @param array $data 数据
|
||||
* @return int 插入记录ID
|
||||
*/
|
||||
public function saveUsedCarBasicInfo(array $data): int
|
||||
{
|
||||
$data['created_at'] = time();
|
||||
$data['updated_at'] = time();
|
||||
return Db::table('usedcar_crawl_results')->insertGetId($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置信息到数据库
|
||||
*
|
||||
* @param array $data 配置数据
|
||||
* @param int $id 记录ID
|
||||
* @return bool|int
|
||||
*/
|
||||
public function saveUsedCarConfigInfo(array $data, int $id)
|
||||
{
|
||||
$record = Db::table('usedcar_crawl_results')->find($id);
|
||||
|
||||
if (!$record) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$groups = $data['groups'];
|
||||
$formatCarInfo = array_merge(
|
||||
$data['format_car_info'],
|
||||
json_decode($record->format_car_info, true)
|
||||
);
|
||||
|
||||
return Db::table('usedcar_crawl_results')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'updated_at' => time()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建值映射表
|
||||
*
|
||||
* @param array $carInfo 车辆信息
|
||||
* @return array
|
||||
*/
|
||||
public function buildValuesMap(array $carInfo): array
|
||||
{
|
||||
$valuesMap = [];
|
||||
|
||||
// 处理顶层字段
|
||||
foreach ($carInfo as $k => $v) {
|
||||
if ($k === 'info' || is_array($v)) {
|
||||
continue;
|
||||
}
|
||||
$valuesMap[$k] = ['value' => $v];
|
||||
}
|
||||
|
||||
// 处理info中的配置项
|
||||
if (!empty($carInfo['info']) && is_array($carInfo['info'])) {
|
||||
foreach ($carInfo['info'] as $k => $v) {
|
||||
if (is_array($v) && array_key_exists('value', $v)) {
|
||||
$valuesMap[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $valuesMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建车辆规格分组
|
||||
*
|
||||
* @param array $properties 属性列表
|
||||
* @param array $valuesMap 值映射表
|
||||
* @return array
|
||||
*/
|
||||
public function buildCarSpecGroups(array $properties, array $valuesMap): array
|
||||
{
|
||||
$groups = [];
|
||||
$currentKey = null;
|
||||
|
||||
foreach ($properties as $item) {
|
||||
$type = (int)($item['type'] ?? 1);
|
||||
$key = $item['key'] ?? '';
|
||||
$text = $item['text'] ?? '';
|
||||
$subList = $item['sub_list'] ?? null;
|
||||
|
||||
// 分类字段:type = 0 / 2
|
||||
if (in_array($type, [0, 2], true)) {
|
||||
$currentKey = $key ?: ($text ?: ('group_' . count($groups)));
|
||||
|
||||
if (!isset($groups[$currentKey])) {
|
||||
$groups[$currentKey] = [
|
||||
'group_key' => $currentKey,
|
||||
'group_text' => $text,
|
||||
'group_type' => $type,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 普通字段:挂到最近的分类下
|
||||
if ($currentKey === null) {
|
||||
// 如果一开始就没有遇到分类,给一个默认分组
|
||||
$currentKey = '_default';
|
||||
$groups[$currentKey] = [
|
||||
'group_key' => '_default',
|
||||
'group_text' => '其他',
|
||||
'group_type' => -1,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// 取 value
|
||||
$value = '';
|
||||
if ($key && isset($valuesMap[$key])) {
|
||||
$value = trim((string)($valuesMap[$key]['value'] ?? ''));
|
||||
}
|
||||
|
||||
$node = [
|
||||
'key' => $key,
|
||||
'text' => $text,
|
||||
'value' => $value,
|
||||
];
|
||||
|
||||
// 处理子项
|
||||
if (is_array($subList) && !empty($subList)) {
|
||||
$children = [];
|
||||
foreach ($subList as $sub) {
|
||||
$subKey = $sub['key'] ?? '';
|
||||
$subText = $sub['text'] ?? '';
|
||||
$subValue = '';
|
||||
|
||||
if ($subKey && isset($valuesMap[$subKey])) {
|
||||
$subValue = trim((string)($valuesMap[$subKey]['value'] ?? ''));
|
||||
}
|
||||
|
||||
$children[] = [
|
||||
'key' => $subKey,
|
||||
'text' => $subText,
|
||||
'value' => $subValue,
|
||||
];
|
||||
}
|
||||
|
||||
$node['children'] = $children;
|
||||
}
|
||||
|
||||
$groups[$currentKey]['items'][] = $node;
|
||||
}
|
||||
|
||||
// 转成索引数组方便前端遍历
|
||||
return array_values($groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码懂车帝价格
|
||||
*
|
||||
* @param string $str 加密字符串
|
||||
* @return string 解码后的字符串
|
||||
*/
|
||||
protected function decodePrice(string $str): string
|
||||
{
|
||||
// 映射表:PUA 字符 => 数字 / 万
|
||||
$map = [
|
||||
"\u{E53D}" => '1',
|
||||
"\u{E3F0}" => '2',
|
||||
"\u{E422}" => '3',
|
||||
"\u{E42C}" => '4',
|
||||
"\u{E49C}" => '5',
|
||||
"\u{E42B}" => '6',
|
||||
"\u{E4FE}" => '7',
|
||||
"\u{E548}" => '8',
|
||||
"\u{E4C8}" => '9',
|
||||
"\u{E453}" => '0',
|
||||
"\u{E45F}" => '万',
|
||||
];
|
||||
|
||||
return strtr($str, $map);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL中提取ID
|
||||
*
|
||||
* @param string $url URL
|
||||
* @return int|false
|
||||
*/
|
||||
protected function extractIdFromUrl(string $url)
|
||||
{
|
||||
if (preg_match('/\/(\d+)$/', $url, $matches)) {
|
||||
return (int)$matches[1];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取符合正则表达式的值
|
||||
*
|
||||
* @param string $text 文本
|
||||
* @param string $pattern 正则表达式
|
||||
* @return string
|
||||
*/
|
||||
protected function extractPatternValue(string $text, string $pattern): string
|
||||
{
|
||||
$value = '';
|
||||
if ($text) {
|
||||
if (preg_match($pattern, $text, $m)) {
|
||||
$value = trim(preg_replace('/[^0-9.]/', '', $m[1]));
|
||||
}
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载单张图片
|
||||
*
|
||||
* @param string $url 图片完整URL
|
||||
* @param string $saveDir 保存目录
|
||||
* @param string|null $filename 指定文件名
|
||||
* @param array $headers 额外HTTP头
|
||||
* @return string|null 成功返回保存路径,失败返回null
|
||||
*/
|
||||
public function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string
|
||||
{
|
||||
// 目录检查 & 创建
|
||||
if (!is_dir($saveDir)) {
|
||||
if (!mkdir($saveDir, 0777, true) && !is_dir($saveDir)) {
|
||||
echo "[Image] 创建目录失败:{$saveDir}\n";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 自动生成文件名
|
||||
if ($filename === null) {
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
$ext = pathinfo($path, PATHINFO_EXTENSION);
|
||||
|
||||
if ($ext === '') {
|
||||
$ext = 'jpg';
|
||||
}
|
||||
|
||||
$filename = md5($url) . '.' . $ext;
|
||||
}
|
||||
|
||||
$savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename;
|
||||
|
||||
// 组装HTTP头
|
||||
$defaultHeaders = [
|
||||
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Referer: https://www.dongchedi.com/',
|
||||
];
|
||||
|
||||
foreach ($headers as $h) {
|
||||
$defaultHeaders[] = $h;
|
||||
}
|
||||
|
||||
// 使用cURL下载
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_HTTPHEADER => $defaultHeaders,
|
||||
]);
|
||||
|
||||
$data = curl_exec($ch);
|
||||
$errNo = curl_errno($ch);
|
||||
$errMsg = curl_error($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errNo !== 0) {
|
||||
// echo "[Image] 下载失败(cURL):{$url} | {$errNo} {$errMsg}\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($code !== 200 || $data === false || $data === '') {
|
||||
// echo "[Image] 下载失败(HTTP {$code}):{$url}\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
// 写入本地文件
|
||||
$bytes = file_put_contents($savePath, $data);
|
||||
if ($bytes === false) {
|
||||
// echo "[Image] 保存失败:{$savePath}\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
// echo "[Image] 下载成功:{$url} => {$savePath}\n";
|
||||
|
||||
return $savePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载相册
|
||||
*
|
||||
* @param array $urls 图片URL数组
|
||||
* @param string $saveDir 保存目录
|
||||
* @return array [原始URL => 保存路径或null]
|
||||
*/
|
||||
public function downloadImageAlbum(array $urls, string $saveDir): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($urls as $url) {
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 补全协议
|
||||
if (strpos($url, '//') === 0) {
|
||||
$url = 'https:' . $url;
|
||||
}
|
||||
|
||||
$path = $this->downloadImage($url, $saveDir);
|
||||
$result[$url] = $path;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
public function uploadAndRemove($images, $path, $usedCarId, $id)
|
||||
{
|
||||
// 下载图片
|
||||
$filePatch = $this->downloadImageAlbum($images, $path);
|
||||
|
||||
// 检查图片
|
||||
if (empty($filePatch)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 上传图片
|
||||
foreach ($filePatch as $url => $path) {
|
||||
if ($path === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 上传云端
|
||||
$param = [
|
||||
'path' => $path,
|
||||
'scene' => 'usedcar_crawl',
|
||||
'category' => 'usedcar'
|
||||
];
|
||||
$up = new \app\common\service\UploadService();
|
||||
$result = $up->uploadLocalFile($param);
|
||||
|
||||
// 保存图片信息
|
||||
UsedcarCrawlResultsImages::create([
|
||||
'uc_id' => $id,
|
||||
'used_car_id' => $usedCarId,
|
||||
'original_name' => $result['original_name'],
|
||||
'file_path' => $result['file_name'],
|
||||
]);
|
||||
|
||||
// 删除本地文件
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,9 +434,15 @@ class Emails
|
||||
$channel = 12;
|
||||
$channelInfo = InquiryChannel::where('id', $channel)->first();
|
||||
|
||||
// 从库实时获取
|
||||
$users = \app\common\model\User::where('corp_id', 3)->where('status', 1)->orderBy('id')->pluck('id')->toArray();
|
||||
|
||||
// 移除不参与分配人员
|
||||
$users = array_values(array_diff($users, [190]));
|
||||
|
||||
// 初始化 UID 轮询器
|
||||
$uidRotator = new \app\common\utils\RedisUidRotator('email_parsed_result_20250804');
|
||||
$uidRotator->setUids([64, 63, 93, 108, 132, 145, 150, 143, 156, 157, 158], false);
|
||||
$uidRotator->setUids($users, false);
|
||||
|
||||
EmailParsedResult::where('status', 0)
|
||||
->chunkById($batchSize, function ($records) use ($delaySeconds, $throttleAfter, $throttleSeconds, $enterUser, $channelInfo, $uidRotator) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils\email;
|
||||
|
||||
class InquiryDigestMail
|
||||
{
|
||||
/**
|
||||
* 构建汇总邮件 HTML
|
||||
*
|
||||
* @param string $userName 收件人姓名
|
||||
* @param array $inquiries 询盘数组,每项含 code/contact/email/phone/country_cn/channel/remark
|
||||
* @param string $date 日期串 Y-m-d
|
||||
* @return string HTML
|
||||
*/
|
||||
public static function build(string $userName, array $inquiries, string $date): string
|
||||
{
|
||||
$count = count($inquiries);
|
||||
$rows = '';
|
||||
$idx = 1;
|
||||
foreach ($inquiries as $q) {
|
||||
$remark = mb_substr((string)($q['remark'] ?? ''), 0, 100);
|
||||
$td = '<td style="border:1px solid #eee;padding:6px;">';
|
||||
$rows .= '<tr>'
|
||||
. $td . $idx++ . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['code'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['contact'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['email'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['phone'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['country_cn'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['channel'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars($remark) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
$thStyle = 'style="border:1px solid #eee;padding:6px;background:#f0f0f0;"';
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"></head>'
|
||||
. '<body style="margin:0;padding:0;background:#f6f7f9;font-family:Arial,Helvetica,sans-serif;">'
|
||||
. '<div style="max-width:960px;margin:0 auto;padding:20px;">'
|
||||
. '<h2 style="color:#111;">询盘分配汇总</h2>'
|
||||
. '<p>收件人:<strong>' . htmlspecialchars($userName) . '</strong> 日期:' . $date
|
||||
. ' 共 <strong>' . $count . '</strong> 条</p>'
|
||||
. '<table style="border-collapse:collapse;width:100%;font-size:13px;color:#333;">'
|
||||
. '<thead><tr>'
|
||||
. '<th ' . $thStyle . '>#</th>'
|
||||
. '<th ' . $thStyle . '>编号</th>'
|
||||
. '<th ' . $thStyle . '>联系人</th>'
|
||||
. '<th ' . $thStyle . '>邮箱</th>'
|
||||
. '<th ' . $thStyle . '>电话</th>'
|
||||
. '<th ' . $thStyle . '>国家</th>'
|
||||
. '<th ' . $thStyle . '>渠道</th>'
|
||||
. '<th ' . $thStyle . '>意向/备注</th>'
|
||||
. '</tr></thead><tbody>'
|
||||
. $rows
|
||||
. '</tbody></table></div></body></html>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils\email;
|
||||
|
||||
use app\common\model\Country;
|
||||
use app\common\model\CrmCustomer;
|
||||
use app\common\model\EmailMailbox;
|
||||
use app\common\model\EmailPull;
|
||||
use app\common\model\EmailPullParsed;
|
||||
use app\common\model\EmailPullUids;
|
||||
use app\common\model\InquiryChannel;
|
||||
use app\common\model\InquiryLog;
|
||||
use app\common\model\User;
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
use support\Db;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
class Mailbox
|
||||
{
|
||||
/**
|
||||
* 多邮箱处理:拉取uid
|
||||
*/
|
||||
function mailbox()
|
||||
{
|
||||
$mailboxModel = EmailMailbox::where('last_fetch_at', '<=', time())
|
||||
->where('status',1)->first();
|
||||
//$mailboxModel['last_fetch_at'] = time();
|
||||
//$mailboxModel->save();
|
||||
|
||||
$mailbox = $this->mailboxSplice($mailboxModel);
|
||||
// $INBOX= $mailboxModel['imap_host'].':'.$mailboxModel['imap_port'].$mailboxModel['imap_flags'].$mailboxModel['mailbox'];
|
||||
// $mailbox = '{'.$INBOX.'}'.'INBOX';
|
||||
//$username = $mailbox['username'];
|
||||
//$password = $mailbox['password'];
|
||||
|
||||
$this->pullUid($mailboxModel, $mailbox);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接INBOX
|
||||
* @param $mailboxModel
|
||||
* @return string
|
||||
*/
|
||||
function mailboxSplice($mailboxModel){
|
||||
$INBOX= $mailboxModel['imap_host'].':'.$mailboxModel['imap_port'].$mailboxModel['imap_flags'];
|
||||
$mailbox = '{'.$INBOX.'}'.'INBOX';
|
||||
return $mailbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取邮件uid
|
||||
*/
|
||||
function pullUid($mailboxModel, $mailbox)
|
||||
{
|
||||
try {
|
||||
$username = $mailboxModel['username'];
|
||||
$password = $mailboxModel['password'];
|
||||
$lastUid = $mailboxModel['last_uid'];
|
||||
|
||||
$retryTimes = 3;
|
||||
$retryDelay = 10;
|
||||
$inbox = false;
|
||||
$attempt = 0;
|
||||
|
||||
while ($attempt < $retryTimes) {
|
||||
$attempt++;
|
||||
flg("尝试第 {$attempt} 次连接 IMAP...", false,'mailbox');
|
||||
|
||||
$inbox = @imap_open($mailbox, $username, $password);
|
||||
|
||||
if ($inbox) {
|
||||
break;
|
||||
}
|
||||
|
||||
$error = imap_last_error();
|
||||
flg("<comment>{$username}第 {$attempt} 次失败:{$error}</comment>", false,'mailbox');
|
||||
|
||||
// 若出现 too many login failures 立即暂停一段时间
|
||||
if (stripos($error, 'Too many login failures') !== false) {
|
||||
flg("<error>检测到登录失败次数过多,暂停 60 秒避免封禁</error>", false,'mailbox');
|
||||
sleep(60);
|
||||
} else {
|
||||
sleep($retryDelay);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$inbox) {
|
||||
flg("{$username}<error>最终仍然连接失败:" . imap_last_error() . "</error>", false,'mailbox');
|
||||
return false;
|
||||
}
|
||||
// 拉取 UID 并入库
|
||||
flg("开始拉取 UID 并入库 …", false,'mailbox');
|
||||
$uids = imap_search($inbox, 'ALL', SE_UID) ?: [];
|
||||
|
||||
$uids = array_filter($uids, function($uid) use ($lastUid) {
|
||||
return $uid > $lastUid;
|
||||
});
|
||||
|
||||
sort($uids);
|
||||
$now = time();
|
||||
$count = 0;
|
||||
|
||||
foreach ($uids as $uid) {
|
||||
try {
|
||||
Db::table('email_pull_uids')->insert([
|
||||
'uid' => $uid,
|
||||
'fetched_at' => $now,
|
||||
'email' => $username
|
||||
]);
|
||||
$count++;
|
||||
flg("{$username} UID:".$uid." 已写入数据库", false,'mailbox');
|
||||
} catch (\Throwable $e) {
|
||||
flg("{$username} <comment>{$e->getMessage()}</comment>", false,'mailbox');
|
||||
}
|
||||
}
|
||||
|
||||
imap_close($inbox);
|
||||
flg("✔ {$username}获取到新询盘邮件 {$count} 条 ...", false,'mailbox');
|
||||
|
||||
|
||||
if(!empty($uids)){
|
||||
$mailboxModel->last_uid = max($uids);
|
||||
}
|
||||
$mailboxModel->last_fetch_at = time();
|
||||
$mailboxModel->save();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $th) {
|
||||
flg(dpr($th), false,'mailbox');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多邮箱处理:拉取详情
|
||||
*/
|
||||
function pullDetails()
|
||||
{
|
||||
$emails = EmailPullUids::where('status', 0)
|
||||
->select('email')
|
||||
->distinct()
|
||||
->get();
|
||||
foreach ($emails as $email) {
|
||||
$mailboxModel = EmailMailbox::where('username', $email->email)->first();
|
||||
$mailbox = $this->mailboxSplice($mailboxModel);
|
||||
$this->details($mailboxModel, $mailbox);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取邮件详情
|
||||
*/
|
||||
public function details($mailboxModel, $mailbox)
|
||||
{
|
||||
try {
|
||||
|
||||
$username = $mailboxModel['username'];
|
||||
$password = $mailboxModel['password'];
|
||||
$inbox = @imap_open($mailbox, $username, $password);
|
||||
if (!$inbox) {
|
||||
flg("<error>{$username} IMAP 连接失败:". imap_last_error() ."</error>", false,'mailbox');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 依次拉取详情并写入 emails 表
|
||||
flg("依次拉取详情并写入 email_pull 表 …", false,'mailbox');
|
||||
while (true) {
|
||||
$pending = Db::table('email_pull_uids')
|
||||
->where('email', $username)
|
||||
->where('status', 0)
|
||||
->orderBy('uid', 'desc')
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
if (count($pending) === 0) {
|
||||
flg("邮件UID的详情,已拉取完毕。", false,'mailbox');
|
||||
break;
|
||||
}
|
||||
|
||||
flg("处理下一批 " . count($pending) . " 条 …", false,'mailbox');
|
||||
|
||||
foreach ($pending as $row) {
|
||||
$uid = $row->uid;
|
||||
|
||||
// 包裹整行处理在 try/catch 中,任何异常都不会中断循环
|
||||
try {
|
||||
flg("---- 开始处理 UID={$uid} ----", false,'mailbox');
|
||||
|
||||
// 1. fetch overview
|
||||
$ov = @imap_fetch_overview($inbox, $uid, FT_UID)[0] ?? null;
|
||||
if (!$ov) {
|
||||
$err = imap_last_error() ?: '未知错误';
|
||||
flg("UID={$uid} overview 失败:{$err}", false,'mailbox');
|
||||
continue;
|
||||
}
|
||||
$subject = mb_convert_encoding($ov->subject ?? '', 'UTF-8', 'GBK,UTF-8');
|
||||
$from = $ov->from ?? '';
|
||||
$date = $ov->date ?? '';
|
||||
|
||||
flg("UID={$uid} overview 成功,subject={$subject}", false,'mailbox');
|
||||
|
||||
// 2. fetch body
|
||||
$body = $this->getMailBody($inbox, $uid, true);
|
||||
flg("UID={$uid} body 长度:" . strlen($body), false,'mailbox');
|
||||
|
||||
// 3. sanitize & insert
|
||||
$body = $this->sanitizeUtf8($body);
|
||||
$now = time();
|
||||
|
||||
try {
|
||||
Db::table('email_pull')->insert([
|
||||
'email' => $username,
|
||||
'uid' => $uid,
|
||||
'subject' => $subject,
|
||||
'from' => $from,
|
||||
'date' => $date,
|
||||
'body' => $body,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
flg($username." UID={$uid} 写入 emails 成功", false,'mailbox');
|
||||
} catch (\Throwable $e) {
|
||||
flg($username." UID={$uid} 写入失败:{$e->getMessage()}", false,'mailbox');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. 标记已处理
|
||||
try {
|
||||
Db::table('email_pull_uids')
|
||||
->where('uid', $uid)
|
||||
->update([
|
||||
'status' => 1,
|
||||
'processed_at' => $now,
|
||||
]);
|
||||
flg($username." UID={$uid} 标记为已处理", false,'mailbox');
|
||||
} catch (\Throwable $e) {
|
||||
flg($username." UID={$uid} 标记状态失败:{$e->getMessage()}", false,'mailbox');
|
||||
// 即使更新 status 失败,也继续下一条
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 捕获任何非预期错误
|
||||
flg($username." UID={$uid} 处理异常:{$e->getMessage()}", false,'mailbox');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 批次结束后稍作休眠,防止服务器过载
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
imap_close($inbox);
|
||||
flg($username." ✔ 新邮件详情拉取完成 ...", false,'mailbox');
|
||||
return true;
|
||||
} catch (\Throwable $th) {
|
||||
flg(dpr($th), false,'mailbox');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将邮件按编码解码并保留 "\r\n"
|
||||
*/
|
||||
protected function getMailBody($inbox, int $id, bool $isUid = false): string
|
||||
{
|
||||
$flags = $isUid ? FT_UID : 0;
|
||||
$structure = imap_fetchstructure($inbox, $id, $flags);
|
||||
|
||||
if (!empty($structure->parts)) {
|
||||
// HTML 部分
|
||||
foreach ($structure->parts as $i => $part) {
|
||||
if (isset($part->subtype) && strtolower($part->subtype) === 'html') {
|
||||
$data = imap_fetchbody($inbox, $id, (string)($i+1), $flags);
|
||||
return $this->decodeBody($data, $part->encoding);
|
||||
}
|
||||
}
|
||||
// 文本部分
|
||||
foreach ($structure->parts as $i => $part) {
|
||||
if (isset($part->subtype) && strtolower($part->subtype) === 'plain') {
|
||||
$data = imap_fetchbody($inbox, $id, (string)($i+1), $flags);
|
||||
return $this->decodeBody($data, $part->encoding);
|
||||
}
|
||||
}
|
||||
// 原始 body
|
||||
return imap_body($inbox, $id, $flags);
|
||||
}
|
||||
|
||||
return imap_body($inbox, $id, $flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任意字符串清洗为有效 UTF-8,移除或替换无效字节
|
||||
*/
|
||||
protected function sanitizeUtf8(string $text): string
|
||||
{
|
||||
return iconv('UTF-8', 'UTF-8//IGNORE', $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按编码解码
|
||||
*/
|
||||
protected function decodeBody(string $body, int $encoding): string
|
||||
{
|
||||
switch ($encoding) {
|
||||
case 3: return imap_base64($body);
|
||||
case 4: return imap_qprint($body);
|
||||
default: return $body;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多邮箱处理:清洗数据
|
||||
*/
|
||||
function processMailbox()
|
||||
{
|
||||
$emails = EmailPull::where('status', 0)
|
||||
->select('email')
|
||||
->distinct()
|
||||
->get();
|
||||
foreach ($emails as $email) {
|
||||
$mailboxModel = EmailMailbox::where('username', $email->email)->first();
|
||||
$this->process($mailboxModel);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function process($mailboxModel)
|
||||
{
|
||||
switch ($mailboxModel['username']) {
|
||||
case 'paika@paikacar.com':
|
||||
$this->paika($mailboxModel);
|
||||
break;
|
||||
case 'hesu@hesucar.com':
|
||||
$this->hesu($mailboxModel);
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗询盘邮件
|
||||
* @param $mailbox
|
||||
* @return void
|
||||
*/
|
||||
function paika($mailbox)
|
||||
{
|
||||
$batchSize = 100; // 每次处理 100 条
|
||||
$page = 0;
|
||||
|
||||
// 明确定义 parsed_contacts 表里要插入的所有列(顺序无所谓,但要全部包含)
|
||||
$fields = [
|
||||
'email_id',
|
||||
'date',
|
||||
'email',
|
||||
'name',
|
||||
'tel',
|
||||
'company',
|
||||
'country',
|
||||
'form_url',
|
||||
'message',
|
||||
'ip',
|
||||
'iplocation',
|
||||
'ilang',
|
||||
'mailto',
|
||||
'navigator_language',
|
||||
'navigator_platform',
|
||||
'screen_width',
|
||||
'screen_height',
|
||||
'ucountry',
|
||||
];
|
||||
|
||||
while (true) {
|
||||
$rows = EmailPull::where('email', $mailbox['username'])
|
||||
->where('status', 0)
|
||||
->orderBy('id', 'asc')
|
||||
->limit($batchSize)
|
||||
->offset($page * $batchSize)
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
foreach ($rows as $row) {
|
||||
// $parsed = $this->parseHtml($row->body);
|
||||
$parsed = $this->parsePaikaInquiry($row->body, $row->date);
|
||||
if ($parsed === null) {
|
||||
$row->status = 2; //其他邮件
|
||||
$row->save();
|
||||
flg("<error>{$mailbox['username']} {$row->id} skip: not a contact-form HTML</error>", false,'mailbox');
|
||||
continue;
|
||||
}
|
||||
|
||||
$row->status = 1; //已处理
|
||||
$row->save();
|
||||
|
||||
// 严格按 $fields 顺序填充每条记录,缺失的字段补 null
|
||||
$record = [];
|
||||
foreach ($fields as $col) {
|
||||
if ($col === 'email_id') {
|
||||
$record[$col] = $row->id;
|
||||
} else {
|
||||
$record[$col] = $parsed[$col] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$toInsert[] = $record;
|
||||
}
|
||||
if (!empty($toInsert)) {
|
||||
$emailId = array_column($toInsert, 'email_id');
|
||||
$mailto = array_column($toInsert, 'mailto')[0];
|
||||
$email = Db::table('email_pull_parsed')->whereIn('email_id', $emailId)
|
||||
// ->where('mailto', $mailto)
|
||||
->first();
|
||||
if(!empty($email)){
|
||||
continue;
|
||||
}
|
||||
Db::table('email_pull_parsed')->insert($toInsert);
|
||||
}
|
||||
|
||||
$page++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析https://paikacar.com/wp-admin询盘邮件
|
||||
* DOM 解析为数组
|
||||
*/
|
||||
public function parsePaikaInquiry(string $html, $date)
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$dom = new DOMDocument();
|
||||
|
||||
//$dom->loadHTML($html);
|
||||
$dom->loadHTML('<?xml encoding="utf-8"?>' . $html);
|
||||
libxml_clear_errors();
|
||||
|
||||
$xpath = new DOMXPath($dom);
|
||||
|
||||
if (
|
||||
!str_contains($html, 'Product Inquiry') ||
|
||||
!str_contains($html, 'Product Name') ||
|
||||
!str_contains($html, 'Your Name')
|
||||
) {
|
||||
// 认为是询盘
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
|
||||
// 找到所有 tr
|
||||
$rows = $xpath->query('//table//tr');
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$th = $xpath->query('.//th//p', $row)->item(0);
|
||||
$td = $xpath->query('.//td//p', $row)->item(0);
|
||||
|
||||
if (!$th || !$td) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = trim(str_replace(':', '', $th->textContent));
|
||||
$value = trim($td->textContent);
|
||||
|
||||
// 特殊字段:Product Name 是链接
|
||||
if ($key === 'Product Name') {
|
||||
$a = $xpath->query('.//a', $td)->item(0);
|
||||
if ($a) {
|
||||
$data['product_name'] = trim($a->textContent);
|
||||
$data['product_url'] = $a->getAttribute('href');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$data[$key] = $value;
|
||||
}
|
||||
$data['date'] = $date;
|
||||
|
||||
return $this->mapInquiryFields($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* paika.@paikacar.com字段处理
|
||||
*/
|
||||
function mapInquiryFields(array $raw): array
|
||||
{
|
||||
$result = [
|
||||
'product_name' => $raw['product_name'] ?? '',
|
||||
'form_url' => $raw['product_url'] ?? '',
|
||||
'quantity' => $this->normalizeValue($raw['Quantity'] ?? ''), //数量
|
||||
'name' => $raw['Your Name'] ?? '',
|
||||
'user_type' => $raw['User type'] ?? '',
|
||||
'email' => $raw['E-mail'] ?? '',
|
||||
'tel' => $this->normalizePhone($raw['Phone'] ?? ''),
|
||||
'whatsapp' => $raw['WhatsApp'] ?? '',
|
||||
// 'skype' => $raw['Skype'] ?? '',
|
||||
'company' => $raw['Company'] ?? '',
|
||||
'country' => $this->normalizeCountry($raw['Country'] ?? ''),
|
||||
'ip' => $this->extractIp($raw['IP'] ?? ''),
|
||||
'content' => $raw['Content'] ?? '',
|
||||
'date' => date('Y-m-d H:i:s',strtotime($raw['date'])),
|
||||
];
|
||||
//只有ipv4分析ip所属地区
|
||||
if (filter_var($result['ip'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
$result['iplocation'] = empty($result['ip'])?ip2region($result['ip']).'(仅供参考)':'';
|
||||
}
|
||||
// $message = trim(
|
||||
// (!empty($result['product_name']) ? "意向车型:{$result['product_name']}" : '') .
|
||||
// (!empty($result['quantity']) ? (!empty($result['product_name']) ? ', ' : '') . "数量:{$result['quantity']}" : '')
|
||||
// ) ?: '';
|
||||
$message[] = !empty($result['product_name']) ? "意向车型:{$result['product_name']}" : '';
|
||||
$message[] = !empty($result['quantity']) ? "数量:{$result['quantity']}" : '';
|
||||
$message[] = !empty($result['user_type']) ? "用户类型:{$result['user_type']}" : '';
|
||||
$message[] = "<p>询盘来源:" . "paikacar.com</p>";
|
||||
$message[] = "<p>FB 创建时间原始值:".$raw['date']."</p>";
|
||||
|
||||
$result['message'] = implode(',', $message);
|
||||
$result['ilang'] = 'en';
|
||||
$result['mailto'] = 'paika.@paikacar.com';
|
||||
unset($result['product_name']);
|
||||
unset($result['quantity']);
|
||||
return $result;
|
||||
}
|
||||
|
||||
function normalizeValue(string $value): string
|
||||
{
|
||||
if (str_contains($value, '[your-')) {
|
||||
return '';
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
function normalizePhone(string $phone): string
|
||||
{
|
||||
return preg_replace('/[^\d+]/', '', $phone);
|
||||
}
|
||||
|
||||
function extractIp(string $text): string
|
||||
{
|
||||
if (preg_match('/\b\d{1,3}(\.\d{1,3}){3}\b/', $text, $m)) {
|
||||
return $m[0];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeCountry(string $country): string
|
||||
{
|
||||
if (str_contains($country, 'Please select')) {
|
||||
return '';
|
||||
}
|
||||
return $country;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗合塑询盘邮件
|
||||
* @param $mailbox
|
||||
* @return void
|
||||
*/
|
||||
function hesu($mailbox)
|
||||
{
|
||||
$batchSize = 100; // 每次处理 100 条
|
||||
$page = 0;
|
||||
|
||||
// 明确定义 parsed_contacts 表里要插入的所有列(顺序无所谓,但要全部包含)
|
||||
$fields = [
|
||||
'email_id',
|
||||
'date',
|
||||
'email',
|
||||
'name',
|
||||
'tel',
|
||||
'company',
|
||||
'country',
|
||||
'form_url',
|
||||
'message',
|
||||
'ip',
|
||||
'iplocation',
|
||||
'ilang',
|
||||
'mailto',
|
||||
'navigator_language',
|
||||
'navigator_platform',
|
||||
'screen_width',
|
||||
'screen_height',
|
||||
'ucountry',
|
||||
];
|
||||
|
||||
while (true) {
|
||||
$rows = EmailPull::where('email', $mailbox['username'])
|
||||
->where('status', 0)
|
||||
->orderBy('id', 'asc')
|
||||
->limit($batchSize)
|
||||
->offset($page * $batchSize)
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$toInsert = [];
|
||||
foreach ($rows as $row) {
|
||||
// $parsed = $this->parseHtml($row->body);
|
||||
$parsed = $this->parseHesucarInquiry($row->body, $row->date);
|
||||
if ($parsed === null) {
|
||||
$row->status = 2; //其他邮件
|
||||
$row->save();
|
||||
flg($mailbox['username']." <error>{$mailbox['username']} {$row->id} skip: not a contact-form HTML</error>", false,'mailbox');
|
||||
continue;
|
||||
}
|
||||
|
||||
$row->status = 1; //已处理
|
||||
$row->save();
|
||||
|
||||
// 严格按 $fields 顺序填充每条记录,缺失的字段补 null
|
||||
$record = [];
|
||||
foreach ($fields as $col) {
|
||||
if ($col === 'email_id') {
|
||||
$record[$col] = $row->id;
|
||||
} else {
|
||||
$record[$col] = $parsed[$col] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$toInsert[] = $record;
|
||||
}
|
||||
if (!empty($toInsert)) {
|
||||
|
||||
$emailId = array_column($toInsert, 'email_id');
|
||||
$mailto = array_column($toInsert, 'mailto')[0];
|
||||
$email = Db::table('email_pull_parsed')->whereIn('email_id', $emailId)
|
||||
//->where('mailto', $mailto)
|
||||
->first();
|
||||
if(!empty($email)){
|
||||
continue;
|
||||
}
|
||||
Db::table('email_pull_parsed')->insert($toInsert);
|
||||
}
|
||||
|
||||
$page++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析https://hesucar.com询盘邮件
|
||||
* DOM 解析为数组
|
||||
*/
|
||||
public function parseHesucarInquiry(string $html, $date)
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$dom = new DOMDocument();
|
||||
|
||||
//$dom->loadHTML($html);
|
||||
$dom->loadHTML('<?xml encoding="utf-8"?>' . $html);
|
||||
libxml_clear_errors();
|
||||
|
||||
$xpath = new DOMXPath($dom);
|
||||
|
||||
if (
|
||||
!str_contains($html, 'HESUCAR')
|
||||
|| !str_contains($html, 'Tel/Whatsapp')
|
||||
|| !str_contains($html, 'Sent from')
|
||||
) {
|
||||
// 认为是询盘
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'product_url' => '',
|
||||
'name' => '',
|
||||
'phone' => '',
|
||||
'message' => '',
|
||||
'source' => 'hesucar_inquiry',
|
||||
'date' => $date,
|
||||
];
|
||||
|
||||
/**
|
||||
* 1️⃣ 产品链接(正文最前面的 a)
|
||||
*/
|
||||
$productLink = $xpath->query(
|
||||
'//td[contains(@class,"mcnTextContent")]//a[starts-with(@href,"http")]'
|
||||
)->item(0);
|
||||
|
||||
if ($productLink) {
|
||||
$data['product_url'] = trim($productLink->getAttribute('href'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 2️⃣ Name / Tel / Message
|
||||
* 结构特点:
|
||||
* <strong>Label</strong>
|
||||
* 下一行 td 就是 value
|
||||
*/
|
||||
$labels = $xpath->query('//strong');
|
||||
|
||||
foreach ($labels as $labelNode) {
|
||||
$label = trim($labelNode->textContent);
|
||||
|
||||
// 当前 strong 所在 td
|
||||
$td = $labelNode->parentNode;
|
||||
|
||||
// 下一个 tr 的 td 才是值
|
||||
$valueTd = $td->parentNode->nextSibling;
|
||||
while ($valueTd && $valueTd->nodeName !== 'tr') {
|
||||
$valueTd = $valueTd->nextSibling;
|
||||
}
|
||||
|
||||
if (!$valueTd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = trim($valueTd->textContent);
|
||||
|
||||
switch ($label) {
|
||||
case 'Name':
|
||||
$data['name'] = $value;
|
||||
break;
|
||||
|
||||
case 'Tel/Whatsapp':
|
||||
$data['phone'] = $this->normalizePhone($value);
|
||||
break;
|
||||
|
||||
case 'Message':
|
||||
$data['message'] = $this->cleanMessage($value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// return $data;
|
||||
return $this->hesucarInquiryFields($data);
|
||||
}
|
||||
/**
|
||||
* hesucar.@hesucar.com字段处理
|
||||
*/
|
||||
function hesucarInquiryFields(array $raw): array
|
||||
{
|
||||
$result = [
|
||||
'form_url' => $raw['product_url'] ?? '',
|
||||
'name' => $raw['name'] ?? '',
|
||||
'tel' => $this->normalizePhone($raw['phone'] ?? ''),
|
||||
'whatsapp' => $raw['phone'] ?? '',
|
||||
'phone' => $raw['phone'] ?? '',
|
||||
'country' => '未知',
|
||||
'message' => $raw['message'] ?? '',
|
||||
'date' => date('Y-m-d H:i:s',strtotime($raw['date'])),
|
||||
];
|
||||
|
||||
$message = [];
|
||||
$message[] = "<p>询盘来源:" . $raw['product_url']."</p>";
|
||||
$message[] = "<p>Tel/Whatsapp:" . $raw['phone']."</p>";
|
||||
$message[] = "<p>FB 创建时间原始值:".$raw['date']."</p>";
|
||||
|
||||
$result['message'] = $result['message'].implode(',', $message);
|
||||
$result['ilang'] = 'en';
|
||||
$result['mailto'] = 'hesu.@hesucar.com';
|
||||
return $result;
|
||||
}
|
||||
|
||||
function cleanMessage(string $message): string
|
||||
{
|
||||
return trim(preg_replace("/\s+/", " ", $message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 多邮箱处理:分配客户
|
||||
*/
|
||||
function fenpaiMailbox()
|
||||
{
|
||||
$emails = EmailPullParsed::where('status', 0)
|
||||
->select('mailto')
|
||||
->distinct()
|
||||
->get();
|
||||
foreach ($emails as $email) {
|
||||
$mailboxModel = EmailMailbox::where('username', $email['mailto'])->first();
|
||||
$this->fenpai($mailboxModel);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配清洗好的数据
|
||||
* @param $mailbox
|
||||
* @return false|void
|
||||
*/
|
||||
public function fenpai($mailbox)
|
||||
{
|
||||
try {
|
||||
// 配置参数
|
||||
$batchSize = 100; // 每批处理量
|
||||
$delaySeconds = 2; // 每封邮件间隔秒数
|
||||
$throttleAfter = 50; // 每发送多少封后增加休息
|
||||
$throttleSeconds = 5; // 批量休息秒数
|
||||
|
||||
// 固定录单人
|
||||
$enterUserId = 186;
|
||||
$enterUser = User::where('id', $enterUserId)->first();
|
||||
|
||||
// 固定渠道
|
||||
//$channel = 12;
|
||||
$channel = $mailbox['channel_id'];
|
||||
$channelInfo = InquiryChannel::where('id', $channel)->first();
|
||||
|
||||
// 从库实时获取
|
||||
$corp_id = $mailbox['corp_id'];
|
||||
// $users = \app\common\model\User::where('corp_id', $corp_id)->where('status', 1)->orderBy('id')->pluck('id')->toArray();
|
||||
if($corp_id == 1){ //合塑
|
||||
$users = \app\common\model\UserGroupMembers::where('group_id', 10)
|
||||
->orderBy('user_id')->pluck('user_id')->toArray();
|
||||
}else if($corp_id == 3){
|
||||
$users = \app\common\model\UserGroupMembers::where('group_id', 9)
|
||||
->orderBy('user_id')->pluck('user_id')->toArray();
|
||||
}
|
||||
|
||||
// 移除不参与分配人员
|
||||
$users = array_values(array_diff($users, [186]));
|
||||
|
||||
// 初始化 UID 轮询器
|
||||
$uidRotator = new \app\common\utils\RedisUidRotator('email_parsed_result_20250804');
|
||||
$uidRotator->setUids($users, false);
|
||||
|
||||
EmailPullParsed::where('status', 0)
|
||||
->chunkById($batchSize, function ($records) use ($delaySeconds, $throttleAfter, $throttleSeconds, $enterUser, $channelInfo, $uidRotator) {
|
||||
$count = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
try {
|
||||
// 获取国家信息
|
||||
$countryName = $record->iplocation ? removeParentheses($record->iplocation) : '未知';
|
||||
$countryInfo = Country::where('cn_addr', $countryName)->first();
|
||||
|
||||
// 获取下一个分配的UID
|
||||
$assignedUid = $uidRotator->next();
|
||||
|
||||
// 获取分配的用户信息
|
||||
$recipientInfo = User::where('id', $assignedUid)->where('status', 1)->first();
|
||||
if (!$recipientInfo) {
|
||||
flg("用户ID=" . $assignedUid . " 已离职 跳过自动分配,需要手动移除 UID", false,'mailbox');
|
||||
|
||||
$assignedUid = $uidRotator->next();
|
||||
$recipientInfo = User::where('id', $assignedUid)->where('status', 1)->first();
|
||||
}
|
||||
|
||||
// 生成录单
|
||||
$log = [
|
||||
'order_no' => orderNum(),
|
||||
'enter_user_id' => $enterUser->id,
|
||||
'recipient' => $assignedUid,
|
||||
'quality' => 2,
|
||||
'channel' => $channelInfo->id,
|
||||
'email' => $record->email,
|
||||
'tel' => $record->tel,
|
||||
'whatsapp' => '',
|
||||
'country' => $countryInfo->id,
|
||||
'contacts' => $record->name,
|
||||
'content' => $record->message,
|
||||
'r_time' => time(),
|
||||
't_time' => time(),
|
||||
'status' => 2,
|
||||
'sync_crm' => 1
|
||||
];
|
||||
$inquiryLogResult = InquiryLog::create($log);
|
||||
if (!$inquiryLogResult) {
|
||||
throw new BusinessException('录单失败~');
|
||||
}
|
||||
|
||||
// 同步客户
|
||||
$crmCustomer = new CrmCustomer();
|
||||
$crmCustomer->sn = $inquiryLogResult->order_no;
|
||||
$crmCustomer->corp_id = $recipientInfo->corp_id;
|
||||
$crmCustomer->user_id = $assignedUid;
|
||||
$crmCustomer->type = 1;
|
||||
$crmCustomer->channel_id = $inquiryLogResult->channel;
|
||||
$crmCustomer->country_id = $inquiryLogResult->country;
|
||||
$crmCustomer->magnitude = $inquiryLogResult->quality;
|
||||
$crmCustomer->email = $inquiryLogResult->email;
|
||||
$crmCustomer->phone = $inquiryLogResult->tel;
|
||||
$crmCustomer->WhatsApp = $inquiryLogResult->whatsapp;
|
||||
$crmCustomer->contacts = $inquiryLogResult->contacts;
|
||||
$crmCustomer->desc_collect = $inquiryLogResult->content;
|
||||
$crmCustomer->enter_user_id = $inquiryLogResult->enter_user_id;
|
||||
if (!$crmCustomer->save()) {
|
||||
throw new BusinessException('同步失败~');
|
||||
}
|
||||
|
||||
// 9.2 修改消息发送
|
||||
$data = [
|
||||
'id' => $inquiryLogResult->id
|
||||
];
|
||||
\Webman\RedisQueue\Redis::send('inquiry-to-crm-new', $data);
|
||||
|
||||
// 更新状态
|
||||
$record->update(['status' => 1]);
|
||||
flg('====== 编号:'.$inquiryLogResult->order_no.' 分配给:'.$recipientInfo->name.' email:'.$recipientInfo->email, false,'mailbox');
|
||||
|
||||
// 每封邮件后延迟
|
||||
if ($delaySeconds > 0) {
|
||||
sleep($delaySeconds);
|
||||
}
|
||||
|
||||
// 每发送一定数量后额外休息
|
||||
if (++$count % $throttleAfter === 0) {
|
||||
sleep($throttleSeconds);
|
||||
flg("已发送 {$count} 封邮件,休息 {$throttleSeconds} 秒", false,'mailbox');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
flg("邮件发送失败 ID:{$record->id} - " . $e->getMessage(), false,'mailbox');
|
||||
// 更新状态
|
||||
$record->update(['status' => 3]);
|
||||
// 失败后也加入延迟,避免快速连续失败
|
||||
sleep($delaySeconds);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $th) {
|
||||
flg(dpr($th), false,'mailbox');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class InquiryTransmitConfigValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class InquiryTransmitValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class UsedcarCrawlResultsImagesValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class UsedcarCrawlResultsValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class UserDashboardRuleValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -74,25 +74,25 @@ class IndexController
|
||||
$options = [
|
||||
'controller' => [
|
||||
'module' => 'common',
|
||||
'name' => 'finance_recon_dubai_files'
|
||||
'name' => 'usedcar_crawl_results_images'
|
||||
],
|
||||
'model' => [
|
||||
'module' => 'common',
|
||||
'name' => 'finance_recon_dubai_files'
|
||||
'name' => 'usedcar_crawl_results_images'
|
||||
],
|
||||
'service' => [
|
||||
'module' => 'common',
|
||||
'name' => 'finance_recon_dubai_files'
|
||||
'name' => 'usedcar_crawl_results_images'
|
||||
],
|
||||
'logic' => [
|
||||
'module' => 'common',
|
||||
'name' => 'finance_recon_dubai_files'
|
||||
'name' => 'usedcar_crawl_results_images'
|
||||
],
|
||||
'validate' => [
|
||||
'module' => 'common',
|
||||
'name' => 'finance_recon_dubai_files'
|
||||
'name' => 'usedcar_crawl_results_images'
|
||||
],
|
||||
'table' => 'finance_recon_dubai_files'
|
||||
'table' => 'usedcar_crawl_results_images'
|
||||
];
|
||||
|
||||
$result = (new Generate($options))->doGenerate();
|
||||
@@ -159,11 +159,16 @@ class IndexController
|
||||
|
||||
public function test(Request $request)
|
||||
{
|
||||
$tmp = (new \app\common\service\OssService)->generatePrivateUrl('fdesk_test/20251104/20251104_财务.xlsx');;
|
||||
|
||||
$service = new \app\common\service\FinanceRecoDubaiFilesService();
|
||||
$result = $service->import($tmp, true);
|
||||
$service = new \app\common\utils\crawl\service\UsedCarCrawl();
|
||||
|
||||
return json(['code' => 0, 'msg' => 'ok', 'data' => $result]);
|
||||
$result = $service->pushUrl('https://www.dongchedi.com/usedcar/21631642');
|
||||
|
||||
// $result = $service->doData();
|
||||
|
||||
// $result = $service->decodeDongchediPrice('.');
|
||||
|
||||
// $result = $service->detectEnergyType('油电混合');
|
||||
|
||||
return json($result);
|
||||
}
|
||||
}
|
||||
@@ -435,6 +435,11 @@ class CustomerController extends BaseController
|
||||
{
|
||||
$id = Util::getParam($request, 'id');
|
||||
$scene = Util::getParam($request, 'scene','');
|
||||
$status_desc = Util::getParam($request, 'status_desc','');
|
||||
|
||||
if($scene == 'force' && empty($status_desc)){
|
||||
return $this->error( '移除原因不能为空');
|
||||
}
|
||||
|
||||
$userId = $request->user->uid;
|
||||
$where = [
|
||||
@@ -450,6 +455,7 @@ class CustomerController extends BaseController
|
||||
if ($scene == 'force') {
|
||||
//status=5:业务员自己删除
|
||||
$customer->status = 5;
|
||||
$customer->status_desc = $status_desc;
|
||||
$res = $customer->save();
|
||||
}else{
|
||||
$customer->user_id = 0;
|
||||
|
||||
@@ -139,6 +139,10 @@ class StockOrdersController extends BaseController
|
||||
];
|
||||
}
|
||||
unset($item['options']);
|
||||
// 附件 url 响应期重签(历史签名URL会过期)
|
||||
if (!empty($item['attachment']) && is_array($item['attachment'])) {
|
||||
$item['attachment'] = \app\common\service\OssService::resignAttachmentUrls($item['attachment']);
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use app\common\model\Corp;
|
||||
use app\common\model\CustomerAudit;
|
||||
use app\common\model\InquiryLog;
|
||||
use app\common\model\User;
|
||||
use app\common\service\OssService;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\crm\logic\CustomerLogic;
|
||||
use app\crm\model\Channel;
|
||||
@@ -120,7 +121,7 @@ class CustomerService
|
||||
if($corpId == 1){
|
||||
$query->whereIn('channel_id', [1,2,3,4,5,6,7,8]);
|
||||
} elseif($corpId == 3) {
|
||||
$query->whereIn('channel_id', [9,10,11]);
|
||||
$query->whereIn('channel_id', [9,10,11,16,17]);
|
||||
} else{
|
||||
$query->whereIn('channel_id', [0]);
|
||||
}
|
||||
@@ -377,9 +378,36 @@ class CustomerService
|
||||
}
|
||||
}
|
||||
|
||||
// WhatsApp导入截图:desc_collect 里的 OSS 对象键 -> 带签名 <img>(前端 v-html 渲染)
|
||||
$customer['desc_collect'] = self::signScreenshotImages((string)($customer['desc_collect'] ?? ''));
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 desc_collect 中 crm_whatsapp 截图的 OSS 对象键替换为带签名的 <img> 标签。
|
||||
* 键按原文匹配签名(历史记录为双斜杠键,对象即存于该键,不可归一)。
|
||||
*/
|
||||
private static function signScreenshotImages(string $desc): string
|
||||
{
|
||||
if ($desc === '' || strpos($desc, 'crm_whatsapp/') === false) {
|
||||
return $desc;
|
||||
}
|
||||
return preg_replace_callback(
|
||||
'/([A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*\/+crm_whatsapp\/[A-Za-z0-9_\-\.]+\.(?:png|jpe?g|webp))/',
|
||||
function ($m) {
|
||||
try {
|
||||
$url = (new OssService())->generatePrivateUrl($m[1], 7 * 86400);
|
||||
return '<img src="' . htmlspecialchars($url, ENT_QUOTES) . '" '
|
||||
. 'style="max-width:100%;height:auto;display:block;margin:4px 0" />';
|
||||
} catch (\Throwable $e) {
|
||||
return $m[0];
|
||||
}
|
||||
},
|
||||
$desc
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function detail($params)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
namespace app\crm\service;
|
||||
|
||||
use app\common\model\Role;
|
||||
use app\common\model\User;
|
||||
use app\common\model\UserGroupMembers;
|
||||
use app\common\model\UserRoles;
|
||||
use support\Request;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
@@ -17,10 +21,31 @@ class StockOrdersService extends \app\common\service\StockOrdersService
|
||||
switch ($scene) {
|
||||
case 'index':
|
||||
|
||||
// ====== view_scope:仅 user_id=73 生效,最高优先级,覆盖下方业务id默认值与角色范围逻辑 ======
|
||||
$viewScope = $p['view_scope'] ?? '';
|
||||
if ($request->user->uid == 73 && in_array($viewScope, ['all', 'self'], true)) {
|
||||
if ($viewScope === 'self') {
|
||||
$config['where']['salesperson_id'] = $request->user->uid; // 只看自己(含自己草稿)
|
||||
} else { // 'all':自己和别人的,排除草稿(approval_status=0)
|
||||
$config['where']['approval_status'] = ['>=', 1];
|
||||
}
|
||||
$config['where']['is_final'] = $p['is_final'] ?? 0;
|
||||
if (isset($p['keyword']) && $p['keyword']) {
|
||||
$k = '%' . $p['keyword'] . '%';
|
||||
foreach (['pi_no', 'order_no', 'customer_snapshot'] as $col) {
|
||||
$config['orWhere'][] = [$col, 'like', $k];
|
||||
}
|
||||
}
|
||||
$config['order']['id'] = 'desc';
|
||||
break; // 最高优先级:跳过下方业务id默认值与角色范围逻辑
|
||||
}
|
||||
|
||||
// ====== ====== 筛选 ====== ======
|
||||
// 业务id
|
||||
if (isset($p['salesperson_id']) && $p['salesperson_id']) {
|
||||
$config['where']['salesperson_id'] = $p['salesperson_id'];
|
||||
} else {
|
||||
$config['where']['salesperson_id'] = $request->user->uid;
|
||||
}
|
||||
// 初、终版
|
||||
$config['where']['is_final'] = $p['is_final'] ?? 0;
|
||||
@@ -34,7 +59,43 @@ class StockOrdersService extends \app\common\service\StockOrdersService
|
||||
|
||||
$config['order']['id'] = 'desc';
|
||||
|
||||
$config['where']['salesperson_id'] = $request->user->uid;
|
||||
$user_role_id = UserRoles::where('user_id', $request->user->uid)->pluck('role_id')->toArray();
|
||||
//$roles_id = array_intersect([1,6], $request->user->role_ids);
|
||||
$roles_id = array_intersect([1,4,5,6], $user_role_id);
|
||||
if (count($roles_id) != 0){
|
||||
// if(in_array(4, $request->user->role_ids)){ //业务主管
|
||||
// $report = User::where('report_to',$request->user->uid)->pluck('id')->toArray();
|
||||
// $report = array_merge($report, [$request->user->uid]);
|
||||
// $config['where']['salesperson_id'] = ['in',$report];
|
||||
// } else
|
||||
|
||||
//内勤分组的人可以看到自己分组的备货单
|
||||
if (in_array(5,$roles_id)){ //内勤
|
||||
$userGroupMember = UserGroupMembers::where('user_id',$request->user->uid)
|
||||
->where('is_leader',1)
|
||||
->first();
|
||||
if($userGroupMember){
|
||||
$report = UserGroupMembers::where('group_id',$userGroupMember->group_id)
|
||||
->pluck('user_id')
|
||||
->toArray();
|
||||
$config['where']['salesperson_id'] = ['in',$report];
|
||||
$config['where']['approval_status'] = ['>=',1];
|
||||
}else{ //内勤没有负责的组时
|
||||
$config['where']['salesperson_id'] = $request->user->uid;
|
||||
}
|
||||
} elseif (in_array(1,$roles_id)) { //超管可以看所有的
|
||||
//$config['where']['approval_status'] = ['>=',1];
|
||||
$config['orWhere'][] = ['approval_status','>=',1];
|
||||
$config['orWhere'][] = ['salesperson_id','=',$request->user->uid];
|
||||
unset($config['where']['salesperson_id']);
|
||||
} elseif (in_array(4,$roles_id)) { //业务主管:自己+直属下属(report_to指向他,一级,含草稿)
|
||||
$report = User::where('report_to',$request->user->uid)->pluck('id')->toArray();
|
||||
$report[] = $request->user->uid;
|
||||
$config['where']['salesperson_id'] = ['in',$report];
|
||||
}else{
|
||||
$config['where']['salesperson_id'] = $request->user->uid;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'print':
|
||||
case 'approval':
|
||||
|
||||
@@ -134,7 +134,7 @@ class Util
|
||||
if($length <= 6){
|
||||
return $str;
|
||||
}
|
||||
return substr($str, 0, 3) . '****' . substr($str, -4);
|
||||
return mb_substr($str, 0, 3) . '****' . mb_substr($str, -4);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -144,6 +144,12 @@ class CustomerController extends BaseController
|
||||
{
|
||||
try {
|
||||
$param = $request->all();
|
||||
$scene = $param['scene'] ?? '';
|
||||
$status_desc = $param['status_desc'] ?? '';
|
||||
|
||||
if($scene != 'force' && empty($param['status_desc'])){
|
||||
return $this->error( '移除原因不能为空');
|
||||
}
|
||||
if (empty($param['customer_id'])) {
|
||||
return $this->error('customer_id'.trans('not_null'));
|
||||
}
|
||||
@@ -153,13 +159,12 @@ class CustomerController extends BaseController
|
||||
if(!$query->exists()){
|
||||
return $this->error( trans('fail'));
|
||||
}
|
||||
$scene = $param['scene'] ?? '';
|
||||
if ($scene === 'list') { //管理端客户列表移除
|
||||
$res = $query->update(['status' => 4, 'deleted_at' => time()]);
|
||||
$res = $query->update(['status' => 4, 'status_desc' => $status_desc, 'deleted_at' => time()]);
|
||||
} elseif ($scene === 'force') { //强删除
|
||||
$res = $query->forceDelete();
|
||||
} else { //公海软删除
|
||||
$res = $query->update(['status' => 3, 'deleted_at' => time()]);
|
||||
$res = $query->update(['status' => 3, 'status_desc' => $status_desc, 'deleted_at' => time()]);
|
||||
}
|
||||
|
||||
return $this->success($res);
|
||||
|
||||
@@ -21,12 +21,13 @@ class CustomerLogic
|
||||
return $res;
|
||||
}
|
||||
|
||||
public function forceDelete(int $id)
|
||||
public function forceDelete(int $id ,string $status_desc = '')
|
||||
{
|
||||
$customer = Customer::withTrashed()->find($id);
|
||||
//强制删除
|
||||
// $res = $customer->forceDelete();
|
||||
$customer->status = 3;
|
||||
$customer->status_desc = $status_desc;
|
||||
$res = $customer->save();
|
||||
return $res;
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ class CustomerService
|
||||
$audit->status = 3;
|
||||
$audit->save();
|
||||
}
|
||||
return $logic->forceDelete($params['id']);
|
||||
return $logic->forceDelete($params['id'],$audit->remark);
|
||||
} else {
|
||||
if(!empty($audit)){
|
||||
$audit->status = 2;
|
||||
@@ -463,9 +463,36 @@ class CustomerService
|
||||
$customer['enter_user_name'] = empty($customer['enter_user_id']) ? '' : User::where('id',$customer['enter_user_id'])->value('name');
|
||||
$customer['user_name'] = empty($customer['user_id']) ? '公海' : User::where('id',$customer['user_id'])->value('name');
|
||||
|
||||
// WhatsApp导入截图:desc_collect 里的 OSS 对象键 -> 带签名 <img>(前端 v-html 渲染)
|
||||
$customer['desc_collect'] = self::signScreenshotImages((string)($customer['desc_collect'] ?? ''));
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 desc_collect 中 crm_whatsapp 截图的 OSS 对象键替换为带签名的 <img> 标签。
|
||||
* 键按原文匹配签名(历史记录为双斜杠键,对象即存于该键,不可归一)。
|
||||
*/
|
||||
private static function signScreenshotImages(string $desc): string
|
||||
{
|
||||
if ($desc === '' || strpos($desc, 'crm_whatsapp/') === false) {
|
||||
return $desc;
|
||||
}
|
||||
return preg_replace_callback(
|
||||
'/([A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*\/+crm_whatsapp\/[A-Za-z0-9_\-\.]+\.(?:png|jpe?g|webp))/',
|
||||
function ($m) {
|
||||
try {
|
||||
$url = (new OssService())->generatePrivateUrl($m[1], 7 * 86400);
|
||||
return '<img src="' . htmlspecialchars($url, ENT_QUOTES) . '" '
|
||||
. 'style="max-width:100%;height:auto;display:block;margin:4px 0" />';
|
||||
} catch (\Throwable $e) {
|
||||
return $m[0];
|
||||
}
|
||||
},
|
||||
$desc
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function detail($params)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace app\inquiry\service;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use support\Db;
|
||||
use app\inquiry\utils\FbLeadHeaderMapper;
|
||||
use support\exception\BusinessException;
|
||||
use support\Log;
|
||||
|
||||
class FbFileImportService
|
||||
{
|
||||
/**
|
||||
* 导入指定目录下所有 .xlsx 文件
|
||||
*/
|
||||
public function importDir(string $dir, int $channelId): int
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
throw new BusinessException("目录不存在:{$dir}");
|
||||
}
|
||||
|
||||
// 只扫描 .xlsx,之前处理过的 .xlsx.done 不会被匹配到
|
||||
$files = glob($dir . DIRECTORY_SEPARATOR . '*.xlsx');
|
||||
if (!$files) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($files as $filePath) {
|
||||
if (!is_file($filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$doneFlag = $filePath . '.done';
|
||||
if (is_file($doneFlag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = $this->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':
|
||||
$item['fb_created_time'] = $this->normalizeCreatedTime($raw); // 你方案B的datetime
|
||||
break;
|
||||
|
||||
// ✅ 这些是数值ID字段:可能出现 ag:/as:/c:/f:/l: 前缀
|
||||
case 'ad_id':
|
||||
case 'adset_id':
|
||||
case 'campaign_id':
|
||||
case 'form_id':
|
||||
$item[$field] = $this->normalizeNumericId($raw);
|
||||
break;
|
||||
|
||||
// ✅ lead id 也经常是 l:xxxx
|
||||
case 'fb_lead_id':
|
||||
$item['fb_lead_id'] = $this->normalizeNumericId($raw);
|
||||
break;
|
||||
|
||||
// ✅ 电话可能是 p:+233xxxx
|
||||
case 'phone':
|
||||
$item['phone'] = $this->normalizePhone($raw);
|
||||
break;
|
||||
|
||||
// ✅ 姓名:去掉粘连的 URL,并截断到下游 inquiry_log.contacts 列长度(255)
|
||||
case 'full_name':
|
||||
$item['full_name'] = $this->normalizeFullName($raw);
|
||||
break;
|
||||
|
||||
default:
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 "ag:120..." / "l:831..." / "120..." 这种值清洗成纯数字(int 或 string数字)
|
||||
* - 如果你的 DB 字段是 BIGINT UNSIGNED,PHP int 在 64 位环境下是够用的
|
||||
* - 解析失败返回 0(避免 insert 失败)
|
||||
*/
|
||||
protected function normalizeNumericId($raw)
|
||||
{
|
||||
if ($raw === null || $raw === '') return 0;
|
||||
|
||||
// 数字直接返回
|
||||
if (is_int($raw)) return $raw;
|
||||
if (is_float($raw)) return (int)$raw;
|
||||
|
||||
$s = trim((string)$raw);
|
||||
if ($s === '') return 0;
|
||||
|
||||
// 取出所有数字拼成一段(ag:120... -> 120..., l:xxx -> xxx)
|
||||
$digits = preg_replace('/\D+/', '', $s);
|
||||
if ($digits === '') return 0;
|
||||
|
||||
// 这里返回字符串数字也可以,MySQL 会转;返回 int 在 64 位一般没问题
|
||||
// 若你担心超出 int,可直接 return $digits;
|
||||
return $digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 电话清洗:去掉 "p:" 前缀,保留 + 和数字
|
||||
*/
|
||||
protected function normalizePhone($raw): string
|
||||
{
|
||||
if ($raw === null) return '';
|
||||
|
||||
$s = trim((string)$raw);
|
||||
if ($s === '') return '';
|
||||
|
||||
// 去掉类似 "p:" "tel:" 等前缀
|
||||
$s = preg_replace('/^[a-zA-Z]+:\s*/', '', $s);
|
||||
|
||||
// 保留 + 和数字
|
||||
$s = preg_replace('/[^\d\+]+/', '', $s);
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 姓名清洗:
|
||||
* - 去掉粘连的 URL(如 "Saif Ullahhttps://..." → "Saif Ullah")
|
||||
* - 纯链接记录清洗后为空字符串
|
||||
* - 截断到 255(下游 inquiry_log.contacts 列长度,避免同步入库时超长报错)
|
||||
*/
|
||||
protected function normalizeFullName($raw): string
|
||||
{
|
||||
if ($raw === null) return '';
|
||||
|
||||
$s = trim((string)$raw);
|
||||
if ($s === '') return '';
|
||||
|
||||
// 去掉 URL(连同前面可能的空白/不可见字符)
|
||||
$s = preg_replace('#\s*https?://\S+#i', '', $s);
|
||||
$s = trim($s);
|
||||
|
||||
// 截断到下游 contacts 列长度兜底
|
||||
return mb_substr($s, 0, 255);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 规范化 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:写 DATETIME,且永不为 null(因为 fb_created_time NOT NULL)
|
||||
* 兼容:
|
||||
* - Excel 数字日期
|
||||
* - 10/13/25
|
||||
* - 2025-12-10 04:25:09
|
||||
* - 25年12月21日 / 2025年12月21日
|
||||
* - 25年12月21日 13:05 / 25年12月21日13:05:09
|
||||
* 为空/解析失败:用当前时间兜底
|
||||
*/
|
||||
protected function normalizeCreatedTime($raw): string
|
||||
{
|
||||
$fallback = date('Y-m-d H:i:s');
|
||||
|
||||
if ($raw === null) return $fallback;
|
||||
|
||||
// Excel 日期序列:数字
|
||||
if (is_numeric($raw)) {
|
||||
$n = (float)$raw;
|
||||
$ts = (int)round(($n - 25569) * 86400);
|
||||
if ($ts > 0) return date('Y-m-d H:i:s', $ts);
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$s = trim((string)$raw);
|
||||
if ($s === '') return $fallback;
|
||||
|
||||
// 先尝试 strtotime(能覆盖很多格式:2025-12-10 04:25:09 / 2025/12/10 等)
|
||||
$ts = strtotime($s);
|
||||
if ($ts !== false) {
|
||||
return date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
|
||||
// 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;
|
||||
$ts2 = mktime(0, 0, 0, $mm, $dd, $year);
|
||||
return date('Y-m-d H:i:s', $ts2);
|
||||
}
|
||||
|
||||
// ✅ 中文日期:25年12月21日 / 2025年12月21日(可带时间)
|
||||
// 支持:
|
||||
// - 25年12月21日
|
||||
// - 2025年12月21日
|
||||
// - 25年12月21日 13:05
|
||||
// - 25年12月21日13:05:09
|
||||
$cn = preg_replace('/\s+/', '', $s); // 去空格方便匹配
|
||||
if (preg_match('/^(\d{2,4})年(\d{1,2})月(\d{1,2})日(?:(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/u', $cn, $m)) {
|
||||
$y = (int)$m[1];
|
||||
$month = (int)$m[2];
|
||||
$day = (int)$m[3];
|
||||
|
||||
// 2位年 -> 2000+(你这里是 25年 = 2025)
|
||||
if ($y < 100) $y += 2000;
|
||||
|
||||
$hh = isset($m[4]) && $m[4] !== '' ? (int)$m[4] : 0;
|
||||
$ii = isset($m[5]) && $m[5] !== '' ? (int)$m[5] : 0;
|
||||
$ss = isset($m[6]) && $m[6] !== '' ? (int)$m[6] : 0;
|
||||
|
||||
$ts3 = mktime($hh, $ii, $ss, $month, $day, $y);
|
||||
return date('Y-m-d H:i:s', $ts3);
|
||||
}
|
||||
|
||||
// 解析失败兜底:当前时间
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace app\inquiry\service;
|
||||
|
||||
use app\inquiry\utils\PhoneCountryHelper;
|
||||
use support\Db;
|
||||
|
||||
class FbLeadSyncService
|
||||
{
|
||||
/**
|
||||
* 同步指定渠道的 fb_leads.sync_status=0 数据到 inquiry_log
|
||||
* 并将对应 fb_leads.sync_status 更新为 1
|
||||
*
|
||||
* @param int $enterUserId 录入人ID
|
||||
* @param int $channelId 渠道ID(既用于 fb_leads.channel,也用于 inquiry_log.channel)
|
||||
* @return int 同步条数
|
||||
*/
|
||||
public function syncToInquiryLog(int $enterUserId, int $channelId): int
|
||||
{
|
||||
$leads = Db::table('fb_leads')
|
||||
->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[] = '<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>';
|
||||
}
|
||||
if (!empty($lead['drive_type'])) {
|
||||
$lines[] = '<p><strong>驱动形式偏好:</strong>' . $e($lead['drive_type']) . '</p>';
|
||||
}
|
||||
if (!empty($lead['extra_data'])) {
|
||||
$extra = json_decode((string)$lead['extra_data'], true);
|
||||
if (is_array($extra) && $extra) {
|
||||
$lines[] = '<hr />';
|
||||
$lines[] = '<p><strong>补充问答信息:</strong></p>';
|
||||
foreach ($extra as $k => $v) {
|
||||
$lines[] = '<p>' . $e($k) . ':' . $e($v) . '</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,167 @@
|
||||
<?php
|
||||
|
||||
namespace app\inquiry\service;
|
||||
|
||||
use support\Db;
|
||||
use support\Redis;
|
||||
use support\exception\BusinessException;
|
||||
use Webman\RedisQueue\Redis as RedisQueueRedis;
|
||||
|
||||
class InquiryAssignService
|
||||
{
|
||||
/**
|
||||
* 按渠道分配 inquiry_log 中待分配的记录,轮询业务员,并推送到 CRM 队列
|
||||
*
|
||||
* @param int[] $salesUserIds 默认参与轮询的业务员ID列表(该渠道默认池)
|
||||
* @param string $roundRobinKey 默认轮询分组key(Redis记指针)
|
||||
* @param int $channelId 渠道ID(对应 inquiry_log.channel)
|
||||
* @param array $assignRules 渠道内规则:按国家切换业务员池(可选)
|
||||
* @param int $limit 每次最多分配多少条
|
||||
*
|
||||
* @return int 实际分配的条数
|
||||
*/
|
||||
public function assignBatch(
|
||||
array $salesUserIds,
|
||||
string $roundRobinKey,
|
||||
int $channelId,
|
||||
array $assignRules = [],
|
||||
int $limit = 100
|
||||
): int {
|
||||
// 默认池(渠道默认业务员列表)
|
||||
$defaultUserIds = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
<?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
|
||||
|
||||
// ===== Google 表格(大写表头)兼容(防御式)=====
|
||||
'FULL_NAME' => 'full_name',
|
||||
'EMAIL' => 'email',
|
||||
'PHONE_NUMBER' => 'phone',
|
||||
];
|
||||
|
||||
/**
|
||||
* 宽松模式下仍然强制要求存在的“核心 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
class ApiKeyAuth implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$apiKey = $request->header('Fdesk-Api-Key', '');
|
||||
$validKey = getenv('PUBLIC_API_KEY');
|
||||
|
||||
if ($validKey === false || $apiKey === '' || !hash_equals($validKey, $apiKey)) {
|
||||
$body = [
|
||||
'status' => 'fail',
|
||||
'code' => 401,
|
||||
'message' => '无效的 API Key',
|
||||
'data' => [],
|
||||
];
|
||||
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ class CheckWeb
|
||||
// });
|
||||
// }
|
||||
|
||||
// 每秒执行:队列二手车数据处理
|
||||
new Crontab('* * * * * *', function(){
|
||||
$service = new \app\common\utils\crawl\service\UsedCarCrawl();
|
||||
$service->execute();
|
||||
});
|
||||
|
||||
new Crontab('*/50 * * * * *', function(){
|
||||
$userIds = User::where('status', 1)->pluck('id')->toArray();
|
||||
foreach ($userIds as $userId) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\common\utils\email\Mailbox;
|
||||
use Workerman\Crontab\Crontab;
|
||||
use app\common\utils\email\Emails as EmailsUtils;
|
||||
|
||||
@@ -52,6 +53,28 @@ class Emails
|
||||
$email = new EmailsUtils();
|
||||
$email->fenpai();
|
||||
});
|
||||
|
||||
//-------------多邮箱处理----------------
|
||||
// 拉取新邮件uid - 每小时的第40分钟0秒执行
|
||||
new Crontab('0 */40 * * * *', function(){
|
||||
$email = new Mailbox();
|
||||
$email->mailbox();
|
||||
});
|
||||
// 拉取邮件详情 - 每小时的第35分钟0秒执行
|
||||
new Crontab('0 */45 * * * *', function(){
|
||||
$email = new Mailbox();
|
||||
$email->pullDetails();
|
||||
});
|
||||
// 清洗数据 - 每小时的第55分钟0秒执行
|
||||
new Crontab('0 */55 * * * *', function(){
|
||||
$email = new Mailbox();
|
||||
$email->processMailbox();
|
||||
});
|
||||
// 邮件分派 - 每小时的第10分钟0秒执行
|
||||
new Crontab('0 */10 * * * *', function(){
|
||||
$email = new Mailbox();
|
||||
$email->fenpaiMailbox();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\common\model\UserGroup;
|
||||
use app\common\model\UserGroupMembers;
|
||||
use app\inquiry\service\FbFileImportService;
|
||||
use app\inquiry\service\FbLeadSyncService;
|
||||
use app\inquiry\service\InquiryAssignService;
|
||||
use Workerman\Timer;
|
||||
use support\Log;
|
||||
|
||||
class InquiryAutoProcess
|
||||
{
|
||||
protected int $interval;
|
||||
|
||||
/**
|
||||
* @param int $interval 间隔秒数,默认 30 秒
|
||||
*/
|
||||
public function __construct(int $interval = 30)
|
||||
{
|
||||
$this->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;
|
||||
|
||||
// 查分组
|
||||
$group = UserGroup::where('scenario_code', $cfg['group_key'])->where('status', 1)->first();
|
||||
// 查组长
|
||||
$enterUserId = UserGroupMembers::where('group_id', $group->id)->where('is_leader', 1)->where('status', 1)->value('user_id');
|
||||
// 查分组成员
|
||||
$salesUserIds = UserGroupMembers::where('group_id', $group->id)->where('is_leader', 0)->where('status', 1)->pluck('user_id')->toArray();
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class Task
|
||||
{
|
||||
// 每半小时执行一次,通知待跟进客户
|
||||
new Crontab('0 */30 * * * *', function(){
|
||||
echo date('Y-m-d H:i:s') . "\n";
|
||||
// echo date('Y-m-d H:i:s') . "\n";
|
||||
Log::info('send follow start',[time()]);
|
||||
|
||||
$where = [
|
||||
@@ -52,7 +52,6 @@ class Task
|
||||
if ($G > 22 || $G < 8) {
|
||||
return;
|
||||
}
|
||||
Log::info('fin_payment_apply submit_files start', [time()]);
|
||||
$where = [
|
||||
['submit_files', '=', ''],
|
||||
['warn', '<', 2],
|
||||
@@ -78,7 +77,6 @@ class Task
|
||||
$item->save();
|
||||
}
|
||||
|
||||
Log::info('fin_payment_apply submit_files end', [time()]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ class InquiryToCrmNew implements Consumer
|
||||
发过 没分 更新 (要同步)7
|
||||
发过 分过 更新 (要同步)8
|
||||
*/
|
||||
|
||||
// var_dump('询单同步开始:'.$data['id']);
|
||||
try {
|
||||
// 获取询盘信息
|
||||
$info = InquiryLog::with(['channelInfo', 'countryInfo', 'enter'])->where('id', $data['id'])->first();
|
||||
@@ -57,6 +57,7 @@ class InquiryToCrmNew implements Consumer
|
||||
// 更改库信息
|
||||
$this->syncCustomer($info);
|
||||
} catch (\Throwable $th) {
|
||||
flg('询单同步失败:'.$th->getMessage().' | '.$th->getLine().' | '.$th->getFile(), false);
|
||||
throw $th;
|
||||
}
|
||||
}
|
||||
@@ -132,6 +133,11 @@ class InquiryToCrmNew implements Consumer
|
||||
if (!$crmCustomerInfo->update($udata)) {
|
||||
throw new BusinessException('同步失败,更新客户线索失败');
|
||||
}
|
||||
|
||||
$info->sync_crm = 1;
|
||||
if (!$info->save()) {
|
||||
throw new BusinessException('标记询盘同步状态失败');
|
||||
}
|
||||
} else {
|
||||
$crmCustomer = new CrmCustomer();
|
||||
$crmCustomer->sn = $info->order_no;
|
||||
|
||||
@@ -53,6 +53,10 @@ class PermAgt implements Consumer
|
||||
'role_id' => 2
|
||||
];
|
||||
$systems = [
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'system_id' => 1
|
||||
],
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'system_id' => 2
|
||||
|
||||
+3
-1
@@ -47,7 +47,9 @@
|
||||
"phpmailer/phpmailer": "^6.10",
|
||||
"webman/gateway-worker": "^1.0",
|
||||
"mpdf/mpdf": "^8.2",
|
||||
"jaeger/querylist": "^4.4"
|
||||
"jaeger/querylist": "^4.4",
|
||||
"zoujingli/ip2region": "^3.0",
|
||||
"webman/log": "^2.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-event": "For better performance. "
|
||||
|
||||
Generated
+900
-518
File diff suppressed because it is too large
Load Diff
+13
-3
@@ -22,13 +22,23 @@ return [
|
||||
'from_name' => ''
|
||||
],
|
||||
|
||||
// 'smtp_xyh' => [
|
||||
// 'host' => 'smtp.feishu.cn',
|
||||
// 'port' => 465,
|
||||
// 'username' => 'app@hnxinyouhe.com',
|
||||
// 'password' => 'akyIUFJ6ihGGjI5h',
|
||||
// 'encryption' => 'ssl',
|
||||
// 'from_address' => 'app@hnxinyouhe.com',
|
||||
// 'from_name' => '【XYH】'
|
||||
// ],
|
||||
|
||||
'smtp_xyh' => [
|
||||
'host' => 'smtp.feishu.cn',
|
||||
'port' => 465,
|
||||
'username' => 'app@hnxinyouhe.com',
|
||||
'password' => 'akyIUFJ6ihGGjI5h',
|
||||
'username' => 'x@r49.cn',
|
||||
'password' => 'BhcWGdBdkLYTgK3w',
|
||||
'encryption' => 'ssl',
|
||||
'from_address' => 'app@hnxinyouhe.com',
|
||||
'from_address' => 'x@r49.cn',
|
||||
'from_name' => '【XYH】'
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// 派卡社媒
|
||||
'fb_paika_shemei' => [
|
||||
// 监听目录(相对 public_path)
|
||||
'watch_dir' => 'excel/fb_paika_shemei',
|
||||
|
||||
// 清洗到 inquiry_log 时使用的录入人 & 渠道
|
||||
'enter_user_id' => 201,
|
||||
'channel_id' => 10, // 系统的渠道ID
|
||||
|
||||
// 参与轮询分配的业务员 ID 列表
|
||||
'sales_user_ids' => [
|
||||
63, 67, 93, 108, 132, 143, 145, 157, 160, 168, 172, 191, 194
|
||||
],
|
||||
|
||||
// 轮询用的分组 key(Redis 记指针用)
|
||||
'round_robin_key'=> 'fb_paika_shemei_inquiry',
|
||||
|
||||
// ✅ 渠道内分配规则(按顺序匹配,先命中先用)
|
||||
'assign_rules' => [
|
||||
[
|
||||
'name' => '加纳池',
|
||||
'countries' => [],
|
||||
'sales_user_ids' => [],
|
||||
'round_robin_key' => 'fb_paika_shemei_gh',
|
||||
]
|
||||
],
|
||||
|
||||
'group_key' => 'paika_inquiry'
|
||||
],
|
||||
|
||||
// 合塑社媒
|
||||
'fb_hesu_shemei' => [
|
||||
'watch_dir' => 'excel/fb_hesu_shemei',
|
||||
'enter_user_id' => 17,
|
||||
'channel_id' => 6,
|
||||
'sales_user_ids' => [
|
||||
22, 23, 34, 44, 49, 71, 72, 73, 96, 104, 125, 135, 142, 155, 187
|
||||
],
|
||||
'round_robin_key'=> 'fb_hesu_shemei_inquiry',
|
||||
// ✅ 渠道内分配规则(按顺序匹配,先命中先用)
|
||||
'assign_rules' => [
|
||||
[
|
||||
'name' => '加纳池',
|
||||
'countries' => [],
|
||||
'sales_user_ids' => [],
|
||||
'round_robin_key' => 'fb_hesu_shemei_gh',
|
||||
]
|
||||
],
|
||||
|
||||
'group_key' => 'hesu_inquiry'
|
||||
]
|
||||
];
|
||||
@@ -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
|
||||
]
|
||||
];
|
||||
@@ -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 秒跑一轮所有渠道
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
+45
-2
@@ -39,6 +39,11 @@ Route::any('/upload', [app\common\controller\UploadController::class, 'uploadFil
|
||||
Route::any('/upload/image', [app\common\controller\UploadController::class, 'uploadImage']);
|
||||
Route::any('/upload/delete', [app\common\controller\UploadController::class, 'deleteFile']);
|
||||
|
||||
// 询单转发
|
||||
Route::group('/inquiry/transmit', function () {
|
||||
Route::any('/form', [app\admin\controller\InquiryTransmitController::class, 'form']);
|
||||
});
|
||||
|
||||
// 公共
|
||||
Route::group('/common', function () {
|
||||
Route::group('/auth', function () {
|
||||
@@ -111,6 +116,8 @@ Route::group('/common', function () {
|
||||
Route::any('/crawlModelCreate', [app\common\controller\BrandSeriesController::class, 'crawlModelCreate']);
|
||||
Route::any('/crawlKeyword', [app\common\controller\BrandSeriesController::class, 'crawlKeyword']);
|
||||
Route::any('/crawlKeywordCreate', [app\common\controller\BrandSeriesController::class, 'crawlKeywordCreate']);
|
||||
Route::any('/crawlUrl', [app\common\controller\BrandSeriesController::class, 'crawlUrl']);
|
||||
Route::any('/crawl', [app\common\controller\BrandSeriesController::class, 'crawl']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,6 +222,24 @@ Route::group('/admin', function () {
|
||||
});
|
||||
});
|
||||
|
||||
// 询单转发
|
||||
Route::group('/inquiry', function () {
|
||||
Route::group('/transmit', function () {
|
||||
Route::any('/list', [app\admin\controller\InquiryTransmitController::class, 'index']);
|
||||
Route::any('/show', [app\admin\controller\InquiryTransmitController::class, 'show']);
|
||||
Route::any('/store', [app\admin\controller\InquiryTransmitController::class, 'store']);
|
||||
Route::any('/update', [app\admin\controller\InquiryTransmitController::class, 'update']);
|
||||
Route::any('/destroy', [app\admin\controller\InquiryTransmitController::class, 'destroy']);
|
||||
});
|
||||
Route::group('/config', function () {
|
||||
Route::any('/list', [app\admin\controller\InquiryTransmitConfigController::class, 'index']);
|
||||
Route::any('/show', [app\admin\controller\InquiryTransmitConfigController::class, 'show']);
|
||||
Route::any('/store', [app\admin\controller\InquiryTransmitConfigController::class, 'store']);
|
||||
Route::any('/update', [app\admin\controller\InquiryTransmitConfigController::class, 'update']);
|
||||
Route::any('/destroy', [app\admin\controller\InquiryTransmitConfigController::class, 'destroy']);
|
||||
});
|
||||
});
|
||||
|
||||
// crm
|
||||
Route::group('/crm', function () {
|
||||
Route::group('/customer', function () {
|
||||
@@ -327,7 +352,7 @@ Route::group('/admin', function () {
|
||||
Route::any('/destroy', [app\admin\controller\SysAccountController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// 账户
|
||||
// 港口
|
||||
Route::group('/port', function () {
|
||||
Route::any('/list', [app\admin\controller\SysPortController::class, 'index']);
|
||||
Route::any('/show', [app\admin\controller\SysPortController::class, 'show']);
|
||||
@@ -482,6 +507,17 @@ Route::group('/admin', function () {
|
||||
Route::any('/destroy', [app\admin\controller\FinanceReconDubaiFilesController::class, 'destroy']);
|
||||
});
|
||||
});
|
||||
|
||||
//仪表盘路由规则
|
||||
Route::group('/dashboard', function () {
|
||||
Route::group('/rule', function () {
|
||||
Route::any('/list', [app\admin\controller\UserDashboardRuleController::class, 'index']);
|
||||
Route::any('/show', [app\admin\controller\UserDashboardRuleController::class, 'show']);
|
||||
Route::any('/store', [app\admin\controller\UserDashboardRuleController::class, 'store']);
|
||||
Route::any('/update', [app\admin\controller\UserDashboardRuleController::class, 'update']);
|
||||
Route::any('/destroy', [app\admin\controller\UserDashboardRuleController::class, 'destroy']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -601,4 +637,11 @@ Route::group('/crm', function () {
|
||||
Route::any('/update', [app\common\controller\VehModificationController::class, 'update']);
|
||||
Route::any('/destroy', [app\common\controller\VehModificationController::class, 'destroy']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 公共 API(API Key 鉴权;通过 noNeedLogin/noNeedHeaderData 跳过 JWT 和 BasicData)
|
||||
Route::group('/common/public', function () {
|
||||
Route::any('/pi/list', [app\common\controller\PublicApiController::class, 'piList']);
|
||||
Route::any('/vin/list', [app\common\controller\PublicApiController::class, 'vinList']);
|
||||
Route::any('/staff/list', [app\common\controller\PublicApiController::class, 'staffList']);
|
||||
})->middleware([app\middleware\AccessControl::class, app\middleware\ApiKeyAuth::class]);
|
||||
+1
-1
@@ -19,5 +19,5 @@ return [
|
||||
'status_file' => runtime_path() . '/webman.status',
|
||||
'stdout_file' => runtime_path() . '/logs/stdout.log',
|
||||
'log_file' => runtime_path() . '/logs/workerman.log',
|
||||
'max_package_size' => 10 * 1024 * 1024
|
||||
'max_package_size' => 50 * 1024 * 1024
|
||||
];
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# 备货单公共 API 对接文档
|
||||
|
||||
## 基础信息
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| Base URL | `https://your-domain.com` |
|
||||
| 鉴权方式 | 请求头 `Fdesk-Api-Key` |
|
||||
| 响应格式 | JSON |
|
||||
|
||||
## 鉴权
|
||||
|
||||
所有请求必须携带 API Key 请求头:
|
||||
|
||||
```
|
||||
Fdesk-Api-Key: <your-api-key>
|
||||
```
|
||||
|
||||
缺少或无效时返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "fail",
|
||||
"code": 401,
|
||||
"message": "无效的 API Key",
|
||||
"data": []
|
||||
}
|
||||
```
|
||||
|
||||
## 通用响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success | fail",
|
||||
"code": 200,
|
||||
"message": "描述信息",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
| code | 含义 |
|
||||
|------|------|
|
||||
| 200 | 成功 |
|
||||
| 400 | 查询失败 |
|
||||
| 401 | API Key 无效或缺失 |
|
||||
|
||||
---
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. PI 号列表
|
||||
|
||||
查询已审批通过的备货单 PI 号,含关联的车架号和车型。
|
||||
|
||||
**请求**
|
||||
|
||||
```
|
||||
GET /common/public/pi/list
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| page | int | 否 | 页码,默认 1 |
|
||||
| page_size | int | 否 | 每页条数,默认 20,最大 200 |
|
||||
| pi_no | string | 否 | PI 号筛选,模糊匹配 |
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"message": "请求成功",
|
||||
"data": {
|
||||
"total": 150,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"pi_no": "PI20250601001",
|
||||
"vins": ["LSVAU2A39N2123456", "LSVAU2A39N2123457"],
|
||||
"model_names": ["BYD Song Plus DM-i", "BYD Seal"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| pi_no | string | 备货单 PI 号 |
|
||||
| vins | string[] | 该 PI 下所有车架号 |
|
||||
| model_names | string[] | 该 PI 下所有车型名称(与 vins 一一对应) |
|
||||
|
||||
---
|
||||
|
||||
### 2. VIN 号列表
|
||||
|
||||
查询已审批通过的备货单车架号及对应车型。
|
||||
|
||||
**请求**
|
||||
|
||||
```
|
||||
GET /common/public/vin/list
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| page | int | 否 | 页码,默认 1 |
|
||||
| page_size | int | 否 | 每页条数,默认 20,最大 200 |
|
||||
| vin | string | 否 | 车架号筛选,模糊匹配 |
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"message": "请求成功",
|
||||
"data": {
|
||||
"total": 500,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"vin": "LSVAU2A39N2123456",
|
||||
"model_name": "BYD Song Plus DM-i"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| vin | string | 车架号 |
|
||||
| model_name | string | 车型名称 |
|
||||
|
||||
---
|
||||
|
||||
### 3. 业务人员列表
|
||||
|
||||
查询业务人员(角色 role_id=2,在职),全量返回。
|
||||
|
||||
**请求**
|
||||
|
||||
```
|
||||
GET /common/public/staff/list
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| keyword | string | 否 | 关键词,模糊匹配姓名/编号/电话/邮箱 |
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"message": "请求成功",
|
||||
"data": [
|
||||
{
|
||||
"id": 17,
|
||||
"name": "张三",
|
||||
"sn": "S0017",
|
||||
"phone": "13800138000",
|
||||
"email": "zhangsan@example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int | 用户ID |
|
||||
| name | string | 姓名 |
|
||||
| sn | string | 编号 |
|
||||
| phone | string | 电话 |
|
||||
| email | string | 邮箱 |
|
||||
|
||||
---
|
||||
|
||||
## 调用示例
|
||||
|
||||
```bash
|
||||
# 查询 PI 列表(第一页,每页10条)
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/pi/list?page=1&page_size=10"
|
||||
|
||||
# 按 PI 号搜索
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/pi/list?pi_no=PI202506"
|
||||
|
||||
# 查询 VIN 列表(第一页,每页50条)
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/vin/list?page=1&page_size=50"
|
||||
|
||||
# 按 VIN 号搜索
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/vin/list?vin=LSVAU2A3"
|
||||
|
||||
# 查询业务人员列表
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/staff/list?page=1&page_size=50"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 两个接口仅返回审批状态为"已通过"(approval_status=2)的备货单数据
|
||||
- 已删除的备货单不会出现在查询结果中
|
||||
- 接口为只读,不支持新增、修改、删除操作
|
||||
- `pi_no` 和 `vin` 筛选参数支持模糊匹配(包含即可)
|
||||
@@ -0,0 +1,290 @@
|
||||
# Public Stock Order API Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add two read-only public APIs (PI list, VIN list) for internal systems to query approved stock orders, authenticated via API Key.
|
||||
|
||||
**Architecture:** New `PublicApiController` in `app/common/controller/` with a dedicated `ApiKeyAuth` middleware. Routes registered in a standalone route group that bypasses JWT and BasicData middleware. Pagination via Eloquent's `paginate()`.
|
||||
|
||||
**Tech Stack:** PHP 8.1+, Webman framework, Eloquent ORM, illuminate/pagination
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `app/middleware/ApiKeyAuth.php` | Validate `Fdesk-Api-Key` header against env config |
|
||||
| Create | `app/common/controller/PublicApiController.php` | Two endpoints: PI list, VIN list |
|
||||
| Modify | `config/route.php` | Add `/common/public/*` route group with ApiKeyAuth middleware |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create ApiKeyAuth Middleware
|
||||
|
||||
**Files:**
|
||||
- Create: `app/middleware/ApiKeyAuth.php`
|
||||
|
||||
- [ ] **Step 1: Create the middleware file**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
class ApiKeyAuth implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$apiKey = $request->header('Fdesk-Api-Key');
|
||||
$validKey = getenv('PUBLIC_API_KEY');
|
||||
|
||||
if (!$apiKey || !$validKey || $apiKey !== $validKey) {
|
||||
$body = [
|
||||
'status' => 'fail',
|
||||
'code' => 401,
|
||||
'message' => '无效的 API Key',
|
||||
'data' => [],
|
||||
];
|
||||
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/middleware/ApiKeyAuth.php
|
||||
git commit -m "feat: add ApiKeyAuth middleware for public API endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create PublicApiController
|
||||
|
||||
**Files:**
|
||||
- Create: `app/common/controller/PublicApiController.php`
|
||||
|
||||
- [ ] **Step 1: Create the controller file**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\model\StockOrders;
|
||||
use app\common\model\StockOrderItems;
|
||||
use support\Request;
|
||||
|
||||
class PublicApiController extends BaseController
|
||||
{
|
||||
protected $noNeedLogin = ['piList', 'vinList'];
|
||||
protected $noNeedHeaderData = ['piList', 'vinList'];
|
||||
|
||||
/**
|
||||
* PI 号列表(含关联的 VIN 和车型名称)
|
||||
* GET /common/public/pi/list
|
||||
*/
|
||||
public function piList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$piNo = $request->input('pi_no', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrders::where('approval_status', 2)
|
||||
->whereNotNull('pi_no')
|
||||
->where('pi_no', '<>', '');
|
||||
|
||||
if ($piNo !== '') {
|
||||
$query->where('pi_no', 'like', "%{$piNo}%");
|
||||
}
|
||||
|
||||
$query->with(['items' => function ($q) {
|
||||
$q->select('id', 'order_id', 'vin', 'model_snapshot');
|
||||
}]);
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'pi_no'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($order) {
|
||||
return [
|
||||
'pi_no' => $order->pi_no,
|
||||
'vins' => $order->items->pluck('vin')->filter()->values()->toArray(),
|
||||
'model_names' => $order->items->pluck('model_snapshot')->filter()->values()->toArray(),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VIN 号列表(含车型名称)
|
||||
* GET /common/public/vin/list
|
||||
*/
|
||||
public function vinList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$vin = $request->input('vin', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrderItems::whereHas('order', function ($q) {
|
||||
$q->where('approval_status', 2);
|
||||
})->whereNotNull('vin')
|
||||
->where('vin', '<>', '');
|
||||
|
||||
if ($vin !== '') {
|
||||
$query->where('vin', 'like', "%{$vin}%");
|
||||
}
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'vin', 'model_snapshot'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($item) {
|
||||
return [
|
||||
'vin' => $item->vin,
|
||||
'model_name' => $item->model_snapshot,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/common/controller/PublicApiController.php
|
||||
git commit -m "feat: add PublicApiController with PI and VIN list endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Register Routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/route.php`
|
||||
|
||||
- [ ] **Step 1: Add the route group**
|
||||
|
||||
在 `config/route.php` 文件末尾(最后一个 `});` 之后)追加:
|
||||
|
||||
```php
|
||||
// 公共 API(API Key 鉴权,不走 JWT / BasicData)
|
||||
Route::group('/common/public', function () {
|
||||
Route::any('/pi/list', [app\common\controller\PublicApiController::class, 'piList']);
|
||||
Route::any('/vin/list', [app\common\controller\PublicApiController::class, 'vinList']);
|
||||
})->middleware([app\middleware\AccessControl::class, app\middleware\ApiKeyAuth::class]);
|
||||
```
|
||||
|
||||
This route group only uses `AccessControl` (CORS) + `ApiKeyAuth`. It explicitly bypasses `BasicData` and `AuthCheck`.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add config/route.php
|
||||
git commit -m "feat: register public API routes with ApiKeyAuth middleware"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Configure API Key in Environment
|
||||
|
||||
**Files:**
|
||||
- Modify: `.env` (or equivalent environment config)
|
||||
|
||||
- [ ] **Step 1: Add the API key to the environment config**
|
||||
|
||||
Add to the `.env` file (or equivalent environment variable source):
|
||||
|
||||
```
|
||||
PUBLIC_API_KEY=your-secret-api-key-here
|
||||
```
|
||||
|
||||
Generate a real key, e.g.:
|
||||
|
||||
```bash
|
||||
php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"
|
||||
```
|
||||
|
||||
Use the generated value as the key.
|
||||
|
||||
- [ ] **Step 2: Verify the middleware reads it correctly**
|
||||
|
||||
The middleware uses `getenv('PUBLIC_API_KEY')`. Confirm this is consistent with how other env vars are read in the project (e.g., `getenv('DB_HOST')` in `config/database.php`).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Manual Verification
|
||||
|
||||
- [ ] **Step 1: Test PI list endpoint (no auth)**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8787/common/public/pi/list | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: `{"status":"fail","code":401,"message":"无效的 API Key"}`
|
||||
|
||||
- [ ] **Step 2: Test PI list endpoint (with auth)**
|
||||
|
||||
```bash
|
||||
curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/pi/list?page=1&page_size=5" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: JSON with `status: success`, `data` containing `total`, `per_page`, `current_page`, and array of `{pi_no, vins, model_names}`.
|
||||
|
||||
- [ ] **Step 3: Test VIN list endpoint (with auth)**
|
||||
|
||||
```bash
|
||||
curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/vin/list?page=1&page_size=5" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: JSON with `status: success`, `data` containing array of `{vin, model_name}`.
|
||||
|
||||
- [ ] **Step 4: Test with pi_no filter**
|
||||
|
||||
```bash
|
||||
curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/pi/list?pi_no=PI2025" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: filtered results or empty data array.
|
||||
|
||||
- [ ] **Step 5: Commit final verification**
|
||||
|
||||
No code changes expected. If fixes were needed during testing, commit them:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: adjustments from manual verification of public stock API"
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# Public Stock Order API Design
|
||||
|
||||
## Goal
|
||||
|
||||
Provide two read-only public APIs for internal systems to query approved stock order PI numbers and VIN numbers, with API Key authentication.
|
||||
|
||||
## Data Model
|
||||
|
||||
- `stock_orders.approval_status = 2` means approved
|
||||
- `stock_orders.pi_no` — PI number (string)
|
||||
- `stock_order_items.vin` — VIN/chassis number (string)
|
||||
- `stock_order_items.model_snapshot` — car model name (plain string)
|
||||
- `stock_order_items.order_id` → `stock_orders.id`
|
||||
|
||||
## Authentication
|
||||
|
||||
New middleware `ApiKeyAuth`:
|
||||
|
||||
- Read `Fdesk-Api-Key` header
|
||||
- Compare against key stored in `.env` (`API_KEY`) or `config/app.php`
|
||||
- Reject with 401 on mismatch or missing key
|
||||
- Bypass both `AuthCheck` (JWT) and `BasicData` middleware
|
||||
|
||||
## Endpoints
|
||||
|
||||
### A. PI Number List
|
||||
|
||||
```
|
||||
GET /common/public/pi/list
|
||||
Headers: Fdesk-Api-Key: <key>
|
||||
Query: page, page_size, pi_no (optional filter)
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"data": {
|
||||
"total": 150,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"pi_no": "PI20250601001",
|
||||
"vins": ["LSVAU2A39N2123456", "LSVAU2A39N2123457"],
|
||||
"model_names": ["Model Y", "Model 3"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Logic:
|
||||
1. Query `stock_orders` where `approval_status = 2` and `pi_no IS NOT NULL` and `pi_no != ''`
|
||||
2. Optional filter by `pi_no` (LIKE match)
|
||||
3. Paginate
|
||||
4. For each order, eager-load `items` → collect `vin` and `model_snapshot`
|
||||
5. Return aggregated result
|
||||
|
||||
### B. VIN Number List
|
||||
|
||||
```
|
||||
GET /common/public/vin/list
|
||||
Headers: Fdesk-Api-Key: <key>
|
||||
Query: page, page_size, vin (optional filter)
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"data": {
|
||||
"total": 500,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"vin": "LSVAU2A39N2123456",
|
||||
"model_name": "Model Y"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Logic:
|
||||
1. Query `stock_order_items` via `stock_orders` where `approval_status = 2`
|
||||
2. Optional filter by `vin` (LIKE match)
|
||||
3. Paginate
|
||||
4. Return `vin` + `model_snapshot` per item
|
||||
|
||||
## File Changes
|
||||
|
||||
| Action | File | Description |
|
||||
|--------|------|-------------|
|
||||
| New | `app/middleware/ApiKeyAuth.php` | API Key auth middleware |
|
||||
| New | `app/common/controller/PublicApiController.php` | Two endpoints |
|
||||
| Modify | `config/route.php` | Add `/common/public/*` route group with ApiKeyAuth |
|
||||
|
||||
## Constraints
|
||||
|
||||
- Read-only, no write operations
|
||||
- Only `approval_status = 2` records
|
||||
- Pagination required (default page_size = 20)
|
||||
- No JWT / BasicData middleware for these routes
|
||||
@@ -0,0 +1,112 @@
|
||||
# Inquiry Bulk Assign + Digest Email Design
|
||||
|
||||
## Goal
|
||||
|
||||
一次性将 `docs/崔琪2.xlsx` 中的 1427 条询盘,按 Excel 行顺序轮询分配给 6 个业务员(user_id: 34、73、72、155、64、211),更新 `crm_customer.user_id`,并为每人发送一封汇总邮件,列出分给其名下的全部询盘。
|
||||
|
||||
这是一次性数据操作,以 webman console 命令形式交付,**不做成永久 API / 控制器 / 定时任务**。
|
||||
|
||||
## Data Model(已核实)
|
||||
|
||||
- **匹配键**:Excel 第一列 `编号` = `crm_customer.code`(如 `CM815EE43E7D4C1C`)。
|
||||
- `crm_customer.code` —— varchar(32),**无唯一索引**(仅主键 + USER_ID/SN/TYPE 普通索引),匹配需防御撞多条。
|
||||
- `crm_customer.user_id` —— 待更新字段(`所属用户`,bigint unsigned nullable)。
|
||||
- `crm_customer.deleted_at` —— int 时间戳软删除标记,匹配需 `IS NULL`。
|
||||
- `crm_customer_his` —— 历史表,记录 `customer_id / user_his(原 user_id) / user_id(新)`,复用 `CustomerHis` model。
|
||||
- `user` 表:`id / name / email / corp_id / status`,6 人收件邮箱取自 `user.email`。
|
||||
- Excel 行数:1428(含表头)→ **1427 条询盘**,13 列(编号/录入人/接单人/渠道/邮箱/电话/WhatsApp/联系人/备注/国家(中)/国家(英)/备注_原始HTML/x)。
|
||||
|
||||
## 复用的现成组件(不引入新依赖)
|
||||
|
||||
- `app/command/CrmRotateUserCommand.php` —— 轮转 user_id + 事务 + 写 CustomerHis 的模板。
|
||||
- `app/common/utils/RedisUidRotator.php` —— 轮询分配器(本方案改用**内存确定性轮询**,见下)。
|
||||
- `app/common/utils/email/MailSend.php` —— `MailSend::send($to, $subject, $html, $attachments)`,SMTP 配置在 `config/imap.smtp_xyh`。
|
||||
- `app/admin/controller/InquiryTransmitController.php::emailHtml()` —— 现成询盘 HTML 邮件模板,改写为列表版复用。
|
||||
- `phpoffice/phpspreadsheet`(已在依赖)—— 读 Excel。
|
||||
|
||||
## Command Interface
|
||||
|
||||
```
|
||||
php webman inquiry:bulk-assign \
|
||||
--file=docs/崔琪2.xlsx \
|
||||
--users=34,73,72,155,64,211 \
|
||||
[--dry-run] # 只解析+匹配+分配,打印报告,不写库不发邮件(强制先跑)
|
||||
[--only-user=34] # 仅给指定 user_id 发邮件(单点试发)
|
||||
[--notify-only] # 跳过写库,按 runtime 清单补发未成功的邮件
|
||||
```
|
||||
|
||||
webman console 自动发现 `app/command/` 下命令,无需改 `config/command.php`。
|
||||
|
||||
## Execution Flow
|
||||
|
||||
### ① 解析 Excel(PhpSpreadsheet)
|
||||
读 Sheet1(跳过表头),按行顺序提取 `code` 及邮件所需字段:联系人、邮箱、电话、WhatsApp、国家(中)、渠道、备注_原始HTML。得到有序列表,长度 N≈1427。
|
||||
|
||||
### ② 匹配 crm_customer(关键防错)
|
||||
批量查询:
|
||||
```sql
|
||||
SELECT id, code, user_id FROM crm_customer
|
||||
WHERE code IN (...) AND deleted_at IS NULL
|
||||
```
|
||||
- 撞多条 code → 取 `MIN(id)`,记 warning;
|
||||
- Excel 有、库查不到(或仅软删除)→ 记入「未匹配清单」,**跳过不分配**(不中止);
|
||||
- 输出匹配率;保留 Excel 行顺序,记下每个客户原 `user_id`。
|
||||
|
||||
### ③ 内存轮询分配(确定性 `i % 6`)
|
||||
`targetUserIds = [34, 73, 72, 155, 64, 211]`,第 i 条匹配客户 → `targetUserIds[i % 6]`。
|
||||
**不依赖 Redis**(避免跨 run 状态残留、保证可重跑确定性),按目标用户分组得到每人清单。
|
||||
`--dry-run` 到此为止:打印匹配率、6 人条数(应 ≈238 均衡)、未匹配清单、抽样 3 条后退出。
|
||||
|
||||
### ④ 写库(单事务,可回滚)
|
||||
事务内对每条匹配客户:
|
||||
```sql
|
||||
UPDATE crm_customer SET user_id = ? WHERE id = ?;
|
||||
INSERT INTO crm_customer_his (customer_id, user_his, user_id) VALUES (?, ?, ?);
|
||||
```
|
||||
提交后才进入发邮件。`CustomerHis` 保留原归属以便回滚。
|
||||
|
||||
### ⑤ 发汇总邮件(每人 1 封,共 6 封)
|
||||
- 从 `user` 表取 6 人 `email/name`,空邮箱跳过并告警;
|
||||
- 复用 `emailHtml()` 改成列表版,把该用户分到的询盘做成 HTML 表格;
|
||||
- 每条展示字段:**编号 / 联系人 / 邮箱 / 电话 / 国家 / 渠道 / 意向车型或备注**;
|
||||
- 主题:`询盘分配汇总(共 N 条)- {name} - 2026-07-17`;
|
||||
- `MailSend::send($user_email, $subject, $html)`。
|
||||
|
||||
## Engineering Points
|
||||
|
||||
### (a) DB 与邮件解耦 + 防重复发送
|
||||
- 顺序:**先事务提交 → 再发邮件**(邮件不可回滚)。
|
||||
- 写运行清单 `runtime/inquiry_bulk_assign_2026-07-17.json`:分配结果 + 每人发送状态。
|
||||
- 重跑时:写库幂等(覆盖 user_id,会追加 CustomerHis 历史);**邮件步骤只给清单中未标记成功的人发**——杜绝跑两次双倍发。
|
||||
|
||||
### (b) 邮件失败不连坐
|
||||
`MailSend` 失败返回 false 并记日志。命令收集每人结果、失败重试 1 次,**最终汇总报告**,单封失败不中断(库已提交)。
|
||||
|
||||
### (c) 回滚
|
||||
- DB 事务失败 → 回滚,零影响。
|
||||
- 已提交但分错 → `crm_customer_his.user_his` 保留原归属,可反查/还原。
|
||||
- 邮件发出无法撤回 → 故**强制 dry-run**,并建议先 `--only-user` 单点试发。
|
||||
|
||||
## File Changes
|
||||
|
||||
| Action | File | Description |
|
||||
|--------|------|-------------|
|
||||
| New | `app/command/InquiryBulkAssignCommand.php` | 主命令:解析→匹配→分配→写库→发邮件,含 dry-run/only-user/notify-only |
|
||||
| New | `app/common/utils/email/InquiryDigestMail.php` | 汇总邮件 HTML 模板构建(基于 emailHtml 改写) |
|
||||
| Write | `runtime/inquiry_bulk_assign_2026-07-17.json` | 运行清单(分配结果 + 发送状态,防重复发,.gitignore 已忽略 runtime) |
|
||||
|
||||
不修改任何现有文件、不改路由、不改表结构。
|
||||
|
||||
## Verification
|
||||
|
||||
1. `--dry-run`:核对匹配率、6 人分配条数均衡、未匹配清单、抽样分配结果。
|
||||
2. `--only-user=34`:单点试发,确认模板与送达。
|
||||
3. 全量执行,检查运行清单 + 终端汇总报告。
|
||||
|
||||
## Constraints / Out of Scope
|
||||
|
||||
- 仅处理 `deleted_at IS NULL` 的活跃客户。
|
||||
- 未匹配 code 跳过 + 记清单,**不**中止整个流程。
|
||||
- 轮询顺序 = Excel 行顺序(用户已确认)。
|
||||
- 邮件粒度 = 每人一封汇总(用户已确认),不做逐条邮件。
|
||||
- 不做永久 API / Web 界面 / 定时任务;不引入新依赖。
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
备货单成交统计报告生成器(固定脚本)
|
||||
|
||||
口径:
|
||||
成交 = 有备货单(stock_orders);时间按单据日期 biz_date;
|
||||
剔除 草稿/未提交(approval_status=0)、已拒绝(3)、已作废(4);排除软删除;
|
||||
初版与关联终版(object_id 互指)同月并存时只计终版,跨月各计各月;
|
||||
车况:明细 mileage>0 为二手车、=0 为新车;国家取清关目的国(去英文括号后缀)。
|
||||
|
||||
用法:
|
||||
python3 scripts/stock_deal_report.py --year 2026 --month 7 # 单月
|
||||
python3 scripts/stock_deal_report.py --from 2026-01 --to 2026-06 # 区间
|
||||
可选:--file runtime/fdesk-20260813.sql(默认)
|
||||
|
||||
输出:runtime/<期间>成交统计报告.pdf + runtime/<期间>成交详单.csv
|
||||
依赖:python3 + weasyprint(首次对每个 dump 建解析缓存,之后秒级重跑)
|
||||
"""
|
||||
import argparse, re, csv, html, os, pickle
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from weasyprint import HTML
|
||||
|
||||
TZ = timezone(timedelta(hours=8))
|
||||
EXCLUDE = {0, 3, 4} # 草稿/拒绝/作废
|
||||
DEFAULT_DUMP = 'runtime/fdesk-20260813.sql'
|
||||
|
||||
# ---------------- SQL dump 解析 ----------------
|
||||
def pvl(s, i):
|
||||
vals = []; cur = ''; instr = False; n = len(s)
|
||||
while i < n:
|
||||
c = s[i]
|
||||
if instr:
|
||||
if c == '\\' and i + 1 < n: cur += c + s[i + 1]; i += 2; continue
|
||||
if c == "'":
|
||||
if i + 1 < n and s[i + 1] == "'": cur += "''"; i += 2; continue
|
||||
cur += "'"; i += 1; instr = False; continue
|
||||
cur += c; i += 1; continue
|
||||
else:
|
||||
if c == "'": instr = True; cur += "'"; i += 1; continue
|
||||
if c == ',': vals.append(cur.strip()); cur = ''; i += 1; continue
|
||||
if c == ')': vals.append(cur.strip()); i += 1; break
|
||||
cur += c; i += 1
|
||||
return vals, i
|
||||
|
||||
def unesc(raw):
|
||||
raw = raw.strip()
|
||||
if raw.upper() == 'NULL': return None
|
||||
if len(raw) >= 2 and raw[0] == "'" and raw[-1] == "'":
|
||||
inner = raw[1:-1]; out = []; i = 0; L = len(inner)
|
||||
mp = {'n': '\n', 'r': '\r', 't': '\t', '0': '\x00', '\\': '\\', "'": "'", '"': '"', 'b': '\b', 'Z': '\x1a'}
|
||||
while i < L:
|
||||
c = inner[i]
|
||||
if c == '\\' and i + 1 < L: out.append(mp.get(inner[i + 1], inner[i + 1])); i += 2
|
||||
elif c == "'" and i + 1 < L and inner[i + 1] == "'": out.append("'"); i += 2
|
||||
else: out.append(c); i += 1
|
||||
return ''.join(out)
|
||||
try: return int(raw)
|
||||
except:
|
||||
try: return float(raw)
|
||||
except: return raw
|
||||
|
||||
def load_dump(path):
|
||||
"""返回 (orders: id->dict, items: [(order_id, model, qty, mileage)])"""
|
||||
cache = path + '.cache.pkl'
|
||||
if os.path.exists(cache):
|
||||
with open(cache, 'rb') as f:
|
||||
return pickle.load(f)
|
||||
cols = {}; cur_create = None
|
||||
orders = {}; items = []
|
||||
with open(path, encoding='utf-8', errors='replace') as f:
|
||||
for line in f:
|
||||
if cur_create is not None:
|
||||
m = re.match(r'\s*`(\w+)`\s', line)
|
||||
if m: cols[cur_create].append(m.group(1))
|
||||
if re.search(r'\)\s*ENGINE', line): cur_create = None
|
||||
continue
|
||||
if line.startswith('CREATE TABLE `'):
|
||||
t = line[14:line.index('`', 14)]
|
||||
if t in ('stock_orders', 'stock_order_items'):
|
||||
cur_create = t; cols[t] = []
|
||||
continue
|
||||
if line.startswith('INSERT INTO `stock_orders` VALUES ('):
|
||||
v = [unesc(x) for x in pvl(line, line.index('VALUES (') + 8)[0]]
|
||||
ci = {c: i for i, c in enumerate(cols['stock_orders'])}
|
||||
orders[v[ci['id']]] = dict(
|
||||
order_no=v[ci['order_no']], biz=v[ci['biz_date']] or 0,
|
||||
fin=v[ci['is_final']], oid=v[ci['object_id']] or 0,
|
||||
ap=v[ci['approval_status']], deleted=v[ci['deleted_at']] is not None,
|
||||
country=str(v[ci['clearance_country_snapshot']] or '').strip(),
|
||||
sales=str(v[ci['salesperson_snapshot']] or '').strip(),
|
||||
)
|
||||
elif line.startswith('INSERT INTO `stock_order_items` VALUES ('):
|
||||
v = [unesc(x) for x in pvl(line, line.index('VALUES (') + 8)[0]]
|
||||
ci = {c: i for i, c in enumerate(cols['stock_order_items'])}
|
||||
items.append((v[ci['order_id']], str(v[ci['model_snapshot']] or '').strip(),
|
||||
int(v[ci['quantity']] or 0), int(v[ci['mileage']] or 0)))
|
||||
with open(cache, 'wb') as f:
|
||||
pickle.dump((orders, items), f)
|
||||
return orders, items
|
||||
|
||||
# ---------------- 统计 ----------------
|
||||
_PAREN = re.compile(r'\((?:[^()]|\([^()]*\))*\)\s*$')
|
||||
|
||||
def clean_country(c):
|
||||
while True:
|
||||
c2 = _PAREN.sub('', c).strip()
|
||||
if c2 == c: return c
|
||||
c = c2
|
||||
|
||||
def month_key(ts):
|
||||
return datetime.fromtimestamp(ts, TZ).strftime('%Y-%m')
|
||||
|
||||
def build_report(orders, items, months):
|
||||
"""months: [(y, m), ...] 连续月份列表"""
|
||||
for o in orders.values():
|
||||
o['country'] = clean_country(o['country'])
|
||||
|
||||
t0 = int(datetime(months[0][0], months[0][1], 1, tzinfo=TZ).timestamp())
|
||||
ly, lm = months[-1]
|
||||
t1 = int((datetime(ly, lm, 1, tzinfo=TZ) + timedelta(days=32)).replace(day=1).timestamp())
|
||||
|
||||
def in_range(o):
|
||||
return (not o['deleted']) and t0 <= o['biz'] < t1
|
||||
|
||||
cand = {i: o for i, o in orders.items() if in_range(o) and o['ap'] not in EXCLUDE}
|
||||
excluded = {a: sum(1 for o in orders.values() if in_range(o) and o['ap'] == a) for a in sorted(EXCLUDE)}
|
||||
|
||||
# 初终版去重(按月):初版的关联终版与初版同月 -> 跳过初版
|
||||
sel = {}; skipped = []; cross_month_fin = 0
|
||||
for i, o in cand.items():
|
||||
if o['fin'] == 0 and o['oid'] > 0:
|
||||
fin = orders.get(o['oid'])
|
||||
if fin and fin['fin'] == 1 and not fin['deleted']:
|
||||
if month_key(fin['biz']) == month_key(o['biz']):
|
||||
skipped.append(i); continue
|
||||
cross_month_fin += 1
|
||||
sel[i] = o
|
||||
|
||||
order_ids = set(sel)
|
||||
items_by_order = defaultdict(list)
|
||||
for oid, model, qty, mile in items:
|
||||
if oid in order_ids:
|
||||
items_by_order[oid].append((model, qty, mile))
|
||||
|
||||
by_model = Counter(); by_mc = defaultdict(Counter); by_country = Counter()
|
||||
by_sales = Counter(); by_sales_orders = Counter(); by_cond = Counter()
|
||||
by_model_cond = defaultdict(Counter); by_country_cond = defaultdict(Counter)
|
||||
by_sales_cond = defaultdict(Counter); by_month = defaultdict(lambda: Counter())
|
||||
detail = []
|
||||
|
||||
for oid, o in sel.items():
|
||||
sp = o['sales'] or '(未填)'
|
||||
c = o['country'] or '(未填)'
|
||||
mk = month_key(o['biz'])
|
||||
by_sales_orders[sp] += 1
|
||||
by_month[mk]['orders'] += 1
|
||||
for model, qty, mile in items_by_order.get(oid, []):
|
||||
m = model or '(未填车型)'
|
||||
cd = '二手车' if (mile or 0) > 0 else '新车'
|
||||
by_model[m] += qty; by_mc[m][c] += qty; by_country[c] += qty; by_sales[sp] += qty
|
||||
by_cond[cd] += qty
|
||||
by_model_cond[m][cd] += qty; by_country_cond[c][cd] += qty; by_sales_cond[sp][cd] += qty
|
||||
by_month[mk][cd] += qty; by_month[mk]['veh'] += qty
|
||||
detail.append(dict(model=m, country=c, qty=qty, sales=sp, month=mk,
|
||||
cond=cd if cd == '新车' else f'二手车({mile:,}km)',
|
||||
order_no=o['order_no'], date=datetime.fromtimestamp(o['biz'], TZ).strftime('%m-%d')))
|
||||
detail.sort(key=lambda r: (r['month'], r['sales'], r['order_no']))
|
||||
return dict(sel=sel, excluded=excluded, skipped=skipped, cross=cross_month_fin,
|
||||
by_model=by_model, by_mc=by_mc, by_country=by_country, by_sales=by_sales,
|
||||
by_sales_orders=by_sales_orders, by_cond=by_cond,
|
||||
by_model_cond=by_model_cond, by_country_cond=by_country_cond,
|
||||
by_sales_cond=by_sales_cond, by_month=by_month, detail=detail)
|
||||
|
||||
# ---------------- 渲染 ----------------
|
||||
def esc(x): return html.escape(str(x))
|
||||
def tr(rows):
|
||||
return ''.join('<tr><td>' + '</td><td>'.join(esc(c) for c in r) + '</td></tr>' for r in rows)
|
||||
|
||||
def render_pdf(R, months, label, out_pdf):
|
||||
total = sum(R['by_model'].values())
|
||||
new_n = R['by_cond'].get('新车', 0); used_n = R['by_cond'].get('二手车', 0)
|
||||
multi = len(months) > 1
|
||||
|
||||
model_rows = [(m, R['by_model_cond'][m].get('新车', 0), R['by_model_cond'][m].get('二手车', 0), n,
|
||||
'、'.join(f'{c}×{k}' for c, k in R['by_mc'][m].most_common()))
|
||||
for m, n in sorted(R['by_model'].items(), key=lambda x: -x[1])]
|
||||
country_rows = [(c, R['by_country_cond'][c].get('新车', 0), R['by_country_cond'][c].get('二手车', 0), n, f'{n / total * 100:.1f}%')
|
||||
for c, n in R['by_country'].most_common()]
|
||||
sales_rows = [(s, R['by_sales_orders'][s], R['by_sales_cond'][s].get('新车', 0), R['by_sales_cond'][s].get('二手车', 0),
|
||||
R['by_sales'][s], f'{R["by_sales"][s] / total * 100:.1f}%')
|
||||
for s, _ in sorted(R['by_sales'].items(), key=lambda x: -R['by_sales'][x[0]])]
|
||||
month_rows = [(mk, c['orders'], c['veh'], c.get('新车', 0), c.get('二手车', 0))
|
||||
for mk, c in sorted(R['by_month'].items())]
|
||||
dcol = [(r['sales'], r['month'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']) for r in R['detail']] \
|
||||
if multi else \
|
||||
[(r['sales'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']) for r in R['detail']]
|
||||
|
||||
sec = []
|
||||
if multi:
|
||||
sec.append(('月度分布', '<table><thead><tr><th style="width:14%">月份</th><th style="width:14%">单数</th>'
|
||||
'<th style="width:16%">车辆数</th><th style="width:14%">新车</th><th>二手车</th></tr></thead>'
|
||||
f'<tbody>{tr(month_rows)}</tbody></table>'))
|
||||
sec.append(('车型成交排行', '<table><thead><tr><th style="width:36%">车型</th><th style="width:6%">新车</th>'
|
||||
'<th style="width:6%">二手</th><th style="width:6%">合计</th><th>成交国家(国家×辆数)</th></tr></thead>'
|
||||
f'<tbody>{tr(model_rows)}</tbody></table>'))
|
||||
sec.append(('国家成交排行', '<table><thead><tr><th style="width:22%">国家(清关目的国)</th><th style="width:8%">新车</th>'
|
||||
'<th style="width:8%">二手</th><th style="width:8%">合计</th><th>占比</th></tr></thead>'
|
||||
f'<tbody>{tr(country_rows)}</tbody></table>'))
|
||||
sec.append(('业务员成交排行', '<table><thead><tr><th style="width:14%">业务员</th><th style="width:8%">单数</th>'
|
||||
'<th style="width:8%">新车</th><th style="width:8%">二手</th><th style="width:10%">辆数合计</th>'
|
||||
'<th>占车辆数比</th></tr></thead>'
|
||||
f'<tbody>{tr(sales_rows)}</tbody></table>'))
|
||||
dhead = ('<th style="width:8%">业务员</th><th style="width:7%">月份</th><th>车型</th><th style="width:10%">国家</th>'
|
||||
'<th style="width:11%">车况</th><th style="width:5%">数量</th><th style="width:12%">单号</th><th style="width:7%">单据日期</th>') if multi else \
|
||||
('<th style="width:8%">业务员</th><th>车型</th><th style="width:10%">国家</th><th style="width:11%">车况</th>'
|
||||
'<th style="width:5%">数量</th><th style="width:12%">单号</th><th style="width:7%">单据日期</th>')
|
||||
sec.append(('成交详单(按月份、业务员)', f'<table><thead><tr>{dhead}</tr></thead><tbody>{tr(dcol)}</tbody></table>'))
|
||||
sections = ''.join(f'<h2>{i}、{t}</h2>{b}' for i, (t, b) in enumerate(sec, 1))
|
||||
|
||||
ex = R['excluded']
|
||||
doc = f"""<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
@page {{ size: A4 landscape; margin: 1.3cm; }}
|
||||
body {{ font-family:"WenQuanYi Zen Hei","Noto Sans CJK SC",sans-serif; color:#222; font-size:10.5px; }}
|
||||
h1 {{ font-size:19px; margin:0 0 2px; }}
|
||||
.meta {{ color:#666; font-size:9px; margin-bottom:5px; }}
|
||||
.summary {{ background:#f5f7fa; border:1px solid #e3e8ef; padding:7px 10px; border-radius:4px; margin:5px 0 10px; font-size:10.5px; line-height:1.7; }}
|
||||
h2 {{ font-size:12.5px; margin:12px 0 5px; border-left:4px solid #BB0010; padding-left:7px; }}
|
||||
table {{ border-collapse:collapse; width:100%; margin-bottom:4px; }}
|
||||
th,td {{ border:1px solid #cfd6e0; padding:3px 5px; vertical-align:top; font-size:9px; }}
|
||||
th {{ background:#eef1f6; text-align:left; }}
|
||||
thead {{ display:table-header-group; }}
|
||||
tr {{ page-break-inside:avoid; }}
|
||||
.kpis {{ width:100%; margin:8px 0 10px; }}
|
||||
.kpis td {{ border:none; text-align:center; padding:6px 2px; }}
|
||||
.kpis .v {{ font-size:19px; font-weight:bold; color:#BB0010; }}
|
||||
.kpis .l {{ font-size:9px; color:#666; }}
|
||||
.foot {{ color:#888; font-size:8.5px; margin-top:8px; line-height:1.6; }}
|
||||
</style></head><body>
|
||||
<h1>备货单成交统计报告 · {label}</h1>
|
||||
<div class="meta">数据来源:stock_orders / stock_order_items · 生成日期 {datetime.now(TZ).date().isoformat()} · 统计口径:有备货单即成交</div>
|
||||
<div class="summary"><b>口径说明:</b>单据日期(biz_date)落在 {label};已剔除 草稿/未提交({ex.get(0, 0)} 单)、已拒绝({ex.get(3, 0)} 单)、已作废({ex.get(4, 0)} 单);
|
||||
初版与关联终版(object_id 互指)同月并存时只计终版(本期跳过初版 {len(R['skipped'])} 单);跨月关联终版 {R['cross']} 单按所属月份计入。
|
||||
<b>车况判定:</b>系统无显式新车/二手车字段,按明细 mileage 判定:里程>0 为<b>二手车</b>、=0 为新车。国家按清关目的国。</div>
|
||||
<table class="kpis"><tr>
|
||||
<td><div class="v">{len(R['sel'])}</div><div class="l">成交单数</div></td>
|
||||
<td><div class="v">{total}</div><div class="l">成交车辆数</div></td>
|
||||
<td><div class="v">{new_n}</div><div class="l">新车(辆)</div></td>
|
||||
<td><div class="v">{used_n}</div><div class="l">二手车(辆)</div></td>
|
||||
<td><div class="v">{len(R['by_model'])}</div><div class="l">成交车型数</div></td>
|
||||
<td><div class="v">{len(R['by_country'])}</div><div class="l">覆盖国家数</div></td>
|
||||
</tr></table>
|
||||
{sections}
|
||||
<p class="foot">注:明细按 stock_order_items 逐行展开(数量取 quantity);车况括号内为二手车明细里程(km);业务员为建单时快照(salesperson_snapshot)。</p>
|
||||
</body></html>"""
|
||||
HTML(string=doc).write_pdf(out_pdf)
|
||||
|
||||
def write_csv(R, label, multi):
|
||||
path = f'runtime/{label}成交详单.csv'
|
||||
with open(path, 'w', newline='', encoding='utf-8-sig') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(['业务员', '月份', '车型', '国家', '车况', '数量', '单号', '单据日期'] if multi
|
||||
else ['业务员', '车型', '国家', '车况', '数量', '单号', '单据日期'])
|
||||
for r in R['detail']:
|
||||
row = [r['sales'], r['month'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']] if multi \
|
||||
else [r['sales'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']]
|
||||
w.writerow(row)
|
||||
return path
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='备货单成交统计报告')
|
||||
ap.add_argument('--file', default=DEFAULT_DUMP, help='Navicat 全库 dump 路径')
|
||||
g = ap.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument('--year', type=int); ap.add_argument('--month', type=int, default=None)
|
||||
g.add_argument('--frm', '--from', dest='frm', default=None, help='起始月 2026-01')
|
||||
ap.add_argument('--to', default=None, help='结束月 2026-06')
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.frm:
|
||||
y1, m1 = map(int, args.frm.split('-'))
|
||||
y2, m2 = map(int, (args.to or args.frm).split('-'))
|
||||
months = []
|
||||
y, m = y1, m1
|
||||
while (y, m) <= (y2, m2):
|
||||
months.append((y, m)); m += 1
|
||||
if m == 13: y, m = y + 1, 1
|
||||
else:
|
||||
months = [(args.year, args.month or datetime.now(TZ).month)]
|
||||
|
||||
if len(months) == 1:
|
||||
y, m = months[0]; label = f'{y}年{m}月'
|
||||
else:
|
||||
(y1, m1), (y2, m2) = months[0], months[-1]
|
||||
label = f'{y1}年{m1}-{m2}月' if y1 == y2 else f'{y1}年{m1}月-{y2}年{m2}月'
|
||||
|
||||
orders, items = load_dump(args.file)
|
||||
print(f'dump: {args.file}(orders {len(orders)} / items {len(items)}),期间: {label}')
|
||||
R = build_report(orders, items, months)
|
||||
total = sum(R['by_model'].values())
|
||||
print(f"入选 {len(R['sel'])} 单 / {total} 辆(新车 {R['by_cond'].get('新车', 0)} / 二手 {R['by_cond'].get('二手车', 0)}),"
|
||||
f"车型 {len(R['by_model'])},国家 {len(R['by_country'])},剔除 {R['excluded']},同月终版跳过初版 {len(R['skipped'])} 单")
|
||||
pdf = f'runtime/{label}成交统计报告.pdf'
|
||||
render_pdf(R, months, label, pdf)
|
||||
csvp = write_csv(R, label, len(months) > 1)
|
||||
print('已生成:', pdf, '|', csvp)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user