962 lines
33 KiB
PHP
962 lines
33 KiB
PHP
<?php
|
|
|
|
namespace app\support;
|
|
|
|
use support\Log;
|
|
use support\think\Db;
|
|
|
|
class ExpressCompanyService
|
|
{
|
|
public function __construct()
|
|
{
|
|
$this->ensureTable();
|
|
$this->ensureCatalogTable();
|
|
$this->bootstrapDefaults();
|
|
}
|
|
|
|
public function list(bool $enabledOnly = false): array
|
|
{
|
|
$query = Db::name('express_companies')
|
|
->order('is_default', 'desc')
|
|
->order('sort_order', 'asc')
|
|
->order('id', 'asc');
|
|
|
|
if ($enabledOnly) {
|
|
$query->where('status', 'enabled');
|
|
}
|
|
|
|
return array_map(fn (array $item) => $this->format($item), $query->select()->toArray());
|
|
}
|
|
|
|
public function save(array $payload, int $id = 0): int
|
|
{
|
|
$now = date('Y-m-d H:i:s');
|
|
$companyName = trim((string)($payload['company_name'] ?? ''));
|
|
$companyCode = trim((string)($payload['company_code'] ?? ''));
|
|
$status = trim((string)($payload['status'] ?? 'enabled'));
|
|
$isDefault = !empty($payload['is_default']);
|
|
|
|
if ($companyName === '') {
|
|
throw new \RuntimeException('快递公司名称不能为空');
|
|
}
|
|
if (!in_array($status, ['enabled', 'disabled'], true)) {
|
|
throw new \RuntimeException('快递公司状态无效');
|
|
}
|
|
if ($isDefault && $status !== 'enabled') {
|
|
throw new \RuntimeException('默认快递公司必须保持启用');
|
|
}
|
|
if ($companyCode === '') {
|
|
$companyCode = $this->generateCompanyCode($companyName);
|
|
}
|
|
|
|
$existsByName = Db::name('express_companies')
|
|
->where('company_name', $companyName)
|
|
->when($id > 0, fn ($query) => $query->where('id', '<>', $id))
|
|
->find();
|
|
if ($existsByName) {
|
|
throw new \RuntimeException('快递公司名称已存在');
|
|
}
|
|
|
|
$existsByCode = Db::name('express_companies')
|
|
->where('company_code', $companyCode)
|
|
->when($id > 0, fn ($query) => $query->where('id', '<>', $id))
|
|
->find();
|
|
if ($existsByCode) {
|
|
throw new \RuntimeException('快递公司编码已存在');
|
|
}
|
|
|
|
$data = [
|
|
'company_name' => $companyName,
|
|
'company_code' => $companyCode,
|
|
'status' => $status,
|
|
'is_default' => $isDefault ? 1 : 0,
|
|
'sort_order' => (int)($payload['sort_order'] ?? 0),
|
|
'remark' => trim((string)($payload['remark'] ?? '')),
|
|
'updated_at' => $now,
|
|
];
|
|
|
|
Db::startTrans();
|
|
try {
|
|
if ($isDefault) {
|
|
Db::name('express_companies')->update([
|
|
'is_default' => 0,
|
|
'updated_at' => $now,
|
|
]);
|
|
}
|
|
|
|
if ($id > 0) {
|
|
Db::name('express_companies')->where('id', $id)->update($data);
|
|
$companyId = $id;
|
|
} else {
|
|
$data['created_at'] = $now;
|
|
$companyId = (int)Db::name('express_companies')->insertGetId($data);
|
|
}
|
|
|
|
$this->ensureEnabledDefault($now);
|
|
Db::commit();
|
|
} catch (\Throwable $e) {
|
|
Db::rollback();
|
|
throw $e;
|
|
}
|
|
|
|
return $companyId;
|
|
}
|
|
|
|
public function defaultName(): string
|
|
{
|
|
$row = Db::name('express_companies')
|
|
->where('status', 'enabled')
|
|
->where('is_default', 1)
|
|
->find();
|
|
|
|
if (!$row) {
|
|
$row = Db::name('express_companies')
|
|
->where('status', 'enabled')
|
|
->order('sort_order', 'asc')
|
|
->order('id', 'asc')
|
|
->find();
|
|
}
|
|
|
|
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');
|
|
|
|
return [
|
|
'id' => (int)$item['id'],
|
|
'company_name' => (string)$item['company_name'],
|
|
'company_code' => (string)$item['company_code'],
|
|
'status' => $status,
|
|
'status_text' => $status === 'enabled' ? '启用中' : '已停用',
|
|
'is_default' => (bool)$item['is_default'],
|
|
'sort_order' => (int)$item['sort_order'],
|
|
'remark' => (string)($item['remark'] ?? ''),
|
|
'created_at' => (string)($item['created_at'] ?? ''),
|
|
'updated_at' => (string)($item['updated_at'] ?? ''),
|
|
];
|
|
}
|
|
|
|
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');
|
|
$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);
|
|
}
|
|
|
|
private function ensureEnabledDefault(string $now): void
|
|
{
|
|
$default = Db::name('express_companies')
|
|
->where('status', 'enabled')
|
|
->where('is_default', 1)
|
|
->find();
|
|
if ($default) {
|
|
return;
|
|
}
|
|
|
|
$firstEnabled = Db::name('express_companies')
|
|
->where('status', 'enabled')
|
|
->order('sort_order', 'asc')
|
|
->order('id', 'asc')
|
|
->find();
|
|
if ($firstEnabled) {
|
|
Db::name('express_companies')->where('id', $firstEnabled['id'])->update([
|
|
'is_default' => 1,
|
|
'updated_at' => $now,
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function backfillLocalCompanyCodesFromCatalog(string $now): int
|
|
{
|
|
$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
|
|
{
|
|
Db::execute(<<<'SQL'
|
|
CREATE TABLE IF NOT EXISTS express_companies (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
company_name VARCHAR(64) NOT NULL DEFAULT '',
|
|
company_code VARCHAR(64) NOT NULL DEFAULT '',
|
|
status VARCHAR(32) NOT NULL DEFAULT 'enabled',
|
|
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
|
sort_order INT NOT NULL DEFAULT 0,
|
|
remark VARCHAR(255) NOT NULL DEFAULT '',
|
|
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_express_companies_name (company_name),
|
|
UNIQUE KEY uk_express_companies_code (company_code),
|
|
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);
|
|
}
|
|
}
|