feat: add kuaidi100 logistics sync

This commit is contained in:
wushumin
2026-05-26 17:08:33 +08:00
parent 09d9fcbe69
commit a5f00d7e31
31 changed files with 2596 additions and 67 deletions

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # 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. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # 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. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -253,6 +253,9 @@ export interface AdminOrderDetail {
tracking_no: string; tracking_no: string;
tracking_status: string; tracking_status: string;
tracking_status_text: string; tracking_status_text: string;
provider_status_text: string;
sync_status_text: string;
sync_error: string;
latest_desc: string; latest_desc: string;
latest_time: string; latest_time: string;
nodes: Array<{ nodes: Array<{
@@ -266,6 +269,9 @@ export interface AdminOrderDetail {
tracking_no: string; tracking_no: string;
tracking_status: string; tracking_status: string;
tracking_status_text: string; tracking_status_text: string;
provider_status_text: string;
sync_status_text: string;
sync_error: string;
latest_desc: string; latest_desc: string;
latest_time: string; latest_time: string;
nodes: Array<{ nodes: Array<{
@@ -1183,6 +1189,37 @@ export interface AdminExpressCompanyItem {
updated_at: string; 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 { export interface AdminExpressCompanyPayload {
id?: number; id?: number;
company_name: string; 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) { saveExpressCompany(data: AdminExpressCompanyPayload) {
return request.post("/api/admin/express-company/save", data) as Promise<{ return request.post("/api/admin/express-company/save", data) as Promise<{
code: number; code: number;

View File

@@ -1,20 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { import {
adminApi, adminApi,
type AdminExpressCompanyCatalogItem,
type AdminExpressCompanyItem, type AdminExpressCompanyItem,
type AdminExpressCompanyPayload, type AdminExpressCompanyPayload,
} from "../../api/admin"; } from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue"; import OrderStatusTag from "../../components/OrderStatusTag.vue";
type CatalogSuggestion = AdminExpressCompanyCatalogItem & { value: string };
const loading = ref(false); const loading = ref(false);
const submitting = ref(false); const submitting = ref(false);
const dialogVisible = ref(false); const dialogVisible = ref(false);
const companies = ref<AdminExpressCompanyItem[]>([]); const companies = ref<AdminExpressCompanyItem[]>([]);
const defaultCompany = ref(""); 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 enabledCount = computed(() => companies.value.filter((item) => item.status === "enabled").length);
const displaySyncedAt = computed(() => (catalogSyncedAt.value && catalogSyncedAt.value !== "0" ? catalogSyncedAt.value : "-"));
const form = reactive<AdminExpressCompanyPayload>({ const form = reactive<AdminExpressCompanyPayload>({
company_name: "", 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) { function openDialog(row?: AdminExpressCompanyItem) {
if (row) { if (row) {
form.id = row.id; form.id = row.id;
@@ -60,6 +95,27 @@ function openDialog(row?: AdminExpressCompanyItem) {
dialogVisible.value = true; 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() { async function submit() {
if (!form.company_name.trim()) { if (!form.company_name.trim()) {
ElMessage.warning("请填写快递公司名称"); ElMessage.warning("请填写快递公司名称");
@@ -70,6 +126,10 @@ async function submit() {
return; return;
} }
if (!/^[a-z0-9]+$/i.test(form.company_code.trim()) || form.company_code.trim().startsWith("express_")) {
await resolveCatalogCode();
}
submitting.value = true; submitting.value = true;
try { try {
await adminApi.saveExpressCompany({ 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) { function statusTagText(row: AdminExpressCompanyItem) {
return row.is_default ? `${row.status_text} / 默认` : row.status_text; return row.is_default ? `${row.status_text} / 默认` : row.status_text;
} }
onMounted(fetchCompanies); onMounted(async () => {
await Promise.all([fetchCompanies(), fetchCatalogSummary()]);
});
</script> </script>
<template> <template>
@@ -109,6 +198,16 @@ onMounted(fetchCompanies);
<div class="metric-card__value">{{ enabledCount }}</div> <div class="metric-card__value">{{ enabledCount }}</div>
<div class="metric-card__desc">仓管寄回下拉列表中可选择</div> <div class="metric-card__desc">仓管寄回下拉列表中可选择</div>
</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">
<div class="metric-card__label">默认快递公司</div> <div class="metric-card__label">默认快递公司</div>
<div class="metric-card__value metric-card__value--text">{{ defaultCompany || "-" }}</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"> <el-card class="panel-card" shadow="never">
<div class="filters-row" style="justify-content: space-between;"> <div class="filters-row" style="justify-content: space-between;">
<div style="color: var(--admin-text-subtle);"> <div style="color: var(--admin-text-subtle);">
维护仓管寄回时可选的快递公司停用后不会出现在寄回下拉列表中 维护仓管寄回时可选的快递公司公司码优先从快递100官方码表检索停用后不会出现在寄回下拉列表中
</div> </div>
<el-button type="primary" @click="openDialog()">新增快递公司</el-button> <el-button :icon="Refresh" :loading="catalogSyncing" type="primary" @click="syncCatalog">
同步快递100公司码表
</el-button>
</div> </div>
</el-card> </el-card>
<el-card class="panel-card orders-table" shadow="never"> <el-card class="panel-card orders-table" shadow="never">
<el-table :data="companies" stripe> <el-table :data="companies" stripe>
<el-table-column prop="company_name" label="快递公司" min-width="180" /> <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"> <el-table-column label="状态" min-width="140">
<template #default="{ row }"> <template #default="{ row }">
<OrderStatusTag :status="statusTagText(row)" /> <OrderStatusTag :status="statusTagText(row)" />
@@ -150,8 +251,28 @@ onMounted(fetchCompanies);
<el-form-item label="快递公司名称"> <el-form-item label="快递公司名称">
<el-input v-model="form.company_name" maxlength="64" placeholder="例如:顺丰速运" /> <el-input v-model="form.company_name" maxlength="64" placeholder="例如:顺丰速运" />
</el-form-item> </el-form-item>
<el-form-item label="编码"> <el-form-item label="快递100编码">
<el-input v-model="form.company_code" maxlength="64" placeholder="可留空,系统自动生成" /> <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-form-item>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
@@ -172,7 +293,7 @@ onMounted(fetchCompanies);
<el-checkbox v-model="form.is_default">设为默认快递公司</el-checkbox> <el-checkbox v-model="form.is_default">设为默认快递公司</el-checkbox>
</el-form-item> </el-form-item>
<el-form-item label="备注"> <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-item>
</el-form> </el-form>
<template #footer> <template #footer>

View File

@@ -1,7 +1,16 @@
<script setup lang="ts"> <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 { 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 OrderStatusTag from "../../components/OrderStatusTag.vue";
import { recognizeReturnAddress } from "../../utils/address-recognition"; import { recognizeReturnAddress } from "../../utils/address-recognition";
@@ -19,6 +28,8 @@ const returnDialogVisible = ref(false);
const returnSubmitting = ref(false); const returnSubmitting = ref(false);
const returnExpressCompany = ref(""); const returnExpressCompany = ref("");
const returnTrackingNo = ref(""); const returnTrackingNo = ref("");
const returnRecognitionLoading = ref(false);
const returnRecognitionCandidates = ref<AdminExpressCompanyRecognitionCandidate[]>([]);
const expressCompanyLoading = ref(false); const expressCompanyLoading = ref(false);
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]); const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
const defaultExpressCompany = ref(""); const defaultExpressCompany = ref("");
@@ -139,6 +150,8 @@ const expressCompanySelectOptions = computed(() => {
...expressCompanyOptions.value, ...expressCompanyOptions.value,
]; ];
}); });
let returnRecognitionTimer: ReturnType<typeof setTimeout> | undefined;
function createManualOrderForm(): AdminManualOrderCreatePayload { function createManualOrderForm(): AdminManualOrderCreatePayload {
return { return {
service_provider: "anxinyan", service_provider: "anxinyan",
@@ -377,7 +390,53 @@ async function openReturnDialog() {
await ensureExpressCompanyOptions(); await ensureExpressCompanyOptions();
returnExpressCompany.value = detail.value.return_logistics?.express_company || defaultExpressCompany.value || expressCompanyOptions.value[0]?.company_name || ""; returnExpressCompany.value = detail.value.return_logistics?.express_company || defaultExpressCompany.value || expressCompanyOptions.value[0]?.company_name || "";
returnTrackingNo.value = detail.value.return_logistics?.tracking_no || ""; returnTrackingNo.value = detail.value.return_logistics?.tracking_no || "";
returnRecognitionCandidates.value = [];
returnDialogVisible.value = true; 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() { async function submitReturnLogistics() {
@@ -409,6 +468,18 @@ async function submitReturnLogistics() {
} }
} }
watch(returnTrackingNo, () => {
if (returnDialogVisible.value) {
scheduleReturnRecognition();
}
});
onBeforeUnmount(() => {
if (returnRecognitionTimer) {
clearTimeout(returnRecognitionTimer);
}
});
async function markReturnReceived() { async function markReturnReceived() {
if (!detail.value) return; if (!detail.value) return;
returnReceiveSubmitting.value = true; returnReceiveSubmitting.value = true;
@@ -655,10 +726,18 @@ onMounted(fetchOrders);
<div class="order-detail-item__label">物流状态</div> <div class="order-detail-item__label">物流状态</div>
<div class="order-detail-item__value">{{ logisticsActionText }}</div> <div class="order-detail-item__value">{{ logisticsActionText }}</div>
</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 order-detail-item--full">
<div class="order-detail-item__label">最新节点</div> <div class="order-detail-item__label">最新节点</div>
<div class="order-detail-item__value">{{ detail.logistics_info.latest_desc || "-" }}</div> <div class="order-detail-item__value">{{ detail.logistics_info.latest_desc || "-" }}</div>
</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 order-detail-item--full" v-if="detail.logistics_info.latest_time">
<div class="order-detail-item__label">最新更新时间</div> <div class="order-detail-item__label">最新更新时间</div>
<div class="order-detail-item__value">{{ detail.logistics_info.latest_time }}</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__label">物流状态</div>
<div class="order-detail-item__value">{{ detail.return_logistics.tracking_status_text }}</div> <div class="order-detail-item__value">{{ detail.return_logistics.tracking_status_text }}</div>
</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 order-detail-item--full">
<div class="order-detail-item__label">最新节点</div> <div class="order-detail-item__label">最新节点</div>
<div class="order-detail-item__value">{{ detail.return_logistics.latest_desc || "-" }}</div> <div class="order-detail-item__value">{{ detail.return_logistics.latest_desc || "-" }}</div>
</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 order-detail-item--full" v-if="detail.return_logistics.latest_time">
<div class="order-detail-item__label">最新更新时间</div> <div class="order-detail-item__label">最新更新时间</div>
<div class="order-detail-item__value">{{ detail.return_logistics.latest_time }}</div> <div class="order-detail-item__value">{{ detail.return_logistics.latest_time }}</div>
@@ -738,6 +825,18 @@ onMounted(fetchOrders);
</div> </div>
</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" v-if="detail.supplement_task" style="grid-column: 1 / -1">
<div class="detail-card__title">补图任务</div> <div class="detail-card__title">补图任务</div>
<div class="detail-card__desc"> <div class="detail-card__desc">
@@ -810,6 +909,20 @@ onMounted(fetchOrders);
<el-form-item label="回寄运单号"> <el-form-item label="回寄运单号">
<el-input v-model="returnTrackingNo" placeholder="请输入回寄运单号" /> <el-input v-model="returnTrackingNo" placeholder="请输入回寄运单号" />
</el-form-item> </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 <el-alert
v-if="detail?.return_address" v-if="detail?.return_address"
type="info" type="info"

View File

@@ -10,7 +10,7 @@ const uploadingKey = ref("");
const groups = ref<AdminSystemConfigGroupItem[]>([]); const groups = ref<AdminSystemConfigGroupItem[]>([]);
const groupSnapshots = ref<Record<string, Record<string, string>>>({}); 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[]) { function cloneSnapshot(groupsList: AdminSystemConfigGroupItem[]) {
return Object.fromEntries( return Object.fromEntries(

View File

@@ -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) public function save(Request $request)
{ {
$id = (int)$request->input('id', 0); $id = (int)$request->input('id', 0);

View File

@@ -3,8 +3,9 @@
namespace app\controller\admin; namespace app\controller\admin;
use app\support\AppraisalEvidenceService; use app\support\AppraisalEvidenceService;
use app\support\MessageDispatcher;
use app\support\EnterpriseWebhookService; use app\support\EnterpriseWebhookService;
use app\support\MessageDispatcher;
use app\support\OrderLogisticsSyncService;
use app\support\WarehouseService; use app\support\WarehouseService;
use support\Request; use support\Request;
use support\think\Db; use support\think\Db;
@@ -292,6 +293,17 @@ class OrdersController
->select() ->select()
->toArray(); ->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([ return api_success([
'order_info' => [ 'order_info' => [
@@ -376,23 +388,14 @@ class OrdersController
'tracking_no' => $sendLogistics['tracking_no'], 'tracking_no' => $sendLogistics['tracking_no'],
'tracking_status' => $sendLogistics['tracking_status'], 'tracking_status' => $sendLogistics['tracking_status'],
'tracking_status_text' => $this->trackingStatusText($sendLogistics['tracking_status'], 'send_to_center'), 'tracking_status_text' => $this->trackingStatusText($sendLogistics['tracking_status'], 'send_to_center'),
'latest_desc' => $this->formatAdminLogisticsDesc( 'provider_status_text' => $sendSyncStatus['provider_status_text'],
'send_to_center', 'sync_status_text' => $sendSyncStatus['sync_status_text'],
$sendLogistics['tracking_status'], 'sync_error' => $sendSyncStatus['sync_error'],
$sendLogistics['express_company'], 'latest_desc' => (string)($sendLogistics['latest_desc'] ?? ''),
$sendLogistics['tracking_no'],
$sendLogistics['latest_desc']
),
'latest_time' => $sendLogistics['latest_time'], 'latest_time' => $sendLogistics['latest_time'],
'nodes' => array_map(fn (array $item) => [ 'nodes' => array_map(fn (array $item) => [
'node_time' => $item['node_time'], 'node_time' => $item['node_time'],
'node_desc' => $this->formatAdminLogisticsDesc( 'node_desc' => $item['node_desc'],
'send_to_center',
$sendLogistics['tracking_status'],
$sendLogistics['express_company'],
$sendLogistics['tracking_no'],
$item['node_desc']
),
'node_location' => $item['node_location'], 'node_location' => $item['node_location'],
], $logisticsNodes), ], $logisticsNodes),
] : null, ] : null,
@@ -402,23 +405,14 @@ class OrdersController
'tracking_no' => $returnLogistics['tracking_no'], 'tracking_no' => $returnLogistics['tracking_no'],
'tracking_status' => $returnLogistics['tracking_status'], 'tracking_status' => $returnLogistics['tracking_status'],
'tracking_status_text' => $this->trackingStatusText($returnLogistics['tracking_status'], 'return_to_user'), 'tracking_status_text' => $this->trackingStatusText($returnLogistics['tracking_status'], 'return_to_user'),
'latest_desc' => $this->formatAdminLogisticsDesc( 'provider_status_text' => $returnSyncStatus['provider_status_text'],
'return_to_user', 'sync_status_text' => $returnSyncStatus['sync_status_text'],
$returnLogistics['tracking_status'], 'sync_error' => $returnSyncStatus['sync_error'],
$returnLogistics['express_company'], 'latest_desc' => (string)($returnLogistics['latest_desc'] ?? ''),
$returnLogistics['tracking_no'],
$returnLogistics['latest_desc']
),
'latest_time' => $returnLogistics['latest_time'], 'latest_time' => $returnLogistics['latest_time'],
'nodes' => array_map(fn (array $item) => [ 'nodes' => array_map(fn (array $item) => [
'node_time' => $item['node_time'], 'node_time' => $item['node_time'],
'node_desc' => $this->formatAdminLogisticsDesc( 'node_desc' => $item['node_desc'],
'return_to_user',
$returnLogistics['tracking_status'],
$returnLogistics['express_company'],
$returnLogistics['tracking_no'],
$item['node_desc']
),
'node_location' => $item['node_location'], 'node_location' => $item['node_location'],
], $returnLogisticsNodes), ], $returnLogisticsNodes),
] : null, ] : null,
@@ -833,6 +827,7 @@ class OrdersController
'tracking_no' => $trackingNo, 'tracking_no' => $trackingNo,
'shipped_at' => $now, 'shipped_at' => $now,
]); ]);
(new OrderLogisticsSyncService())->subscribeAsync($logisticsId);
return api_success([ return api_success([
'id' => $id, 'id' => $id,

View File

@@ -448,6 +448,30 @@ class SystemConfigsController
['config_key' => 'endpoint', 'title' => '短信 Endpoint', 'field_type' => 'text', 'placeholder' => '默认可留空', 'remark' => '如不填写则按 SDK 默认规则解析', 'is_secret' => false], ['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')); $driver = (new FileStorageConfigService())->normalizeDriver((string)($configValueMap['file_storage.driver'] ?? 'local'));
if ($driver === 'local') { if ($driver === 'local') {
$this->validateKuaidi100Config($configValueMap);
return; return;
} }
@@ -489,10 +514,12 @@ class SystemConfigsController
throw new \RuntimeException('直传文件大小上限需填写 1-2048 之间的整数'); throw new \RuntimeException('直传文件大小上限需填写 1-2048 之间的整数');
} }
$this->validateKuaidi100Config($configValueMap);
return; return;
} }
if ($driver !== 'qiniu') { if ($driver !== 'qiniu') {
$this->validateKuaidi100Config($configValueMap);
return; return;
} }
@@ -513,6 +540,35 @@ class SystemConfigsController
if ($publicBaseUrl === '' && $bucketDomain === '') { if ($publicBaseUrl === '' && $bucketDomain === '') {
throw new \RuntimeException('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名'); 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 private function applyDerivedConfigValues(array &$configValueMap): void

View File

@@ -7,6 +7,7 @@ use app\model\OrderProduct;
use app\model\OrderSupplementTask; use app\model\OrderSupplementTask;
use app\model\OrderSupplementTaskItem; use app\model\OrderSupplementTaskItem;
use app\model\OrderTimeline; use app\model\OrderTimeline;
use app\support\OrderLogisticsSyncService;
use app\support\PublicAssetUrlService; use app\support\PublicAssetUrlService;
use support\Request; use support\Request;
use support\think\Db; use support\think\Db;
@@ -208,6 +209,11 @@ class OrdersController
->select() ->select()
->toArray(); ->toArray();
} }
$returnSyncStatus = $returnLogistics ? (new OrderLogisticsSyncService())->formatSyncStatus((int)$returnLogistics['id']) : [
'provider_status_text' => '',
'sync_status_text' => '未同步',
'sync_error' => '',
];
return api_success([ return api_success([
'order_info' => [ 'order_info' => [
@@ -261,6 +267,9 @@ class OrdersController
'tracking_no' => $returnLogistics['tracking_no'], 'tracking_no' => $returnLogistics['tracking_no'],
'tracking_status' => $returnLogistics['tracking_status'], 'tracking_status' => $returnLogistics['tracking_status'],
'tracking_status_text' => $this->trackingStatusText((string)$returnLogistics['tracking_status'], 'return_to_user'), '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_desc' => $returnLogistics['latest_desc'],
'latest_time' => $returnLogistics['latest_time'], 'latest_time' => $returnLogistics['latest_time'],
'nodes' => array_map(fn (array $item) => [ 'nodes' => array_map(fn (array $item) => [

View File

@@ -2,6 +2,8 @@
namespace app\controller\app; namespace app\controller\app;
use app\support\ExpressCompanyService;
use app\support\OrderLogisticsSyncService;
use app\support\WarehouseService; use app\support\WarehouseService;
use support\Request; use support\Request;
use support\think\Db; use support\think\Db;
@@ -35,6 +37,11 @@ class ShippingController
->select() ->select()
->toArray(); ->toArray();
} }
$syncStatus = $logistics ? (new OrderLogisticsSyncService())->formatSyncStatus((int)$logistics['id']) : [
'provider_status_text' => '',
'sync_status_text' => '未同步',
'sync_error' => '',
];
$warehouseService = new WarehouseService(); $warehouseService = new WarehouseService();
$categoryId = (int)($product['category_id'] ?? 0); $categoryId = (int)($product['category_id'] ?? 0);
@@ -76,6 +83,9 @@ class ShippingController
'tracking_no' => $logistics['tracking_no'] ?? '', 'tracking_no' => $logistics['tracking_no'] ?? '',
'tracking_status' => $logistics['tracking_status'] ?? '', 'tracking_status' => $logistics['tracking_status'] ?? '',
'tracking_status_text' => $this->trackingStatusText((string)($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_desc' => $logistics['latest_desc'] ?? '',
'latest_time' => $logistics['latest_time'] ?? '', 'latest_time' => $logistics['latest_time'] ?? '',
'is_submitted' => $trackingSubmitted, '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) public function save(Request $request)
{ {
$orderId = (int)$request->input('order_id', 0); $orderId = (int)$request->input('order_id', 0);
@@ -257,6 +288,8 @@ class ShippingController
]); ]);
} }
(new OrderLogisticsSyncService())->subscribeAsync($logisticsId);
return api_success([ return api_success([
'order_id' => $orderId, 'order_id' => $orderId,
'express_company' => $expressCompany, 'express_company' => $expressCompany,

View 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,
]);
}
}

View File

@@ -72,6 +72,7 @@ class AdminAuthMiddleware implements MiddlewareInterface
str_starts_with($path, '/api/admin/customer/') => ['customers.manage'], 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/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-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/express-company/') => ['warehouses.manage'],
str_starts_with($path, '/api/admin/warehouses'), str_starts_with($path, '/api/admin/warehouses'),
str_starts_with($path, '/api/admin/warehouse/') => ['warehouses.manage'], str_starts_with($path, '/api/admin/warehouse/') => ['warehouses.manage'],

View 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(),
]);
}
}
}

View File

@@ -2,6 +2,7 @@
namespace app\support; namespace app\support;
use support\Log;
use support\think\Db; use support\think\Db;
class ExpressCompanyService class ExpressCompanyService
@@ -9,6 +10,7 @@ class ExpressCompanyService
public function __construct() public function __construct()
{ {
$this->ensureTable(); $this->ensureTable();
$this->ensureCatalogTable();
$this->bootstrapDefaults(); $this->bootstrapDefaults();
} }
@@ -118,6 +120,414 @@ class ExpressCompanyService
return trim((string)($row['company_name'] ?? '')); 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 private function format(array $item): array
{ {
$status = (string)($item['status'] ?? 'enabled'); $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 private function bootstrapDefaults(): void
{ {
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$exists = Db::name('express_companies')->where('company_name', '顺丰速运')->find(); $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) { if (!$exists) {
Db::name('express_companies')->insert([ Db::name('express_companies')->insert([
'company_name' => '顺丰速运', 'company_name' => $default['company_name'],
'company_code' => 'sf_express', 'company_code' => $default['company_code'],
'status' => 'enabled', 'status' => 'enabled',
'is_default' => 1, 'is_default' => $default['is_default'],
'sort_order' => 1, 'sort_order' => $default['sort_order'],
'remark' => '系统默认快递公司', 'remark' => '系统默认快递公司',
'created_at' => $now, 'created_at' => $now,
'updated_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); $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 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_status (status),
KEY idx_express_companies_default (is_default) KEY idx_express_companies_default (is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快递公司字典' ) 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); SQL);
} }
} }

View File

@@ -501,6 +501,7 @@ class FulfillmentFlowService
'tracking_no' => $trackingNo, 'tracking_no' => $trackingNo,
'shipped_at' => $now, 'shipped_at' => $now,
]); ]);
(new OrderLogisticsSyncService())->subscribeAsync($logisticsId);
return $this->formatOrderContext($orderId); return $this->formatOrderContext($orderId);
} }
@@ -535,6 +536,9 @@ class FulfillmentFlowService
$product = Db::name('order_products')->where('order_id', $orderId)->find() ?: []; $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(); $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(); $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(); $flow = Db::name('order_transfer_flows')->where('order_id', $orderId)->order('id', 'desc')->find();
$report = $this->latestReport($orderId); $report = $this->latestReport($orderId);
$content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : null; $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'], 'express_company' => (string)$sendLogistics['express_company'],
'tracking_no' => (string)$sendLogistics['tracking_no'], 'tracking_no' => (string)$sendLogistics['tracking_no'],
'tracking_status' => (string)$sendLogistics['tracking_status'], '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, ] : null,
'return_address' => $returnAddress ? [ 'return_address' => $returnAddress ? [
'consignee' => (string)($returnAddress['consignee'] ?? ''), 'consignee' => (string)($returnAddress['consignee'] ?? ''),
@@ -580,6 +595,17 @@ class FulfillmentFlowService
'express_company' => (string)$returnLogistics['express_company'], 'express_company' => (string)$returnLogistics['express_company'],
'tracking_no' => (string)$returnLogistics['tracking_no'], 'tracking_no' => (string)$returnLogistics['tracking_no'],
'tracking_status' => (string)$returnLogistics['tracking_status'], '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, ] : null,
'transfer_flow' => $flow ? $this->formatFlow($flow) : null, 'transfer_flow' => $flow ? $this->formatFlow($flow) : null,
'report_info' => $report ? [ '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 private function decodeJsonArray(mixed $value): array
{ {
if (is_array($value)) { if (is_array($value)) {

View 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;
}
}

View 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';
}
}

View 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);
}
}

View File

@@ -58,5 +58,9 @@ return [
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/', 'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
] ]
] ]
],
'kuaidi100-logistics-sync' => [
'handler' => app\process\Kuaidi100LogisticsSyncProcess::class,
'count' => 1,
] ]
]; ];

View File

@@ -49,6 +49,7 @@ use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbench
use app\controller\admin\ExpressCompaniesController as AdminExpressCompaniesController; use app\controller\admin\ExpressCompaniesController as AdminExpressCompaniesController;
use app\controller\admin\FileUploadController as AdminFileUploadController; use app\controller\admin\FileUploadController as AdminFileUploadController;
use app\controller\open\OrdersController as OpenOrdersController; use app\controller\open\OrdersController as OpenOrdersController;
use app\controller\open\Kuaidi100Controller as OpenKuaidi100Controller;
Route::get('/', [app\controller\IndexController::class, 'json']); Route::get('/', [app\controller\IndexController::class, 'json']);
Route::get('/T/{token}', [AppMaterialTagRedirectController::class, 'redirect']); 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/upload', [AppTicketsController::class, 'uploadFile']);
Route::post('/api/app/ticket/file/delete', [AppTicketsController::class, 'deleteFile']); Route::post('/api/app/ticket/file/delete', [AppTicketsController::class, 'deleteFile']);
Route::get('/api/app/order/shipping', [AppShippingController::class, 'detail']); 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::post('/api/app/order/shipping/save', [AppShippingController::class, 'save']);
Route::get('/api/app/addresses', [AppAddressesController::class, 'index']); Route::get('/api/app/addresses', [AppAddressesController::class, 'index']);
Route::get('/api/app/address/detail', [AppAddressesController::class, 'detail']); 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::post('/api/open/v1/orders', [OpenOrdersController::class, 'create']);
Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']); Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']);
Route::get('/api/open/v1/orders/{external_order_no}', [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 () { Route::get('/api/admin/ping', function () {
return api_success(['pong' => true]); 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::get('/api/admin/warehouses', [AdminWarehousesController::class, 'index']);
Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']); Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']);
Route::get('/api/admin/express-companies', [AdminExpressCompaniesController::class, 'index']); 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::post('/api/admin/express-company/save', [AdminExpressCompaniesController::class, 'save']);
Route::get('/api/admin/warehouse-workbench/inbound/lookup', [AdminWarehouseWorkbenchController::class, 'inboundLookup']); Route::get('/api/admin/warehouse-workbench/inbound/lookup', [AdminWarehouseWorkbenchController::class, 'inboundLookup']);
Route::post('/api/admin/warehouse-workbench/inbound/receive', [AdminWarehouseWorkbenchController::class, 'inboundReceive']); Route::post('/api/admin/warehouse-workbench/inbound/receive', [AdminWarehouseWorkbenchController::class, 'inboundReceive']);

View File

@@ -12,6 +12,7 @@ DROP TABLE IF EXISTS enterprise_customer_order_refs;
DROP TABLE IF EXISTS enterprise_api_nonces; DROP TABLE IF EXISTS enterprise_api_nonces;
DROP TABLE IF EXISTS enterprise_customer_apps; DROP TABLE IF EXISTS enterprise_customer_apps;
DROP TABLE IF EXISTS enterprise_customers; DROP TABLE IF EXISTS enterprise_customers;
DROP TABLE IF EXISTS kuaidi100_express_company_catalog;
DROP TABLE IF EXISTS express_companies; DROP TABLE IF EXISTS express_companies;
DROP TABLE IF EXISTS shipping_warehouses; DROP TABLE IF EXISTS shipping_warehouses;
DROP TABLE IF EXISTS user_api_tokens; 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 order_transfer_flows;
DROP TABLE IF EXISTS internal_transfer_tags; DROP TABLE IF EXISTS internal_transfer_tags;
DROP TABLE IF EXISTS internal_transfer_tag_batches; 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_nodes;
DROP TABLE IF EXISTS order_logistics; DROP TABLE IF EXISTS order_logistics;
DROP TABLE IF EXISTS order_supplement_task_items; 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) KEY idx_order_logistics_nodes_logistics_id (logistics_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物流节点'; ) 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 ( CREATE TABLE express_companies (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL DEFAULT '', company_name VARCHAR(64) NOT NULL DEFAULT '',
@@ -748,6 +771,22 @@ CREATE TABLE express_companies (
KEY idx_express_companies_default (is_default) KEY idx_express_companies_default (is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快递公司字典'; ) 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 ( CREATE TABLE order_abnormals (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL, order_id BIGINT UNSIGNED NOT NULL,

View File

@@ -26,6 +26,7 @@ $pdo = new PDO(
); );
$tables = [ $tables = [
'kuaidi100_express_company_catalog',
'express_companies', 'express_companies',
'shipping_warehouses', 'shipping_warehouses',
'order_shipping_targets', 'order_shipping_targets',
@@ -39,6 +40,7 @@ $tables = [
'upload_template_items', 'upload_templates', 'upload_template_items', 'upload_templates',
'report_verifies', 'report_contents', 'reports', 'report_verifies', 'report_contents', 'reports',
'appraisal_task_key_points', 'appraisal_task_results', 'appraisal_task_reviews', 'appraisal_task_logs', 'appraisal_tasks', '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', 'order_supplement_task_items', 'order_supplement_tasks', 'order_timelines', 'order_extras', 'order_products', 'orders',
'catalog_brand_categories', 'catalog_brands', 'catalog_categories', 'catalog_brand_categories', 'catalog_brands', 'catalog_categories',
'user_addresses', 'user_auths', 'users', '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}'); (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 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 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}'), (1, '奢侈品箱包', 'luxury_bag', 1, 1, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'),

View File

@@ -45,14 +45,18 @@ CREATE TABLE IF NOT EXISTS express_companies (
SQL); SQL);
$now = date('Y-m-d H:i:s'); $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(['顺丰速运']); $stmt->execute(['顺丰速运']);
$exists = $stmt->fetch(); $exists = $stmt->fetch();
if (!$exists) { 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 = $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"; 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(); $defaultCount = (int)$pdo->query("SELECT COUNT(*) FROM express_companies WHERE status = 'enabled' AND is_default = 1")->fetchColumn();

View File

@@ -207,6 +207,9 @@ export interface OrderDetailData {
tracking_no: string; tracking_no: string;
tracking_status: string; tracking_status: string;
tracking_status_text: string; tracking_status_text: string;
provider_status_text: string;
sync_status_text: string;
sync_error: string;
latest_desc: string; latest_desc: string;
latest_time: string; latest_time: string;
nodes: Array<{ nodes: Array<{
@@ -302,6 +305,9 @@ export interface ShippingDetailData {
tracking_no: string; tracking_no: string;
tracking_status: string; tracking_status: string;
tracking_status_text: string; tracking_status_text: string;
provider_status_text: string;
sync_status_text: string;
sync_error: string;
latest_desc: string; latest_desc: string;
latest_time: string; latest_time: string;
is_submitted: boolean; is_submitted: boolean;
@@ -314,6 +320,27 @@ export interface ShippingDetailData {
can_submit_tracking: boolean; 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 { export interface UserAddressItem {
id: number; id: number;
consignee: string; consignee: string;
@@ -627,6 +654,12 @@ export const appApi = {
params: { order_id: orderId }, 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: { saveOrderShipping(payload: {
order_id: number; order_id: number;
express_company: string; express_company: string;

View File

@@ -301,6 +301,9 @@ export const shippingDetailFallback: ShippingDetailData = {
tracking_no: "", tracking_no: "",
tracking_status: "", tracking_status: "",
tracking_status_text: "待提交", tracking_status_text: "待提交",
provider_status_text: "",
sync_status_text: "未同步",
sync_error: "",
latest_desc: "", latest_desc: "",
latest_time: "", latest_time: "",
is_submitted: false, is_submitted: false,

View File

@@ -63,6 +63,10 @@ const materialItems = computed(() => detail.value.materials || []);
const hasReturnAddress = computed(() => Boolean(detail.value.return_address)); const hasReturnAddress = computed(() => Boolean(detail.value.return_address));
const hasReturnLogistics = computed(() => Boolean(detail.value.return_logistics?.tracking_no)); const hasReturnLogistics = computed(() => Boolean(detail.value.return_logistics?.tracking_no));
const returnReceived = computed(() => detail.value.return_logistics?.tracking_status === "received"); 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 canEditReturnAddress = computed(() => detail.value.order_info.can_edit_return_address);
const heroTagClass = computed(() => { const heroTagClass = computed(() => {
@@ -459,7 +463,7 @@ onShow(fetchDetail);
<view v-if="detail.return_logistics" id="return-logistics-card" class="return-logistics-card"> <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__top">
<view class="return-logistics-card__title">回寄物流</view> <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>
<view class="report-meta__row"> <view class="report-meta__row">
<text class="report-meta__label">快递公司</text> <text class="report-meta__label">快递公司</text>
@@ -473,6 +477,27 @@ onShow(fetchDetail);
<text class="report-meta__label">最新进展</text> <text class="report-meta__label">最新进展</text>
<text class="report-meta__value">{{ detail.return_logistics.latest_desc || "待平台登记回寄运单" }}</text> <text class="report-meta__value">{{ detail.return_logistics.latest_desc || "待平台登记回寄运单" }}</text>
</view> </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>
</view> </view>
@@ -770,6 +795,10 @@ onShow(fetchDetail);
line-height: 1.6; line-height: 1.6;
} }
.return-logistics-card__timeline {
margin-top: 24rpx;
}
.return-address-card__address, .return-address-card__address,
.return-address-sheet__item-address { .return-address-sheet__item-address {
margin-top: 14rpx; margin-top: 14rpx;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, onBeforeUnmount, ref, watch } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app"; 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 { shippingDetailFallback } from "../../mocks/app";
import { resolveErrorMessage, showErrorToast, showInfoToast, withLoading } from "../../utils/feedback"; import { resolveErrorMessage, showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
import { getPrivacyMode, maskOrderNo } from "../../utils/privacy"; import { getPrivacyMode, maskOrderNo } from "../../utils/privacy";
@@ -17,9 +17,18 @@ const warehouseSheetVisible = ref(false);
const loading = ref(false); const loading = ref(false);
const pageReady = ref(false); const pageReady = ref(false);
const loadError = ref(""); 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 submitted = computed(() => detail.value.logistics_info.is_submitted);
const canEditTracking = computed(() => !submitted.value && detail.value.can_submit_tracking); 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( const hasWarehouseChoices = computed(
() => detail.value.shipping_options.can_select_warehouse && detail.value.shipping_options.list.length > 1, () => 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; 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() { async function fetchDetail() {
if (!orderId.value) return; if (!orderId.value) return;
loading.value = true; loading.value = true;
@@ -168,6 +218,16 @@ onLoad((options) => {
}); });
onShow(fetchDetail); onShow(fetchDetail);
watch(trackingNo, () => {
scheduleRecognition();
});
onBeforeUnmount(() => {
if (recognitionTimer) {
clearTimeout(recognitionTimer);
}
});
</script> </script>
<template> <template>
@@ -254,7 +314,7 @@ onShow(fetchDetail);
<view class="metric-card__label">当前寄送商品请确保与订单信息一致</view> <view class="metric-card__label">当前寄送商品请确保与订单信息一致</view>
</view> </view>
<view class="metric-card"> <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 class="metric-card__label">寄送状态提交运单后我们会继续同步节点</view>
</view> </view>
</view> </view>
@@ -288,6 +348,17 @@ onShow(fetchDetail);
<input v-model="trackingNo" class="field-input" maxlength="40" placeholder="请输入快递单号" :disabled="submitted" /> <input v-model="trackingNo" class="field-input" maxlength="40" placeholder="请输入快递单号" :disabled="submitted" />
</view> </view>
</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"> <view class="form-group__hint">
{{ submitted ? "如物流信息存在异常,请联系平台客服协助处理。" : "提交后将进入待签收跟踪状态,请确认信息无误后再提交。" }} {{ submitted ? "如物流信息存在异常,请联系平台客服协助处理。" : "提交后将进入待签收跟踪状态,请确认信息无误后再提交。" }}
</view> </view>
@@ -295,7 +366,7 @@ onShow(fetchDetail);
<view v-if="submitted" class="section timeline-panel"> <view v-if="submitted" class="section timeline-panel">
<view class="section__title">寄送轨迹</view> <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 class="timeline" style="margin-top: 24rpx">
<view <view
v-for="item in detail.logistics_nodes" v-for="item in detail.logistics_nodes"

View File

@@ -46,6 +46,37 @@ export interface AdminExpressCompanyItem {
updated_at: string; 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) { function filenameFromPath(filePath: string) {
return filePath.split(/[\\/]/).pop() || `upload-${Date.now()}`; return filePath.split(/[\\/]/).pop() || `upload-${Date.now()}`;
} }
@@ -236,6 +267,17 @@ export interface AdminWarehouseWorkbenchContext {
express_company: string; express_company: string;
tracking_no: string; tracking_no: string;
tracking_status: 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 | { return_address: null | {
consignee: string; consignee: string;
@@ -246,6 +288,17 @@ export interface AdminWarehouseWorkbenchContext {
express_company: string; express_company: string;
tracking_no: string; tracking_no: string;
tracking_status: 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 | { transfer_flow: null | {
id?: number; id?: number;
@@ -491,6 +544,23 @@ export const adminApi = {
getExpressCompanies(params: { enabled_only?: 0 | 1 } = { enabled_only: 1 }) { getExpressCompanies(params: { enabled_only?: 0 | 1 } = { enabled_only: 1 }) {
return request<{ list: AdminExpressCompanyItem[]; default_company: string }>("/api/admin/express-companies", { params }); 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) { createManualOrder(data: AdminManualOrderCreatePayload) {
return request<AdminManualOrderCreateResponse>("/api/admin/manual-order/create", { return request<AdminManualOrderCreateResponse>("/api/admin/manual-order/create", {
method: "POST", method: "POST",

View File

@@ -188,6 +188,10 @@ onShow(() => {
<view class="meta-label">寄送到中心</view> <view class="meta-label">寄送到中心</view>
<view class="meta-value">{{ detail.logistics_info ? `${detail.logistics_info.express_company || "-"} / ${detail.logistics_info.tracking_no || "-"}` : "-" }}</view> <view class="meta-value">{{ detail.logistics_info ? `${detail.logistics_info.express_company || "-"} / ${detail.logistics_info.tracking_no || "-"}` : "-" }}</view>
</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-item">
<view class="meta-label">寄回地址</view> <view class="meta-label">寄回地址</view>
<view class="meta-value">{{ displayAddress(detail.return_address) }}</view> <view class="meta-value">{{ displayAddress(detail.return_address) }}</view>
@@ -196,6 +200,24 @@ onShow(() => {
<view class="meta-label">回寄运单</view> <view class="meta-label">回寄运单</view>
<view class="meta-value">{{ detail.return_logistics ? `${detail.return_logistics.express_company || "-"} / ${detail.return_logistics.tracking_no || "-"}` : "-" }}</view> <view class="meta-value">{{ detail.return_logistics ? `${detail.return_logistics.express_company || "-"} / ${detail.return_logistics.tracking_no || "-"}` : "-" }}</view>
</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>
</view> </view>
@@ -445,6 +467,31 @@ onShow(() => {
font-weight: 800; 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 { .attachment-play {
position: absolute; position: absolute;
left: 50%; left: 50%;

View File

@@ -1,7 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, onBeforeUnmount, ref, watch } from "vue";
import { onLoad } from "@dcloudio/uni-app"; 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"; import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
const internalTagNo = ref(""); const internalTagNo = ref("");
@@ -10,6 +16,8 @@ const trackingNo = ref("");
const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]); const expressCompanyOptions = ref<AdminExpressCompanyItem[]>([]);
const expressCompanyLoading = ref(false); const expressCompanyLoading = ref(false);
const defaultExpressCompany = ref(""); const defaultExpressCompany = ref("");
const recognitionLoading = ref(false);
const recognitionCandidates = ref<AdminExpressCompanyRecognitionCandidate[]>([]);
const context = ref<AdminWarehouseWorkbenchContext | null>(null); const context = ref<AdminWarehouseWorkbenchContext | null>(null);
const loading = ref(false); const loading = ref(false);
const submitting = ref(false); const submitting = ref(false);
@@ -34,6 +42,7 @@ const canSubmit = computed(() =>
!uploading.value && !uploading.value &&
!submitting.value, !submitting.value,
); );
let recognitionTimer: ReturnType<typeof setTimeout> | undefined;
function readQueryString(value: unknown) { function readQueryString(value: unknown) {
const raw = Array.isArray(value) ? value[0] : value; const raw = Array.isArray(value) ? value[0] : value;
@@ -85,6 +94,47 @@ function onExpressCompanyChange(event: any) {
expressCompany.value = expressCompanyNames.value[index] || ""; 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() { function scanTrackingNo() {
uni.scanCode({ uni.scanCode({
scanType: ["barCode", "qrCode"], scanType: ["barCode", "qrCode"],
@@ -224,6 +274,16 @@ onLoad((options) => {
void fetchExpressCompanies(); void fetchExpressCompanies();
void fetchContext(); void fetchContext();
}); });
watch(trackingNo, () => {
scheduleRecognition();
});
onBeforeUnmount(() => {
if (recognitionTimer) {
clearTimeout(recognitionTimer);
}
});
</script> </script>
<template> <template>
@@ -281,6 +341,17 @@ onLoad((options) => {
<text class="picker-field__arrow"></text> <text class="picker-field__arrow"></text>
</view> </view>
</picker> </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"> <view class="scan-control">
<input v-model="trackingNo" class="field scan-input" placeholder="扫描或输入回寄运单号" /> <input v-model="trackingNo" class="field scan-input" placeholder="扫描或输入回寄运单号" />
<button class="btn scan-button" @click="scanTrackingNo">扫码</button> <button class="btn scan-button" @click="scanTrackingNo">扫码</button>
@@ -382,6 +453,40 @@ onLoad((options) => {
margin-top: 14rpx; 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 { .scan-input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;