feat: add kuaidi100 logistics sync
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **anxinyan** (4880 symbols, 11926 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **anxinyan** (5063 symbols, 12441 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **anxinyan** (4880 symbols, 11926 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **anxinyan** (5063 symbols, 12441 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -253,6 +253,9 @@ export interface AdminOrderDetail {
|
||||
tracking_no: string;
|
||||
tracking_status: string;
|
||||
tracking_status_text: string;
|
||||
provider_status_text: string;
|
||||
sync_status_text: string;
|
||||
sync_error: string;
|
||||
latest_desc: string;
|
||||
latest_time: string;
|
||||
nodes: Array<{
|
||||
@@ -266,6 +269,9 @@ export interface AdminOrderDetail {
|
||||
tracking_no: string;
|
||||
tracking_status: string;
|
||||
tracking_status_text: string;
|
||||
provider_status_text: string;
|
||||
sync_status_text: string;
|
||||
sync_error: string;
|
||||
latest_desc: string;
|
||||
latest_time: string;
|
||||
nodes: Array<{
|
||||
@@ -1183,6 +1189,37 @@ export interface AdminExpressCompanyItem {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminExpressCompanyCatalogItem {
|
||||
id: number;
|
||||
company_name: string;
|
||||
company_code: string;
|
||||
company_type: string;
|
||||
display_text: string;
|
||||
source: string;
|
||||
synced_at: string;
|
||||
}
|
||||
|
||||
export interface AdminExpressCompanyRecognitionCandidate {
|
||||
company_name: string;
|
||||
company_code: string;
|
||||
official_name?: string;
|
||||
display_text: string;
|
||||
length_pre?: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface AdminExpressCompanyRecognitionResult {
|
||||
input: string;
|
||||
tracking_no: string;
|
||||
company_code: string;
|
||||
company_name: string;
|
||||
status: string;
|
||||
status_text: string;
|
||||
error_message?: string;
|
||||
resolved: null | AdminExpressCompanyRecognitionCandidate;
|
||||
candidates: AdminExpressCompanyRecognitionCandidate[];
|
||||
}
|
||||
|
||||
export interface AdminExpressCompanyPayload {
|
||||
id?: number;
|
||||
company_name: string;
|
||||
@@ -2288,6 +2325,37 @@ export const adminApi = {
|
||||
};
|
||||
}>;
|
||||
},
|
||||
getExpressCompanyCatalog(params?: { keyword?: string; limit?: number }) {
|
||||
return request.get("/api/admin/express-company/catalog", { params }) as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
list: AdminExpressCompanyCatalogItem[];
|
||||
total: number;
|
||||
synced_at: string;
|
||||
};
|
||||
}>;
|
||||
},
|
||||
syncExpressCompanyCatalog() {
|
||||
return request.post("/api/admin/express-company/catalog/sync") as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
total: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
backfilled: number;
|
||||
synced_at: string;
|
||||
};
|
||||
}>;
|
||||
},
|
||||
recognizeExpressCompany(data: { tracking_no: string; company_name?: string; company_code?: string }) {
|
||||
return request.post("/api/admin/express-company/recognize", data) as Promise<{
|
||||
code: number;
|
||||
message: string;
|
||||
data: AdminExpressCompanyRecognitionResult;
|
||||
}>;
|
||||
},
|
||||
saveExpressCompany(data: AdminExpressCompanyPayload) {
|
||||
return request.post("/api/admin/express-company/save", data) as Promise<{
|
||||
code: number;
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { Refresh } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
adminApi,
|
||||
type AdminExpressCompanyCatalogItem,
|
||||
type AdminExpressCompanyItem,
|
||||
type AdminExpressCompanyPayload,
|
||||
} from "../../api/admin";
|
||||
import OrderStatusTag from "../../components/OrderStatusTag.vue";
|
||||
|
||||
type CatalogSuggestion = AdminExpressCompanyCatalogItem & { value: string };
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const companies = ref<AdminExpressCompanyItem[]>([]);
|
||||
const defaultCompany = ref("");
|
||||
const catalogTotal = ref(0);
|
||||
const catalogSyncedAt = ref("");
|
||||
const catalogSyncing = ref(false);
|
||||
const catalogLoading = ref(false);
|
||||
|
||||
const enabledCount = computed(() => companies.value.filter((item) => item.status === "enabled").length);
|
||||
const displaySyncedAt = computed(() => (catalogSyncedAt.value && catalogSyncedAt.value !== "0" ? catalogSyncedAt.value : "-"));
|
||||
|
||||
const form = reactive<AdminExpressCompanyPayload>({
|
||||
company_name: "",
|
||||
@@ -39,6 +48,32 @@ async function fetchCompanies() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCatalogSummary() {
|
||||
try {
|
||||
const response = await adminApi.getExpressCompanyCatalog({ limit: 1 });
|
||||
catalogTotal.value = response.data.total;
|
||||
catalogSyncedAt.value = String(response.data.synced_at || "");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCatalog() {
|
||||
catalogSyncing.value = true;
|
||||
try {
|
||||
const response = await adminApi.syncExpressCompanyCatalog();
|
||||
catalogTotal.value = response.data.total;
|
||||
catalogSyncedAt.value = String(response.data.synced_at || "");
|
||||
ElMessage.success(`公司码表已同步,共 ${response.data.total} 条`);
|
||||
await fetchCompanies();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
ElMessage.error(error?.message || "快递100公司码表同步失败");
|
||||
} finally {
|
||||
catalogSyncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(row?: AdminExpressCompanyItem) {
|
||||
if (row) {
|
||||
form.id = row.id;
|
||||
@@ -60,6 +95,27 @@ function openDialog(row?: AdminExpressCompanyItem) {
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function resolveCatalogCode() {
|
||||
const keyword = form.company_code.trim() || form.company_name.trim();
|
||||
if (!keyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await adminApi.getExpressCompanyCatalog({ keyword, limit: 10 });
|
||||
const exact = response.data.list.find((item) => item.company_code === keyword || item.company_name === keyword);
|
||||
const candidate = exact || response.data.list[0];
|
||||
if (candidate) {
|
||||
form.company_code = candidate.company_code;
|
||||
if (!form.company_name.trim()) {
|
||||
form.company_name = candidate.company_name;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.company_name.trim()) {
|
||||
ElMessage.warning("请填写快递公司名称");
|
||||
@@ -70,6 +126,10 @@ async function submit() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9]+$/i.test(form.company_code.trim()) || form.company_code.trim().startsWith("express_")) {
|
||||
await resolveCatalogCode();
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
await adminApi.saveExpressCompany({
|
||||
@@ -89,11 +149,40 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function queryCatalog(queryString: string, cb: (items: CatalogSuggestion[]) => void) {
|
||||
catalogLoading.value = true;
|
||||
try {
|
||||
const response = await adminApi.getExpressCompanyCatalog({
|
||||
keyword: queryString.trim(),
|
||||
limit: 20,
|
||||
});
|
||||
const suggestions = response.data.list.map((item) => ({
|
||||
...item,
|
||||
value: item.display_text,
|
||||
}));
|
||||
cb(suggestions);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
cb([]);
|
||||
} finally {
|
||||
catalogLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCatalogSelect(item: CatalogSuggestion) {
|
||||
form.company_code = item.company_code;
|
||||
if (!form.company_name.trim()) {
|
||||
form.company_name = item.company_name;
|
||||
}
|
||||
}
|
||||
|
||||
function statusTagText(row: AdminExpressCompanyItem) {
|
||||
return row.is_default ? `${row.status_text} / 默认` : row.status_text;
|
||||
}
|
||||
|
||||
onMounted(fetchCompanies);
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchCompanies(), fetchCatalogSummary()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -109,6 +198,16 @@ onMounted(fetchCompanies);
|
||||
<div class="metric-card__value">{{ enabledCount }}</div>
|
||||
<div class="metric-card__desc">仓管寄回下拉列表中可选择</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">官方码表</div>
|
||||
<div class="metric-card__value">{{ catalogTotal }}</div>
|
||||
<div class="metric-card__desc">快递100 官方公司编码库</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">最近同步</div>
|
||||
<div class="metric-card__value metric-card__value--text">{{ displaySyncedAt }}</div>
|
||||
<div class="metric-card__desc">官方码表最后一次同步时间</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__label">默认快递公司</div>
|
||||
<div class="metric-card__value metric-card__value--text">{{ defaultCompany || "-" }}</div>
|
||||
@@ -119,16 +218,18 @@ onMounted(fetchCompanies);
|
||||
<el-card class="panel-card" shadow="never">
|
||||
<div class="filters-row" style="justify-content: space-between;">
|
||||
<div style="color: var(--admin-text-subtle);">
|
||||
维护仓管寄回时可选的快递公司。停用后不会出现在寄回下拉列表中。
|
||||
维护仓管寄回时可选的快递公司。公司码优先从快递100官方码表检索,停用后不会出现在寄回下拉列表中。
|
||||
</div>
|
||||
<el-button type="primary" @click="openDialog()">新增快递公司</el-button>
|
||||
<el-button :icon="Refresh" :loading="catalogSyncing" type="primary" @click="syncCatalog">
|
||||
同步快递100公司码表
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="panel-card orders-table" shadow="never">
|
||||
<el-table :data="companies" stripe>
|
||||
<el-table-column prop="company_name" label="快递公司" min-width="180" />
|
||||
<el-table-column prop="company_code" label="编码" min-width="160" />
|
||||
<el-table-column prop="company_code" label="快递100编码" min-width="160" />
|
||||
<el-table-column label="状态" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<OrderStatusTag :status="statusTagText(row)" />
|
||||
@@ -150,8 +251,28 @@ onMounted(fetchCompanies);
|
||||
<el-form-item label="快递公司名称">
|
||||
<el-input v-model="form.company_name" maxlength="64" placeholder="例如:顺丰速运" />
|
||||
</el-form-item>
|
||||
<el-form-item label="编码">
|
||||
<el-input v-model="form.company_code" maxlength="64" placeholder="可留空,系统自动生成" />
|
||||
<el-form-item label="快递100编码">
|
||||
<el-autocomplete
|
||||
v-model="form.company_code"
|
||||
:fetch-suggestions="queryCatalog"
|
||||
:loading="catalogLoading"
|
||||
clearable
|
||||
placeholder="输入名称或编码搜索官方码表"
|
||||
style="width: 100%"
|
||||
@select="handleCatalogSelect"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div style="display: grid; gap: 4px;">
|
||||
<div style="font-weight: 700;">{{ item.company_name }}</div>
|
||||
<div style="color: var(--admin-text-subtle); font-size: 12px;">
|
||||
{{ item.company_code }}{{ item.company_type ? ` / ${item.company_type}` : "" }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
<div style="margin-top: 6px; color: var(--admin-text-subtle); font-size: 12px;">
|
||||
支持按名称或编码检索快递100官方码表,选中后会自动回填公司码。
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
@@ -172,7 +293,7 @@ onMounted(fetchCompanies);
|
||||
<el-checkbox v-model="form.is_default">设为默认快递公司</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="255" placeholder="内部备注,可不填" />
|
||||
<el-input v-model="form.remark" :rows="3" maxlength="255" placeholder="内部备注,可不填" type="textarea" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { adminApi, type AdminExpressCompanyItem, type AdminManualOrderCreatePayload, type AdminManualOrderMeta, type AdminOrderDetail, type AdminOrderListItem, type AdminOrderWarehouseOption } from "../../api/admin";
|
||||
import {
|
||||
adminApi,
|
||||
type AdminExpressCompanyItem,
|
||||
type AdminExpressCompanyRecognitionCandidate,
|
||||
type AdminManualOrderCreatePayload,
|
||||
type AdminManualOrderMeta,
|
||||
type AdminOrderDetail,
|
||||
type AdminOrderListItem,
|
||||
type AdminOrderWarehouseOption,
|
||||
} from "../../api/admin";
|
||||
import OrderStatusTag from "../../components/OrderStatusTag.vue";
|
||||
import { recognizeReturnAddress } from "../../utils/address-recognition";
|
||||
|
||||
@@ -19,6 +28,8 @@ const returnDialogVisible = ref(false);
|
||||
const returnSubmitting = ref(false);
|
||||
const returnExpressCompany = ref("");
|
||||
const returnTrackingNo = ref("");
|
||||
const returnRecognitionLoading = ref(false);
|
||||
const returnRecognitionCandidates = ref<AdminExpressCompanyRecognitionCandidate[]>([]);
|
||||
const expressCompanyLoading = ref(false);
|
||||
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
|
||||
const defaultExpressCompany = ref("");
|
||||
@@ -139,6 +150,8 @@ const expressCompanySelectOptions = computed(() => {
|
||||
...expressCompanyOptions.value,
|
||||
];
|
||||
});
|
||||
let returnRecognitionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function createManualOrderForm(): AdminManualOrderCreatePayload {
|
||||
return {
|
||||
service_provider: "anxinyan",
|
||||
@@ -377,7 +390,53 @@ async function openReturnDialog() {
|
||||
await ensureExpressCompanyOptions();
|
||||
returnExpressCompany.value = detail.value.return_logistics?.express_company || defaultExpressCompany.value || expressCompanyOptions.value[0]?.company_name || "";
|
||||
returnTrackingNo.value = detail.value.return_logistics?.tracking_no || "";
|
||||
returnRecognitionCandidates.value = [];
|
||||
returnDialogVisible.value = true;
|
||||
if (returnTrackingNo.value) {
|
||||
scheduleReturnRecognition();
|
||||
}
|
||||
}
|
||||
|
||||
async function recognizeReturnExpressCompany() {
|
||||
const trackingNo = returnTrackingNo.value.trim();
|
||||
if (!returnDialogVisible.value || !trackingNo) {
|
||||
returnRecognitionCandidates.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
returnRecognitionLoading.value = true;
|
||||
try {
|
||||
const response = await adminApi.recognizeExpressCompany({
|
||||
tracking_no: trackingNo,
|
||||
company_name: returnExpressCompany.value.trim(),
|
||||
});
|
||||
const result = response.data;
|
||||
returnRecognitionCandidates.value = result.candidates || [];
|
||||
if (result.resolved) {
|
||||
returnExpressCompany.value = result.resolved.company_name;
|
||||
} else if (result.candidates.length === 1) {
|
||||
returnExpressCompany.value = result.candidates[0].company_name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
returnRecognitionCandidates.value = [];
|
||||
} finally {
|
||||
returnRecognitionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReturnRecognition() {
|
||||
if (returnRecognitionTimer) {
|
||||
clearTimeout(returnRecognitionTimer);
|
||||
}
|
||||
returnRecognitionTimer = setTimeout(() => {
|
||||
void recognizeReturnExpressCompany();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function chooseReturnRecognitionCandidate(candidate: AdminExpressCompanyRecognitionCandidate) {
|
||||
returnExpressCompany.value = candidate.company_name;
|
||||
returnRecognitionCandidates.value = [candidate];
|
||||
}
|
||||
|
||||
async function submitReturnLogistics() {
|
||||
@@ -409,6 +468,18 @@ async function submitReturnLogistics() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(returnTrackingNo, () => {
|
||||
if (returnDialogVisible.value) {
|
||||
scheduleReturnRecognition();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (returnRecognitionTimer) {
|
||||
clearTimeout(returnRecognitionTimer);
|
||||
}
|
||||
});
|
||||
|
||||
async function markReturnReceived() {
|
||||
if (!detail.value) return;
|
||||
returnReceiveSubmitting.value = true;
|
||||
@@ -655,10 +726,18 @@ onMounted(fetchOrders);
|
||||
<div class="order-detail-item__label">物流状态</div>
|
||||
<div class="order-detail-item__value">{{ logisticsActionText }}</div>
|
||||
</div>
|
||||
<div class="order-detail-item">
|
||||
<div class="order-detail-item__label">快递100状态</div>
|
||||
<div class="order-detail-item__value">{{ detail.logistics_info.provider_status_text || detail.logistics_info.sync_status_text || "-" }}</div>
|
||||
</div>
|
||||
<div class="order-detail-item order-detail-item--full">
|
||||
<div class="order-detail-item__label">最新节点</div>
|
||||
<div class="order-detail-item__value">{{ detail.logistics_info.latest_desc || "-" }}</div>
|
||||
</div>
|
||||
<div class="order-detail-item order-detail-item--full" v-if="detail.logistics_info.sync_error">
|
||||
<div class="order-detail-item__label">同步异常</div>
|
||||
<div class="order-detail-item__value">{{ detail.logistics_info.sync_error }}</div>
|
||||
</div>
|
||||
<div class="order-detail-item order-detail-item--full" v-if="detail.logistics_info.latest_time">
|
||||
<div class="order-detail-item__label">最新更新时间</div>
|
||||
<div class="order-detail-item__value">{{ detail.logistics_info.latest_time }}</div>
|
||||
@@ -680,10 +759,18 @@ onMounted(fetchOrders);
|
||||
<div class="order-detail-item__label">物流状态</div>
|
||||
<div class="order-detail-item__value">{{ detail.return_logistics.tracking_status_text }}</div>
|
||||
</div>
|
||||
<div class="order-detail-item">
|
||||
<div class="order-detail-item__label">快递100状态</div>
|
||||
<div class="order-detail-item__value">{{ detail.return_logistics.provider_status_text || detail.return_logistics.sync_status_text || "-" }}</div>
|
||||
</div>
|
||||
<div class="order-detail-item order-detail-item--full">
|
||||
<div class="order-detail-item__label">最新节点</div>
|
||||
<div class="order-detail-item__value">{{ detail.return_logistics.latest_desc || "-" }}</div>
|
||||
</div>
|
||||
<div class="order-detail-item order-detail-item--full" v-if="detail.return_logistics.sync_error">
|
||||
<div class="order-detail-item__label">同步异常</div>
|
||||
<div class="order-detail-item__value">{{ detail.return_logistics.sync_error }}</div>
|
||||
</div>
|
||||
<div class="order-detail-item order-detail-item--full" v-if="detail.return_logistics.latest_time">
|
||||
<div class="order-detail-item__label">最新更新时间</div>
|
||||
<div class="order-detail-item__value">{{ detail.return_logistics.latest_time }}</div>
|
||||
@@ -738,6 +825,18 @@ onMounted(fetchOrders);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card" v-if="detail.return_logistics" style="grid-column: 1 / -1">
|
||||
<div class="detail-card__title">回寄物流轨迹</div>
|
||||
<div v-if="detail.return_logistics.nodes.length" class="timeline-list" style="margin-top: 14px">
|
||||
<div v-for="item in detail.return_logistics.nodes" :key="`${item.node_time}-${item.node_desc}`" class="timeline-node">
|
||||
<div class="timeline-node__title">{{ item.node_desc }}</div>
|
||||
<div class="timeline-node__time">{{ item.node_time }}</div>
|
||||
<div class="timeline-node__desc">{{ item.node_location || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无回寄物流轨迹" :image-size="64" />
|
||||
</div>
|
||||
|
||||
<div class="detail-card" v-if="detail.supplement_task" style="grid-column: 1 / -1">
|
||||
<div class="detail-card__title">补图任务</div>
|
||||
<div class="detail-card__desc">
|
||||
@@ -810,6 +909,20 @@ onMounted(fetchOrders);
|
||||
<el-form-item label="回寄运单号">
|
||||
<el-input v-model="returnTrackingNo" placeholder="请输入回寄运单号" />
|
||||
</el-form-item>
|
||||
<div v-if="returnRecognitionLoading" style="margin: -8px 0 12px; color: var(--admin-text-subtle); font-size: 12px;">
|
||||
正在识别快递公司...
|
||||
</div>
|
||||
<div v-if="returnRecognitionCandidates.length" style="display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px;">
|
||||
<el-tag
|
||||
v-for="candidate in returnRecognitionCandidates"
|
||||
:key="`${candidate.company_code}-${candidate.company_name}`"
|
||||
effect="plain"
|
||||
style="cursor: pointer;"
|
||||
@click="chooseReturnRecognitionCandidate(candidate)"
|
||||
>
|
||||
{{ candidate.company_name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="detail?.return_address"
|
||||
type="info"
|
||||
|
||||
@@ -10,7 +10,7 @@ const uploadingKey = ref("");
|
||||
const groups = ref<AdminSystemConfigGroupItem[]>([]);
|
||||
const groupSnapshots = ref<Record<string, Record<string, string>>>({});
|
||||
|
||||
const groupOrder = ["file_storage", "mini_program", "h5", "payment", "sms"];
|
||||
const groupOrder = ["file_storage", "mini_program", "h5", "payment", "sms", "kuaidi100"];
|
||||
|
||||
function cloneSnapshot(groupsList: AdminSystemConfigGroupItem[]) {
|
||||
return Object.fromEntries(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -207,6 +207,9 @@ export interface OrderDetailData {
|
||||
tracking_no: string;
|
||||
tracking_status: string;
|
||||
tracking_status_text: string;
|
||||
provider_status_text: string;
|
||||
sync_status_text: string;
|
||||
sync_error: string;
|
||||
latest_desc: string;
|
||||
latest_time: string;
|
||||
nodes: Array<{
|
||||
@@ -302,6 +305,9 @@ export interface ShippingDetailData {
|
||||
tracking_no: string;
|
||||
tracking_status: string;
|
||||
tracking_status_text: string;
|
||||
provider_status_text: string;
|
||||
sync_status_text: string;
|
||||
sync_error: string;
|
||||
latest_desc: string;
|
||||
latest_time: string;
|
||||
is_submitted: boolean;
|
||||
@@ -314,6 +320,27 @@ export interface ShippingDetailData {
|
||||
can_submit_tracking: boolean;
|
||||
}
|
||||
|
||||
export interface ExpressCompanyRecognitionCandidate {
|
||||
company_name: string;
|
||||
company_code: string;
|
||||
official_name?: string;
|
||||
display_text: string;
|
||||
length_pre?: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface ExpressCompanyRecognitionResult {
|
||||
input: string;
|
||||
tracking_no: string;
|
||||
company_code: string;
|
||||
company_name: string;
|
||||
status: string;
|
||||
status_text: string;
|
||||
error_message?: string;
|
||||
resolved: null | ExpressCompanyRecognitionCandidate;
|
||||
candidates: ExpressCompanyRecognitionCandidate[];
|
||||
}
|
||||
|
||||
export interface UserAddressItem {
|
||||
id: number;
|
||||
consignee: string;
|
||||
@@ -627,6 +654,12 @@ export const appApi = {
|
||||
params: { order_id: orderId },
|
||||
});
|
||||
},
|
||||
recognizeOrderShippingCompany(data: { tracking_no: string; company_name?: string; company_code?: string }) {
|
||||
return request<ExpressCompanyRecognitionResult>("/api/app/order/shipping/recognize", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
saveOrderShipping(payload: {
|
||||
order_id: number;
|
||||
express_company: string;
|
||||
|
||||
@@ -301,6 +301,9 @@ export const shippingDetailFallback: ShippingDetailData = {
|
||||
tracking_no: "",
|
||||
tracking_status: "",
|
||||
tracking_status_text: "待提交",
|
||||
provider_status_text: "",
|
||||
sync_status_text: "未同步",
|
||||
sync_error: "",
|
||||
latest_desc: "",
|
||||
latest_time: "",
|
||||
is_submitted: false,
|
||||
|
||||
@@ -63,6 +63,10 @@ const materialItems = computed(() => detail.value.materials || []);
|
||||
const hasReturnAddress = computed(() => Boolean(detail.value.return_address));
|
||||
const hasReturnLogistics = computed(() => Boolean(detail.value.return_logistics?.tracking_no));
|
||||
const returnReceived = computed(() => detail.value.return_logistics?.tracking_status === "received");
|
||||
const returnLogisticsNodes = computed(() => detail.value.return_logistics?.nodes || []);
|
||||
const returnLogisticsStatusText = computed(
|
||||
() => detail.value.return_logistics?.provider_status_text || detail.value.return_logistics?.tracking_status_text || "",
|
||||
);
|
||||
const canEditReturnAddress = computed(() => detail.value.order_info.can_edit_return_address);
|
||||
|
||||
const heroTagClass = computed(() => {
|
||||
@@ -459,7 +463,7 @@ onShow(fetchDetail);
|
||||
<view v-if="detail.return_logistics" id="return-logistics-card" class="return-logistics-card">
|
||||
<view class="return-logistics-card__top">
|
||||
<view class="return-logistics-card__title">回寄物流</view>
|
||||
<text class="detail-chip">{{ detail.return_logistics.tracking_status_text }}</text>
|
||||
<text class="detail-chip">{{ returnLogisticsStatusText || detail.return_logistics.tracking_status_text }}</text>
|
||||
</view>
|
||||
<view class="report-meta__row">
|
||||
<text class="report-meta__label">快递公司</text>
|
||||
@@ -473,6 +477,27 @@ onShow(fetchDetail);
|
||||
<text class="report-meta__label">最新进展</text>
|
||||
<text class="report-meta__value">{{ detail.return_logistics.latest_desc || "待平台登记回寄运单" }}</text>
|
||||
</view>
|
||||
<view v-if="returnLogisticsStatusText" class="report-meta__row">
|
||||
<text class="report-meta__label">快递状态</text>
|
||||
<text class="report-meta__value">{{ returnLogisticsStatusText }}</text>
|
||||
</view>
|
||||
<view v-if="returnLogisticsNodes.length" class="compact-timeline return-logistics-card__timeline">
|
||||
<view
|
||||
v-for="(item, index) in returnLogisticsNodes"
|
||||
:key="`${item.node_time}-${item.node_desc}`"
|
||||
:class="['compact-timeline__item', index === 0 ? 'compact-timeline__item--current' : '']"
|
||||
>
|
||||
<view class="compact-timeline__rail"></view>
|
||||
<view class="compact-timeline__dot"></view>
|
||||
<view class="compact-timeline__content">
|
||||
<view class="compact-timeline__row">
|
||||
<text class="compact-timeline__title">{{ item.node_desc }}</text>
|
||||
<text class="compact-timeline__time">{{ item.node_time }}</text>
|
||||
</view>
|
||||
<view v-if="item.node_location" class="compact-timeline__desc">{{ item.node_location }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -770,6 +795,10 @@ onShow(fetchDetail);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.return-logistics-card__timeline {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.return-address-card__address,
|
||||
.return-address-sheet__item-address {
|
||||
margin-top: 14rpx;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { appApi, type ShippingDetailData } from "../../api/app";
|
||||
import { appApi, type ExpressCompanyRecognitionCandidate, type ShippingDetailData } from "../../api/app";
|
||||
import { shippingDetailFallback } from "../../mocks/app";
|
||||
import { resolveErrorMessage, showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
import { getPrivacyMode, maskOrderNo } from "../../utils/privacy";
|
||||
@@ -17,9 +17,18 @@ const warehouseSheetVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const pageReady = ref(false);
|
||||
const loadError = ref("");
|
||||
const recognitionLoading = ref(false);
|
||||
const recognitionCandidates = ref<ExpressCompanyRecognitionCandidate[]>([]);
|
||||
let recognitionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const submitted = computed(() => detail.value.logistics_info.is_submitted);
|
||||
const canEditTracking = computed(() => !submitted.value && detail.value.can_submit_tracking);
|
||||
const logisticsStatusText = computed(
|
||||
() => detail.value.logistics_info.provider_status_text || detail.value.logistics_info.tracking_status_text || "待提交",
|
||||
);
|
||||
const logisticsLatestDesc = computed(
|
||||
() => detail.value.logistics_info.latest_desc || detail.value.logistics_info.sync_status_text || "",
|
||||
);
|
||||
const hasWarehouseChoices = computed(
|
||||
() => detail.value.shipping_options.can_select_warehouse && detail.value.shipping_options.list.length > 1,
|
||||
);
|
||||
@@ -102,6 +111,47 @@ function useCompany(name: string) {
|
||||
expressCompany.value = name;
|
||||
}
|
||||
|
||||
async function recognizeExpressCompany() {
|
||||
const trackingValue = trackingNo.value.trim();
|
||||
if (!trackingValue || submitted.value) {
|
||||
recognitionCandidates.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
recognitionLoading.value = true;
|
||||
try {
|
||||
const result = await appApi.recognizeOrderShippingCompany({
|
||||
tracking_no: trackingValue,
|
||||
company_name: expressCompany.value.trim(),
|
||||
});
|
||||
recognitionCandidates.value = result.candidates || [];
|
||||
if (result.resolved) {
|
||||
expressCompany.value = result.resolved.company_name;
|
||||
} else if (result.candidates.length === 1) {
|
||||
expressCompany.value = result.candidates[0].company_name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
recognitionCandidates.value = [];
|
||||
} finally {
|
||||
recognitionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRecognition() {
|
||||
if (recognitionTimer) {
|
||||
clearTimeout(recognitionTimer);
|
||||
}
|
||||
recognitionTimer = setTimeout(() => {
|
||||
void recognizeExpressCompany();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function chooseRecognitionCandidate(candidate: ExpressCompanyRecognitionCandidate) {
|
||||
expressCompany.value = candidate.company_name;
|
||||
recognitionCandidates.value = [candidate];
|
||||
}
|
||||
|
||||
async function fetchDetail() {
|
||||
if (!orderId.value) return;
|
||||
loading.value = true;
|
||||
@@ -168,6 +218,16 @@ onLoad((options) => {
|
||||
});
|
||||
|
||||
onShow(fetchDetail);
|
||||
|
||||
watch(trackingNo, () => {
|
||||
scheduleRecognition();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (recognitionTimer) {
|
||||
clearTimeout(recognitionTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -254,7 +314,7 @@ onShow(fetchDetail);
|
||||
<view class="metric-card__label">当前寄送商品,请确保与订单信息一致</view>
|
||||
</view>
|
||||
<view class="metric-card">
|
||||
<view class="metric-card__value">{{ submitted ? detail.logistics_info.tracking_status_text : "待提交" }}</view>
|
||||
<view class="metric-card__value">{{ submitted ? logisticsStatusText : "待提交" }}</view>
|
||||
<view class="metric-card__label">寄送状态,提交运单后我们会继续同步节点</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -288,6 +348,17 @@ onShow(fetchDetail);
|
||||
<input v-model="trackingNo" class="field-input" maxlength="40" placeholder="请输入快递单号" :disabled="submitted" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="recognitionLoading" class="form-group__hint">正在识别快递公司...</view>
|
||||
<view v-if="recognitionCandidates.length" class="chip-list" style="margin-top: 16rpx;">
|
||||
<view
|
||||
v-for="candidate in recognitionCandidates"
|
||||
:key="`${candidate.company_code}-${candidate.company_name}`"
|
||||
class="choice-chip"
|
||||
@click="chooseRecognitionCandidate(candidate)"
|
||||
>
|
||||
{{ candidate.company_name }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-group__hint">
|
||||
{{ submitted ? "如物流信息存在异常,请联系平台客服协助处理。" : "提交后将进入待签收跟踪状态,请确认信息无误后再提交。" }}
|
||||
</view>
|
||||
@@ -295,7 +366,7 @@ onShow(fetchDetail);
|
||||
|
||||
<view v-if="submitted" class="section timeline-panel">
|
||||
<view class="section__title">寄送轨迹</view>
|
||||
<view class="section__desc">{{ detail.logistics_info.latest_desc }}</view>
|
||||
<view class="section__desc">{{ logisticsLatestDesc }}</view>
|
||||
<view class="timeline" style="margin-top: 24rpx">
|
||||
<view
|
||||
v-for="item in detail.logistics_nodes"
|
||||
|
||||
@@ -46,6 +46,37 @@ export interface AdminExpressCompanyItem {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminExpressCompanyCatalogItem {
|
||||
id: number;
|
||||
company_name: string;
|
||||
company_code: string;
|
||||
company_type: string;
|
||||
display_text: string;
|
||||
source: string;
|
||||
synced_at: string;
|
||||
}
|
||||
|
||||
export interface AdminExpressCompanyRecognitionCandidate {
|
||||
company_name: string;
|
||||
company_code: string;
|
||||
official_name?: string;
|
||||
display_text: string;
|
||||
length_pre?: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface AdminExpressCompanyRecognitionResult {
|
||||
input: string;
|
||||
tracking_no: string;
|
||||
company_code: string;
|
||||
company_name: string;
|
||||
status: string;
|
||||
status_text: string;
|
||||
error_message?: string;
|
||||
resolved: null | AdminExpressCompanyRecognitionCandidate;
|
||||
candidates: AdminExpressCompanyRecognitionCandidate[];
|
||||
}
|
||||
|
||||
function filenameFromPath(filePath: string) {
|
||||
return filePath.split(/[\\/]/).pop() || `upload-${Date.now()}`;
|
||||
}
|
||||
@@ -236,6 +267,17 @@ export interface AdminWarehouseWorkbenchContext {
|
||||
express_company: string;
|
||||
tracking_no: string;
|
||||
tracking_status: string;
|
||||
tracking_status_text: string;
|
||||
provider_status_text: string;
|
||||
sync_status_text: string;
|
||||
sync_error: string;
|
||||
latest_desc: string;
|
||||
latest_time: string;
|
||||
nodes: Array<{
|
||||
node_time: string;
|
||||
node_desc: string;
|
||||
node_location: string;
|
||||
}>;
|
||||
};
|
||||
return_address: null | {
|
||||
consignee: string;
|
||||
@@ -246,6 +288,17 @@ export interface AdminWarehouseWorkbenchContext {
|
||||
express_company: string;
|
||||
tracking_no: string;
|
||||
tracking_status: string;
|
||||
tracking_status_text: string;
|
||||
provider_status_text: string;
|
||||
sync_status_text: string;
|
||||
sync_error: string;
|
||||
latest_desc: string;
|
||||
latest_time: string;
|
||||
nodes: Array<{
|
||||
node_time: string;
|
||||
node_desc: string;
|
||||
node_location: string;
|
||||
}>;
|
||||
};
|
||||
transfer_flow: null | {
|
||||
id?: number;
|
||||
@@ -491,6 +544,23 @@ export const adminApi = {
|
||||
getExpressCompanies(params: { enabled_only?: 0 | 1 } = { enabled_only: 1 }) {
|
||||
return request<{ list: AdminExpressCompanyItem[]; default_company: string }>("/api/admin/express-companies", { params });
|
||||
},
|
||||
getExpressCompanyCatalog(params: { keyword?: string; limit?: number } = {}) {
|
||||
return request<{ list: AdminExpressCompanyCatalogItem[]; total: number; synced_at: string }>("/api/admin/express-company/catalog", {
|
||||
params,
|
||||
});
|
||||
},
|
||||
syncExpressCompanyCatalog() {
|
||||
return request<{ total: number; inserted: number; updated: number; backfilled: number; synced_at: string }>(
|
||||
"/api/admin/express-company/catalog/sync",
|
||||
{ method: "POST" },
|
||||
);
|
||||
},
|
||||
recognizeExpressCompany(data: { tracking_no: string; company_name?: string; company_code?: string }) {
|
||||
return request<AdminExpressCompanyRecognitionResult>("/api/admin/express-company/recognize", {
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
createManualOrder(data: AdminManualOrderCreatePayload) {
|
||||
return request<AdminManualOrderCreateResponse>("/api/admin/manual-order/create", {
|
||||
method: "POST",
|
||||
|
||||
@@ -188,6 +188,10 @@ onShow(() => {
|
||||
<view class="meta-label">寄送到中心</view>
|
||||
<view class="meta-value">{{ detail.logistics_info ? `${detail.logistics_info.express_company || "-"} / ${detail.logistics_info.tracking_no || "-"}` : "-" }}</view>
|
||||
</view>
|
||||
<view v-if="detail.logistics_info" class="meta-item">
|
||||
<view class="meta-label">寄送状态</view>
|
||||
<view class="meta-value">{{ detail.logistics_info.provider_status_text || detail.logistics_info.sync_status_text || detail.logistics_info.tracking_status_text || "-" }}</view>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<view class="meta-label">寄回地址</view>
|
||||
<view class="meta-value">{{ displayAddress(detail.return_address) }}</view>
|
||||
@@ -196,6 +200,24 @@ onShow(() => {
|
||||
<view class="meta-label">回寄运单</view>
|
||||
<view class="meta-value">{{ detail.return_logistics ? `${detail.return_logistics.express_company || "-"} / ${detail.return_logistics.tracking_no || "-"}` : "-" }}</view>
|
||||
</view>
|
||||
<view v-if="detail.return_logistics" class="meta-item">
|
||||
<view class="meta-label">回寄状态</view>
|
||||
<view class="meta-value">{{ detail.return_logistics.provider_status_text || detail.return_logistics.sync_status_text || detail.return_logistics.tracking_status_text || "-" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="detail.logistics_info?.nodes?.length" class="logistics-timeline">
|
||||
<view class="card-desc">寄送轨迹</view>
|
||||
<view v-for="item in detail.logistics_info.nodes" :key="`send-${item.node_time}-${item.node_desc}`" class="logistics-timeline__item">
|
||||
<view class="logistics-timeline__title">{{ item.node_desc }}</view>
|
||||
<view class="logistics-timeline__meta">{{ item.node_time }}{{ item.node_location ? ` / ${item.node_location}` : "" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="detail.return_logistics?.nodes?.length" class="logistics-timeline">
|
||||
<view class="card-desc">回寄轨迹</view>
|
||||
<view v-for="item in detail.return_logistics.nodes" :key="`return-${item.node_time}-${item.node_desc}`" class="logistics-timeline__item">
|
||||
<view class="logistics-timeline__title">{{ item.node_desc }}</view>
|
||||
<view class="logistics-timeline__meta">{{ item.node_time }}{{ item.node_location ? ` / ${item.node_location}` : "" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -445,6 +467,31 @@ onShow(() => {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.logistics-timeline {
|
||||
margin-top: 22rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1px solid var(--work-border);
|
||||
}
|
||||
|
||||
.logistics-timeline__item {
|
||||
padding: 16rpx 0 16rpx 22rpx;
|
||||
border-left: 4rpx solid var(--work-primary);
|
||||
}
|
||||
|
||||
.logistics-timeline__title {
|
||||
color: var(--work-text);
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.logistics-timeline__meta {
|
||||
margin-top: 6rpx;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.attachment-play {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { adminApi, type AdminExpressCompanyItem, type AdminFileAsset, type AdminWarehouseWorkbenchContext } from "../../api/admin";
|
||||
import {
|
||||
adminApi,
|
||||
type AdminExpressCompanyItem,
|
||||
type AdminExpressCompanyRecognitionCandidate,
|
||||
type AdminFileAsset,
|
||||
type AdminWarehouseWorkbenchContext,
|
||||
} from "../../api/admin";
|
||||
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
|
||||
|
||||
const internalTagNo = ref("");
|
||||
@@ -10,6 +16,8 @@ const trackingNo = ref("");
|
||||
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
|
||||
const expressCompanyLoading = ref(false);
|
||||
const defaultExpressCompany = ref("");
|
||||
const recognitionLoading = ref(false);
|
||||
const recognitionCandidates = ref<AdminExpressCompanyRecognitionCandidate[]>([]);
|
||||
const context = ref<AdminWarehouseWorkbenchContext | null>(null);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
@@ -34,6 +42,7 @@ const canSubmit = computed(() =>
|
||||
!uploading.value &&
|
||||
!submitting.value,
|
||||
);
|
||||
let recognitionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function readQueryString(value: unknown) {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
@@ -85,6 +94,47 @@ function onExpressCompanyChange(event: any) {
|
||||
expressCompany.value = expressCompanyNames.value[index] || "";
|
||||
}
|
||||
|
||||
async function recognizeExpressCompany() {
|
||||
const trackingValue = trackingNo.value.trim();
|
||||
if (!trackingValue) {
|
||||
recognitionCandidates.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
recognitionLoading.value = true;
|
||||
try {
|
||||
const result = await adminApi.recognizeExpressCompany({
|
||||
tracking_no: trackingValue,
|
||||
company_name: expressCompany.value.trim(),
|
||||
});
|
||||
recognitionCandidates.value = result.candidates || [];
|
||||
if (result.resolved) {
|
||||
expressCompany.value = result.resolved.company_name;
|
||||
} else if (result.candidates.length === 1) {
|
||||
expressCompany.value = result.candidates[0].company_name;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
recognitionCandidates.value = [];
|
||||
} finally {
|
||||
recognitionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRecognition() {
|
||||
if (recognitionTimer) {
|
||||
clearTimeout(recognitionTimer);
|
||||
}
|
||||
recognitionTimer = setTimeout(() => {
|
||||
void recognizeExpressCompany();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function chooseRecognitionCandidate(candidate: AdminExpressCompanyRecognitionCandidate) {
|
||||
expressCompany.value = candidate.company_name;
|
||||
recognitionCandidates.value = [candidate];
|
||||
}
|
||||
|
||||
function scanTrackingNo() {
|
||||
uni.scanCode({
|
||||
scanType: ["barCode", "qrCode"],
|
||||
@@ -224,6 +274,16 @@ onLoad((options) => {
|
||||
void fetchExpressCompanies();
|
||||
void fetchContext();
|
||||
});
|
||||
|
||||
watch(trackingNo, () => {
|
||||
scheduleRecognition();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (recognitionTimer) {
|
||||
clearTimeout(recognitionTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -281,6 +341,17 @@ onLoad((options) => {
|
||||
<text class="picker-field__arrow"></text>
|
||||
</view>
|
||||
</picker>
|
||||
<view v-if="recognitionLoading" class="recognition-tip">正在识别快递公司...</view>
|
||||
<view v-if="recognitionCandidates.length" class="chip-list recognition-chip-list">
|
||||
<text
|
||||
v-for="candidate in recognitionCandidates"
|
||||
:key="`${candidate.company_code}-${candidate.company_name}`"
|
||||
class="choice-chip"
|
||||
@click="chooseRecognitionCandidate(candidate)"
|
||||
>
|
||||
{{ candidate.company_name }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="scan-control">
|
||||
<input v-model="trackingNo" class="field scan-input" placeholder="扫描或输入回寄运单号" />
|
||||
<button class="btn scan-button" @click="scanTrackingNo">扫码</button>
|
||||
@@ -382,6 +453,40 @@ onLoad((options) => {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.recognition-tip {
|
||||
margin-top: 14rpx;
|
||||
color: var(--work-text-soft);
|
||||
font-size: 22rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.recognition-chip-list {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.choice-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44rpx;
|
||||
padding: 0 18rpx;
|
||||
border-radius: var(--work-radius-pill);
|
||||
background: var(--work-card-muted);
|
||||
color: var(--work-text);
|
||||
font-size: 22rpx;
|
||||
line-height: 44rpx;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.choice-chip:active {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.scan-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
Reference in New Issue
Block a user