新增
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\command;
|
||||
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
|
||||
/**
|
||||
* 定时:补全订单 creator_id(逻辑在 SyncWechatWorkBills::fillOrderCreatorsFromPayee)
|
||||
*/
|
||||
class FillOrderCreatorFromPayee extends SyncWechatWorkBills
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('fill_order_creator_from_payee')
|
||||
->setDescription('补全订单创建人:creator_id 为空时按 payee_userid 匹配 zyt_admin.work_wechat_userid');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$fixed = $this->fillOrderCreatorsFromPayee();
|
||||
$output->writeln("补全创建人完成,共更新 {$fixed} 条订单");
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@ declare(strict_types=1);
|
||||
namespace app\common\command;
|
||||
|
||||
use app\adminapi\logic\order\OrderLogic;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\Order;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Argument;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
use think\facade\Log;
|
||||
@@ -23,11 +24,24 @@ class SyncWechatWorkBills extends Command
|
||||
$this->setName('sync_wechat_work_bills')
|
||||
->setDescription('同步企业微信对外收款到订单(按天拉取)')
|
||||
->addOption('date', 'd', Option::VALUE_OPTIONAL, '指定日期 Y-m-d,默认今天', '')
|
||||
->addOption('days', null, Option::VALUE_OPTIONAL, '拉取最近N天,每天单独请求', '1');
|
||||
->addOption('days', null, Option::VALUE_OPTIONAL, '拉取最近N天,每天单独请求', '1')
|
||||
->addOption(
|
||||
'fill-order-creators-only',
|
||||
null,
|
||||
Option::VALUE_NONE,
|
||||
'仅补全订单创建人:creator_id 为空时用 payee_userid 匹配 admin.work_wechat_userid(可单独做定时任务)'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
if ($input->getOption('fill-order-creators-only')) {
|
||||
$fixed = $this->fillOrderCreatorsFromPayee();
|
||||
$output->writeln("补全创建人完成,共更新 {$fixed} 条订单");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dateStr = $input->getOption('date');
|
||||
$days = (int)$input->getOption('days');
|
||||
$days = $days > 0 ? min($days, 31) : 1;
|
||||
@@ -68,4 +82,32 @@ class SyncWechatWorkBills extends Command
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* creator_id 为空的订单,用 payee_userid 匹配管理员 work_wechat_userid 写入 creator_id
|
||||
*/
|
||||
protected function fillOrderCreatorsFromPayee(): int
|
||||
{
|
||||
$fixed = 0;
|
||||
Order::whereRaw('(creator_id IS NULL OR creator_id = 0)')
|
||||
->whereNotNull('payee_userid')
|
||||
->where('payee_userid', '<>', '')
|
||||
->chunk(200, function ($orders) use (&$fixed) {
|
||||
foreach ($orders as $order) {
|
||||
$payee = trim((string) $order->getAttr('payee_userid'));
|
||||
if ($payee === '') {
|
||||
continue;
|
||||
}
|
||||
$admin = Admin::where('work_wechat_userid', $payee)->whereNull('delete_time')->find();
|
||||
if (!$admin) {
|
||||
continue;
|
||||
}
|
||||
$order->creator_id = (int) $admin->getAttr('id');
|
||||
$order->save();
|
||||
$fixed++;
|
||||
}
|
||||
});
|
||||
|
||||
return $fixed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,16 @@ use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 挂号单状态自动更新
|
||||
* - 预约时间已过超过 35 分钟(且未满 8 小时):status -> 4(已过号)
|
||||
* - 预约时间已过超过 8 小时:status -> 2(已取消)
|
||||
* - 不处理:status=3(已完成)、未到时间、刚过号未满 35 分钟的单子
|
||||
* - 已预约(status=1) 且预约时间已过超过 35 分钟 → status=4(已过号)
|
||||
* - 已过号(status=4) 且预约时间已过超过 8 小时 → status=2(已取消)
|
||||
* - 不处理:status=3(已完成)、未到预约时间、距预约未满 35 分钟的单子
|
||||
*/
|
||||
class UpdateAppointmentStatus extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('update_appointment_status')
|
||||
->setDescription('自动更新挂号单状态:过号超35分钟->4,超8小时->2(已取消)')
|
||||
->setDescription('挂号单:已预约过号35分钟→已过号;已过号超8小时→已取消')
|
||||
->addOption('dry', null, Option::VALUE_NONE, '仅预览不执行');
|
||||
}
|
||||
|
||||
@@ -33,26 +33,30 @@ class UpdateAppointmentStatus extends Command
|
||||
$now = time();
|
||||
$table = (new Appointment())->getTable();
|
||||
|
||||
// 1. 超过8小时 -> 已取消(status=2)
|
||||
$cancelWhere = "status = 1 AND CONCAT(appointment_date, ' ', IFNULL(appointment_time, '00:00:00')) <= DATE_SUB(NOW(), INTERVAL 8 HOUR)";
|
||||
$cancelSql = "UPDATE {$table} SET status = 2, update_time = ? WHERE {$cancelWhere}";
|
||||
$cancelCount = $dryRun ? 0 : Db::execute($cancelSql, [$now]);
|
||||
$dtExpr = "CONCAT(appointment_date, ' ', IFNULL(appointment_time, '00:00:00'))";
|
||||
|
||||
// 2. 已过预约时间超过 35 分钟且未满 8 小时 -> 已过号(status=4)
|
||||
$missedWhere = "status = 1 AND CONCAT(appointment_date, ' ', IFNULL(appointment_time, '00:00:00')) < DATE_SUB(NOW(), INTERVAL 35 MINUTE) AND CONCAT(appointment_date, ' ', IFNULL(appointment_time, '00:00:00')) > DATE_SUB(NOW(), INTERVAL 8 HOUR)";
|
||||
// 1. 已预约 → 已过号:预约时间早于「当前 − 35 分钟」
|
||||
$missedWhere = "status = 1 AND {$dtExpr} < DATE_SUB(NOW(), INTERVAL 35 MINUTE)";
|
||||
$missedSql = "UPDATE {$table} SET status = 4, update_time = ? WHERE {$missedWhere}";
|
||||
$missedCount = $dryRun ? 0 : Db::execute($missedSql, [$now]);
|
||||
|
||||
// 2. 已过号 → 已取消:预约时间早于或等于「当前 − 8 小时」
|
||||
$cancelWhere = "status = 4 AND {$dtExpr} <= DATE_SUB(NOW(), INTERVAL 8 HOUR)";
|
||||
$cancelSql = "UPDATE {$table} SET status = 2, update_time = ? WHERE {$cancelWhere}";
|
||||
|
||||
if ($dryRun) {
|
||||
$cancelPreview = Db::query("SELECT id FROM {$table} WHERE {$cancelWhere}");
|
||||
$missedPreview = Db::query("SELECT id FROM {$table} WHERE {$missedWhere}");
|
||||
$output->writeln('[预览] 将改为已取消: ' . count($cancelPreview) . ' 条');
|
||||
$output->writeln('[预览] 将改为已过号: ' . count($missedPreview) . ' 条');
|
||||
$cancelPreview = Db::query("SELECT id FROM {$table} WHERE {$cancelWhere}");
|
||||
$output->writeln('[预览] 将改为已过号(1→4): ' . count($missedPreview) . ' 条');
|
||||
$output->writeln('[预览] 将改为已取消(4→2,过号超8小时): ' . count($cancelPreview) . ' 条');
|
||||
$output->writeln('[说明] 真实执行时先执行 1→4,再执行 4→2;同一次内刚由 1 变 4 且已超 8 小时的会再被改为 2。');
|
||||
return;
|
||||
}
|
||||
|
||||
$output->writeln("已取消(超8小时): {$cancelCount} 条");
|
||||
$output->writeln("已过号: {$missedCount} 条");
|
||||
Log::info("update_appointment_status: 已取消 {$cancelCount}, 已过号 {$missedCount}");
|
||||
$missedCount = Db::execute($missedSql, [$now]);
|
||||
$cancelCount = Db::execute($cancelSql, [$now]);
|
||||
|
||||
$output->writeln("已过号(1→4): {$missedCount} 条");
|
||||
$output->writeln("已取消(4→2,过号超8小时): {$cancelCount} 条");
|
||||
Log::info("update_appointment_status: 已过号 {$missedCount}, 已取消 {$cancelCount}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
/**
|
||||
* 物流查询日志模型
|
||||
*/
|
||||
class ExpressQueryLog extends BaseModel
|
||||
{
|
||||
protected $name = 'express_query_log';
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
/**
|
||||
* 物流状态变更记录模型
|
||||
*/
|
||||
class ExpressStateLog extends BaseModel
|
||||
{
|
||||
protected $name = 'express_state_log';
|
||||
|
||||
/**
|
||||
* 关联主表
|
||||
*/
|
||||
public function tracking()
|
||||
{
|
||||
return $this->belongsTo(ExpressTracking::class, 'tracking_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
/**
|
||||
* 物流轨迹明细模型
|
||||
*/
|
||||
class ExpressTrace extends BaseModel
|
||||
{
|
||||
protected $name = 'express_trace';
|
||||
|
||||
/**
|
||||
* 关联主表
|
||||
*/
|
||||
public function tracking()
|
||||
{
|
||||
return $this->belongsTo(ExpressTracking::class, 'tracking_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 物流追踪模型
|
||||
*/
|
||||
class ExpressTracking extends BaseModel
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
protected $name = 'express_tracking';
|
||||
protected $deleteTime = 'delete_time';
|
||||
|
||||
// 物流状态常量
|
||||
public const STATE_IN_TRANSIT = '0'; // 在途
|
||||
public const STATE_COLLECTED = '1'; // 揽收
|
||||
public const STATE_PROBLEM = '2'; // 疑难
|
||||
public const STATE_SIGNED = '3'; // 签收
|
||||
public const STATE_RETURN_SIGNED = '4'; // 退签
|
||||
public const STATE_DELIVERING = '5'; // 派件
|
||||
public const STATE_RETURNING = '6'; // 退回
|
||||
public const STATE_FORWARDING = '7'; // 转投
|
||||
public const STATE_CUSTOMS = '8'; // 清关
|
||||
public const STATE_WAIT_CUSTOMS = '10'; // 待清关
|
||||
public const STATE_IN_CUSTOMS = '11'; // 清关中
|
||||
public const STATE_CUSTOMS_DONE = '12'; // 已清关
|
||||
public const STATE_CUSTOMS_ERROR = '13'; // 清关异常
|
||||
public const STATE_REJECTED = '14'; // 拒签
|
||||
|
||||
/**
|
||||
* 获取状态文本
|
||||
*/
|
||||
public static function getStateText(string $state): string
|
||||
{
|
||||
$map = [
|
||||
self::STATE_IN_TRANSIT => '在途',
|
||||
self::STATE_COLLECTED => '揽收',
|
||||
self::STATE_PROBLEM => '疑难',
|
||||
self::STATE_SIGNED => '已签收',
|
||||
self::STATE_RETURN_SIGNED => '退签',
|
||||
self::STATE_DELIVERING => '派件中',
|
||||
self::STATE_RETURNING => '退回',
|
||||
self::STATE_FORWARDING => '转投',
|
||||
self::STATE_CUSTOMS => '清关',
|
||||
self::STATE_WAIT_CUSTOMS => '待清关',
|
||||
self::STATE_IN_CUSTOMS => '清关中',
|
||||
self::STATE_CUSTOMS_DONE => '已清关',
|
||||
self::STATE_CUSTOMS_ERROR => '清关异常',
|
||||
self::STATE_REJECTED => '拒签',
|
||||
];
|
||||
|
||||
return $map[$state] ?? '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为终态(不再需要更新)
|
||||
*/
|
||||
public static function isFinalState(string $state): bool
|
||||
{
|
||||
return in_array($state, [
|
||||
self::STATE_SIGNED,
|
||||
self::STATE_RETURN_SIGNED,
|
||||
self::STATE_REJECTED,
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为异常状态
|
||||
*/
|
||||
public static function isProblemState(string $state): bool
|
||||
{
|
||||
return in_array($state, [
|
||||
self::STATE_PROBLEM,
|
||||
self::STATE_CUSTOMS_ERROR,
|
||||
self::STATE_REJECTED,
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联轨迹明细
|
||||
*/
|
||||
public function traces()
|
||||
{
|
||||
return $this->hasMany(ExpressTrace::class, 'tracking_id', 'id')
|
||||
->order('trace_time_stamp', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联状态变更记录
|
||||
*/
|
||||
public function stateLogs()
|
||||
{
|
||||
return $this->hasMany(ExpressStateLog::class, 'tracking_id', 'id')
|
||||
->order('change_time', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 消费者处方业务订单(非支付单 zyt_order)
|
||||
*/
|
||||
class PrescriptionOrder extends BaseModel
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
protected $name = 'tcm_prescription_order';
|
||||
|
||||
protected $deleteTime = 'delete_time';
|
||||
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
protected $createTime = 'create_time';
|
||||
|
||||
protected $updateTime = 'update_time';
|
||||
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 处方业务订单与支付单(zyt_order)关联
|
||||
*/
|
||||
class PrescriptionOrderPayOrder extends BaseModel
|
||||
{
|
||||
protected $name = 'tcm_prescription_order_pay_order';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 快递轨迹:顺丰(shunfeng)、京东(jd),优先走快递100;未配置时返回官网查询链接
|
||||
*/
|
||||
class ExpressTrackService
|
||||
{
|
||||
private const KUAIDI_COM_SF = 'shunfeng';
|
||||
|
||||
private const KUAIDI_COM_JD = 'jingdong'; // 京东快递(快递100编码)
|
||||
|
||||
private const KUAIDI_COM_JT = 'jtexpress'; // 极兔速递
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* carrier: string,
|
||||
* carrier_label: string,
|
||||
* kuaidi_com: string,
|
||||
* traces: list<array{time:string,context:string}>,
|
||||
* state: string,
|
||||
* state_text: string,
|
||||
* source: string,
|
||||
* hint: string,
|
||||
* official_url: string
|
||||
* }
|
||||
*/
|
||||
public static function query(string $expressCompany, string $trackingNumber, string $recipientPhone = ''): array
|
||||
{
|
||||
$num = trim($trackingNumber);
|
||||
$phoneDigits = preg_replace('/\D/', '', $recipientPhone) ?? '';
|
||||
$phoneLast4 = strlen($phoneDigits) >= 4 ? substr($phoneDigits, -4) : '';
|
||||
|
||||
$resolved = self::resolveCarrier($expressCompany, $num);
|
||||
$carrier = $resolved['carrier'];
|
||||
$kuaidiCom = $resolved['kuaidi_com'];
|
||||
$label = $resolved['label'];
|
||||
|
||||
$officialUrl = self::buildOfficialUrl($carrier, $num);
|
||||
|
||||
$out = [
|
||||
'carrier' => $carrier,
|
||||
'carrier_label' => $label,
|
||||
'kuaidi_com' => $kuaidiCom,
|
||||
'traces' => [],
|
||||
'state' => '',
|
||||
'state_text' => '',
|
||||
'source' => 'official_only',
|
||||
'hint' => '',
|
||||
'official_url' => $officialUrl,
|
||||
];
|
||||
|
||||
$cfg = Config::get('logistics.kuaidi100', []);
|
||||
$enable = !empty($cfg['enable']);
|
||||
if (! $enable) {
|
||||
$out['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$paramArr = [
|
||||
'com' => $kuaidiCom,
|
||||
'num' => $num,
|
||||
'resultv2' => '1',
|
||||
];
|
||||
if ($phoneLast4 !== '') {
|
||||
$paramArr['phone'] = $phoneLast4;
|
||||
}
|
||||
|
||||
$paramJson = json_encode($paramArr, JSON_UNESCAPED_UNICODE);
|
||||
$customer = (string) $cfg['customer'];
|
||||
$key = (string) $cfg['key'];
|
||||
$sign = strtoupper(md5($paramJson . $key . $customer));
|
||||
$postBody = http_build_query([
|
||||
'customer' => $customer,
|
||||
'param' => $paramJson,
|
||||
'sign' => $sign,
|
||||
]);
|
||||
|
||||
$url = (string) ($cfg['query_url'] ?? 'https://poll.kuaidi100.com/poll/query.do');
|
||||
$raw = self::httpPostForm($url, $postBody);
|
||||
if ($raw === null || $raw === '') {
|
||||
$out['hint'] = '快递100接口无响应,请稍后重试或使用官网查询';
|
||||
Log::warning('ExpressTrackService kuaidi100 empty response', ['num' => $num]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$json = json_decode($raw, true);
|
||||
if (!is_array($json)) {
|
||||
$out['hint'] = '快递100返回异常,请使用官网查询';
|
||||
Log::warning('ExpressTrackService kuaidi100 invalid json', ['raw' => mb_substr($raw, 0, 500)]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
if (isset($json['result']) && $json['result'] === false) {
|
||||
$msg = (string) ($json['message'] ?? '查询失败');
|
||||
$returnCode = (string) ($json['returnCode'] ?? '');
|
||||
|
||||
// 特殊处理"找不到对应公司"错误
|
||||
if ($msg === '找不到对应公司' || $returnCode === '400') {
|
||||
$out['hint'] = '快递100暂不支持该快递公司或编码错误,请使用下方官网链接查询';
|
||||
} else {
|
||||
$out['hint'] = $msg;
|
||||
}
|
||||
|
||||
Log::info('ExpressTrackService kuaidi100 business fail', [
|
||||
'message' => $msg,
|
||||
'returnCode' => $returnCode,
|
||||
'num' => $num,
|
||||
'com' => $kuaidiCom
|
||||
]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$data = $json['data'] ?? null;
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
if (($json['message'] ?? '') !== 'ok' && $data === []) {
|
||||
$out['hint'] = (string) ($json['message'] ?? '未查到轨迹');
|
||||
Log::info('ExpressTrackService kuaidi100 no data', ['json' => $json]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$traces = [];
|
||||
foreach ($data as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$t = (string) ($row['ftime'] ?? $row['time'] ?? '');
|
||||
$c = (string) ($row['context'] ?? '');
|
||||
if ($t === '' && $c === '') {
|
||||
continue;
|
||||
}
|
||||
$traces[] = ['time' => $t, 'context' => $c];
|
||||
}
|
||||
|
||||
$out['traces'] = $traces;
|
||||
$out['state'] = (string) ($json['state'] ?? '');
|
||||
$out['state_text'] = self::stateText($out['state']);
|
||||
$out['source'] = 'kuaidi100';
|
||||
$out['hint'] = $traces === [] ? '暂无轨迹节点,单号可能尚未揽收' : '';
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}
|
||||
*/
|
||||
private static function resolveCarrier(string $expressCompany, string $num): array
|
||||
{
|
||||
$ec = strtolower(trim($expressCompany));
|
||||
if ($ec === 'sf') {
|
||||
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运'];
|
||||
}
|
||||
if ($ec === 'jd') {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递'];
|
||||
}
|
||||
if ($ec === 'jt' || $ec === 'jtexpress') {
|
||||
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递'];
|
||||
}
|
||||
|
||||
$u = strtoupper($num);
|
||||
if (preg_match('/^SF\d/i', $num)) {
|
||||
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^(JD|JDV|JDK|JDEX|JD[A-Z0-9])/i', $u)) {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^JT\d{13}$/i', $num)) {
|
||||
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递(单号识别)'];
|
||||
}
|
||||
|
||||
return ['carrier' => 'auto', 'kuaidi_com' => 'auto', 'label' => '自动识别'];
|
||||
}
|
||||
|
||||
private static function stateText(string $state): string
|
||||
{
|
||||
$m = [
|
||||
'0' => '在途',
|
||||
'1' => '揽收',
|
||||
'2' => '疑难',
|
||||
'3' => '已签收',
|
||||
'4' => '退签',
|
||||
'5' => '派件中',
|
||||
'6' => '退回',
|
||||
'7' => '转投',
|
||||
'10' => '待清关',
|
||||
'11' => '清关中',
|
||||
'12' => '已清关',
|
||||
'13' => '清关异常',
|
||||
'14' => '收件人拒签',
|
||||
];
|
||||
|
||||
return $m[$state] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sf: string, jd: string, jt: string}
|
||||
*/
|
||||
public static function officialUrls(string $trackingNumber): array
|
||||
{
|
||||
$n = trim($trackingNumber);
|
||||
$enc = rawurlencode($n);
|
||||
|
||||
return [
|
||||
// 顺丰速运官网查询(新版)
|
||||
'sf' => 'https://www.sf-express.com/cn/sc/dynamic_function/waybill/#search/bill-number/' . $enc,
|
||||
// 京东物流官网查询
|
||||
'jd' => 'https://www.jdl.com/#/trackQuery?waybillCode=' . $enc,
|
||||
// 极兔速递官网查询
|
||||
'jt' => 'https://www.jtexpress.com.cn/index/query/gzquery.html?bills=' . $enc,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildOfficialUrl(string $carrier, string $num): string
|
||||
{
|
||||
$urls = self::officialUrls($num);
|
||||
if ($carrier === 'sf') {
|
||||
return $urls['sf'];
|
||||
}
|
||||
if ($carrier === 'jd') {
|
||||
return $urls['jd'];
|
||||
}
|
||||
if ($carrier === 'jt') {
|
||||
return $urls['jt'];
|
||||
}
|
||||
|
||||
return $urls['jt']; // 默认返回极兔
|
||||
}
|
||||
|
||||
private static function httpPostForm(string $url, string $body): ?string
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return null;
|
||||
}
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
$resp = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return $resp === false ? null : (string) $resp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\model\ExpressTracking;
|
||||
use app\common\model\ExpressTrace;
|
||||
use app\common\model\ExpressStateLog;
|
||||
use app\common\model\ExpressQueryLog;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 物流追踪服务(存储和自动更新)
|
||||
*/
|
||||
class ExpressTrackingService
|
||||
{
|
||||
/**
|
||||
* 创建或更新物流追踪记录
|
||||
*
|
||||
* @param array $params [
|
||||
* 'order_id' => 订单ID,
|
||||
* 'order_type' => 订单类型,
|
||||
* 'tracking_number' => 快递单号,
|
||||
* 'express_company' => 快递公司,
|
||||
* 'recipient_phone' => 收件人手机,
|
||||
* 'recipient_name' => 收件人姓名,
|
||||
* 'recipient_address' => 收件地址,
|
||||
* ]
|
||||
* @return ExpressTracking|null
|
||||
*/
|
||||
public static function createOrUpdate(array $params): ?ExpressTracking
|
||||
{
|
||||
$trackingNumber = trim($params['tracking_number'] ?? '');
|
||||
if ($trackingNumber === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tracking = ExpressTracking::where('tracking_number', $trackingNumber)
|
||||
->whereNull('delete_time')
|
||||
->find();
|
||||
|
||||
$now = time();
|
||||
|
||||
if (!$tracking) {
|
||||
$tracking = new ExpressTracking();
|
||||
$tracking->tracking_number = $trackingNumber;
|
||||
$tracking->create_time = $now;
|
||||
}
|
||||
|
||||
$tracking->order_id = (int) ($params['order_id'] ?? 0);
|
||||
$tracking->order_type = (string) ($params['order_type'] ?? 'prescription');
|
||||
$tracking->express_company = (string) ($params['express_company'] ?? 'auto');
|
||||
$tracking->recipient_phone = (string) ($params['recipient_phone'] ?? '');
|
||||
$tracking->recipient_name = (string) ($params['recipient_name'] ?? '');
|
||||
$tracking->recipient_address = (string) ($params['recipient_address'] ?? '');
|
||||
$tracking->update_time = $now;
|
||||
|
||||
// 设置下次更新时间(立即查询)
|
||||
if ($tracking->next_update_time === 0) {
|
||||
$tracking->next_update_time = $now;
|
||||
}
|
||||
|
||||
$tracking->save();
|
||||
|
||||
// 立即查询一次物流信息
|
||||
self::queryAndUpdate($tracking->id);
|
||||
|
||||
return $tracking;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询并更新物流信息
|
||||
*
|
||||
* @param int $trackingId
|
||||
* @param bool $isAuto 是否自动查询
|
||||
* @return array|null
|
||||
*/
|
||||
public static function queryAndUpdate(int $trackingId, bool $isAuto = false): ?array
|
||||
{
|
||||
$tracking = ExpressTracking::where('id', $trackingId)
|
||||
->whereNull('delete_time')
|
||||
->find();
|
||||
|
||||
if (!$tracking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// 调用快递100查询
|
||||
$result = ExpressTrackService::query(
|
||||
$tracking->express_company,
|
||||
$tracking->tracking_number,
|
||||
$tracking->recipient_phone
|
||||
);
|
||||
|
||||
$responseTime = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
// 记录查询日志
|
||||
self::logQuery(
|
||||
$tracking->tracking_number,
|
||||
$tracking->express_company,
|
||||
$isAuto ? 'auto' : 'manual',
|
||||
$result,
|
||||
$responseTime
|
||||
);
|
||||
|
||||
// 更新追踪记录
|
||||
$now = time();
|
||||
$oldState = $tracking->current_state;
|
||||
|
||||
$tracking->express_company_name = $result['carrier_label'] ?? '';
|
||||
$tracking->current_state = $result['state'] ?? '0';
|
||||
$tracking->current_state_text = $result['state_text'] ?? '';
|
||||
$tracking->data_source = $result['source'] ?? '';
|
||||
$tracking->last_query_time = $now;
|
||||
$tracking->query_count = $tracking->query_count + 1;
|
||||
|
||||
// 更新预计信息
|
||||
if (!empty($result['arrivalTime'])) {
|
||||
$tracking->estimated_arrival_time = $result['arrivalTime'];
|
||||
}
|
||||
if (!empty($result['totalTime'])) {
|
||||
$tracking->total_time = $result['totalTime'];
|
||||
}
|
||||
if (!empty($result['remainTime'])) {
|
||||
$tracking->remain_time = $result['remainTime'];
|
||||
}
|
||||
|
||||
// 更新路由信息
|
||||
if (!empty($result['routeInfo'])) {
|
||||
$tracking->route_info = json_encode($result['routeInfo'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
// 更新最新轨迹
|
||||
if (!empty($result['traces']) && is_array($result['traces'])) {
|
||||
$latestTrace = $result['traces'][0];
|
||||
$tracking->latest_trace_time = $latestTrace['time'] ?? '';
|
||||
$tracking->latest_trace_context = $latestTrace['context'] ?? '';
|
||||
$tracking->latest_location = $latestTrace['location'] ?? '';
|
||||
|
||||
// 保存轨迹明细
|
||||
self::saveTraces($tracking->id, $tracking->tracking_number, $result['traces']);
|
||||
}
|
||||
|
||||
// 检查是否签收
|
||||
if (ExpressTracking::isFinalState($tracking->current_state)) {
|
||||
$tracking->is_signed = 1;
|
||||
$tracking->sign_time = $now;
|
||||
$tracking->auto_update = 0; // 停止自动更新
|
||||
}
|
||||
|
||||
// 计算下次更新时间
|
||||
if ($tracking->auto_update == 1 && !ExpressTracking::isFinalState($tracking->current_state)) {
|
||||
$tracking->next_update_time = $now + $tracking->update_interval;
|
||||
}
|
||||
|
||||
$tracking->update_time = $now;
|
||||
$tracking->save();
|
||||
|
||||
// 记录状态变更
|
||||
if ($oldState !== $tracking->current_state) {
|
||||
self::logStateChange($tracking, $oldState);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存轨迹明细
|
||||
*/
|
||||
private static function saveTraces(int $trackingId, string $trackingNumber, array $traces): void
|
||||
{
|
||||
foreach ($traces as $trace) {
|
||||
$traceTime = $trace['time'] ?? $trace['ftime'] ?? '';
|
||||
if ($traceTime === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
$exists = ExpressTrace::where('tracking_id', $trackingId)
|
||||
->where('trace_time', $traceTime)
|
||||
->where('trace_context', $trace['context'] ?? '')
|
||||
->count();
|
||||
|
||||
if ($exists > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$model = new ExpressTrace();
|
||||
$model->tracking_id = $trackingId;
|
||||
$model->tracking_number = $trackingNumber;
|
||||
$model->trace_time = $traceTime;
|
||||
$model->trace_time_stamp = strtotime($traceTime) ?: time();
|
||||
$model->trace_context = $trace['context'] ?? '';
|
||||
$model->status = $trace['status'] ?? '';
|
||||
$model->status_code = $trace['statusCode'] ?? '';
|
||||
$model->location = $trace['location'] ?? '';
|
||||
$model->area_code = $trace['areaCode'] ?? '';
|
||||
$model->area_name = $trace['areaName'] ?? '';
|
||||
$model->area_center = $trace['areaCenter'] ?? '';
|
||||
$model->extra_data = json_encode($trace, JSON_UNESCAPED_UNICODE);
|
||||
$model->create_time = time();
|
||||
$model->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录状态变更
|
||||
*/
|
||||
private static function logStateChange(ExpressTracking $tracking, string $oldState): void
|
||||
{
|
||||
$log = new ExpressStateLog();
|
||||
$log->tracking_id = $tracking->id;
|
||||
$log->tracking_number = $tracking->tracking_number;
|
||||
$log->old_state = $oldState;
|
||||
$log->old_state_text = ExpressTracking::getStateText($oldState);
|
||||
$log->new_state = $tracking->current_state;
|
||||
$log->new_state_text = $tracking->current_state_text;
|
||||
$log->change_time = time();
|
||||
$log->change_reason = $tracking->latest_trace_context;
|
||||
$log->create_time = time();
|
||||
$log->save();
|
||||
|
||||
// 检查是否需要通知
|
||||
$shouldNotify = false;
|
||||
|
||||
// 签收通知
|
||||
if ($tracking->notify_on_sign == 1 && ExpressTracking::isFinalState($tracking->current_state)) {
|
||||
$shouldNotify = true;
|
||||
}
|
||||
|
||||
// 异常通知
|
||||
if ($tracking->notify_on_problem == 1 && ExpressTracking::isProblemState($tracking->current_state)) {
|
||||
$shouldNotify = true;
|
||||
}
|
||||
|
||||
if ($shouldNotify) {
|
||||
self::sendNotification($tracking, $log);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送通知(可扩展对接企业微信、短信等)
|
||||
*/
|
||||
private static function sendNotification(ExpressTracking $tracking, ExpressStateLog $log): void
|
||||
{
|
||||
// TODO: 对接通知渠道
|
||||
// 1. 企业微信消息
|
||||
// 2. 短信通知
|
||||
// 3. 系统内通知
|
||||
|
||||
Log::info('Express state changed notification', [
|
||||
'tracking_number' => $tracking->tracking_number,
|
||||
'old_state' => $log->old_state_text,
|
||||
'new_state' => $log->new_state_text,
|
||||
'context' => $log->change_reason,
|
||||
]);
|
||||
|
||||
$log->is_notified = 1;
|
||||
$log->notify_time = time();
|
||||
$log->notify_result = 'logged';
|
||||
$log->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录查询日志
|
||||
*/
|
||||
private static function logQuery(
|
||||
string $trackingNumber,
|
||||
string $expressCompany,
|
||||
string $queryType,
|
||||
array $result,
|
||||
int $responseTime
|
||||
): void {
|
||||
$log = new ExpressQueryLog();
|
||||
$log->tracking_number = $trackingNumber;
|
||||
$log->express_company = $expressCompany;
|
||||
$log->query_type = $queryType;
|
||||
$log->query_source = $result['source'] ?? '';
|
||||
$log->query_time = time();
|
||||
$log->is_success = !empty($result['traces']) ? 1 : 0;
|
||||
$log->error_code = $result['hint'] ? '400' : '';
|
||||
$log->error_message = $result['hint'] ?? '';
|
||||
$log->response_time = $responseTime;
|
||||
$log->trace_count = count($result['traces'] ?? []);
|
||||
$log->create_time = time();
|
||||
$log->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量自动更新(定时任务调用)
|
||||
*
|
||||
* @param int $limit 每次处理数量
|
||||
* @return array
|
||||
*/
|
||||
public static function autoUpdateBatch(int $limit = 50): array
|
||||
{
|
||||
$now = time();
|
||||
|
||||
// 查询需要更新的记录
|
||||
// 条件:
|
||||
// 1. auto_update = 1(启用自动更新)
|
||||
// 2. next_update_time <= now(到达更新时间)
|
||||
// 3. is_signed = 0(未签收)
|
||||
// 4. current_state 不是终态(排除已签收、退签、拒签)
|
||||
$list = ExpressTracking::where('auto_update', 1)
|
||||
->where('next_update_time', '<=', $now)
|
||||
->where('is_signed', 0)
|
||||
->whereNotIn('current_state', [
|
||||
ExpressTracking::STATE_SIGNED,
|
||||
ExpressTracking::STATE_RETURN_SIGNED,
|
||||
ExpressTracking::STATE_REJECTED,
|
||||
])
|
||||
->whereNull('delete_time')
|
||||
->limit($limit)
|
||||
->select();
|
||||
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($list as $tracking) {
|
||||
try {
|
||||
self::queryAndUpdate($tracking->id, true);
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
Log::error('Auto update express tracking failed', [
|
||||
'tracking_id' => $tracking->id,
|
||||
'tracking_number' => $tracking->tracking_number,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => count($list),
|
||||
'success' => $success,
|
||||
'failed' => $failed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取追踪详情(包含轨迹)
|
||||
*/
|
||||
public static function getDetail(int $trackingId): ?array
|
||||
{
|
||||
$tracking = ExpressTracking::with(['traces', 'stateLogs'])
|
||||
->where('id', $trackingId)
|
||||
->whereNull('delete_time')
|
||||
->find();
|
||||
|
||||
if (!$tracking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $tracking->toArray();
|
||||
$data['traces'] = $tracking->traces ? $tracking->traces->toArray() : [];
|
||||
$data['state_logs'] = $tracking->stateLogs ? $tracking->stateLogs->toArray() : [];
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据快递单号获取追踪详情(包含轨迹)
|
||||
*/
|
||||
public static function getDetailByTrackingNumber(string $trackingNumber): ?array
|
||||
{
|
||||
$tracking = ExpressTracking::with(['traces' => function($query) {
|
||||
// 按时间倒序排列(最新的在前)
|
||||
$query->order('trace_time_stamp', 'desc');
|
||||
}])
|
||||
->where('tracking_number', $trackingNumber)
|
||||
->whereNull('delete_time')
|
||||
->find();
|
||||
|
||||
if (!$tracking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $tracking->toArray();
|
||||
$data['traces'] = $tracking->traces ? $tracking->traces->toArray() : [];
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user