diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts index 9b8a22a0..d5bc92b4 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts @@ -43,6 +43,7 @@ import { resolveKuaishouCloudVnKeyCandidates, resolvePersistedCloudtentaclesContext, } from './kuaishou-cloud-helpers.js' +import { syncKuaishouCloudRoleInfoBeforeDispatch } from '../../fulfillment/kuaishou-cloud/dispatch-role-sync.js' import type { AdminEntityIdInput, @@ -273,31 +274,49 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment( }) } + const synced = await syncKuaishouCloudRoleInfoBeforeDispatch(task, { + now, + actor: session + ? { + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } + : null, + source: 'admin_task_dispatch', + errorCodePrefix: 'admin_task_kuaishou_cloud', + cloudContext, + taskContext, + flow, + }) + const syncedFlow = synced.flow + const syncedTaskContext = synced.taskContext + const dispatchResult = await useCloudtentaclesSku({ ...cloudContext, - id: flow.binding.skuId, - virtualNumberId: flow.binding.vnId, - phone: flow.binding.vnPhone, + id: syncedFlow.binding.skuId, + virtualNumberId: syncedFlow.binding.vnId, + phone: syncedFlow.binding.vnPhone, }) const nextContext = { - ...taskContext, + ...syncedTaskContext, kuaishouCloudFulfillment: { - ...flow, + ...syncedFlow, ticket: { - ...flow.ticket, + ...syncedFlow.ticket, code: ticketCode || persistedTicketCode, - capturedAt: ticketCode ? now : flow.ticket.capturedAt, + capturedAt: ticketCode ? now : syncedFlow.ticket.capturedAt, capturedBy: ticketCode && session ? { userId: Number(session.userId || 0) || 0, username: String(session.username || '').trim(), role: String(session.role || '').trim(), } - : flow.ticket.capturedBy, + : syncedFlow.ticket.capturedBy, }, dispatch: { - ...flow.dispatch, + ...syncedFlow.dispatch, status: 'success', dispatchAt: now, dispatchBy: session @@ -325,9 +344,9 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment( await createTaskEvent(task.id, 'kuaishou_cloud_dispatched', { ticketCodeMasked: maskCode(ticketCode || persistedTicketCode), - skuId: flow.binding.skuId, - vnId: flow.binding.vnId, - vnPhoneMasked: maskPhone(flow.binding.vnPhone), + skuId: syncedFlow.binding.skuId, + vnId: syncedFlow.binding.vnId, + vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone), sendType: dispatchResult.sendType, note: dispatchResult.note, }, now) diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index a77dd140..1c54d5eb 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -87,6 +87,7 @@ export async function getClaimContext(token: unknown) { export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) + const displaySkuName = resolveClaimOrderItemDisplaySkuName(orderItem, kuaishouCloudFulfillment) return { tokenStatus: claimToken.status, @@ -119,7 +120,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: orderItem: { orderItemId: orderItem.id, skuCode: orderItem.sku_code, - skuName: orderItem.sku_name, + skuName: displaySkuName, quantity: orderItem.quantity, }, session: null as null, @@ -135,6 +136,33 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: } } +function resolveClaimOrderItemDisplaySkuName( + orderItem: OrderItemRow, + kuaishouCloudFulfillment: JsonObject | null, +) { + const fulfillment = isPlainObject(kuaishouCloudFulfillment) ? kuaishouCloudFulfillment : {} + const binding = isPlainObject(fulfillment.binding) ? fulfillment.binding : {} + const ticket = isPlainObject(fulfillment.ticket) ? fulfillment.ticket : {} + const skuCode = String(orderItem.sku_code || '').trim() + const rawSkuName = String(orderItem.sku_name || '').trim() + const candidates = [ + fulfillment.internalSkuName, + binding.skuName, + ticket.goodsTitle, + rawSkuName, + skuCode, + ] + + for (const value of candidates) { + const normalized = String(value || '').trim() + if (normalized && !isOrderItemIdentifierName(normalized, skuCode)) { + return normalized + } + } + + return rawSkuName || skuCode +} + export function mapClaimKuaishouCloudFulfillment(task: TaskRow, order: OrderRow) { if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') { return null @@ -229,6 +257,24 @@ function parseTaskContext(task: TaskRow): JsonObject { return parseTaskContextValue(task) } +function isOrderItemIdentifierName(value: string, skuCode: string) { + const normalized = String(value || '').trim() + if (!normalized) { + return true + } + + const normalizedSkuCode = String(skuCode || '').trim() + if (normalizedSkuCode && normalized === normalizedSkuCode) { + return true + } + + return /^\d{8,}$/.test(normalized) +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} + async function expireClaimContext(claimToken: ClaimTokenRow, task: TaskRow) { const now = nowIso() const nextClaimToken = await updateClaimToken(claimToken.id, { diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts new file mode 100644 index 00000000..f7e9f461 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts @@ -0,0 +1,216 @@ +import { createTaskEvent } from "../../../repositories/task-event-repo.js"; +import { updateTask } from "../../../repositories/task-repo.js"; +import type { TaskRow } from "../../../types/repository/rows.js"; +import { createHttpError } from "../../../utils/http.js"; +import { getCloudtentaclesBindInfo } from "../../platforms/cloudtentacles/virtual-number-service.js"; +import { + normalizeKuaishouCloudFlow, + normalizeKuaishouCloudRoleInfo, + type JsonObject, +} from "./domain.js"; + +export async function syncKuaishouCloudRoleInfoBeforeDispatch( + task: TaskRow, + input: JsonObject = {} +) { + const now = String(input.now || "").trim() || new Date().toISOString(); + const source = + String(input.source || "system_before_dispatch").trim() || + "system_before_dispatch"; + const errorCodePrefix = + String(input.errorCodePrefix || "kuaishou_cloud").trim() || + "kuaishou_cloud"; + const cloudContext = + input.cloudContext && typeof input.cloudContext === "object" + ? input.cloudContext + : {}; + const taskContext = + input.taskContext && typeof input.taskContext === "object" + ? input.taskContext + : {}; + const flow = normalizeKuaishouCloudFlow( + input.flow || taskContext.kuaishouCloudFulfillment + ); + + if (!flow.binding.vnId || !flow.binding.vnKey) { + throw createHttpError("当前任务缺少可同步角色的虚拟号信息,暂时不能发货", { + statusCode: 409, + errorCode: `${errorCodePrefix}_dispatch_missing_bind_info_context`, + }); + } + + const bindInfoResult = await getCloudtentaclesBindInfo({ + ...cloudContext, + key: flow.binding.vnKey, + id: flow.binding.vnId, + }); + const roleInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo); + + if (!roleInfo.name || !roleInfo.rid) { + throw createHttpError( + "cloudtentacles 还没有同步到客户绑定角色,请稍后刷新角色信息后再发货", + { + statusCode: 409, + errorCode: `${errorCodePrefix}_dispatch_role_not_ready`, + } + ); + } + + const confirmedRoleName = String( + flow.binding.roleName || flow.role.name || task.role_name || "" + ).trim(); + const confirmedRoleId = String( + flow.binding.roleId || flow.role.rid || task.role_id || "" + ).trim(); + const roleMatches = isSameKuaishouCloudRole( + { + name: confirmedRoleName, + rid: confirmedRoleId, + }, + { + name: roleInfo.name, + rid: roleInfo.rid, + } + ); + + if (!roleMatches) { + await createTaskEvent( + task.id, + "kuaishou_cloud_dispatch_role_mismatch", + { + source, + vnId: flow.binding.vnId, + confirmedRoleName, + confirmedRoleId, + cloudtentaclesRoleName: roleInfo.name, + cloudtentaclesRoleId: roleInfo.rid, + actor: input.actor || null, + }, + now + ); + + throw createHttpError( + `cloudtentacles 当前绑定角色(${roleInfo.name}/${roleInfo.rid})与客户确认角色(${confirmedRoleName || "-"}/${confirmedRoleId || "-"})不一致,请重新刷新角色后再发货`, + { + statusCode: 409, + errorCode: `${errorCodePrefix}_dispatch_role_mismatch`, + } + ); + } + + const nextFlow = normalizeKuaishouCloudFlow({ + ...flow, + binding: { + ...flow.binding, + roleName: roleInfo.name, + roleId: roleInfo.rid, + }, + role: { + ...flow.role, + status: "ready", + name: roleInfo.name, + rid: roleInfo.rid, + refreshedAt: now, + errorMessage: "", + rawInfo: roleInfo.rawInfo, + }, + }); + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: nextFlow, + }; + + const updatedTask = await updateTask(task.id, { + role_id: roleInfo.rid, + role_name: roleInfo.name, + context_json: JSON.stringify(nextContext), + updated_at: now, + }); + + if (!updatedTask) { + throw createHttpError("发货前角色信息同步失败", { + statusCode: 500, + errorCode: `${errorCodePrefix}_dispatch_role_update_failed`, + }); + } + + await createTaskEvent( + task.id, + "kuaishou_cloud_role_info_synced_before_dispatch", + { + source, + roleName: roleInfo.name, + roleId: roleInfo.rid, + vnId: flow.binding.vnId, + actor: input.actor || null, + }, + now + ); + + return { + task: updatedTask, + taskContext: nextContext, + flow: nextFlow, + roleInfo, + }; +} + +function isSameKuaishouCloudRole( + confirmedRole: { name: string; rid: string }, + cloudtentaclesRole: { name: string; rid: string } +) { + if (confirmedRole.rid && cloudtentaclesRole.rid) { + return confirmedRole.rid === cloudtentaclesRole.rid; + } + + const confirmedName = normalizeKuaishouCloudRoleNameForCompare( + confirmedRole.name + ); + const cloudtentaclesName = normalizeKuaishouCloudRoleNameForCompare( + cloudtentaclesRole.name + ); + + if (!confirmedName || !cloudtentaclesName) { + return true; + } + + return confirmedName === cloudtentaclesName; +} + +function normalizeKuaishouCloudRoleNameForCompare(value: unknown) { + const name = String(value || "").trim(); + if (!name) { + return ""; + } + + const parts = name + .split("-") + .map((part) => part.trim()) + .filter(Boolean); + const devicePart = parts[0] || ""; + const channelPart = parts[1] || ""; + if ( + parts.length >= 3 && + isKuaishouCloudDevicePart(devicePart) && + isKuaishouCloudChannelPart(channelPart) + ) { + return normalizeTextForCompare(parts.slice(2).join("-")); + } + + return normalizeTextForCompare(name); +} + +function isKuaishouCloudDevicePart(value: string) { + return ["安卓", "android", "ios", "苹果"].includes(value.toLowerCase()); +} + +function isKuaishouCloudChannelPart(value: string) { + return ["qq", "微信", "vx", "wechat", "v"].includes(value.toLowerCase()); +} + +function normalizeTextForCompare(value: unknown) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/\s+/g, ""); +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts index b52a132f..47756f90 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts @@ -19,6 +19,7 @@ import { type JsonObject, } from "./domain.js"; import { resolvePersistedCloudtentaclesContextBySourceKeys } from "./cloudtentacles-context.js"; +import { syncKuaishouCloudRoleInfoBeforeDispatch } from "./dispatch-role-sync.js"; import { normalizeActor, parseTaskContext } from "./task-context.js"; import type { TaskRow } from "../../../types/repository/rows.js"; @@ -68,25 +69,36 @@ export async function dispatchKuaishouCloudFulfillmentTask( return { task, flow }; } + const synced = await syncKuaishouCloudRoleInfoBeforeDispatch(task, { + now, + actor, + source: options.source || "system_before_dispatch", + cloudContext, + taskContext, + flow, + }); + const syncedFlow = synced.flow; + const syncedTaskContext = synced.taskContext; + const dispatchResult = await useCloudtentaclesSku({ ...cloudContext, - id: flow.binding.skuId, - virtualNumberId: flow.binding.vnId, - phone: flow.binding.vnPhone, + id: syncedFlow.binding.skuId, + virtualNumberId: syncedFlow.binding.vnId, + phone: syncedFlow.binding.vnPhone, }); const nextContext = { - ...taskContext, + ...syncedTaskContext, kuaishouCloudFulfillment: { - ...flow, + ...syncedFlow, ticket: { - ...flow.ticket, + ...syncedFlow.ticket, code: ticketCode || persistedTicketCode, - capturedAt: ticketCode ? now : flow.ticket.capturedAt, - capturedBy: ticketCode && actor ? actor : flow.ticket.capturedBy, + capturedAt: ticketCode ? now : syncedFlow.ticket.capturedAt, + capturedBy: ticketCode && actor ? actor : syncedFlow.ticket.capturedBy, }, dispatch: { - ...flow.dispatch, + ...syncedFlow.dispatch, status: "success", dispatchAt: now, dispatchBy: actor, @@ -127,9 +139,9 @@ export async function dispatchKuaishouCloudFulfillmentTask( { source: String(options.source || "system").trim() || "system", ticketCodeMasked: maskCode(ticketCode || persistedTicketCode), - skuId: flow.binding.skuId, - vnId: flow.binding.vnId, - vnPhoneMasked: maskPhone(flow.binding.vnPhone), + skuId: syncedFlow.binding.skuId, + vnId: syncedFlow.binding.vnId, + vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone), sendType: dispatchResult.sendType, note: dispatchResult.note, actor, diff --git a/apps/backend/src/services/platforms/cloudtentacles/record-service.ts b/apps/backend/src/services/platforms/cloudtentacles/record-service.ts index 54e60662..e0b56429 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/record-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/record-service.ts @@ -1,4 +1,5 @@ import { createHttpError } from '../../../utils/http.js' +import { logInfo } from '../../../utils/logger.js' import { cloudtentaclesRequest } from './http-client.js' import { resolveCloudtentaclesConfig } from './helpers.js' @@ -67,12 +68,23 @@ export async function listCloudtentaclesDeliveryRecords( const data = isPlainObject(result.payload?.data) ? result.payload.data : {} const values = Array.isArray(data.values) ? data.values : [] + const total = normalizeNonNegativeInteger(data.total, values.length) + + logInfo('[cloudtentacles/records]', '发货记录查询完成', { + page, + size, + recordCode: normalizePositiveInteger(payload.recordCode, 4), + startDate, + endDate, + total, + returnedCount: values.length, + }) return { baseUrl: config.baseUrl, page, size, - total: normalizeNonNegativeInteger(data.total, values.length), + total, items: values.map(mapCloudtentaclesDeliveryRecord), raw: data, }