优化快手云领取发货校验

This commit is contained in:
yml2213
2026-05-26 16:24:50 +08:00
parent 604f1bbf54
commit dfcc8982c6
5 changed files with 331 additions and 26 deletions
@@ -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)
@@ -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, {
@@ -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, "");
}
@@ -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,
@@ -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,
}