diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts index aaf38242..59d9db9d 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts @@ -62,6 +62,8 @@ import { maskPhone, } from "./mappers.js"; import { createHttpError } from "../../../../utils/http.js"; +import { query as dbQuery } from "../../../../db/client.js"; +import { parseJsonObject } from "../../../../utils/task-json.js"; import type { AdminCloudtentaclesCatalogQueryInput, @@ -79,6 +81,54 @@ import type { type JsonObject = Record; +type LocalCloudtentaclesTask = { + taskId: number; + taskNo: string; + platformOrderId: string; + orderShopId: string; + orderShopName: string; + taskStatus: string; + deliveryStatus: string; + resultCode: string; + resultMessage: string; + createdAt: string; + updatedAt: string; + skuCode: string; + skuName: string; + sourceKey: string; + vnId: number; + vnPhone: string; + roleName: string; + roleId: string; + localDispatchStatus: string; + localDispatchAt: string; + consumeShopId: string; + consumeShopName: string; +}; + +type LocalCloudtentaclesTaskRow = { + id: number; + task_no: string; + platform_order_id: string; + shop_id: string; + shop_name: string; + task_status: string; + delivery_status: string; + result_code: string; + result_message: string; + context_json: unknown; + created_at: string; + updated_at: string; + sku_code?: string; + sku_name?: string; + kuaishou_source_key?: string; + kuaishou_vn_phone?: string; + kuaishou_role_name?: string; + kuaishou_role_id?: string; + kuaishou_dispatch_status?: string; + kuaishou_consume_status?: string; +}; + export function listAdminCloudtentaclesSources() { const list = listCloudtentaclesSources(); const sessions = getAllCloudtentaclesSessionStates(); @@ -333,14 +383,35 @@ export async function getAdminCloudtentaclesKnapsack( export async function listAdminCloudtentaclesDeliveryRecords( payload: AdminCloudtentaclesDeliveryRecordQueryInput = {} ) { - const context = resolveAdminCloudtentaclesSessionPayload(payload); + const platformOrderId = normalizeText(payload.platformOrderId); + const preferredSourceKey = normalizeText(payload.sourceKey); + const orderMatchedTasks = platformOrderId + ? await listLocalCloudtentaclesTasks({ + platformOrderId, + limit: 50, + }) + : []; + const orderSourceKey = + orderMatchedTasks.find((task) => !preferredSourceKey || task.sourceKey === preferredSourceKey) + ?.sourceKey || orderMatchedTasks.find((task) => task.sourceKey)?.sourceKey; + const resolvedSourceKey = + orderSourceKey || + preferredSourceKey || + "default"; + const context = resolveAdminCloudtentaclesSessionPayload({ + ...payload, + sourceKey: resolvedSourceKey, + }); + const recordWindow = resolveRecordQueryWindow(payload, orderMatchedTasks); + const recordPage = platformOrderId ? 1 : payload.page; + const recordSize = platformOrderId ? 1000 : payload.size; const query = { ...context, ...pickDefined({ - page: payload.page, - size: payload.size, - startDate: payload.startDate, - endDate: payload.endDate, + page: recordPage, + size: recordSize, + startDate: recordWindow.startDate, + endDate: recordWindow.endDate, recordCode: payload.recordCode, }), }; @@ -368,16 +439,59 @@ export async function listAdminCloudtentaclesDeliveryRecords( } const recordItems: JsonObject[] = Array.isArray(records.items) ? records.items : []; + const localTasks = + platformOrderId || recordItems.length === 0 + ? orderMatchedTasks + : await listLocalCloudtentaclesTasks({ + sourceKey: resolvedSourceKey, + startDate: recordWindow.startDate, + endDate: recordWindow.endDate, + limit: 800, + }); + const enrichedItems = recordItems + .map((record) => { + const localMatch = findBestLocalTaskForRecord(record, localTasks); + return { + ...record, + ...(skuImageByName.get(normalizeSkuName(record.name)) || { + skuId: 0, + skuName: "", + skuImage: "", + }), + ...mapLocalTaskRecordEnhancement(localMatch?.task || null, localMatch?.confidence || ""), + }; + }) + .filter((record) => { + if (!platformOrderId) { + return true; + } + return normalizeText(record.platformOrderId) === platformOrderId; + }); + const fallbackItems = platformOrderId + ? orderMatchedTasks + .filter((task) => !enrichedItems.some((record) => Number(record.taskId || 0) === task.taskId)) + .map((task) => ({ + ...createLocalTaskFallbackRecord(task), + ...(skuImageByName.get(normalizeSkuName(task.skuName)) || { + skuId: 0, + skuName: "", + skuImage: "", + }), + ...mapLocalTaskRecordEnhancement(task, "local"), + })) + : []; + const finalItems = platformOrderId ? [...enrichedItems, ...fallbackItems] : enrichedItems; + return { ...records, - items: recordItems.map((record) => ({ - ...record, - ...(skuImageByName.get(normalizeSkuName(record.name)) || { - skuId: 0, - skuName: "", - skuImage: "", - }), - })), + sourceKey: resolvedSourceKey, + queryStartDate: recordWindow.startDate, + queryEndDate: recordWindow.endDate, + total: platformOrderId ? finalItems.length : records.total, + page: platformOrderId ? 1 : records.page, + size: platformOrderId ? recordSize : records.size, + localOrderMatched: platformOrderId ? orderMatchedTasks.length > 0 : false, + items: finalItems, }; } @@ -478,3 +592,284 @@ function pickDefined(values: JsonObject = {}) { function normalizeSkuName(value: unknown) { return String(value || "").trim().replace(/\s+/g, "").toLowerCase(); } + +function normalizeText(value: unknown) { + return String(value || "").trim(); +} + +function normalizePhone(value: unknown) { + return normalizeText(value).replace(/\D+/g, ""); +} + +function normalizeNumber(value: unknown) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function asRecord(value: unknown): JsonObject { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonObject) + : {}; +} + +function pickFirstNonEmptyText(values: unknown[]) { + for (const value of values) { + const normalized = normalizeText(value); + if (normalized) { + return normalized; + } + } + + return ""; +} + +function resolveRecordQueryWindow( + payload: AdminCloudtentaclesDeliveryRecordQueryInput = {}, + tasks: LocalCloudtentaclesTask[] = [] +) { + if (tasks.length === 0) { + return { + startDate: payload.startDate, + endDate: payload.endDate, + }; + } + + const timestamps = tasks + .flatMap((task) => [task.createdAt, task.updatedAt, task.localDispatchAt]) + .map((value) => new Date(value).getTime()) + .filter((value) => Number.isFinite(value)); + if (timestamps.length === 0) { + return { + startDate: payload.startDate, + endDate: payload.endDate, + }; + } + + const hourMs = 60 * 60 * 1000; + return { + startDate: new Date(Math.min(...timestamps) - hourMs).toISOString(), + endDate: new Date(Math.max(...timestamps) + 6 * hourMs).toISOString(), + }; +} + +async function listLocalCloudtentaclesTasks(options: { + platformOrderId?: string | undefined; + sourceKey?: string | undefined; + startDate?: string | undefined; + endDate?: string | undefined; + limit?: number; +} = {}): Promise { + const filters: string[] = ["ft.executor_key = 'kuaishou_ct_assisted'"]; + const params: unknown[] = []; + const platformOrderId = normalizeText(options.platformOrderId); + const sourceKey = normalizeText(options.sourceKey); + const startDate = normalizeText(options.startDate); + const endDate = normalizeText(options.endDate); + + if (platformOrderId) { + params.push(`%${platformOrderId}%`); + filters.push(`ft.platform_order_id ILIKE $${params.length}`); + } + + if (sourceKey) { + params.push(sourceKey); + filters.push( + `COALESCE(NULLIF(kcts.source_key, ''), ft.context_json #>> '{kuaishouCloudFulfillment,binding,resolvedSourceKey}', '') = $${params.length}` + ); + } + + if (startDate) { + params.push(startDate); + filters.push(`ft.updated_at >= ($${params.length})::timestamptz - interval '1 day'`); + } + + if (endDate) { + params.push(endDate); + filters.push(`ft.created_at <= ($${params.length})::timestamptz + interval '1 day'`); + } + + params.push(Math.min(Math.max(Number(options.limit || 500), 1), 1000)); + const result = await dbQuery( + ` + SELECT + ft.id, + ft.task_no, + ft.platform_order_id, + ft.shop_id, + ft.shop_name, + ft.task_status, + ft.delivery_status, + ft.result_code, + ft.result_message, + ft.context_json, + ft.created_at, + ft.updated_at, + oi.sku_code, + oi.sku_name, + kcts.source_key AS kuaishou_source_key, + kcts.vn_phone AS kuaishou_vn_phone, + kcts.role_name AS kuaishou_role_name, + kcts.role_id AS kuaishou_role_id, + kcts.dispatch_status AS kuaishou_dispatch_status, + kcts.consume_status AS kuaishou_consume_status + FROM fulfillment_tasks ft + LEFT JOIN order_items oi ON oi.id = ft.order_item_id + LEFT JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id + WHERE ${filters.join(" AND ")} + ORDER BY ft.id DESC + LIMIT $${params.length} + `, + params + ); + + return result.rows.map(mapLocalCloudtentaclesTask); +} + +function mapLocalCloudtentaclesTask(row: LocalCloudtentaclesTaskRow): LocalCloudtentaclesTask { + const context = parseJsonObject(row.context_json); + const flow = asRecord(context.kuaishouCloudFulfillment); + const binding = asRecord(flow.binding); + const role = asRecord(flow.role); + const dispatch = asRecord(flow.dispatch); + const consume = asRecord(flow.consume); + + return { + taskId: Number(row.id || 0), + taskNo: normalizeText(row.task_no), + platformOrderId: normalizeText(row.platform_order_id), + orderShopId: normalizeText(row.shop_id), + orderShopName: normalizeText(row.shop_name), + taskStatus: normalizeText(row.task_status), + deliveryStatus: normalizeText(row.delivery_status), + resultCode: normalizeText(row.result_code), + resultMessage: normalizeText(row.result_message), + createdAt: normalizeText(row.created_at), + updatedAt: normalizeText(row.updated_at), + skuCode: normalizeText(row.sku_code), + skuName: pickFirstNonEmptyText([row.sku_name, flow.internalSkuName]), + sourceKey: pickFirstNonEmptyText([row.kuaishou_source_key, binding.resolvedSourceKey]), + vnId: normalizeNumber(binding.vnId), + vnPhone: pickFirstNonEmptyText([row.kuaishou_vn_phone, binding.vnPhone]), + roleName: pickFirstNonEmptyText([row.kuaishou_role_name, role.name, binding.roleName]), + roleId: pickFirstNonEmptyText([row.kuaishou_role_id, role.rid, binding.roleId]), + localDispatchStatus: pickFirstNonEmptyText([row.kuaishou_dispatch_status, dispatch.status]), + localDispatchAt: normalizeText(dispatch.dispatchAt), + consumeShopId: normalizeText(consume.shopId), + consumeShopName: normalizeText(consume.shopName), + }; +} + +function findBestLocalTaskForRecord( + record: JsonObject, + tasks: LocalCloudtentaclesTask[] = [] +) { + let best: { task: LocalCloudtentaclesTask; score: number; confidence: string } | null = null; + + for (const task of tasks) { + const score = scoreLocalTaskRecordMatch(record, task); + if (score <= 0 || (best && score <= best.score)) { + continue; + } + + best = { + task, + score, + confidence: score >= 80 ? "high" : score >= 45 ? "medium" : "low", + }; + } + + return best && best.score >= 35 ? best : null; +} + +function scoreLocalTaskRecordMatch(record: JsonObject, task: LocalCloudtentaclesTask) { + let score = 0; + const recordVnId = normalizeNumber(record.virtualNumberId); + const recordPhone = normalizePhone(record.phone); + const taskPhone = normalizePhone(task.vnPhone); + const recordName = normalizeSkuName(record.name); + const taskSkuName = normalizeSkuName(task.skuName); + const recordRoleName = normalizeSkuName(record.fulfillUser); + const taskRoleName = normalizeSkuName(task.roleName); + + if (recordVnId > 0 && task.vnId > 0 && recordVnId === task.vnId) { + score += 50; + } + if (recordPhone && taskPhone && recordPhone === taskPhone) { + score += 30; + } + if (recordName && taskSkuName && recordName === taskSkuName) { + score += 20; + } + if (recordRoleName && taskRoleName && recordRoleName.includes(taskRoleName)) { + score += 10; + } + + const recordTime = new Date(record.createdAt).getTime(); + const dispatchTime = new Date(task.localDispatchAt || task.updatedAt || task.createdAt).getTime(); + if (Number.isFinite(recordTime) && Number.isFinite(dispatchTime)) { + const distanceHours = Math.abs(recordTime - dispatchTime) / (60 * 60 * 1000); + if (distanceHours <= 6) { + score += 15; + } else if (distanceHours <= 24) { + score += 6; + } + } + + return score; +} + +function mapLocalTaskRecordEnhancement( + task: LocalCloudtentaclesTask | null, + confidence = "" +) { + return { + platformOrderId: task?.platformOrderId || "", + taskId: task?.taskId || 0, + taskNo: task?.taskNo || "", + orderShopId: task?.orderShopId || "", + orderShopName: task?.orderShopName || "", + consumeShopId: task?.consumeShopId || "", + consumeShopName: task?.consumeShopName || "", + roleName: task?.roleName || "", + roleId: task?.roleId || "", + localTaskStatus: task?.taskStatus || "", + localDeliveryStatus: task?.deliveryStatus || "", + localDispatchStatus: task?.localDispatchStatus || "", + localDispatchAt: task?.localDispatchAt || "", + localResultCode: task?.resultCode || "", + localResultMessage: task?.resultMessage || "", + matchConfidence: confidence, + }; +} + +function createLocalTaskFallbackRecord(task: LocalCloudtentaclesTask) { + return { + createdAt: task.localDispatchAt || task.updatedAt || task.createdAt, + recordId: "", + virtualNumberId: task.vnId, + userId: 0, + count: 1, + cdk: "", + exchangeUrl: "", + name: task.skuName, + phone: task.vnPhone, + status: mapLocalDispatchStatusToCloudStatus(task.localDispatchStatus), + fulfillUser: task.roleName, + operatorName: "", + raw: {}, + }; +} + +function mapLocalDispatchStatusToCloudStatus(status: string) { + const normalized = normalizeText(status); + if (normalized === "success") { + return 4; + } + if (normalized === "failed") { + return 3; + } + if (normalized === "pending") { + return 2; + } + return 0; +} diff --git a/apps/backend/src/types/admin/write-inputs.ts b/apps/backend/src/types/admin/write-inputs.ts index d2ccf678..26bb6362 100644 --- a/apps/backend/src/types/admin/write-inputs.ts +++ b/apps/backend/src/types/admin/write-inputs.ts @@ -170,6 +170,7 @@ export type AdminCloudtentaclesDeliveryRecordQueryInput = AdminCloudtentaclesCat startDate?: string endDate?: string recordCode?: number | string + platformOrderId?: string } export type AdminCloudtentaclesSkuBuyInput = { diff --git a/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts index 2df066bd..30e5890a 100644 --- a/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts +++ b/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts @@ -195,6 +195,7 @@ export function fetchAdminCloudtentaclesDeliveryRecords(payload: { size?: number startDate?: string endDate?: string + platformOrderId?: string }) { return apiPost( '/api/v1/admin/cloudtentacles-records', diff --git a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts index b6b8e094..6442d35f 100644 --- a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts +++ b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts @@ -137,6 +137,22 @@ export interface AdminCloudtentaclesDeliveryRecordItem { status: number fulfillUser: string operatorName: string + platformOrderId: string + taskId: number + taskNo: string + orderShopId: string + orderShopName: string + consumeShopId: string + consumeShopName: string + roleName: string + roleId: string + localTaskStatus: string + localDeliveryStatus: string + localDispatchStatus: string + localDispatchAt: string + localResultCode: string + localResultMessage: string + matchConfidence: string raw: Record } @@ -145,6 +161,10 @@ export interface AdminCloudtentaclesDeliveryRecordListResult { page: number size: number total: number + sourceKey?: string + queryStartDate?: string + queryEndDate?: string + localOrderMatched?: boolean items: AdminCloudtentaclesDeliveryRecordItem[] raw: Record } diff --git a/apps/frontend/src/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue b/apps/frontend/src/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue index 8e1eff51..25a5f6f3 100644 --- a/apps/frontend/src/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue +++ b/apps/frontend/src/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue @@ -23,6 +23,7 @@ const loading = ref(false) const sourceLoading = ref(false) const errorMessage = ref('') const sourceKey = ref('') +const platformOrderId = ref('') const dateRange = ref<[string, string]>(createDefaultDateRange()) const page = ref(1) const pageSize = ref(100) @@ -58,7 +59,8 @@ async function loadSources() { } async function loadRecords(nextPage = page.value) { - if (!sourceKey.value) { + const orderKeyword = platformOrderId.value.trim() + if (!sourceKey.value && !orderKeyword) { showError('请先选择 cloudtentacles 账号') return } @@ -74,12 +76,16 @@ async function loadRecords(nextPage = page.value) { try { const response = await fetchAdminCloudtentaclesDeliveryRecords({ - sourceKey: sourceKey.value, + sourceKey: sourceKey.value || undefined, page: nextPage, size: pageSize.value, startDate, endDate, + platformOrderId: orderKeyword || undefined, }) + if (response.data.sourceKey && sourceKey.value !== response.data.sourceKey) { + sourceKey.value = response.data.sourceKey + } page.value = response.data.page pageSize.value = response.data.size total.value = response.data.total @@ -92,6 +98,7 @@ async function loadRecords(nextPage = page.value) { } function resetFilters() { + platformOrderId.value = '' dateRange.value = createDefaultDateRange() page.value = 1 pageSize.value = 100 @@ -145,6 +152,22 @@ function getStatusType(status: number) { return 'info' } +function getLocalDispatchStatusLabel(status: string) { + const normalized = String(status || '').trim() + if (normalized === 'success') return '本地已发货' + if (normalized === 'failed') return '本地发货失败' + if (normalized === 'pending') return '本地待发货' + return normalized || '-' +} + +function getMatchConfidenceLabel(value: string) { + if (value === 'high') return '高' + if (value === 'medium') return '中' + if (value === 'low') return '低' + if (value === 'local') return '本地' + return '' +} + function createDefaultDateRange(): [string, string] { const end = new Date() const start = new Date(end) @@ -165,7 +188,7 @@ async function drawRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) { const canvas = document.createElement('canvas') const scale = window.devicePixelRatio || 1 const width = 720 - const height = 260 + const height = 330 canvas.width = width * scale canvas.height = height * scale canvas.style.width = `${width}px` @@ -193,6 +216,7 @@ async function drawRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) { ctx.textAlign = 'left' drawTicketCard(ctx, record, productImage) + drawRecordMeta(ctx, record) return canvas.toDataURL('image/png') } @@ -246,7 +270,7 @@ function drawTicketCard( ctx.textAlign = 'left' const contentX = x + imageWidth + 16 - const orderText = `订单号:${record.virtualNumberId || record.recordId.slice(-6) || '-'}` + const orderText = `订单号:${record.platformOrderId || record.recordId.slice(-8) || '-'}` ctx.fillStyle = '#1d5ea8' ctx.font = '16px sans-serif' drawEllipsisText(ctx, record.name || '未命名商品', contentX, y + 31, 170) @@ -275,6 +299,23 @@ function drawTicketCard( drawEllipsisText(ctx, `账号:${record.fulfillUser || record.phone || '-'}`, contentX, y + 90, 250) } +function drawRecordMeta(ctx: CanvasRenderingContext2D, record: AdminCloudtentaclesDeliveryRecordItem) { + const left = 80 + const top = 250 + const lineHeight = 22 + const values = [ + `订单店铺:${record.orderShopName || record.orderShopId || '-'}`, + `核销店铺:${record.consumeShopName || record.consumeShopId || '-'}`, + `绑定角色:${record.roleName || record.fulfillUser || '-'}${record.roleId ? `(${record.roleId})` : ''}`, + ] + + ctx.fillStyle = '#374151' + ctx.font = '13px sans-serif' + values.forEach((value, index) => { + drawEllipsisText(ctx, value, left, top + index * lineHeight, 560) + }) +} + async function loadRecordProductImage(imageUrl: string) { const normalizedUrl = String(imageUrl || '').trim() if (!normalizedUrl) { @@ -352,7 +393,9 @@ function drawEllipsisText( function buildRecordImageFileName(record: AdminCloudtentaclesDeliveryRecordItem) { const name = sanitizeFileName(record.name || '发货记录') - const id = sanitizeFileName(record.recordId.slice(0, 8) || String(record.virtualNumberId || 'record')) + const id = sanitizeFileName( + record.platformOrderId || record.recordId.slice(0, 8) || String(record.virtualNumberId || 'record'), + ) return `${name}-${id}.png` } @@ -370,7 +413,7 @@ onMounted(async () => { + + + @@ -463,9 +531,14 @@ onMounted(async () => { @@ -476,7 +549,15 @@ onMounted(async () => { @@ -519,7 +600,7 @@ onMounted(async () => { @import '@/styles/admin-list-pages.css'; .cloud-records-filter { - grid-template-columns: minmax(180px, 0.34fr) minmax(360px, 1fr) auto; + grid-template-columns: minmax(180px, 0.28fr) minmax(220px, 0.36fr) minmax(360px, 1fr) auto; } .cloud-records-date-range { @@ -549,6 +630,13 @@ onMounted(async () => { background: #fff; } +.status-stack { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + @media (max-width: 900px) { .cloud-records-filter { grid-template-columns: 1fr;