feat: add kuaidi100 logistics sync
This commit is contained in:
@@ -17,6 +17,54 @@ class ExpressCompaniesController
|
||||
]);
|
||||
}
|
||||
|
||||
public function catalog(Request $request)
|
||||
{
|
||||
$keyword = trim((string)$request->input('keyword', ''));
|
||||
$limit = max(1, min(100, (int)$request->input('limit', 30)));
|
||||
|
||||
return api_success([
|
||||
'list' => $this->service()->catalogList($keyword, $limit),
|
||||
'total' => $this->service()->catalogTotal(),
|
||||
'synced_at' => $this->service()->catalogSyncedAt(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function syncCatalog(Request $request)
|
||||
{
|
||||
try {
|
||||
$result = $this->service()->syncCatalog();
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('快递100公司码表同步失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success($result, '快递100公司码表已同步');
|
||||
}
|
||||
|
||||
public function recognize(Request $request)
|
||||
{
|
||||
$trackingNo = trim((string)$request->input('tracking_no', ''));
|
||||
$companyName = trim((string)$request->input('company_name', $request->input('company_code', '')));
|
||||
if ($trackingNo === '') {
|
||||
return api_error('运单号不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->service()->recognizeCompany($companyName, $trackingNo);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('快递公司识别失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success($result);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$id = (int)$request->input('id', 0);
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\support\AppraisalEvidenceService;
|
||||
use app\support\MessageDispatcher;
|
||||
use app\support\EnterpriseWebhookService;
|
||||
use app\support\MessageDispatcher;
|
||||
use app\support\OrderLogisticsSyncService;
|
||||
use app\support\WarehouseService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
@@ -292,6 +293,17 @@ class OrdersController
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$syncService = new OrderLogisticsSyncService();
|
||||
$sendSyncStatus = $sendLogistics ? $syncService->formatSyncStatus((int)$sendLogistics['id']) : [
|
||||
'provider_status_text' => '',
|
||||
'sync_status_text' => '未同步',
|
||||
'sync_error' => '',
|
||||
];
|
||||
$returnSyncStatus = $returnLogistics ? $syncService->formatSyncStatus((int)$returnLogistics['id']) : [
|
||||
'provider_status_text' => '',
|
||||
'sync_status_text' => '未同步',
|
||||
'sync_error' => '',
|
||||
];
|
||||
|
||||
return api_success([
|
||||
'order_info' => [
|
||||
@@ -376,23 +388,14 @@ class OrdersController
|
||||
'tracking_no' => $sendLogistics['tracking_no'],
|
||||
'tracking_status' => $sendLogistics['tracking_status'],
|
||||
'tracking_status_text' => $this->trackingStatusText($sendLogistics['tracking_status'], 'send_to_center'),
|
||||
'latest_desc' => $this->formatAdminLogisticsDesc(
|
||||
'send_to_center',
|
||||
$sendLogistics['tracking_status'],
|
||||
$sendLogistics['express_company'],
|
||||
$sendLogistics['tracking_no'],
|
||||
$sendLogistics['latest_desc']
|
||||
),
|
||||
'provider_status_text' => $sendSyncStatus['provider_status_text'],
|
||||
'sync_status_text' => $sendSyncStatus['sync_status_text'],
|
||||
'sync_error' => $sendSyncStatus['sync_error'],
|
||||
'latest_desc' => (string)($sendLogistics['latest_desc'] ?? ''),
|
||||
'latest_time' => $sendLogistics['latest_time'],
|
||||
'nodes' => array_map(fn (array $item) => [
|
||||
'node_time' => $item['node_time'],
|
||||
'node_desc' => $this->formatAdminLogisticsDesc(
|
||||
'send_to_center',
|
||||
$sendLogistics['tracking_status'],
|
||||
$sendLogistics['express_company'],
|
||||
$sendLogistics['tracking_no'],
|
||||
$item['node_desc']
|
||||
),
|
||||
'node_desc' => $item['node_desc'],
|
||||
'node_location' => $item['node_location'],
|
||||
], $logisticsNodes),
|
||||
] : null,
|
||||
@@ -402,23 +405,14 @@ class OrdersController
|
||||
'tracking_no' => $returnLogistics['tracking_no'],
|
||||
'tracking_status' => $returnLogistics['tracking_status'],
|
||||
'tracking_status_text' => $this->trackingStatusText($returnLogistics['tracking_status'], 'return_to_user'),
|
||||
'latest_desc' => $this->formatAdminLogisticsDesc(
|
||||
'return_to_user',
|
||||
$returnLogistics['tracking_status'],
|
||||
$returnLogistics['express_company'],
|
||||
$returnLogistics['tracking_no'],
|
||||
$returnLogistics['latest_desc']
|
||||
),
|
||||
'provider_status_text' => $returnSyncStatus['provider_status_text'],
|
||||
'sync_status_text' => $returnSyncStatus['sync_status_text'],
|
||||
'sync_error' => $returnSyncStatus['sync_error'],
|
||||
'latest_desc' => (string)($returnLogistics['latest_desc'] ?? ''),
|
||||
'latest_time' => $returnLogistics['latest_time'],
|
||||
'nodes' => array_map(fn (array $item) => [
|
||||
'node_time' => $item['node_time'],
|
||||
'node_desc' => $this->formatAdminLogisticsDesc(
|
||||
'return_to_user',
|
||||
$returnLogistics['tracking_status'],
|
||||
$returnLogistics['express_company'],
|
||||
$returnLogistics['tracking_no'],
|
||||
$item['node_desc']
|
||||
),
|
||||
'node_desc' => $item['node_desc'],
|
||||
'node_location' => $item['node_location'],
|
||||
], $returnLogisticsNodes),
|
||||
] : null,
|
||||
@@ -833,6 +827,7 @@ class OrdersController
|
||||
'tracking_no' => $trackingNo,
|
||||
'shipped_at' => $now,
|
||||
]);
|
||||
(new OrderLogisticsSyncService())->subscribeAsync($logisticsId);
|
||||
|
||||
return api_success([
|
||||
'id' => $id,
|
||||
|
||||
@@ -448,6 +448,30 @@ class SystemConfigsController
|
||||
['config_key' => 'endpoint', 'title' => '短信 Endpoint', 'field_type' => 'text', 'placeholder' => '默认可留空', 'remark' => '如不填写则按 SDK 默认规则解析', 'is_secret' => false],
|
||||
],
|
||||
],
|
||||
'kuaidi100' => [
|
||||
'group_name' => '快递100',
|
||||
'group_desc' => '配置快递100实时查询与物流订阅推送,用于订单寄送和回寄物流轨迹同步。',
|
||||
'items' => [
|
||||
[
|
||||
'config_key' => 'enabled',
|
||||
'title' => '同步开关',
|
||||
'field_type' => 'select',
|
||||
'placeholder' => '请选择是否启用',
|
||||
'remark' => '启用后,新提交的运单会尝试订阅快递100推送,后台进程会定时补查轨迹。',
|
||||
'is_secret' => false,
|
||||
'default_value' => 'disabled',
|
||||
'options' => [
|
||||
['label' => '停用', 'value' => 'disabled'],
|
||||
['label' => '启用', 'value' => 'enabled'],
|
||||
],
|
||||
],
|
||||
['config_key' => 'customer', 'title' => 'Customer', 'field_type' => 'text', 'placeholder' => '请输入快递100 Customer', 'remark' => '实时查询接口签名使用的 Customer。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'key', 'title' => 'Key', 'field_type' => 'password', 'placeholder' => '请输入快递100 Key', 'remark' => '用于实时查询签名和订阅推送。请妥善保管。', 'is_secret' => true, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'callback_url', 'title' => '推送回调地址', 'field_type' => 'text', 'placeholder' => '例如 https://api.example.com/api/open/kuaidi100/callback', 'remark' => '需公网可访问;生产建议填本系统 /api/open/kuaidi100/callback 的完整地址。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'callback_salt', 'title' => '回调 Salt', 'field_type' => 'password', 'placeholder' => '可选,需与快递100订阅参数保持一致', 'remark' => '用于快递100推送签名增强;如账号未配置可留空。', 'is_secret' => true, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
['config_key' => 'query_min_interval_minutes', 'title' => '最小查询间隔(分钟)', 'field_type' => 'text', 'placeholder' => '默认 30', 'remark' => '定时补查同一运单的最小间隔,允许 5-1440。', 'is_secret' => false, 'default_value' => '30', 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -467,6 +491,7 @@ class SystemConfigsController
|
||||
{
|
||||
$driver = (new FileStorageConfigService())->normalizeDriver((string)($configValueMap['file_storage.driver'] ?? 'local'));
|
||||
if ($driver === 'local') {
|
||||
$this->validateKuaidi100Config($configValueMap);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -489,10 +514,12 @@ class SystemConfigsController
|
||||
throw new \RuntimeException('直传文件大小上限需填写 1-2048 之间的整数');
|
||||
}
|
||||
|
||||
$this->validateKuaidi100Config($configValueMap);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($driver !== 'qiniu') {
|
||||
$this->validateKuaidi100Config($configValueMap);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -513,6 +540,35 @@ class SystemConfigsController
|
||||
if ($publicBaseUrl === '' && $bucketDomain === '') {
|
||||
throw new \RuntimeException('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名');
|
||||
}
|
||||
|
||||
$this->validateKuaidi100Config($configValueMap);
|
||||
}
|
||||
|
||||
private function validateKuaidi100Config(array $configValueMap): void
|
||||
{
|
||||
$enabled = (string)($configValueMap['kuaidi100.enabled'] ?? 'disabled');
|
||||
if (!in_array($enabled, ['enabled', 'disabled'], true)) {
|
||||
throw new \RuntimeException('快递100同步开关配置无效');
|
||||
}
|
||||
if ($enabled !== 'enabled') {
|
||||
return;
|
||||
}
|
||||
|
||||
$required = [
|
||||
'kuaidi100.customer' => '快递100 Customer',
|
||||
'kuaidi100.key' => '快递100 Key',
|
||||
'kuaidi100.callback_url' => '快递100推送回调地址',
|
||||
];
|
||||
foreach ($required as $key => $label) {
|
||||
if (trim((string)($configValueMap[$key] ?? '')) === '') {
|
||||
throw new \RuntimeException(sprintf('当前已启用快递100,请先填写 %s', $label));
|
||||
}
|
||||
}
|
||||
|
||||
$interval = trim((string)($configValueMap['kuaidi100.query_min_interval_minutes'] ?? '30'));
|
||||
if ($interval !== '' && (!ctype_digit($interval) || (int)$interval < 5 || (int)$interval > 1440)) {
|
||||
throw new \RuntimeException('快递100最小查询间隔需填写 5-1440 之间的整数');
|
||||
}
|
||||
}
|
||||
|
||||
private function applyDerivedConfigValues(array &$configValueMap): void
|
||||
|
||||
@@ -7,6 +7,7 @@ use app\model\OrderProduct;
|
||||
use app\model\OrderSupplementTask;
|
||||
use app\model\OrderSupplementTaskItem;
|
||||
use app\model\OrderTimeline;
|
||||
use app\support\OrderLogisticsSyncService;
|
||||
use app\support\PublicAssetUrlService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
@@ -208,6 +209,11 @@ class OrdersController
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$returnSyncStatus = $returnLogistics ? (new OrderLogisticsSyncService())->formatSyncStatus((int)$returnLogistics['id']) : [
|
||||
'provider_status_text' => '',
|
||||
'sync_status_text' => '未同步',
|
||||
'sync_error' => '',
|
||||
];
|
||||
|
||||
return api_success([
|
||||
'order_info' => [
|
||||
@@ -261,6 +267,9 @@ class OrdersController
|
||||
'tracking_no' => $returnLogistics['tracking_no'],
|
||||
'tracking_status' => $returnLogistics['tracking_status'],
|
||||
'tracking_status_text' => $this->trackingStatusText((string)$returnLogistics['tracking_status'], 'return_to_user'),
|
||||
'provider_status_text' => $returnSyncStatus['provider_status_text'],
|
||||
'sync_status_text' => $returnSyncStatus['sync_status_text'],
|
||||
'sync_error' => $returnSyncStatus['sync_error'],
|
||||
'latest_desc' => $returnLogistics['latest_desc'],
|
||||
'latest_time' => $returnLogistics['latest_time'],
|
||||
'nodes' => array_map(fn (array $item) => [
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace app\controller\app;
|
||||
|
||||
use app\support\ExpressCompanyService;
|
||||
use app\support\OrderLogisticsSyncService;
|
||||
use app\support\WarehouseService;
|
||||
use support\Request;
|
||||
use support\think\Db;
|
||||
@@ -35,6 +37,11 @@ class ShippingController
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$syncStatus = $logistics ? (new OrderLogisticsSyncService())->formatSyncStatus((int)$logistics['id']) : [
|
||||
'provider_status_text' => '',
|
||||
'sync_status_text' => '未同步',
|
||||
'sync_error' => '',
|
||||
];
|
||||
|
||||
$warehouseService = new WarehouseService();
|
||||
$categoryId = (int)($product['category_id'] ?? 0);
|
||||
@@ -76,6 +83,9 @@ class ShippingController
|
||||
'tracking_no' => $logistics['tracking_no'] ?? '',
|
||||
'tracking_status' => $logistics['tracking_status'] ?? '',
|
||||
'tracking_status_text' => $this->trackingStatusText((string)($logistics['tracking_status'] ?? '')),
|
||||
'provider_status_text' => $syncStatus['provider_status_text'],
|
||||
'sync_status_text' => $syncStatus['sync_status_text'],
|
||||
'sync_error' => $syncStatus['sync_error'],
|
||||
'latest_desc' => $logistics['latest_desc'] ?? '',
|
||||
'latest_time' => $logistics['latest_time'] ?? '',
|
||||
'is_submitted' => $trackingSubmitted,
|
||||
@@ -89,6 +99,27 @@ class ShippingController
|
||||
]);
|
||||
}
|
||||
|
||||
public function recognize(Request $request)
|
||||
{
|
||||
$trackingNo = trim((string)$request->input('tracking_no', ''));
|
||||
$companyName = trim((string)$request->input('company_name', $request->input('company_code', '')));
|
||||
if ($trackingNo === '') {
|
||||
return api_error('运单号不能为空', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = (new ExpressCompanyService())->recognizeCompany($companyName, $trackingNo);
|
||||
} catch (\RuntimeException $e) {
|
||||
return api_error($e->getMessage(), 422);
|
||||
} catch (\Throwable $e) {
|
||||
return api_error('快递公司识别失败', 500, [
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return api_success($result);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$orderId = (int)$request->input('order_id', 0);
|
||||
@@ -257,6 +288,8 @@ class ShippingController
|
||||
]);
|
||||
}
|
||||
|
||||
(new OrderLogisticsSyncService())->subscribeAsync($logisticsId);
|
||||
|
||||
return api_success([
|
||||
'order_id' => $orderId,
|
||||
'express_company' => $expressCompany,
|
||||
|
||||
43
server-api/app/controller/open/Kuaidi100Controller.php
Normal file
43
server-api/app/controller/open/Kuaidi100Controller.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\open;
|
||||
|
||||
use app\support\OrderLogisticsSyncService;
|
||||
use support\Request;
|
||||
|
||||
class Kuaidi100Controller
|
||||
{
|
||||
public function callback(Request $request)
|
||||
{
|
||||
$payload = json_decode($request->rawBody(), true);
|
||||
if (!is_array($payload)) {
|
||||
$payload = $request->all();
|
||||
}
|
||||
if (isset($payload['param']) && is_string($payload['param'])) {
|
||||
$paramPayload = json_decode($payload['param'], true);
|
||||
if (is_array($paramPayload)) {
|
||||
$payload = $paramPayload;
|
||||
}
|
||||
}
|
||||
if (!is_array($payload)) {
|
||||
return $this->callbackResponse(false, '400', '请求体格式错误');
|
||||
}
|
||||
|
||||
try {
|
||||
(new OrderLogisticsSyncService())->handleCallback($payload);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->callbackResponse(false, '500', $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->callbackResponse(true, '200', '成功');
|
||||
}
|
||||
|
||||
private function callbackResponse(bool $result, string $returnCode, string $message)
|
||||
{
|
||||
return json([
|
||||
'result' => $result,
|
||||
'returnCode' => $returnCode,
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ class AdminAuthMiddleware implements MiddlewareInterface
|
||||
str_starts_with($path, '/api/admin/customer/') => ['customers.manage'],
|
||||
str_starts_with($path, '/api/admin/warehouse-workbench/') => ['warehouse_workbench.manage'],
|
||||
str_starts_with($path, '/api/admin/express-companies') && strtoupper($method) === 'GET' => ['warehouse_workbench.manage', 'orders.manage', 'warehouses.manage'],
|
||||
str_starts_with($path, '/api/admin/express-company/recognize') => ['warehouse_workbench.manage', 'orders.manage', 'warehouses.manage'],
|
||||
str_starts_with($path, '/api/admin/express-company/') => ['warehouses.manage'],
|
||||
str_starts_with($path, '/api/admin/warehouses'),
|
||||
str_starts_with($path, '/api/admin/warehouse/') => ['warehouses.manage'],
|
||||
|
||||
26
server-api/app/process/Kuaidi100LogisticsSyncProcess.php
Normal file
26
server-api/app/process/Kuaidi100LogisticsSyncProcess.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\support\OrderLogisticsSyncService;
|
||||
use support\Log;
|
||||
use Workerman\Timer;
|
||||
|
||||
class Kuaidi100LogisticsSyncProcess
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
Timer::add(600, [$this, 'syncDue'], [], true);
|
||||
}
|
||||
|
||||
public function syncDue(): void
|
||||
{
|
||||
try {
|
||||
(new OrderLogisticsSyncService())->syncDue(50);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('kuaidi100 logistics sync process failed', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\Log;
|
||||
use support\think\Db;
|
||||
|
||||
class ExpressCompanyService
|
||||
@@ -9,6 +10,7 @@ class ExpressCompanyService
|
||||
public function __construct()
|
||||
{
|
||||
$this->ensureTable();
|
||||
$this->ensureCatalogTable();
|
||||
$this->bootstrapDefaults();
|
||||
}
|
||||
|
||||
@@ -118,6 +120,414 @@ class ExpressCompanyService
|
||||
return trim((string)($row['company_name'] ?? ''));
|
||||
}
|
||||
|
||||
public function catalogList(string $keyword = '', int $limit = 30): array
|
||||
{
|
||||
$keyword = trim($keyword);
|
||||
$limit = max(1, min(100, $limit));
|
||||
|
||||
$this->ensureCatalogTable();
|
||||
$query = Db::name('kuaidi100_express_company_catalog')
|
||||
->order('sort_order', 'asc')
|
||||
->order('company_name', 'asc')
|
||||
->order('id', 'asc');
|
||||
|
||||
if ($keyword !== '') {
|
||||
$like = '%' . $keyword . '%';
|
||||
$query->whereRaw('(company_name LIKE :keyword OR company_code LIKE :keyword OR company_type LIKE :keyword)', [
|
||||
'keyword' => $like,
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = $query->limit($limit)->select()->toArray();
|
||||
if (!$rows) {
|
||||
$rows = $this->localCompanyFallbackList($keyword, $limit);
|
||||
}
|
||||
|
||||
return array_map(fn (array $item) => $this->formatCatalog($item), $rows);
|
||||
}
|
||||
|
||||
public function catalogTotal(): int
|
||||
{
|
||||
$this->ensureCatalogTable();
|
||||
return (int)Db::name('kuaidi100_express_company_catalog')->count();
|
||||
}
|
||||
|
||||
public function catalogSyncedAt(): string
|
||||
{
|
||||
$this->ensureCatalogTable();
|
||||
$value = Db::name('kuaidi100_express_company_catalog')
|
||||
->order('synced_at', 'desc')
|
||||
->value('synced_at');
|
||||
$value = trim((string)($value ?? ''));
|
||||
return $value === '0' ? '' : $value;
|
||||
}
|
||||
|
||||
public function syncCatalog(): array
|
||||
{
|
||||
$client = new Kuaidi100Client();
|
||||
$binary = $client->downloadCompanyCatalogWorkbook();
|
||||
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'kdbm_');
|
||||
if ($tmpFile === false) {
|
||||
throw new \RuntimeException('快递100公司码表临时文件创建失败');
|
||||
}
|
||||
|
||||
file_put_contents($tmpFile, $binary);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::startTrans();
|
||||
try {
|
||||
$rows = $this->parseCatalogWorkbook($tmpFile);
|
||||
if (!$rows) {
|
||||
throw new \RuntimeException('快递100公司码表解析结果为空');
|
||||
}
|
||||
|
||||
$existingCodes = Db::name('kuaidi100_express_company_catalog')
|
||||
->column('company_code');
|
||||
$existingCodeMap = array_fill_keys(array_map('strval', $existingCodes), true);
|
||||
$inserted = 0;
|
||||
$updated = 0;
|
||||
$payloadRows = [];
|
||||
foreach ($rows as $index => $item) {
|
||||
$payload = [
|
||||
'company_name' => $item['company_name'],
|
||||
'company_code' => $item['company_code'],
|
||||
'company_type' => $item['company_type'],
|
||||
'sort_order' => (int)($item['sort_order'] ?? $index + 1),
|
||||
'synced_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if (isset($existingCodeMap[$payload['company_code']])) {
|
||||
$updated++;
|
||||
} else {
|
||||
$inserted++;
|
||||
}
|
||||
$payloadRows[$payload['company_code']] = $payload;
|
||||
}
|
||||
|
||||
foreach (array_chunk(array_values($payloadRows), 300) as $chunkIndex => $chunk) {
|
||||
$valuesSql = [];
|
||||
$bindings = [];
|
||||
foreach ($chunk as $rowIndex => $payload) {
|
||||
$prefix = 'r' . $chunkIndex . '_' . $rowIndex;
|
||||
$valuesSql[] = sprintf(
|
||||
'(:%s_company_name, :%s_company_code, :%s_company_type, :%s_sort_order, :%s_synced_at, :%s_created_at, :%s_updated_at)',
|
||||
$prefix,
|
||||
$prefix,
|
||||
$prefix,
|
||||
$prefix,
|
||||
$prefix,
|
||||
$prefix,
|
||||
$prefix
|
||||
);
|
||||
foreach ($payload as $field => $value) {
|
||||
$bindings[$prefix . '_' . $field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
Db::execute(
|
||||
'INSERT INTO kuaidi100_express_company_catalog '
|
||||
. '(company_name, company_code, company_type, sort_order, synced_at, created_at, updated_at) VALUES '
|
||||
. implode(',', $valuesSql)
|
||||
. ' ON DUPLICATE KEY UPDATE '
|
||||
. 'company_name = VALUES(company_name), '
|
||||
. 'company_type = VALUES(company_type), '
|
||||
. 'sort_order = VALUES(sort_order), '
|
||||
. 'synced_at = VALUES(synced_at), '
|
||||
. 'updated_at = VALUES(updated_at)',
|
||||
$bindings
|
||||
);
|
||||
}
|
||||
|
||||
$backfilled = $this->backfillLocalCompanyCodesFromCatalog($now);
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
} finally {
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => count($rows),
|
||||
'inserted' => $inserted ?? 0,
|
||||
'updated' => $updated ?? 0,
|
||||
'backfilled' => $backfilled ?? 0,
|
||||
'synced_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
public function resolveCompanyCode(string $companyNameOrCode, string $trackingNo = ''): string
|
||||
{
|
||||
$result = $this->recognizeCompany($companyNameOrCode, $trackingNo);
|
||||
return (string)($result['company_code'] ?? '');
|
||||
}
|
||||
|
||||
public function recognizeCompany(string $companyNameOrCode, string $trackingNo = ''): array
|
||||
{
|
||||
$companyNameOrCode = trim($companyNameOrCode);
|
||||
$trackingNo = trim($trackingNo);
|
||||
$recognitionError = '';
|
||||
|
||||
$resolved = $this->resolveLocalCompanyMatch($companyNameOrCode);
|
||||
if (!$resolved && $trackingNo !== '') {
|
||||
try {
|
||||
$candidates = $this->recognizeKuaidi100Candidates($trackingNo);
|
||||
} catch (\Throwable $e) {
|
||||
$candidates = [];
|
||||
$recognitionError = $e->getMessage();
|
||||
}
|
||||
$resolved = $this->pickRecognizedCandidate($candidates, $companyNameOrCode);
|
||||
return [
|
||||
'input' => $companyNameOrCode,
|
||||
'tracking_no' => $trackingNo,
|
||||
'company_code' => (string)($resolved['company_code'] ?? ''),
|
||||
'company_name' => (string)($resolved['company_name'] ?? $companyNameOrCode),
|
||||
'status' => $resolved ? 'resolved' : ($candidates ? 'multiple' : 'none'),
|
||||
'status_text' => $resolved ? '已识别' : ($candidates ? '识别到多个候选' : '未识别'),
|
||||
'error_message' => $recognitionError,
|
||||
'resolved' => $resolved,
|
||||
'candidates' => $candidates,
|
||||
];
|
||||
}
|
||||
|
||||
if ($resolved) {
|
||||
return [
|
||||
'input' => $companyNameOrCode,
|
||||
'tracking_no' => $trackingNo,
|
||||
'company_code' => (string)($resolved['company_code'] ?? ''),
|
||||
'company_name' => (string)($resolved['company_name'] ?? $companyNameOrCode),
|
||||
'status' => 'resolved',
|
||||
'status_text' => '已识别',
|
||||
'error_message' => '',
|
||||
'resolved' => $resolved,
|
||||
'candidates' => [$resolved],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'input' => $companyNameOrCode,
|
||||
'tracking_no' => $trackingNo,
|
||||
'company_code' => '',
|
||||
'company_name' => $companyNameOrCode,
|
||||
'status' => 'none',
|
||||
'status_text' => '未识别',
|
||||
'error_message' => $recognitionError,
|
||||
'resolved' => null,
|
||||
'candidates' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function resolveCompanyNameByCode(string $companyCode): string
|
||||
{
|
||||
$companyCode = trim($companyCode);
|
||||
if ($companyCode === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$row = Db::name('express_companies')
|
||||
->where('company_code', $companyCode)
|
||||
->find();
|
||||
$name = trim((string)($row['company_name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$row = Db::name('kuaidi100_express_company_catalog')
|
||||
->where('company_code', $companyCode)
|
||||
->find();
|
||||
$name = trim((string)($row['company_name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return $this->companyAliasName($companyCode);
|
||||
}
|
||||
|
||||
private function resolveLocalCompanyMatch(string $companyNameOrCode): ?array
|
||||
{
|
||||
$companyNameOrCode = trim($companyNameOrCode);
|
||||
if ($companyNameOrCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = Db::name('express_companies')
|
||||
->where('company_name', $companyNameOrCode)
|
||||
->find();
|
||||
$code = trim((string)($row['company_code'] ?? ''));
|
||||
if ($this->isValidCompanyCode($code)) {
|
||||
return [
|
||||
'company_name' => (string)($row['company_name'] ?? $companyNameOrCode),
|
||||
'company_code' => $code,
|
||||
'source' => 'local',
|
||||
];
|
||||
}
|
||||
|
||||
$row = Db::name('express_companies')
|
||||
->where('company_code', $companyNameOrCode)
|
||||
->find();
|
||||
$code = trim((string)($row['company_code'] ?? ''));
|
||||
if ($this->isValidCompanyCode($code)) {
|
||||
$name = trim((string)($row['company_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = $this->resolveCompanyNameByCode($code);
|
||||
}
|
||||
if ($name === '') {
|
||||
$name = $companyNameOrCode;
|
||||
}
|
||||
|
||||
return [
|
||||
'company_name' => $name,
|
||||
'company_code' => $code,
|
||||
'source' => 'local',
|
||||
];
|
||||
}
|
||||
|
||||
$catalogRow = Db::name('kuaidi100_express_company_catalog')
|
||||
->where('company_name', $companyNameOrCode)
|
||||
->find();
|
||||
$code = trim((string)($catalogRow['company_code'] ?? ''));
|
||||
if ($this->isValidCompanyCode($code)) {
|
||||
return [
|
||||
'company_name' => (string)($catalogRow['company_name'] ?? $companyNameOrCode),
|
||||
'company_code' => $code,
|
||||
'source' => 'catalog',
|
||||
];
|
||||
}
|
||||
|
||||
$catalogRow = Db::name('kuaidi100_express_company_catalog')
|
||||
->where('company_code', $companyNameOrCode)
|
||||
->find();
|
||||
$code = trim((string)($catalogRow['company_code'] ?? ''));
|
||||
if ($this->isValidCompanyCode($code)) {
|
||||
$name = trim((string)($catalogRow['company_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = $this->resolveCompanyNameByCode($code);
|
||||
}
|
||||
if ($name === '') {
|
||||
$name = $companyNameOrCode;
|
||||
}
|
||||
|
||||
return [
|
||||
'company_name' => $name,
|
||||
'company_code' => $code,
|
||||
'source' => 'catalog',
|
||||
];
|
||||
}
|
||||
|
||||
$aliasCode = $this->companyAliasCode($companyNameOrCode);
|
||||
if ($aliasCode !== '') {
|
||||
return [
|
||||
'company_name' => $this->resolveCompanyNameByCode($aliasCode) ?: $companyNameOrCode,
|
||||
'company_code' => $aliasCode,
|
||||
'source' => 'alias',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->isValidCompanyCode($companyNameOrCode)
|
||||
? [
|
||||
'company_name' => $this->resolveCompanyNameByCode($companyNameOrCode) ?: $companyNameOrCode,
|
||||
'company_code' => $companyNameOrCode,
|
||||
'source' => 'code',
|
||||
]
|
||||
: null;
|
||||
}
|
||||
|
||||
private function recognizeKuaidi100Candidates(string $trackingNo): array
|
||||
{
|
||||
$client = new Kuaidi100Client();
|
||||
$response = $client->recognize($trackingNo);
|
||||
$items = $this->extractRecognizeItems($response);
|
||||
if (!$items) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
foreach ($items as $item) {
|
||||
$candidate = $this->normalizeRecognizeCandidate($item);
|
||||
if ($candidate) {
|
||||
$candidates[] = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
private function extractRecognizeItems(array $response): array
|
||||
{
|
||||
if (isset($response['data']) && is_array($response['data'])) {
|
||||
$items = $response['data'];
|
||||
} else {
|
||||
$items = $response;
|
||||
}
|
||||
|
||||
if ($this->isAssocArray($items) && (isset($items['comCode']) || isset($items['company_code']))) {
|
||||
return [$items];
|
||||
}
|
||||
|
||||
return array_values(array_filter($items, static fn ($item) => is_array($item)));
|
||||
}
|
||||
|
||||
private function normalizeRecognizeCandidate(array $item): ?array
|
||||
{
|
||||
$companyCode = trim((string)($item['comCode'] ?? $item['company_code'] ?? $item['code'] ?? ''));
|
||||
$officialName = trim((string)($item['name'] ?? $item['company_name'] ?? $item['comName'] ?? ''));
|
||||
if ($companyCode === '' && $officialName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$localName = $companyCode !== '' ? $this->resolveCompanyNameByCode($companyCode) : '';
|
||||
$companyName = $localName !== '' ? $localName : ($officialName !== '' ? $officialName : $companyCode);
|
||||
|
||||
return [
|
||||
'company_name' => $companyName,
|
||||
'company_code' => $companyCode,
|
||||
'official_name' => $officialName,
|
||||
'display_text' => $companyName !== '' && $companyCode !== ''
|
||||
? sprintf('%s / %s', $companyName, $companyCode)
|
||||
: ($companyName !== '' ? $companyName : $companyCode),
|
||||
'length_pre' => (int)($item['lengthPre'] ?? $item['length_pre'] ?? 0),
|
||||
'source' => $localName !== '' ? 'catalog' : 'kuaidi100',
|
||||
];
|
||||
}
|
||||
|
||||
private function pickRecognizedCandidate(array $candidates, string $companyNameOrCode): ?array
|
||||
{
|
||||
if (!$candidates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (count($candidates) === 1) {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
$companyNameOrCode = trim($companyNameOrCode);
|
||||
if ($companyNameOrCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (!is_array($candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidateName = trim((string)($candidate['company_name'] ?? ''));
|
||||
$candidateCode = trim((string)($candidate['company_code'] ?? ''));
|
||||
if ($candidateName === $companyNameOrCode || $candidateCode === $companyNameOrCode) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isAssocArray(array $items): bool
|
||||
{
|
||||
return array_keys($items) !== range(0, count($items) - 1);
|
||||
}
|
||||
|
||||
private function format(array $item): array
|
||||
{
|
||||
$status = (string)($item['status'] ?? 'enabled');
|
||||
@@ -136,21 +546,63 @@ class ExpressCompanyService
|
||||
];
|
||||
}
|
||||
|
||||
private function formatCatalog(array $item): array
|
||||
{
|
||||
$source = (string)($item['source'] ?? 'kuaidi100');
|
||||
$companyName = (string)($item['company_name'] ?? '');
|
||||
$companyCode = (string)($item['company_code'] ?? '');
|
||||
$companyType = (string)($item['company_type'] ?? '');
|
||||
|
||||
return [
|
||||
'id' => (int)($item['id'] ?? 0),
|
||||
'company_name' => $companyName,
|
||||
'company_code' => $companyCode,
|
||||
'company_type' => $companyType,
|
||||
'display_text' => $companyType !== ''
|
||||
? sprintf('%s / %s / %s', $companyName, $companyCode, $companyType)
|
||||
: sprintf('%s / %s', $companyName, $companyCode),
|
||||
'source' => $source,
|
||||
'synced_at' => (string)($item['synced_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function bootstrapDefaults(): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$exists = Db::name('express_companies')->where('company_name', '顺丰速运')->find();
|
||||
if (!$exists) {
|
||||
Db::name('express_companies')->insert([
|
||||
'company_name' => '顺丰速运',
|
||||
'company_code' => 'sf_express',
|
||||
'status' => 'enabled',
|
||||
'is_default' => 1,
|
||||
'sort_order' => 1,
|
||||
'remark' => '系统默认快递公司',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$defaults = [
|
||||
['company_name' => '顺丰速运', 'company_code' => 'shunfeng', 'sort_order' => 1, 'is_default' => 1],
|
||||
['company_name' => '京东快递', 'company_code' => 'jd', 'sort_order' => 2, 'is_default' => 0],
|
||||
['company_name' => 'EMS', 'company_code' => 'ems', 'sort_order' => 3, 'is_default' => 0],
|
||||
['company_name' => '中通快递', 'company_code' => 'zhongtong', 'sort_order' => 4, 'is_default' => 0],
|
||||
['company_name' => '圆通速递', 'company_code' => 'yuantong', 'sort_order' => 5, 'is_default' => 0],
|
||||
['company_name' => '申通快递', 'company_code' => 'shentong', 'sort_order' => 6, 'is_default' => 0],
|
||||
['company_name' => '韵达快递', 'company_code' => 'yunda', 'sort_order' => 7, 'is_default' => 0],
|
||||
['company_name' => '极兔速递', 'company_code' => 'jtexpress', 'sort_order' => 8, 'is_default' => 0],
|
||||
];
|
||||
|
||||
foreach ($defaults as $default) {
|
||||
$exists = Db::name('express_companies')->where('company_name', $default['company_name'])->find();
|
||||
if (!$exists) {
|
||||
Db::name('express_companies')->insert([
|
||||
'company_name' => $default['company_name'],
|
||||
'company_code' => $default['company_code'],
|
||||
'status' => 'enabled',
|
||||
'is_default' => $default['is_default'],
|
||||
'sort_order' => $default['sort_order'],
|
||||
'remark' => '系统默认快递公司',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentCode = (string)($exists['company_code'] ?? '');
|
||||
if ($currentCode === '' || $currentCode === 'sf_express' || str_starts_with($currentCode, 'express_')) {
|
||||
Db::name('express_companies')->where('id', $exists['id'])->update([
|
||||
'company_code' => $default['company_code'],
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->ensureEnabledDefault($now);
|
||||
@@ -179,9 +631,289 @@ class ExpressCompanyService
|
||||
}
|
||||
}
|
||||
|
||||
private function generateCompanyCode(string $companyName): string
|
||||
private function backfillLocalCompanyCodesFromCatalog(string $now): int
|
||||
{
|
||||
return 'express_' . substr(hash('sha256', $companyName), 0, 12);
|
||||
$catalogRows = Db::name('kuaidi100_express_company_catalog')
|
||||
->select()
|
||||
->toArray();
|
||||
if (!$catalogRows) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$catalogMap = [];
|
||||
foreach ($catalogRows as $row) {
|
||||
$name = trim((string)($row['company_name'] ?? ''));
|
||||
$code = trim((string)($row['company_code'] ?? ''));
|
||||
if ($name === '' || !$this->isValidCompanyCode($code)) {
|
||||
continue;
|
||||
}
|
||||
$catalogMap[$name] = $code;
|
||||
}
|
||||
|
||||
if (!$catalogMap) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
$rows = Db::name('express_companies')->select()->toArray();
|
||||
foreach ($rows as $row) {
|
||||
$companyName = trim((string)($row['company_name'] ?? ''));
|
||||
$currentCode = trim((string)($row['company_code'] ?? ''));
|
||||
if ($companyName === '' || !isset($catalogMap[$companyName])) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->shouldBackfillCompanyCode($currentCode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Db::name('express_companies')->where('id', (int)$row['id'])->update([
|
||||
'company_code' => $catalogMap[$companyName],
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
private function shouldBackfillCompanyCode(string $currentCode): bool
|
||||
{
|
||||
return $currentCode === '' || $currentCode === 'sf_express' || str_starts_with($currentCode, 'express_');
|
||||
}
|
||||
|
||||
private function localCompanyFallbackList(string $keyword, int $limit): array
|
||||
{
|
||||
$query = Db::name('express_companies')
|
||||
->order('is_default', 'desc')
|
||||
->order('sort_order', 'asc')
|
||||
->order('id', 'asc');
|
||||
|
||||
if ($keyword !== '') {
|
||||
$like = '%' . $keyword . '%';
|
||||
$query->whereRaw('(company_name LIKE :keyword OR company_code LIKE :keyword)', [
|
||||
'keyword' => $like,
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = $query->limit($limit)->select()->toArray();
|
||||
return array_map(static fn (array $item) => [
|
||||
'id' => (int)$item['id'],
|
||||
'company_name' => (string)$item['company_name'],
|
||||
'company_code' => (string)$item['company_code'],
|
||||
'company_type' => '本地快递公司',
|
||||
'source' => 'local',
|
||||
'synced_at' => '',
|
||||
], $rows);
|
||||
}
|
||||
|
||||
private function parseCatalogWorkbook(string $filePath): array
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($filePath) !== true) {
|
||||
throw new \RuntimeException('快递100公司码表压缩包打开失败');
|
||||
}
|
||||
|
||||
$sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml');
|
||||
if (!is_string($sheetXml) || $sheetXml === '') {
|
||||
$zip->close();
|
||||
throw new \RuntimeException('快递100公司码表缺少工作表数据');
|
||||
}
|
||||
|
||||
$sharedStrings = [];
|
||||
$sharedStringsXml = $zip->getFromName('xl/sharedStrings.xml');
|
||||
if (is_string($sharedStringsXml) && $sharedStringsXml !== '') {
|
||||
$sharedStrings = $this->readSharedStrings($sharedStringsXml);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
$xml = simplexml_load_string($sheetXml, 'SimpleXMLElement', LIBXML_NOCDATA);
|
||||
if ($xml === false) {
|
||||
throw new \RuntimeException('快递100公司码表工作表解析失败');
|
||||
}
|
||||
|
||||
$xml->registerXPathNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main');
|
||||
$rows = $xml->xpath('//x:sheetData/x:row') ?: [];
|
||||
|
||||
$headerMap = [];
|
||||
$parsedRows = [];
|
||||
foreach ($rows as $row) {
|
||||
$values = [];
|
||||
foreach ($row->c as $cell) {
|
||||
$columnIndex = $this->columnIndexFromCellRef((string)$cell['r']);
|
||||
if ($columnIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$columnIndex] = $this->readXlsxCellValue($cell, $sharedStrings);
|
||||
}
|
||||
|
||||
if (!$values) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$headerMap) {
|
||||
$headerMap = $this->buildCatalogHeaderMap($values);
|
||||
if ($headerMap) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$companyName = trim((string)($values[$headerMap['company_name'] ?? 1] ?? ''));
|
||||
$companyCode = trim((string)($values[$headerMap['company_code'] ?? 2] ?? ''));
|
||||
$companyType = trim((string)($values[$headerMap['company_type'] ?? 3] ?? ''));
|
||||
|
||||
if ($companyName === '' || !$this->isValidCompanyCode($companyCode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedRows[] = [
|
||||
'company_name' => $companyName,
|
||||
'company_code' => $companyCode,
|
||||
'company_type' => $companyType,
|
||||
'sort_order' => count($parsedRows) + 1,
|
||||
];
|
||||
}
|
||||
|
||||
return $parsedRows;
|
||||
}
|
||||
|
||||
private function buildCatalogHeaderMap(array $values): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($values as $columnIndex => $value) {
|
||||
$text = trim((string)$value);
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($text, ['公司名称', '快递公司名称', '名称'], true)) {
|
||||
$map['company_name'] = $columnIndex;
|
||||
} elseif (in_array($text, ['公司编码', '快递公司编码', '编码', 'code'], true)) {
|
||||
$map['company_code'] = $columnIndex;
|
||||
} elseif (in_array($text, ['公司类型', '类型'], true)) {
|
||||
$map['company_type'] = $columnIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$map) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map['company_name'] ??= 1;
|
||||
$map['company_code'] ??= 2;
|
||||
$map['company_type'] ??= 3;
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function readSharedStrings(string $xml): array
|
||||
{
|
||||
$document = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
|
||||
if ($document === false) {
|
||||
throw new \RuntimeException('快递100公司码表共享字符串解析失败');
|
||||
}
|
||||
|
||||
$document->registerXPathNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main');
|
||||
$items = $document->xpath('//x:si') ?: [];
|
||||
$values = [];
|
||||
foreach ($items as $item) {
|
||||
$text = '';
|
||||
foreach ($item->xpath('.//x:t') ?: [] as $textNode) {
|
||||
$text .= (string)$textNode;
|
||||
}
|
||||
$values[] = $text;
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function readXlsxCellValue(\SimpleXMLElement $cell, array $sharedStrings): string
|
||||
{
|
||||
$type = (string)$cell['t'];
|
||||
if ($type === 'inlineStr') {
|
||||
$text = '';
|
||||
foreach ($cell->xpath('.//*[local-name()="t"]') ?: [] as $textNode) {
|
||||
$text .= (string)$textNode;
|
||||
}
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
if ($type === 's') {
|
||||
$index = (int)($cell->v ?? 0);
|
||||
return trim((string)($sharedStrings[$index] ?? ''));
|
||||
}
|
||||
|
||||
if (isset($cell->v)) {
|
||||
return trim((string)$cell->v);
|
||||
}
|
||||
|
||||
return trim((string)$cell);
|
||||
}
|
||||
|
||||
private function columnIndexFromCellRef(string $cellRef): int
|
||||
{
|
||||
if (!preg_match('/^([A-Z]+)\d+$/i', $cellRef, $matches)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$letters = strtoupper($matches[1]);
|
||||
$index = 0;
|
||||
$length = strlen($letters);
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$index = $index * 26 + (ord($letters[$i]) - 64);
|
||||
}
|
||||
|
||||
return $index;
|
||||
}
|
||||
|
||||
private function isValidCompanyCode(string $code): bool
|
||||
{
|
||||
return $code !== '' && preg_match('/^[a-z0-9]+$/', $code) === 1 && !str_starts_with($code, 'express_');
|
||||
}
|
||||
|
||||
private function companyAliasCode(string $companyName): string
|
||||
{
|
||||
$aliases = [
|
||||
'顺丰速运' => 'shunfeng',
|
||||
'顺丰' => 'shunfeng',
|
||||
'sf_express' => 'shunfeng',
|
||||
'京东快递' => 'jd',
|
||||
'京东物流' => 'jd',
|
||||
'EMS' => 'ems',
|
||||
'ems' => 'ems',
|
||||
'中通快递' => 'zhongtong',
|
||||
'圆通速递' => 'yuantong',
|
||||
'圆通快递' => 'yuantong',
|
||||
'申通快递' => 'shentong',
|
||||
'韵达快递' => 'yunda',
|
||||
'极兔速递' => 'jtexpress',
|
||||
'极兔快递' => 'jtexpress',
|
||||
'德邦快递' => 'debangwuliu',
|
||||
'邮政快递包裹' => 'youzhengguonei',
|
||||
];
|
||||
|
||||
return $aliases[$companyName] ?? '';
|
||||
}
|
||||
|
||||
private function companyAliasName(string $companyCode): string
|
||||
{
|
||||
$aliases = [
|
||||
'shunfeng' => '顺丰速运',
|
||||
'sf_express' => '顺丰速运',
|
||||
'jd' => '京东快递',
|
||||
'ems' => 'EMS',
|
||||
'zhongtong' => '中通快递',
|
||||
'yuantong' => '圆通速递',
|
||||
'shentong' => '申通快递',
|
||||
'yunda' => '韵达快递',
|
||||
'jtexpress' => '极兔速递',
|
||||
'debangwuliu' => '德邦快递',
|
||||
'youzhengguonei' => '邮政快递包裹',
|
||||
];
|
||||
|
||||
return $aliases[$companyCode] ?? '';
|
||||
}
|
||||
|
||||
private function ensureTable(): void
|
||||
@@ -203,6 +935,27 @@ CREATE TABLE IF NOT EXISTS express_companies (
|
||||
KEY idx_express_companies_status (status),
|
||||
KEY idx_express_companies_default (is_default)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快递公司字典'
|
||||
SQL);
|
||||
}
|
||||
|
||||
private function ensureCatalogTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS kuaidi100_express_company_catalog (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
company_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||
company_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
company_type VARCHAR(64) NOT NULL DEFAULT '',
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_kuaidi100_express_company_catalog_code (company_code),
|
||||
KEY idx_kuaidi100_express_company_catalog_name (company_name),
|
||||
KEY idx_kuaidi100_express_company_catalog_type (company_type),
|
||||
KEY idx_kuaidi100_express_company_catalog_synced_at (synced_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快递100官方公司码表'
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +501,7 @@ class FulfillmentFlowService
|
||||
'tracking_no' => $trackingNo,
|
||||
'shipped_at' => $now,
|
||||
]);
|
||||
(new OrderLogisticsSyncService())->subscribeAsync($logisticsId);
|
||||
|
||||
return $this->formatOrderContext($orderId);
|
||||
}
|
||||
@@ -535,6 +536,9 @@ class FulfillmentFlowService
|
||||
$product = Db::name('order_products')->where('order_id', $orderId)->find() ?: [];
|
||||
$sendLogistics = Db::name('order_logistics')->where('order_id', $orderId)->where('logistics_type', 'send_to_center')->order('id', 'desc')->find();
|
||||
$returnLogistics = Db::name('order_logistics')->where('order_id', $orderId)->where('logistics_type', 'return_to_user')->order('id', 'desc')->find();
|
||||
$syncService = new OrderLogisticsSyncService();
|
||||
$sendSyncStatus = $sendLogistics ? $syncService->formatSyncStatus((int)$sendLogistics['id']) : ['provider_status_text' => '', 'sync_status_text' => '未同步', 'sync_error' => ''];
|
||||
$returnSyncStatus = $returnLogistics ? $syncService->formatSyncStatus((int)$returnLogistics['id']) : ['provider_status_text' => '', 'sync_status_text' => '未同步', 'sync_error' => ''];
|
||||
$flow = Db::name('order_transfer_flows')->where('order_id', $orderId)->order('id', 'desc')->find();
|
||||
$report = $this->latestReport($orderId);
|
||||
$content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : null;
|
||||
@@ -570,6 +574,17 @@ class FulfillmentFlowService
|
||||
'express_company' => (string)$sendLogistics['express_company'],
|
||||
'tracking_no' => (string)$sendLogistics['tracking_no'],
|
||||
'tracking_status' => (string)$sendLogistics['tracking_status'],
|
||||
'tracking_status_text' => $this->trackingStatusText((string)$sendLogistics['tracking_status'], 'send_to_center'),
|
||||
'provider_status_text' => $sendSyncStatus['provider_status_text'],
|
||||
'sync_status_text' => $sendSyncStatus['sync_status_text'],
|
||||
'sync_error' => $sendSyncStatus['sync_error'],
|
||||
'latest_desc' => (string)($sendLogistics['latest_desc'] ?? ''),
|
||||
'latest_time' => (string)($sendLogistics['latest_time'] ?? ''),
|
||||
'nodes' => array_map(fn (array $item) => [
|
||||
'node_time' => (string)$item['node_time'],
|
||||
'node_desc' => (string)$item['node_desc'],
|
||||
'node_location' => (string)$item['node_location'],
|
||||
], $syncService->nodesForLogistics((int)$sendLogistics['id'])),
|
||||
] : null,
|
||||
'return_address' => $returnAddress ? [
|
||||
'consignee' => (string)($returnAddress['consignee'] ?? ''),
|
||||
@@ -580,6 +595,17 @@ class FulfillmentFlowService
|
||||
'express_company' => (string)$returnLogistics['express_company'],
|
||||
'tracking_no' => (string)$returnLogistics['tracking_no'],
|
||||
'tracking_status' => (string)$returnLogistics['tracking_status'],
|
||||
'tracking_status_text' => $this->trackingStatusText((string)$returnLogistics['tracking_status'], 'return_to_user'),
|
||||
'provider_status_text' => $returnSyncStatus['provider_status_text'],
|
||||
'sync_status_text' => $returnSyncStatus['sync_status_text'],
|
||||
'sync_error' => $returnSyncStatus['sync_error'],
|
||||
'latest_desc' => (string)($returnLogistics['latest_desc'] ?? ''),
|
||||
'latest_time' => (string)($returnLogistics['latest_time'] ?? ''),
|
||||
'nodes' => array_map(fn (array $item) => [
|
||||
'node_time' => (string)$item['node_time'],
|
||||
'node_desc' => (string)$item['node_desc'],
|
||||
'node_location' => (string)$item['node_location'],
|
||||
], $syncService->nodesForLogistics((int)$returnLogistics['id'])),
|
||||
] : null,
|
||||
'transfer_flow' => $flow ? $this->formatFlow($flow) : null,
|
||||
'report_info' => $report ? [
|
||||
@@ -994,6 +1020,25 @@ class FulfillmentFlowService
|
||||
};
|
||||
}
|
||||
|
||||
private function trackingStatusText(string $status, string $logisticsType): string
|
||||
{
|
||||
if ($logisticsType === 'return_to_user') {
|
||||
return match ($status) {
|
||||
'submitted' => '已登记回寄运单',
|
||||
'in_transit' => '回寄途中',
|
||||
'received' => '用户已签收',
|
||||
default => $status === '' ? '待回寄' : $status,
|
||||
};
|
||||
}
|
||||
|
||||
return match ($status) {
|
||||
'submitted' => '用户已提交运单',
|
||||
'in_transit' => '用户已寄出,运输中',
|
||||
'received' => '鉴定中心已签收',
|
||||
default => $status === '' ? '待提交' : $status,
|
||||
};
|
||||
}
|
||||
|
||||
private function decodeJsonArray(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
|
||||
187
server-api/app/support/Kuaidi100Client.php
Normal file
187
server-api/app/support/Kuaidi100Client.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
class Kuaidi100Client
|
||||
{
|
||||
private const QUERY_URL = 'https://poll.kuaidi100.com/poll/query.do';
|
||||
private const SUBSCRIBE_URL = 'https://poll.kuaidi100.com/poll';
|
||||
private const AUTONUMBER_URL = 'http://www.kuaidi100.com/autonumber/auto';
|
||||
private const COMPANY_CATALOG_URL = 'http://api.kuaidi100.com/manager/openapi/download/kdbm.do';
|
||||
|
||||
public function __construct(private ?Kuaidi100ConfigService $configService = null)
|
||||
{
|
||||
$this->configService ??= new Kuaidi100ConfigService();
|
||||
}
|
||||
|
||||
public function query(string $companyCode, string $trackingNo, string $phone = ''): array
|
||||
{
|
||||
$config = $this->configService->getConfig();
|
||||
if (!$this->configService->isReadyForQuery()) {
|
||||
throw new \RuntimeException('快递100实时查询配置未完成');
|
||||
}
|
||||
|
||||
$param = [
|
||||
'com' => $companyCode,
|
||||
'num' => $trackingNo,
|
||||
'resultv2' => '1',
|
||||
];
|
||||
if ($phone !== '') {
|
||||
$param['phone'] = $phone;
|
||||
}
|
||||
|
||||
$paramJson = $this->encodeJson($param);
|
||||
$response = $this->postForm(self::QUERY_URL, [
|
||||
'customer' => $config['customer'],
|
||||
'sign' => strtoupper(md5($paramJson . $config['key'] . $config['customer'])),
|
||||
'param' => $paramJson,
|
||||
]);
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new \RuntimeException('快递100实时查询返回格式异常');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public function recognize(string $trackingNo): array
|
||||
{
|
||||
$trackingNo = trim($trackingNo);
|
||||
if ($trackingNo === '') {
|
||||
throw new \InvalidArgumentException('快递100单号不能为空');
|
||||
}
|
||||
if (!$this->configService->isReadyForRecognition()) {
|
||||
throw new \RuntimeException('快递100智能识别配置未完成');
|
||||
}
|
||||
|
||||
$config = $this->configService->getConfig();
|
||||
$response = $this->requestGet(self::AUTONUMBER_URL, [
|
||||
'num' => $trackingNo,
|
||||
'key' => $config['key'],
|
||||
]);
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new \RuntimeException('快递100智能识别返回格式异常');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public function downloadCompanyCatalogWorkbook(): string
|
||||
{
|
||||
return $this->requestGet(self::COMPANY_CATALOG_URL, [], 30, 15);
|
||||
}
|
||||
|
||||
public function subscribe(string $companyCode, string $trackingNo, string $phone = ''): array
|
||||
{
|
||||
$config = $this->configService->getConfig();
|
||||
if (!$this->configService->isReadyForSubscribe()) {
|
||||
throw new \RuntimeException('快递100订阅配置未完成');
|
||||
}
|
||||
|
||||
$parameters = [
|
||||
'callbackurl' => $config['callback_url'],
|
||||
'resultv2' => '1',
|
||||
];
|
||||
if ($config['callback_salt'] !== '') {
|
||||
$parameters['salt'] = $config['callback_salt'];
|
||||
}
|
||||
if ($phone !== '') {
|
||||
$parameters['phone'] = $phone;
|
||||
}
|
||||
|
||||
$requestPayload = [
|
||||
'company' => $companyCode,
|
||||
'number' => $trackingNo,
|
||||
'key' => $config['key'],
|
||||
'parameters' => $parameters,
|
||||
];
|
||||
if ($companyCode === '') {
|
||||
unset($requestPayload['company']);
|
||||
$requestPayload['autoCom'] = '1';
|
||||
}
|
||||
|
||||
$paramJson = $this->encodeJson($requestPayload);
|
||||
|
||||
$response = $this->postForm(self::SUBSCRIBE_URL, [
|
||||
'schema' => 'json',
|
||||
'param' => $paramJson,
|
||||
]);
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new \RuntimeException('快递100订阅返回格式异常');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function postForm(string $url, array $fields): string
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($fields),
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 4,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
],
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno) {
|
||||
throw new \RuntimeException('快递100请求失败:' . $error);
|
||||
}
|
||||
if ($httpStatus < 200 || $httpStatus >= 300) {
|
||||
throw new \RuntimeException('快递100请求 HTTP 状态异常:' . $httpStatus);
|
||||
}
|
||||
|
||||
return is_string($response) ? $response : '';
|
||||
}
|
||||
|
||||
private function requestGet(string $url, array $query = [], int $timeout = 10, int $connectTimeout = 5): string
|
||||
{
|
||||
$fullUrl = $query ? $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($query) : $url;
|
||||
$ch = curl_init($fullUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPGET => true,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno) {
|
||||
throw new \RuntimeException('快递100请求失败:' . $error);
|
||||
}
|
||||
if ($httpStatus < 200 || $httpStatus >= 300) {
|
||||
throw new \RuntimeException('快递100请求 HTTP 状态异常:' . $httpStatus);
|
||||
}
|
||||
|
||||
return is_string($response) ? $response : '';
|
||||
}
|
||||
|
||||
private function encodeJson(array $payload): string
|
||||
{
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($json)) {
|
||||
throw new \RuntimeException('快递100请求参数编码失败');
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
}
|
||||
61
server-api/app/support/Kuaidi100ConfigService.php
Normal file
61
server-api/app/support/Kuaidi100ConfigService.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
class Kuaidi100ConfigService
|
||||
{
|
||||
private const GROUP = 'kuaidi100';
|
||||
|
||||
public function getConfig(): array
|
||||
{
|
||||
$rows = Db::name('system_configs')
|
||||
->where('config_group', self::GROUP)
|
||||
->column('config_value', 'config_key');
|
||||
|
||||
$enabled = $this->normalizeEnabled((string)($rows['enabled'] ?? 'disabled')) === 'enabled';
|
||||
$minInterval = (int)($rows['query_min_interval_minutes'] ?? 30);
|
||||
$minInterval = max(5, min(1440, $minInterval > 0 ? $minInterval : 30));
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'customer' => trim((string)($rows['customer'] ?? '')),
|
||||
'key' => trim((string)($rows['key'] ?? '')),
|
||||
'callback_url' => trim((string)($rows['callback_url'] ?? '')),
|
||||
'callback_salt' => trim((string)($rows['callback_salt'] ?? '')),
|
||||
'query_min_interval_minutes' => $minInterval,
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->getConfig()['enabled'];
|
||||
}
|
||||
|
||||
public function isReadyForQuery(): bool
|
||||
{
|
||||
$config = $this->getConfig();
|
||||
|
||||
return $config['enabled'] && $config['customer'] !== '' && $config['key'] !== '';
|
||||
}
|
||||
|
||||
public function isReadyForSubscribe(): bool
|
||||
{
|
||||
$config = $this->getConfig();
|
||||
|
||||
return $config['enabled'] && $config['key'] !== '' && $config['callback_url'] !== '';
|
||||
}
|
||||
|
||||
public function isReadyForRecognition(): bool
|
||||
{
|
||||
$config = $this->getConfig();
|
||||
|
||||
return $config['enabled'] && $config['key'] !== '';
|
||||
}
|
||||
|
||||
public function normalizeEnabled(string $value): string
|
||||
{
|
||||
return in_array($value, ['enabled', 'disabled'], true) ? $value : 'disabled';
|
||||
}
|
||||
}
|
||||
557
server-api/app/support/OrderLogisticsSyncService.php
Normal file
557
server-api/app/support/OrderLogisticsSyncService.php
Normal file
@@ -0,0 +1,557 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\Log;
|
||||
use support\think\Db;
|
||||
|
||||
class OrderLogisticsSyncService
|
||||
{
|
||||
private const PROVIDER = 'kuaidi100';
|
||||
|
||||
public function __construct(
|
||||
private ?Kuaidi100ConfigService $configService = null,
|
||||
private ?Kuaidi100Client $client = null
|
||||
) {
|
||||
$this->configService ??= new Kuaidi100ConfigService();
|
||||
$this->client ??= new Kuaidi100Client($this->configService);
|
||||
$this->ensureTable();
|
||||
}
|
||||
|
||||
public function subscribeAsync(int $logisticsId): void
|
||||
{
|
||||
try {
|
||||
$this->subscribe($logisticsId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('kuaidi100 subscribe skipped', [
|
||||
'logistics_id' => $logisticsId,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function subscribe(int $logisticsId): array
|
||||
{
|
||||
$logistics = $this->findLogistics($logisticsId);
|
||||
if (!$logistics) {
|
||||
throw new \RuntimeException('物流记录不存在');
|
||||
}
|
||||
|
||||
$sync = $this->ensureSyncRow($logistics);
|
||||
if (!$this->configService->getConfig()['enabled']) {
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'subscription_status' => 'disabled',
|
||||
'last_error' => '',
|
||||
]);
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
|
||||
$companyCode = trim((string)($sync['provider_com'] ?? ''));
|
||||
|
||||
try {
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'subscription_status' => 'subscribing',
|
||||
'last_error' => '',
|
||||
]);
|
||||
$response = $this->client->subscribe(
|
||||
$companyCode,
|
||||
(string)$logistics['tracking_no'],
|
||||
$this->resolvePhoneForLogistics($logistics)
|
||||
);
|
||||
$status = $this->subscriptionSucceeded($response) ? 'subscribed' : 'failed';
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'subscription_status' => $status,
|
||||
'raw_status' => (string)($response['result'] ?? $response['returnCode'] ?? ''),
|
||||
'last_error' => $status === 'subscribed' ? '' : $this->errorMessageFromResponse($response),
|
||||
'raw_summary' => $this->encodeRawSummary($response),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'subscription_status' => 'failed',
|
||||
'last_error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
|
||||
public function syncByLogisticsId(int $logisticsId, bool $force = false): array
|
||||
{
|
||||
$logistics = $this->findLogistics($logisticsId);
|
||||
if (!$logistics) {
|
||||
throw new \RuntimeException('物流记录不存在');
|
||||
}
|
||||
|
||||
$sync = $this->ensureSyncRow($logistics);
|
||||
if (!$this->configService->isReadyForQuery()) {
|
||||
$this->markError((int)$sync['id'], '快递100实时查询配置未完成', ['last_query_at' => date('Y-m-d H:i:s')]);
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
|
||||
if (!$force && !$this->shouldQuery($sync)) {
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
|
||||
$companyCode = trim((string)($sync['provider_com'] ?? ''));
|
||||
if ($companyCode === '') {
|
||||
$companyCode = $this->resolveCompanyCode($logistics);
|
||||
if ($companyCode !== '') {
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'provider_com' => $companyCode,
|
||||
]);
|
||||
} else {
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'last_query_at' => date('Y-m-d H:i:s'),
|
||||
'last_error' => '',
|
||||
]);
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->query(
|
||||
$companyCode,
|
||||
(string)$logistics['tracking_no'],
|
||||
$this->resolvePhoneForLogistics($logistics)
|
||||
);
|
||||
$this->applyTrackPayload($logistics, $response, 'query');
|
||||
} catch (\Throwable $e) {
|
||||
$this->markError((int)$sync['id'], $e->getMessage(), ['last_query_at' => date('Y-m-d H:i:s')]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
|
||||
public function syncDue(int $limit = 50): int
|
||||
{
|
||||
if (!$this->configService->isReadyForQuery()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$config = $this->configService->getConfig();
|
||||
$before = date('Y-m-d H:i:s', time() - $config['query_min_interval_minutes'] * 60);
|
||||
$rows = Db::name('order_logistics')
|
||||
->alias('l')
|
||||
->leftJoin('order_logistics_syncs s', 's.logistics_id = l.id AND s.provider = "' . self::PROVIDER . '"')
|
||||
->field(['l.id'])
|
||||
->where('l.tracking_no', '<>', '')
|
||||
->where('l.tracking_status', '<>', 'received')
|
||||
->whereRaw('(s.last_query_at IS NULL OR s.last_query_at <= :before)', ['before' => $before])
|
||||
->order('l.id', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$count = 0;
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$this->syncByLogisticsId((int)$row['id'], true);
|
||||
$count++;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('kuaidi100 due sync failed', [
|
||||
'logistics_id' => (int)$row['id'],
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function handleCallback(array $payload): array
|
||||
{
|
||||
$result = $this->extractTrackResult($payload);
|
||||
$trackingNo = trim((string)($result['nu'] ?? $result['number'] ?? ''));
|
||||
if ($trackingNo === '') {
|
||||
throw new \InvalidArgumentException('快递100回调缺少运单号');
|
||||
}
|
||||
|
||||
$rows = Db::name('order_logistics')
|
||||
->where('tracking_no', $trackingNo)
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
if (!$rows) {
|
||||
throw new \RuntimeException('未找到对应物流记录');
|
||||
}
|
||||
|
||||
$updatedIds = [];
|
||||
foreach ($rows as $logistics) {
|
||||
$this->ensureSyncRow($logistics, trim((string)($result['com'] ?? '')));
|
||||
$this->applyTrackPayload($logistics, $result, 'push');
|
||||
$updatedIds[] = (int)$logistics['id'];
|
||||
}
|
||||
|
||||
return [
|
||||
'tracking_no' => $trackingNo,
|
||||
'updated_ids' => $updatedIds,
|
||||
];
|
||||
}
|
||||
|
||||
public function formatSyncStatus(int $logisticsId): array
|
||||
{
|
||||
$sync = Db::name('order_logistics_syncs')
|
||||
->where('logistics_id', $logisticsId)
|
||||
->where('provider', self::PROVIDER)
|
||||
->find();
|
||||
|
||||
if (!$sync) {
|
||||
return [
|
||||
'provider_status_text' => '',
|
||||
'sync_status_text' => '未同步',
|
||||
'sync_error' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$error = trim((string)($sync['last_error'] ?? ''));
|
||||
$providerCom = trim((string)($sync['provider_com'] ?? ''));
|
||||
$subscriptionStatus = (string)($sync['subscription_status'] ?? '');
|
||||
if ($error !== '') {
|
||||
$syncStatusText = '同步异常';
|
||||
} elseif ($subscriptionStatus === 'subscribing') {
|
||||
$syncStatusText = '同步中';
|
||||
} elseif ($subscriptionStatus === 'subscribed') {
|
||||
$syncStatusText = '已订阅';
|
||||
} elseif ($subscriptionStatus === 'disabled') {
|
||||
$syncStatusText = '未启用';
|
||||
} elseif ($providerCom === '') {
|
||||
$syncStatusText = '未识别';
|
||||
} elseif (!empty($sync['last_push_at']) || !empty($sync['last_query_at'])) {
|
||||
$syncStatusText = '已同步';
|
||||
} else {
|
||||
$syncStatusText = '待同步';
|
||||
}
|
||||
|
||||
return [
|
||||
'provider_status_text' => (string)($sync['provider_status_text'] ?? ''),
|
||||
'sync_status_text' => $syncStatusText,
|
||||
'sync_error' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
public function nodesForLogistics(int $logisticsId): array
|
||||
{
|
||||
if ($logisticsId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Db::name('order_logistics_nodes')
|
||||
->where('logistics_id', $logisticsId)
|
||||
->order('node_time', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private function applyTrackPayload(array $logistics, array $result, string $source): void
|
||||
{
|
||||
$logisticsId = (int)$logistics['id'];
|
||||
$sync = $this->ensureSyncRow($logistics, trim((string)($result['com'] ?? '')));
|
||||
$nodes = $this->normalizeNodes((array)($result['data'] ?? []));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
foreach ($nodes as $node) {
|
||||
$exists = Db::name('order_logistics_nodes')
|
||||
->where('logistics_id', $logisticsId)
|
||||
->where('node_time', $node['node_time'])
|
||||
->where('node_desc', $node['node_desc'])
|
||||
->where('node_location', $node['node_location'])
|
||||
->find();
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Db::name('order_logistics_nodes')->insert([
|
||||
'logistics_id' => $logisticsId,
|
||||
'node_time' => $node['node_time'],
|
||||
'node_desc' => $node['node_desc'],
|
||||
'node_location' => $node['node_location'],
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$latest = $nodes[0] ?? null;
|
||||
if ($latest) {
|
||||
Db::name('order_logistics')->where('id', $logisticsId)->update([
|
||||
'latest_desc' => $latest['node_desc'],
|
||||
'latest_time' => $latest['node_time'],
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$syncPayload = [
|
||||
'provider_com' => trim((string)($result['com'] ?? $sync['provider_com'] ?? '')),
|
||||
'provider_state' => (string)($result['state'] ?? ''),
|
||||
'provider_status_text' => $this->providerStateText((string)($result['state'] ?? ''), (string)($result['ischeck'] ?? '')),
|
||||
'raw_status' => (string)($result['status'] ?? ''),
|
||||
'last_error' => '',
|
||||
'raw_summary' => $this->encodeRawSummary($result),
|
||||
];
|
||||
if ($source === 'push') {
|
||||
$syncPayload['last_push_at'] = $now;
|
||||
} else {
|
||||
$syncPayload['last_query_at'] = $now;
|
||||
}
|
||||
|
||||
$this->updateSync((int)$sync['id'], $syncPayload);
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeNodes(array $items): array
|
||||
{
|
||||
$nodes = [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$time = trim((string)($item['time'] ?? $item['ftime'] ?? ''));
|
||||
$desc = trim((string)($item['context'] ?? $item['status'] ?? ''));
|
||||
if ($time === '' || $desc === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nodes[] = [
|
||||
'node_time' => $time,
|
||||
'node_desc' => $desc,
|
||||
'node_location' => trim((string)($item['areaName'] ?? $item['areaCenter'] ?? $item['location'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
usort($nodes, static fn (array $left, array $right) => strcmp($right['node_time'], $left['node_time']));
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
|
||||
private function extractTrackResult(array $payload): array
|
||||
{
|
||||
if (isset($payload['lastResult']) && is_array($payload['lastResult'])) {
|
||||
return $payload['lastResult'];
|
||||
}
|
||||
if (isset($payload['data']) && is_array($payload['data']) && isset($payload['data']['lastResult']) && is_array($payload['data']['lastResult'])) {
|
||||
return $payload['data']['lastResult'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function ensureSyncRow(array $logistics, string $providerCom = ''): array
|
||||
{
|
||||
$logisticsId = (int)$logistics['id'];
|
||||
$providerCom = $providerCom !== '' ? $providerCom : $this->resolveCompanyCode($logistics);
|
||||
$row = Db::name('order_logistics_syncs')
|
||||
->where('logistics_id', $logisticsId)
|
||||
->where('provider', self::PROVIDER)
|
||||
->find();
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
if ($row) {
|
||||
if ($providerCom !== '' && (string)($row['provider_com'] ?? '') === '') {
|
||||
Db::name('order_logistics_syncs')->where('id', (int)$row['id'])->update([
|
||||
'provider_com' => $providerCom,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$row['provider_com'] = $providerCom;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
$id = (int)Db::name('order_logistics_syncs')->insertGetId([
|
||||
'logistics_id' => $logisticsId,
|
||||
'provider' => self::PROVIDER,
|
||||
'provider_com' => $providerCom,
|
||||
'subscription_status' => '',
|
||||
'provider_state' => '',
|
||||
'provider_status_text' => '',
|
||||
'last_error' => '',
|
||||
'raw_status' => '',
|
||||
'raw_summary' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return Db::name('order_logistics_syncs')->where('id', $id)->find() ?: [];
|
||||
}
|
||||
|
||||
private function resolveCompanyCode(array $logistics): string
|
||||
{
|
||||
$company = trim((string)($logistics['express_company'] ?? ''));
|
||||
if ($company === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$trackingNo = trim((string)($logistics['tracking_no'] ?? ''));
|
||||
$resolved = (new ExpressCompanyService())->resolveCompanyCode($company, $trackingNo);
|
||||
if ($resolved !== '') {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$row = Db::name('express_companies')->where('company_name', $company)->find();
|
||||
$code = trim((string)($row['company_code'] ?? ''));
|
||||
$aliasCode = $this->companyAliasCode($company, $code);
|
||||
if ($aliasCode !== '') {
|
||||
return $aliasCode;
|
||||
}
|
||||
|
||||
return preg_match('/^[a-z0-9]+$/', $code) === 1 && !str_starts_with($code, 'express_') ? $code : '';
|
||||
}
|
||||
|
||||
private function companyAliasCode(string $companyName, string $companyCode = ''): string
|
||||
{
|
||||
$aliases = [
|
||||
'顺丰速运' => 'shunfeng',
|
||||
'顺丰' => 'shunfeng',
|
||||
'sf_express' => 'shunfeng',
|
||||
'京东快递' => 'jd',
|
||||
'京东物流' => 'jd',
|
||||
'EMS' => 'ems',
|
||||
'ems' => 'ems',
|
||||
'中通快递' => 'zhongtong',
|
||||
'圆通速递' => 'yuantong',
|
||||
'圆通快递' => 'yuantong',
|
||||
'申通快递' => 'shentong',
|
||||
'韵达快递' => 'yunda',
|
||||
'极兔速递' => 'jtexpress',
|
||||
'极兔快递' => 'jtexpress',
|
||||
'德邦快递' => 'debangwuliu',
|
||||
'邮政快递包裹' => 'youzhengguonei',
|
||||
];
|
||||
|
||||
return $aliases[$companyCode] ?? $aliases[$companyName] ?? '';
|
||||
}
|
||||
|
||||
private function resolvePhoneForLogistics(array $logistics): string
|
||||
{
|
||||
$orderId = (int)($logistics['order_id'] ?? 0);
|
||||
if ($orderId <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (($logistics['logistics_type'] ?? '') === 'return_to_user') {
|
||||
$mobile = Db::name('order_return_addresses')->where('order_id', $orderId)->value('mobile');
|
||||
return trim((string)$mobile);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $orderId)->find();
|
||||
if (!$order) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$mobile = Db::name('users')->where('id', (int)$order['user_id'])->value('mobile');
|
||||
return trim((string)$mobile);
|
||||
}
|
||||
|
||||
private function shouldQuery(array $sync): bool
|
||||
{
|
||||
$lastQueryAt = trim((string)($sync['last_query_at'] ?? ''));
|
||||
if ($lastQueryAt === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$config = $this->configService->getConfig();
|
||||
return strtotime($lastQueryAt) <= time() - $config['query_min_interval_minutes'] * 60;
|
||||
}
|
||||
|
||||
private function providerStateText(string $state, string $isCheck): string
|
||||
{
|
||||
if ($isCheck === '1') {
|
||||
return '已签收';
|
||||
}
|
||||
|
||||
return match ($state) {
|
||||
'0' => '在途',
|
||||
'1' => '已揽收',
|
||||
'2' => '疑难',
|
||||
'3' => '已签收',
|
||||
'4' => '退签',
|
||||
'5' => '派件中',
|
||||
'6' => '退回中',
|
||||
'7' => '转投',
|
||||
'8' => '清关中',
|
||||
'14' => '拒签',
|
||||
default => $state === '' ? '' : '物流状态 ' . $state,
|
||||
};
|
||||
}
|
||||
|
||||
private function subscriptionSucceeded(array $response): bool
|
||||
{
|
||||
$result = $response['result'] ?? null;
|
||||
$returnCode = (string)($response['returnCode'] ?? $response['status'] ?? '');
|
||||
|
||||
return $result === true || $result === 'true' || $returnCode === '200';
|
||||
}
|
||||
|
||||
private function errorMessageFromResponse(array $response): string
|
||||
{
|
||||
return trim((string)($response['message'] ?? $response['returnMessage'] ?? '快递100返回失败'));
|
||||
}
|
||||
|
||||
private function markError(int $syncId, string $message, array $extra = []): void
|
||||
{
|
||||
$this->updateSync($syncId, array_merge($extra, [
|
||||
'last_error' => mb_substr($message, 0, 500, 'UTF-8'),
|
||||
]));
|
||||
}
|
||||
|
||||
private function updateSync(int $syncId, array $payload): void
|
||||
{
|
||||
if ($syncId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload['updated_at'] = date('Y-m-d H:i:s');
|
||||
if (isset($payload['last_error'])) {
|
||||
$payload['last_error'] = mb_substr((string)$payload['last_error'], 0, 500, 'UTF-8');
|
||||
}
|
||||
Db::name('order_logistics_syncs')->where('id', $syncId)->update($payload);
|
||||
}
|
||||
|
||||
private function findLogistics(int $logisticsId): ?array
|
||||
{
|
||||
if ($logisticsId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = Db::name('order_logistics')->where('id', $logisticsId)->find();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
private function encodeRawSummary(array $payload): string
|
||||
{
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return mb_substr(is_string($json) ? $json : '', 0, 8000, 'UTF-8');
|
||||
}
|
||||
|
||||
private function ensureTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS order_logistics_syncs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
logistics_id BIGINT UNSIGNED NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'kuaidi100',
|
||||
provider_com VARCHAR(64) NOT NULL DEFAULT '',
|
||||
subscription_status VARCHAR(32) NOT NULL DEFAULT '',
|
||||
provider_state VARCHAR(32) NOT NULL DEFAULT '',
|
||||
provider_status_text VARCHAR(64) NOT NULL DEFAULT '',
|
||||
last_query_at DATETIME NULL DEFAULT NULL,
|
||||
last_push_at DATETIME NULL DEFAULT NULL,
|
||||
last_error VARCHAR(500) NOT NULL DEFAULT '',
|
||||
raw_status VARCHAR(32) NOT NULL DEFAULT '',
|
||||
raw_summary LONGTEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_order_logistics_syncs_provider (logistics_id, provider),
|
||||
KEY idx_order_logistics_syncs_provider_com (provider, provider_com),
|
||||
KEY idx_order_logistics_syncs_last_query (last_query_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单物流第三方同步状态'
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
@@ -58,5 +58,9 @@ return [
|
||||
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
|
||||
]
|
||||
]
|
||||
],
|
||||
'kuaidi100-logistics-sync' => [
|
||||
'handler' => app\process\Kuaidi100LogisticsSyncProcess::class,
|
||||
'count' => 1,
|
||||
]
|
||||
];
|
||||
|
||||
@@ -49,6 +49,7 @@ use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbench
|
||||
use app\controller\admin\ExpressCompaniesController as AdminExpressCompaniesController;
|
||||
use app\controller\admin\FileUploadController as AdminFileUploadController;
|
||||
use app\controller\open\OrdersController as OpenOrdersController;
|
||||
use app\controller\open\Kuaidi100Controller as OpenKuaidi100Controller;
|
||||
|
||||
Route::get('/', [app\controller\IndexController::class, 'json']);
|
||||
Route::get('/T/{token}', [AppMaterialTagRedirectController::class, 'redirect']);
|
||||
@@ -181,6 +182,7 @@ Route::post('/api/app/ticket/reply', [AppTicketsController::class, 'reply']);
|
||||
Route::post('/api/app/ticket/file/upload', [AppTicketsController::class, 'uploadFile']);
|
||||
Route::post('/api/app/ticket/file/delete', [AppTicketsController::class, 'deleteFile']);
|
||||
Route::get('/api/app/order/shipping', [AppShippingController::class, 'detail']);
|
||||
Route::post('/api/app/order/shipping/recognize', [AppShippingController::class, 'recognize']);
|
||||
Route::post('/api/app/order/shipping/save', [AppShippingController::class, 'save']);
|
||||
Route::get('/api/app/addresses', [AppAddressesController::class, 'index']);
|
||||
Route::get('/api/app/address/detail', [AppAddressesController::class, 'detail']);
|
||||
@@ -191,6 +193,7 @@ Route::post('/api/app/address/delete', [AppAddressesController::class, 'delete']
|
||||
Route::post('/api/open/v1/orders', [OpenOrdersController::class, 'create']);
|
||||
Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']);
|
||||
Route::get('/api/open/v1/orders/{external_order_no}', [OpenOrdersController::class, 'detail']);
|
||||
Route::post('/api/open/kuaidi100/callback', [OpenKuaidi100Controller::class, 'callback']);
|
||||
|
||||
Route::get('/api/admin/ping', function () {
|
||||
return api_success(['pong' => true]);
|
||||
@@ -268,6 +271,9 @@ Route::get('/api/admin/warehouses/overview', [AdminWarehousesController::class,
|
||||
Route::get('/api/admin/warehouses', [AdminWarehousesController::class, 'index']);
|
||||
Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']);
|
||||
Route::get('/api/admin/express-companies', [AdminExpressCompaniesController::class, 'index']);
|
||||
Route::get('/api/admin/express-company/catalog', [AdminExpressCompaniesController::class, 'catalog']);
|
||||
Route::post('/api/admin/express-company/catalog/sync', [AdminExpressCompaniesController::class, 'syncCatalog']);
|
||||
Route::post('/api/admin/express-company/recognize', [AdminExpressCompaniesController::class, 'recognize']);
|
||||
Route::post('/api/admin/express-company/save', [AdminExpressCompaniesController::class, 'save']);
|
||||
Route::get('/api/admin/warehouse-workbench/inbound/lookup', [AdminWarehouseWorkbenchController::class, 'inboundLookup']);
|
||||
Route::post('/api/admin/warehouse-workbench/inbound/receive', [AdminWarehouseWorkbenchController::class, 'inboundReceive']);
|
||||
|
||||
@@ -12,6 +12,7 @@ DROP TABLE IF EXISTS enterprise_customer_order_refs;
|
||||
DROP TABLE IF EXISTS enterprise_api_nonces;
|
||||
DROP TABLE IF EXISTS enterprise_customer_apps;
|
||||
DROP TABLE IF EXISTS enterprise_customers;
|
||||
DROP TABLE IF EXISTS kuaidi100_express_company_catalog;
|
||||
DROP TABLE IF EXISTS express_companies;
|
||||
DROP TABLE IF EXISTS shipping_warehouses;
|
||||
DROP TABLE IF EXISTS user_api_tokens;
|
||||
@@ -47,6 +48,7 @@ DROP TABLE IF EXISTS order_transfer_flow_logs;
|
||||
DROP TABLE IF EXISTS order_transfer_flows;
|
||||
DROP TABLE IF EXISTS internal_transfer_tags;
|
||||
DROP TABLE IF EXISTS internal_transfer_tag_batches;
|
||||
DROP TABLE IF EXISTS order_logistics_syncs;
|
||||
DROP TABLE IF EXISTS order_logistics_nodes;
|
||||
DROP TABLE IF EXISTS order_logistics;
|
||||
DROP TABLE IF EXISTS order_supplement_task_items;
|
||||
@@ -731,6 +733,27 @@ CREATE TABLE order_logistics_nodes (
|
||||
KEY idx_order_logistics_nodes_logistics_id (logistics_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物流节点';
|
||||
|
||||
CREATE TABLE order_logistics_syncs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
logistics_id BIGINT UNSIGNED NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'kuaidi100',
|
||||
provider_com VARCHAR(64) NOT NULL DEFAULT '',
|
||||
subscription_status VARCHAR(32) NOT NULL DEFAULT '',
|
||||
provider_state VARCHAR(32) NOT NULL DEFAULT '',
|
||||
provider_status_text VARCHAR(64) NOT NULL DEFAULT '',
|
||||
last_query_at DATETIME NULL DEFAULT NULL,
|
||||
last_push_at DATETIME NULL DEFAULT NULL,
|
||||
last_error VARCHAR(500) NOT NULL DEFAULT '',
|
||||
raw_status VARCHAR(32) NOT NULL DEFAULT '',
|
||||
raw_summary LONGTEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_order_logistics_syncs_provider (logistics_id, provider),
|
||||
KEY idx_order_logistics_syncs_provider_com (provider, provider_com),
|
||||
KEY idx_order_logistics_syncs_last_query (last_query_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单物流第三方同步状态';
|
||||
|
||||
CREATE TABLE express_companies (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
company_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||
@@ -748,6 +771,22 @@ CREATE TABLE express_companies (
|
||||
KEY idx_express_companies_default (is_default)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快递公司字典';
|
||||
|
||||
CREATE TABLE kuaidi100_express_company_catalog (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
company_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||
company_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
company_type VARCHAR(64) NOT NULL DEFAULT '',
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_kuaidi100_express_company_catalog_code (company_code),
|
||||
KEY idx_kuaidi100_express_company_catalog_name (company_name),
|
||||
KEY idx_kuaidi100_express_company_catalog_type (company_type),
|
||||
KEY idx_kuaidi100_express_company_catalog_synced_at (synced_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快递100官方公司码表';
|
||||
|
||||
CREATE TABLE order_abnormals (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
@@ -26,6 +26,7 @@ $pdo = new PDO(
|
||||
);
|
||||
|
||||
$tables = [
|
||||
'kuaidi100_express_company_catalog',
|
||||
'express_companies',
|
||||
'shipping_warehouses',
|
||||
'order_shipping_targets',
|
||||
@@ -39,6 +40,7 @@ $tables = [
|
||||
'upload_template_items', 'upload_templates',
|
||||
'report_verifies', 'report_contents', 'reports',
|
||||
'appraisal_task_key_points', 'appraisal_task_results', 'appraisal_task_reviews', 'appraisal_task_logs', 'appraisal_tasks',
|
||||
'order_logistics_syncs', 'order_logistics_nodes', 'order_logistics',
|
||||
'order_supplement_task_items', 'order_supplement_tasks', 'order_timelines', 'order_extras', 'order_products', 'orders',
|
||||
'catalog_brand_categories', 'catalog_brands', 'catalog_categories',
|
||||
'user_addresses', 'user_auths', 'users',
|
||||
@@ -64,7 +66,7 @@ INSERT INTO shipping_warehouses (id, warehouse_name, warehouse_code, warehouse_t
|
||||
(2, '中检合作鉴定中心', 'ZJ-WH-DEFAULT', 'detection_center', 'zhongjian', '中检合作鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验中检收件中心', '周一至周日 09:30-18:30', '中检鉴定订单请优先附上鉴定单号,寄出后尽快填写运单号。', NULL, NULL, NULL, 'enabled', 1, 1, '默认仓库', '{$now}', '{$now}');
|
||||
|
||||
INSERT INTO express_companies (id, company_name, company_code, status, is_default, sort_order, remark, created_at, updated_at) VALUES
|
||||
(1, '顺丰速运', 'sf_express', 'enabled', 1, 1, '系统默认快递公司', '{$now}', '{$now}');
|
||||
(1, '顺丰速运', 'shunfeng', 'enabled', 1, 1, '系统默认快递公司', '{$now}', '{$now}');
|
||||
|
||||
INSERT INTO catalog_categories (id, name, code, sort_order, is_enabled, need_shipping, supported_service_types, created_at, updated_at) VALUES
|
||||
(1, '奢侈品箱包', 'luxury_bag', 1, 1, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'),
|
||||
|
||||
@@ -45,14 +45,18 @@ CREATE TABLE IF NOT EXISTS express_companies (
|
||||
SQL);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$stmt = $pdo->prepare('SELECT id FROM express_companies WHERE company_name = ? LIMIT 1');
|
||||
$stmt = $pdo->prepare('SELECT id, company_code FROM express_companies WHERE company_name = ? LIMIT 1');
|
||||
$stmt->execute(['顺丰速运']);
|
||||
$exists = $stmt->fetch();
|
||||
|
||||
if (!$exists) {
|
||||
$insert = $pdo->prepare('INSERT INTO express_companies (company_name, company_code, status, is_default, sort_order, remark, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
$insert->execute(['顺丰速运', 'sf_express', 'enabled', 1, 1, '系统默认快递公司', $now, $now]);
|
||||
$insert->execute(['顺丰速运', 'shunfeng', 'enabled', 1, 1, '系统默认快递公司', $now, $now]);
|
||||
echo "SEED_DEFAULT_EXPRESS_COMPANY\n";
|
||||
} elseif (in_array((string)($exists['company_code'] ?? ''), ['', 'sf_express'], true) || str_starts_with((string)($exists['company_code'] ?? ''), 'express_')) {
|
||||
$update = $pdo->prepare('UPDATE express_companies SET company_code = ?, updated_at = ? WHERE id = ?');
|
||||
$update->execute(['shunfeng', $now, (int)$exists['id']]);
|
||||
echo "UPDATE_DEFAULT_EXPRESS_COMPANY_CODE\n";
|
||||
}
|
||||
|
||||
$defaultCount = (int)$pdo->query("SELECT COUNT(*) FROM express_companies WHERE status = 'enabled' AND is_default = 1")->fetchColumn();
|
||||
|
||||
Reference in New Issue
Block a user