diff --git a/apps/backend/data/khhao-sync-state.json b/apps/backend/data/khhao-sync-state.json index 06db5744..7803062d 100644 --- a/apps/backend/data/khhao-sync-state.json +++ b/apps/backend/data/khhao-sync-state.json @@ -1,11 +1,11 @@ { "syncFromCreatedAt": "2026-05-02T10:53:28.550Z", - "lastRunStartedAt": "2026-05-03T09:15:36.131Z", - "lastRunFinishedAt": "2026-05-03T09:15:37.160Z", + "lastRunStartedAt": "2026-05-03T10:44:18.857Z", + "lastRunFinishedAt": "2026-05-03T10:44:19.569Z", "lastRunStatus": "success", "lastErrorMessage": "", "fetchedCount": 40, - "syncedCount": 0, - "ignoredCount": 40, - "lastOrderCreatedAt": "2026-05-03T09:15:32.000Z" + "syncedCount": 1, + "ignoredCount": 39, + "lastOrderCreatedAt": "2026-05-03T10:44:07.000Z" } diff --git a/apps/backend/data/kuaishou-cloud-fulfillment.json b/apps/backend/data/kuaishou-cloud-fulfillment.json new file mode 100644 index 00000000..01ed8f5c --- /dev/null +++ b/apps/backend/data/kuaishou-cloud-fulfillment.json @@ -0,0 +1,4 @@ +{ + "enabled": true, + "items": [] +} diff --git a/apps/backend/data/kuaishou-eticket-source.json b/apps/backend/data/kuaishou-eticket-source.json index aa2bd142..f19b03ef 100644 --- a/apps/backend/data/kuaishou-eticket-source.json +++ b/apps/backend/data/kuaishou-eticket-source.json @@ -7,7 +7,7 @@ "kshopName": "洋洋运营", "cookie": "_did=web_412376696871A458; did=web_pdd5bx2mm4sdx78w7zg3oj1uya9cqs59; bUserId=1000598999789; userId=140000112682050; merchantSellerId=4269276762; sid=kshop.api; kshop.api_st=Cgxrc2hvcC5hcGkuc3QSoAEHQpSWH5NWp5ReO9nWzyqJIZ9NsB7YhqywKcjVzUAf1YBJODvE9HHSA5jNrO1kyoYtQmK7hlUmD7FCkDjvzTNoDJCd5mGiu_oFbUr-JYJ2--q_QNhHlY13he_NQSPQ7EYDf54NBMk1jKQNarlUnB-1WosXNgV7G5YNeZXr8CDMWkDwKUaDnG-2JGmPTsouINryXjteKsj4YZEzwWHR2GTZGhJex1zz60qNCRAHidwVN1PhX4wiIN3oIIsuly3Zub__D_EnvOhbCP_Ma363nNk4hmpKm_NpKAUwAQ; kshop.api_ph=fabd3673d2238257e37fc892abf2e8e73fd4; KS-CSRF-Token=179C60C8C34FB066A71221C9EE64B5CE; merchantSessionKey=1777795509898_209a40cd7ad55a7e842151ee83de41bb", "userAvatar": "https://p2-pro.a.yximgs.com/uhead/AB/2024/07/23/17/BMjAyNDA3MjMxNzA1MThfNDI2OTI3Njc2Ml8yX2hkNDg1XzgxNQ==_s.jpg", - "enabled": true + "enabled": false }, { "shopId": "4269276762", diff --git a/apps/backend/data/order-fulfillment-bindings.json b/apps/backend/data/order-fulfillment-bindings.json index a19dec6f..b8dafb0a 100644 --- a/apps/backend/data/order-fulfillment-bindings.json +++ b/apps/backend/data/order-fulfillment-bindings.json @@ -3,9 +3,9 @@ "provider": "khhao", "platform": "kuaishou", "shopId": "10", - "skuCode": "测试快手1", - "skuName": "测试1", - "profileKey": "manual_review", + "skuCode": "套装-浪漫天命", + "skuName": "套装-浪漫天命", + "profileKey": "kuaishou_ct_assisted", "enabled": true, "priority": 100, "config": {}, @@ -14,7 +14,7 @@ "externalItemId": "830", "externalSkuName": "测试1", "config": { - "resolvedSkuName": "测试1" + "resolvedSkuName": "套装-浪漫天命" } } }, diff --git a/apps/backend/src/routes/admin/platform-config.js b/apps/backend/src/routes/admin/platform-config.js index a936f233..8f459928 100644 --- a/apps/backend/src/routes/admin/platform-config.js +++ b/apps/backend/src/routes/admin/platform-config.js @@ -11,6 +11,7 @@ import { getAdminCloudtentaclesAsset, getAdminCloudtentaclesBindUrl, getAdminCloudtentaclesCategories, + getAdminKuaishouCloudFulfillmentConfig, getAdminKuaishouEticketSourceConfig, getAdminCloudtentaclesKnapsack, getAdminCloudtentaclesSourceConfig, @@ -30,6 +31,7 @@ import { syncAdminKhhaoOrders, testAdminCloudtentaclesLogin, testAdminKhhaoLogin, + updateAdminKuaishouCloudFulfillmentConfig, updateAdminKuaishouEticketSourceConfig, updateAdminCloudtentaclesSourceConfig, updateAdminAgisoShopConfigs, @@ -51,6 +53,7 @@ import { createJsonHandler, requireAdminRoles } from './shared.js' /** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketConsumeRouteBody} AdminKuaishouEticketConsumeRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouEticketShopInfoRouteBody} AdminKuaishouEticketShopInfoRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminCloudtentaclesSourceConfigRouteBody} AdminCloudtentaclesSourceConfigRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminKuaishouCloudFulfillmentConfigRouteBody} AdminKuaishouCloudFulfillmentConfigRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminCloudtentaclesSendSmsCodeRouteBody} AdminCloudtentaclesSendSmsCodeRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminCloudtentaclesTestLoginRouteBody} AdminCloudtentaclesTestLoginRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminCloudtentaclesValidateSessionRouteBody} AdminCloudtentaclesValidateSessionRouteBody */ @@ -579,4 +582,34 @@ router.post('/platform-config/fulfillment-bindings', createJsonHandler( }, )) +router.get('/platform-config/kuaishou-cloud-fulfillment', createJsonHandler( + () => getAdminKuaishouCloudFulfillmentConfig(), + { + successMessage: 'ok', + errorMessage: '读取新履约配置失败', + scope: '[admin/platform-config/kuaishou-cloud-fulfillment]', + }, +)) + +router.post('/platform-config/kuaishou-cloud-fulfillment', createJsonHandler( + (req) => updateAdminKuaishouCloudFulfillmentConfig(/** @type {AdminKuaishouCloudFulfillmentConfigRouteBody} */ (req.body)), + { + successMessage: '新履约配置已保存', + errorMessage: '保存新履约配置失败', + scope: '[admin/platform-config/kuaishou-cloud-fulfillment]', + audit: (_req, data) => { + const result = /** @type {{ filePath?: string, source?: { items?: unknown[] } }} */ (data) + return { + action: 'platform_kuaishou_cloud_fulfillment_updated', + targetType: 'platform_config', + targetId: 'kuaishou_cloud_fulfillment', + data: { + itemCount: Array.isArray(result.source?.items) ? result.source.items.length : 0, + filePath: String(result.filePath || '').trim(), + }, + } + }, + }, +)) + export default router diff --git a/apps/backend/src/routes/admin/tasks.js b/apps/backend/src/routes/admin/tasks.js index 5d46c032..7f34ceb6 100644 --- a/apps/backend/src/routes/admin/tasks.js +++ b/apps/backend/src/routes/admin/tasks.js @@ -6,11 +6,14 @@ import { closeAdminTask, completeAdminTaskManualDispatch, confirmAdminTaskAssistedRole, + dispatchAdminTaskKuaishouCloudFulfillment, markAdminTaskManualReview, + prepareAdminTaskKuaishouCloudFulfillment, regenerateAdminTaskClaimLink, redeemAdminTaskAssisted, releaseAdminTaskInventoryBinding, releaseAdminTaskInventory, + returnNumberAdminTaskKuaishouCloudFulfillment, retryAdminTask, } from '../../services/admin/admin-write-service.js' import { @@ -21,6 +24,7 @@ import { import { createFileHandler, createJsonHandler, requireAdminRoles } from './shared.js' /** @typedef {import('../../types/admin-route-inputs.js').AdminRouteAdminSession} AdminRouteAdminSession */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminTaskKuaishouCloudDispatchRouteBody} AdminTaskKuaishouCloudDispatchRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminTaskManualDispatchRouteBody} AdminTaskManualDispatchRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminTaskRouteParams} AdminTaskRouteParams */ /** @typedef {import('../../types/admin-route-inputs.js').AdminTaskRouteQuery} AdminTaskRouteQuery */ @@ -270,4 +274,83 @@ router.post('/tasks/:taskId/complete-manual-dispatch', requireAdminRoles(['admin }, )) +router.post('/tasks/:taskId/kuaishou-cloud/prepare', requireAdminRoles(['admin', 'operator']), createJsonHandler( + (req) => prepareAdminTaskKuaishouCloudFulfillment( + (/** @type {AdminTaskRouteParams} */ (req.params)).taskId, + /** @type {AdminRouteAdminSession | null} */ (req.adminSession), + ), + { + successMessage: '绑定资源已准备完成', + errorMessage: '准备绑定资源失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/prepare]', + audit: (req, data) => { + const result = /** @type {AdminTaskActionResponse} */ (data) + return { + action: 'task_kuaishou_cloud_prepare', + targetType: 'task', + targetId: String((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), + data: { + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, + }, + } + }, + }, +)) + +router.post('/tasks/:taskId/kuaishou-cloud/dispatch', requireAdminRoles(['admin', 'operator']), createJsonHandler( + (req) => dispatchAdminTaskKuaishouCloudFulfillment( + (/** @type {AdminTaskRouteParams} */ (req.params)).taskId, + /** @type {AdminTaskKuaishouCloudDispatchRouteBody} */ (req.body), + /** @type {AdminRouteAdminSession | null} */ (req.adminSession), + ), + { + successMessage: '已完成绑定确认并发货', + errorMessage: '执行发货失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/dispatch]', + audit: (req, data) => { + const result = /** @type {AdminTaskActionResponse} */ (data) + const body = /** @type {AdminTaskKuaishouCloudDispatchRouteBody} */ (req.body) + return { + action: 'task_kuaishou_cloud_dispatch', + targetType: 'task', + targetId: String((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), + data: { + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, + deliveryStatus: result.task.deliveryStatus, + ticketCodeProvided: Boolean(String(body.ticketCode || '').trim()), + }, + } + }, + }, +)) + +router.post('/tasks/:taskId/kuaishou-cloud/return-number', requireAdminRoles(['admin', 'operator']), createJsonHandler( + (req) => returnNumberAdminTaskKuaishouCloudFulfillment( + (/** @type {AdminTaskRouteParams} */ (req.params)).taskId, + /** @type {AdminRouteAdminSession | null} */ (req.adminSession), + ), + { + successMessage: '号码已退还', + errorMessage: '退还号码失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/return-number]', + audit: (req, data) => { + const result = /** @type {AdminTaskActionResponse} */ (data) + return { + action: 'task_kuaishou_cloud_return_number', + targetType: 'task', + targetId: String((/** @type {AdminTaskRouteParams} */ (req.params)).taskId), + data: { + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, + }, + } + }, + }, +)) + export default router diff --git a/apps/backend/src/services/admin/admin-platform-config-service.js b/apps/backend/src/services/admin/admin-platform-config-service.js index 046db60d..f8df91cf 100644 --- a/apps/backend/src/services/admin/admin-platform-config-service.js +++ b/apps/backend/src/services/admin/admin-platform-config-service.js @@ -68,6 +68,11 @@ import { getOrderFulfillmentBindingsFilePath, saveOrderFulfillmentBindingConfigs, } from '../order/fulfillment-binding-config-service.js' +import { + getKuaishouCloudFulfillmentConfig, + getKuaishouCloudFulfillmentFilePath, + saveKuaishouCloudFulfillmentConfig, +} from '../order/kuaishou-cloud-fulfillment-config-service.js' import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount } from '../../utils/money.js' @@ -84,6 +89,7 @@ import { resolveDisplayShopName } from './admin-read-shared-helpers.js' /** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoOrderQueryInput} AdminKhhaoOrderQueryInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoTestLoginInput} AdminKhhaoTestLoginInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesSourceConfigInput} AdminCloudtentaclesSourceConfigInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminKuaishouCloudFulfillmentConfigInput} AdminKuaishouCloudFulfillmentConfigInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesSendSmsCodeInput} AdminCloudtentaclesSendSmsCodeInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesTestLoginInput} AdminCloudtentaclesTestLoginInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesValidateSessionInput} AdminCloudtentaclesValidateSessionInput */ @@ -778,6 +784,84 @@ export async function getAdminFulfillmentBindingConfigs() { } } +export function getAdminKuaishouCloudFulfillmentConfig() { + const config = getKuaishouCloudFulfillmentConfig() + + return { + filePath: getKuaishouCloudFulfillmentFilePath(), + source: { + enabled: config.enabled !== false, + items: (Array.isArray(config.items) ? config.items : []).map((item) => ({ + id: String(item.id || '').trim(), + enabled: item.enabled !== false, + priority: Number(item.priority || 100) || 100, + provider: String(item.provider || 'khhao').trim() || 'khhao', + platform: String(item.platform || 'kuaishou').trim() || 'kuaishou', + shopId: String(item.shopId || '').trim(), + internalSkuCode: String(item.internalSkuCode || '').trim(), + internalSkuName: String(item.internalSkuName || '').trim(), + externalSkuCode: String(item.externalSkuCode || '').trim(), + externalItemId: String(item.externalItemId || '').trim(), + externalSkuName: String(item.externalSkuName || '').trim(), + resolvedSkuName: String(item.resolvedSkuName || '').trim(), + cloudSourceKey: String(item.cloudSourceKey || 'default').trim() || 'default', + cloudSkuId: Number(item.cloudSkuId || 0) || 0, + cloudSkuName: String(item.cloudSkuName || '').trim(), + vnKey: String(item.vnKey || '').trim(), + autoBuyEnabled: item.autoBuyEnabled !== false, + minAssetReserve: Number(item.minAssetReserve || 0) || 0, + autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch === true, + autoConsumeAfterDispatch: item.autoConsumeAfterDispatch === true, + kuaishouConsumeShopId: String(item.kuaishouConsumeShopId || '').trim(), + notes: String(item.notes || '').trim(), + })), + }, + } +} + +/** @param {AdminKuaishouCloudFulfillmentConfigInput} [payload] */ +export async function updateAdminKuaishouCloudFulfillmentConfig( + payload = /** @type {AdminKuaishouCloudFulfillmentConfigInput} */ ({}), +) { + const saved = saveKuaishouCloudFulfillmentConfig({ + enabled: payload.enabled !== false, + items: Array.isArray(payload.items) ? payload.items : [], + }) + + await syncConfiguredFulfillmentBindings() + + return { + filePath: getKuaishouCloudFulfillmentFilePath(), + source: { + enabled: saved.enabled !== false, + items: (Array.isArray(saved.items) ? saved.items : []).map((item) => ({ + id: String(item.id || '').trim(), + enabled: item.enabled !== false, + priority: Number(item.priority || 100) || 100, + provider: String(item.provider || 'khhao').trim() || 'khhao', + platform: String(item.platform || 'kuaishou').trim() || 'kuaishou', + shopId: String(item.shopId || '').trim(), + internalSkuCode: String(item.internalSkuCode || '').trim(), + internalSkuName: String(item.internalSkuName || '').trim(), + externalSkuCode: String(item.externalSkuCode || '').trim(), + externalItemId: String(item.externalItemId || '').trim(), + externalSkuName: String(item.externalSkuName || '').trim(), + resolvedSkuName: String(item.resolvedSkuName || '').trim(), + cloudSourceKey: String(item.cloudSourceKey || 'default').trim() || 'default', + cloudSkuId: Number(item.cloudSkuId || 0) || 0, + cloudSkuName: String(item.cloudSkuName || '').trim(), + vnKey: String(item.vnKey || '').trim(), + autoBuyEnabled: item.autoBuyEnabled !== false, + minAssetReserve: Number(item.minAssetReserve || 0) || 0, + autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch === true, + autoConsumeAfterDispatch: item.autoConsumeAfterDispatch === true, + kuaishouConsumeShopId: String(item.kuaishouConsumeShopId || '').trim(), + notes: String(item.notes || '').trim(), + })), + }, + } +} + /** @param {AdminFulfillmentBindingLookupInput} [payload] */ export async function lookupAdminFulfillmentBindingOrder(payload = /** @type {AdminFulfillmentBindingLookupInput} */ ({})) { const provider = String(payload.provider || 'agiso').trim() || 'agiso' diff --git a/apps/backend/src/services/admin/admin-read-service.js b/apps/backend/src/services/admin/admin-read-service.js index 0f76390e..df49beef 100644 --- a/apps/backend/src/services/admin/admin-read-service.js +++ b/apps/backend/src/services/admin/admin-read-service.js @@ -27,6 +27,7 @@ import { getTaskPrimaryInventoryItemId, isAssistedClaimTask, isManualDispatchTask, + mapKuaishouCloudFulfillmentContext, mapManualDispatchContext, mapRedeemResolutionContext, parseTaskContext, @@ -278,6 +279,7 @@ export async function getAdminTaskDetail(taskId, session = null) { roleName: String(taskState.reviewRoleName || '').trim() || '', }, redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution), + kuaishouCloudFulfillment: mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment), manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext), events: taskEvents.map(mapAdminTaskEvent), operations: { @@ -289,6 +291,15 @@ export async function getAdminTaskDetail(taskId, session = null) { canClose: canViewerCloseTask(task, viewerContext), canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status), canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status), + canPrepareKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle + && String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' + && ['pending_binding_prepare', 'manual_review', 'failed'].includes(String(task.task_status || '').trim()), + canDispatchKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle + && String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' + && ['waiting_binding'].includes(String(task.task_status || '').trim()), + canReturnKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle + && String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' + && ['dispatched_pending_return'].includes(String(task.task_status || '').trim()), canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext), canSupportRedeem: canViewerRedeemAssistedTask(task, viewerContext), canViewSensitiveTaskData: viewerContext.canViewSensitiveTaskData, diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.js b/apps/backend/src/services/admin/admin-read-shared-helpers.js index ca217893..5e75e591 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.js +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.js @@ -25,6 +25,67 @@ export function mapManualDispatchContext(value, viewerContext = createAdminViewe } } +export function mapKuaishouCloudFulfillmentContext(value) { + if (!value || typeof value !== 'object') { + return null + } + + const ticket = value.ticket && typeof value.ticket === 'object' ? value.ticket : {} + const binding = value.binding && typeof value.binding === 'object' ? value.binding : {} + const purchase = value.purchase && typeof value.purchase === 'object' ? value.purchase : {} + const dispatch = value.dispatch && typeof value.dispatch === 'object' ? value.dispatch : {} + const returnNumber = value.returnNumber && typeof value.returnNumber === 'object' ? value.returnNumber : {} + const consume = value.consume && typeof value.consume === 'object' ? value.consume : {} + + return { + flowType: String(value.flowType || '').trim(), + configId: String(value.configId || '').trim(), + internalSkuCode: String(value.internalSkuCode || '').trim(), + internalSkuName: String(value.internalSkuName || '').trim(), + ticket: { + code: String(ticket.code || '').trim(), + capturedAt: ticket.capturedAt || null, + }, + binding: { + prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', + cloudSourceKey: String(binding.cloudSourceKey || '').trim(), + skuId: Number(binding.skuId || 0) || 0, + skuName: String(binding.skuName || '').trim(), + vnKey: String(binding.vnKey || '').trim(), + vnId: Number(binding.vnId || 0) || 0, + vnPhone: String(binding.vnPhone || '').trim(), + bindUrl: String(binding.bindUrl || '').trim(), + bindPreparedAt: binding.bindPreparedAt || null, + }, + purchase: { + autoBuyEnabled: purchase.autoBuyEnabled !== false, + minAssetReserve: Number(purchase.minAssetReserve || 0) || 0, + usedKnapsack: purchase.usedKnapsack === true, + purchaseTriggered: purchase.purchaseTriggered === true, + assetBefore: Number(purchase.assetBefore || 0) || 0, + assetAfter: Number(purchase.assetAfter || 0) || 0, + purchaseAt: purchase.purchaseAt || null, + }, + dispatch: { + status: String(dispatch.status || 'pending').trim() || 'pending', + dispatchAt: dispatch.dispatchAt || null, + sendType: Number(dispatch.sendType || 0) || 0, + note: String(dispatch.note || '').trim(), + }, + returnNumber: { + status: String(returnNumber.status || 'pending').trim() || 'pending', + returnedAt: returnNumber.returnedAt || null, + autoReturnEnabled: returnNumber.autoReturnEnabled === true, + }, + consume: { + status: String(consume.status || 'pending').trim() || 'pending', + shopId: String(consume.shopId || '').trim(), + autoConsumeEnabled: consume.autoConsumeEnabled === true, + }, + notes: String(value.notes || '').trim(), + } +} + export function mapRedeemResolutionContext(value) { if (!value || typeof value !== 'object') { return null @@ -85,7 +146,7 @@ export function canViewerCloseTask(task, viewerContext) { return false } - return !['redeemed', 'closed'].includes(String(task?.task_status || '').trim()) + return !['redeemed', 'completed', 'closed'].includes(String(task?.task_status || '').trim()) } export function resolveDisplayShopName(provider, shopId, shopName) { @@ -175,6 +236,10 @@ export function resolveOrderItemDeliveryMode(tasks, orderItemId) { return 'manual_dispatch' } + if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') { + return 'kuaishou_cloud' + } + if (task.requires_claim || getTaskPrimaryClaimTokenId(task) || task.primary_claim_token || task.claim_token) { return 'claim_link' } diff --git a/apps/backend/src/services/admin/admin-service.js b/apps/backend/src/services/admin/admin-service.js index 053cca8c..8b596b89 100644 --- a/apps/backend/src/services/admin/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -61,15 +61,18 @@ export { completeAdminTaskManualDispatch, confirmAdminTaskAssistedRole, createAdminInventoryItem, + dispatchAdminTaskKuaishouCloudFulfillment, importAdminInventoryItems, invalidateAdminInventoryItem, markAdminTaskManualReview, + prepareAdminTaskKuaishouCloudFulfillment, redeemAdminTaskAssisted, regenerateAdminTaskClaimLink, releaseAdminInventoryItem, releaseAdminTaskInventory, releaseAdminTaskInventoryBinding, replayAdminWebhookEvent, + returnNumberAdminTaskKuaishouCloudFulfillment, retryAdminTask, } from './admin-write-service.js' diff --git a/apps/backend/src/services/admin/admin-task-read-helpers.js b/apps/backend/src/services/admin/admin-task-read-helpers.js index bc5f48c6..94396f2f 100644 --- a/apps/backend/src/services/admin/admin-task-read-helpers.js +++ b/apps/backend/src/services/admin/admin-task-read-helpers.js @@ -348,6 +348,22 @@ function buildTaskBindingState(task) { return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' } } + if (normalizedStatus === 'pending_binding_prepare') { + return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' } + } + + if (normalizedStatus === 'waiting_binding') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' } + } + + if (normalizedStatus === 'dispatched_pending_return') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' } + } + + if (normalizedStatus === 'completed') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' } + } + if (normalizedStatus === 'manual_review') { return { systemBindingStatus: 'manual_review', userBindingStatus: 'binding_exception' } } diff --git a/apps/backend/src/services/admin/admin-write-service.js b/apps/backend/src/services/admin/admin-write-service.js index 80040d9c..3916b77d 100644 --- a/apps/backend/src/services/admin/admin-write-service.js +++ b/apps/backend/src/services/admin/admin-write-service.js @@ -22,6 +22,23 @@ import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../cl import { reserveInventoryForTask } from '../order/inventory-service.js' import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js' import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js' +import { getCloudtentaclesSourceConfig } from '../platforms/cloudtentacles/source-config-service.js' +import { getCloudtentaclesSessionState } from '../platforms/cloudtentacles/session-state-service.js' +import { + buyCloudtentaclesSku, + getCloudtentaclesAsset, + listCloudtentaclesSku, + useCloudtentaclesSku, +} from '../platforms/cloudtentacles/catalog-service.js' +import { getCloudtentaclesKnapsack } from '../platforms/cloudtentacles/knapsack-service.js' +import { + appointCloudtentaclesVirtualNumber, + backCloudtentaclesVirtualNumber, + fetchCloudtentaclesVirtualNumberCode, + generateCloudtentaclesLoginCode, + getCloudtentaclesBindUrl, + verifyCloudtentaclesLoginCode, +} from '../platforms/cloudtentacles/virtual-number-service.js' import { closeTencentBrowserSession } from '../session/session.js' import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' @@ -56,6 +73,7 @@ import { /** @typedef {import('../../types/admin-write-models.js').AdminTaskBindingReleaseResponse} AdminTaskBindingReleaseResponse */ /** @typedef {import('../../types/admin-write-models.js').AdminTaskManualDispatchResponse} AdminTaskManualDispatchResponse */ /** @typedef {import('../../types/admin-write-models.js').AdminWebhookReplayResponse} AdminWebhookReplayResponse */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminTaskKuaishouCloudDispatchInput} AdminTaskKuaishouCloudDispatchInput */ /** @returns {Promise} */ /** @param {AdminInventoryCreateInput} [payload] */ @@ -675,6 +693,352 @@ export async function completeAdminTaskManualDispatch(taskId, payload = {}, sess } } +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session = null) { + const task = await getRequiredTask(taskId) + const now = nowIso() + const viewerContext = createAdminViewerContext(session) + + if (!viewerContext.canManageTaskLifecycle) { + throw createHttpError('当前账号没有权限准备绑定资源', { + statusCode: 403, + errorCode: 'admin_task_prepare_kuaishou_cloud_forbidden', + }) + } + + if (!isKuaishouCloudTask(task)) { + throw createHttpError('当前任务不是快手 cloud 履约任务', { + statusCode: 409, + errorCode: 'admin_task_not_kuaishou_cloud', + }) + } + + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + const cloudContext = resolvePersistedCloudtentaclesContext() + + if (!flow.binding.skuId || !flow.binding.vnKey) { + throw createHttpError('当前任务缺少 cloud SKU 或 VN Key 配置', { + statusCode: 409, + errorCode: 'admin_task_kuaishou_cloud_missing_binding_config', + }) + } + + const knapsack = await getCloudtentaclesKnapsack(cloudContext) + const knapsackItem = (Array.isArray(knapsack.items) ? knapsack.items : []).find((item) => Number(item.id || 0) === flow.binding.skuId) || null + let usedKnapsack = Number(knapsackItem?.count || 0) > 0 + let purchaseTriggered = false + let assetBefore = 0 + let assetAfter = 0 + + if (!usedKnapsack) { + if (!flow.purchase.autoBuyEnabled) { + throw createHttpError('背包中没有现成库存,且当前配置未开启自动购买', { + statusCode: 409, + errorCode: 'admin_task_kuaishou_cloud_auto_buy_disabled', + }) + } + + const [asset, skuList] = await Promise.all([ + getCloudtentaclesAsset(cloudContext), + listCloudtentaclesSku(cloudContext), + ]) + const targetSku = (Array.isArray(skuList.items) ? skuList.items : []).find((item) => Number(item.id || 0) === flow.binding.skuId) || null + + if (!targetSku) { + throw createHttpError(`cloudtentacles 未找到 SKU ${flow.binding.skuId}`, { + statusCode: 404, + errorCode: 'admin_task_kuaishou_cloud_sku_not_found', + }) + } + + assetBefore = Number(asset.asset || 0) || 0 + const targetPrice = Number(targetSku.price || 0) || 0 + const requiredAsset = targetPrice + flow.purchase.minAssetReserve + if (assetBefore < requiredAsset) { + throw createHttpError(`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, { + statusCode: 409, + errorCode: 'admin_task_kuaishou_cloud_asset_not_enough', + }) + } + + await buyCloudtentaclesSku({ + ...cloudContext, + id: flow.binding.skuId, + count: 1, + }) + purchaseTriggered = true + + const assetResult = await getCloudtentaclesAsset(cloudContext) + assetAfter = Number(assetResult.asset || 0) || 0 + } + + const appointed = await appointCloudtentaclesVirtualNumber({ + ...cloudContext, + key: flow.binding.vnKey, + }) + const vnId = Number(appointed.item?.id || 0) + const vnPhone = String(appointed.item?.phone || '').trim() + + if (!vnId || !vnPhone) { + throw createHttpError('申请虚拟号成功但返回数据不完整', { + statusCode: 502, + errorCode: 'admin_task_kuaishou_cloud_invalid_vn', + }) + } + + await generateCloudtentaclesLoginCode({ + ...cloudContext, + key: flow.binding.vnKey, + id: vnId, + }) + + const fetchedCode = await fetchCloudtentaclesVirtualNumberCode({ + ...cloudContext, + key: flow.binding.vnKey, + phone: vnPhone, + }) + + await verifyCloudtentaclesLoginCode({ + ...cloudContext, + key: flow.binding.vnKey, + id: vnId, + code: fetchedCode.code, + }) + + const bindUrlResult = await getCloudtentaclesBindUrl({ + ...cloudContext, + key: flow.binding.vnKey, + id: vnId, + }) + + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + binding: { + ...flow.binding, + prepareStatus: 'ready', + vnId, + vnPhone, + bindUrl: bindUrlResult.bindUrl, + bindPreparedAt: now, + }, + purchase: { + ...flow.purchase, + usedKnapsack, + purchaseTriggered, + assetBefore, + assetAfter, + purchaseAt: purchaseTriggered ? now : flow.purchase.purchaseAt, + }, + }, + } + + const updatedTask = await updateTask(task.id, { + task_status: 'waiting_binding', + inventory_status: 'not_required', + user_action_status: 'pending', + last_error: '', + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + await createTaskEvent(task.id, 'kuaishou_cloud_binding_prepared', { + skuId: flow.binding.skuId, + vnKey: flow.binding.vnKey, + vnId, + vnPhoneMasked: maskPhone(vnPhone), + bindUrl: bindUrlResult.bindUrl, + purchaseTriggered, + usedKnapsack, + }, now) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +/** @param {AdminTaskKuaishouCloudDispatchInput} [payload] */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function dispatchAdminTaskKuaishouCloudFulfillment(taskId, payload = {}, session = null) { + const task = await getRequiredTask(taskId) + const now = nowIso() + const viewerContext = createAdminViewerContext(session) + + if (!viewerContext.canManageTaskLifecycle) { + throw createHttpError('当前账号没有权限执行发货', { + statusCode: 403, + errorCode: 'admin_task_dispatch_kuaishou_cloud_forbidden', + }) + } + + if (!isKuaishouCloudTask(task)) { + throw createHttpError('当前任务不是快手 cloud 履约任务', { + statusCode: 409, + errorCode: 'admin_task_not_kuaishou_cloud', + }) + } + + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + const cloudContext = resolvePersistedCloudtentaclesContext() + + if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) { + throw createHttpError('当前任务还没有准备好绑定资源,请先准备绑定资源', { + statusCode: 409, + errorCode: 'admin_task_kuaishou_cloud_not_prepared', + }) + } + + const ticketCode = String(payload.ticketCode || '').trim() + const dispatchResult = await useCloudtentaclesSku({ + ...cloudContext, + id: flow.binding.skuId, + virtualNumberId: flow.binding.vnId, + phone: flow.binding.vnPhone, + }) + + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + ticket: { + ...flow.ticket, + code: ticketCode || flow.ticket.code, + capturedAt: ticketCode ? now : flow.ticket.capturedAt, + capturedBy: ticketCode && session + ? { + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } + : flow.ticket.capturedBy, + }, + dispatch: { + ...flow.dispatch, + status: 'success', + dispatchAt: now, + dispatchBy: session + ? { + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } + : null, + sendType: Number(dispatchResult.sendType || 0) || 0, + note: String(dispatchResult.note || dispatchResult.responseMessage || '').trim(), + }, + }, + } + + const updatedTask = await updateTask(task.id, { + task_status: 'dispatched_pending_return', + delivery_status: 'delivered', + result_code: 'kuaishou_cloud_dispatched', + result_message: String(dispatchResult.responseMessage || dispatchResult.note || 'cloudtentacles 发货成功').trim(), + last_error: '', + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + await createTaskEvent(task.id, 'kuaishou_cloud_dispatched', { + ticketCodeMasked: maskCode(ticketCode), + skuId: flow.binding.skuId, + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + sendType: dispatchResult.sendType, + note: dispatchResult.note, + }, now) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +/** @param {AdminViewerSessionInput | null} [session] */ +export async function returnNumberAdminTaskKuaishouCloudFulfillment(taskId, session = null) { + const task = await getRequiredTask(taskId) + const now = nowIso() + const viewerContext = createAdminViewerContext(session) + + if (!viewerContext.canManageTaskLifecycle) { + throw createHttpError('当前账号没有权限退还号码', { + statusCode: 403, + errorCode: 'admin_task_return_kuaishou_cloud_forbidden', + }) + } + + if (!isKuaishouCloudTask(task)) { + throw createHttpError('当前任务不是快手 cloud 履约任务', { + statusCode: 409, + errorCode: 'admin_task_not_kuaishou_cloud', + }) + } + + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + const cloudContext = resolvePersistedCloudtentaclesContext() + + if (!flow.binding.vnId || !flow.binding.vnKey) { + throw createHttpError('当前任务缺少可退还的虚拟号信息', { + statusCode: 409, + errorCode: 'admin_task_kuaishou_cloud_missing_return_context', + }) + } + + await backCloudtentaclesVirtualNumber({ + ...cloudContext, + key: flow.binding.vnKey, + id: flow.binding.vnId, + }) + + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + returnNumber: { + ...flow.returnNumber, + status: 'success', + returnedAt: now, + returnedBy: session + ? { + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } + : null, + }, + }, + } + + const updatedTask = await updateTask(task.id, { + task_status: 'completed', + delivery_status: 'delivered', + result_code: 'kuaishou_cloud_completed', + result_message: 'cloudtentacles 发货并退号完成', + redeemed_at: now, + last_error: '', + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + await createTaskEvent(task.id, 'kuaishou_cloud_number_returned', { + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + }, now) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + /** * @param {AdminInventoryImportInput} payload * @returns {Array>>} @@ -725,6 +1089,116 @@ function normalizeInventoryImportRows(payload) { return dedupeRows(rows) } +function isKuaishouCloudTask(task) { + return String(task?.executor_key || '').trim() === 'kuaishou_ct_assisted' +} + +function resolvePersistedCloudtentaclesContext() { + const source = getCloudtentaclesSourceConfig() + const session = getCloudtentaclesSessionState() + const token = String(session.token || '').trim() + + if (!token) { + throw createHttpError('当前 cloudtentacles 没有可用 token,请先到平台配置完成登录校验', { + statusCode: 409, + errorCode: 'admin_task_kuaishou_cloud_missing_cloud_token', + }) + } + + return { + baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176', + token, + deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-', + deviceType: Number(session.deviceType ?? source.deviceType ?? 0), + } +} + +function normalizeKuaishouCloudFlow(value) { + const source = value && typeof value === 'object' ? value : {} + const binding = source.binding && typeof source.binding === 'object' ? source.binding : {} + const purchase = source.purchase && typeof source.purchase === 'object' ? source.purchase : {} + const dispatch = source.dispatch && typeof source.dispatch === 'object' ? source.dispatch : {} + const returnNumber = source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {} + const consume = source.consume && typeof source.consume === 'object' ? source.consume : {} + const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {} + + return { + ...source, + configId: String(source.configId || '').trim(), + internalSkuCode: String(source.internalSkuCode || '').trim(), + internalSkuName: String(source.internalSkuName || '').trim(), + ticket: { + code: String(ticket.code || '').trim(), + capturedAt: ticket.capturedAt || null, + capturedBy: ticket.capturedBy || null, + }, + binding: { + prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', + cloudSourceKey: String(binding.cloudSourceKey || 'default').trim() || 'default', + skuId: Number(binding.skuId || 0) || 0, + skuName: String(binding.skuName || '').trim(), + vnKey: String(binding.vnKey || '').trim(), + vnId: Number(binding.vnId || 0) || 0, + vnPhone: String(binding.vnPhone || '').trim(), + bindUrl: String(binding.bindUrl || '').trim(), + bindPreparedAt: binding.bindPreparedAt || null, + }, + purchase: { + autoBuyEnabled: purchase.autoBuyEnabled !== false, + minAssetReserve: Number(purchase.minAssetReserve || 0) || 0, + usedKnapsack: purchase.usedKnapsack === true, + purchaseTriggered: purchase.purchaseTriggered === true, + assetBefore: Number(purchase.assetBefore || 0) || 0, + assetAfter: Number(purchase.assetAfter || 0) || 0, + purchaseAt: purchase.purchaseAt || null, + }, + dispatch: { + status: String(dispatch.status || 'pending').trim() || 'pending', + dispatchAt: dispatch.dispatchAt || null, + dispatchBy: dispatch.dispatchBy || null, + sendType: Number(dispatch.sendType || 0) || 0, + note: String(dispatch.note || '').trim(), + }, + returnNumber: { + status: String(returnNumber.status || 'pending').trim() || 'pending', + returnedAt: returnNumber.returnedAt || null, + returnedBy: returnNumber.returnedBy || null, + autoReturnEnabled: returnNumber.autoReturnEnabled === true, + }, + consume: { + status: String(consume.status || 'pending').trim() || 'pending', + shopId: String(consume.shopId || '').trim(), + autoConsumeEnabled: consume.autoConsumeEnabled === true, + }, + } +} + +function maskPhone(value) { + const text = String(value || '').trim() + if (!text) { + return '' + } + + if (text.length <= 7) { + return `${text.slice(0, 2)}***${text.slice(-2)}` + } + + return `${text.slice(0, 3)}****${text.slice(-4)}` +} + +function maskCode(value) { + const text = String(value || '').trim() + if (!text) { + return '' + } + + if (text.length <= 8) { + return `${text.slice(0, 2)}***${text.slice(-2)}` + } + + return `${text.slice(0, 4)}****${text.slice(-4)}` +} + /** * @param {Array>>} rows */ @@ -797,14 +1271,3 @@ function normalizeManualDispatchOutcome(value) { function getTaskClaimExpiresAt(task) { return task?.claim_expires_at || task?.primary_claim_expires_at || null } - -function maskCode(value) { - const text = String(value || '').trim() - if (!text) { - return '' - } - if (text.length <= 8) { - return `${text.slice(0, 2)}****${text.slice(-2)}` - } - return `${text.slice(0, 4)}****${text.slice(-4)}` -} diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js index 7babbce7..fe416681 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.js @@ -9,6 +9,10 @@ import { upsertProductMatchRule } from '../../repositories/product-match-rule-re import { normalizeProductName } from '../order/product-match-service.js' import { query } from '../../db/client.js' import { getOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js' +import { + getKuaishouCloudFulfillmentConfig, + mapKuaishouCloudFulfillmentItemsToBindings, +} from '../order/kuaishou-cloud-fulfillment-config-service.js' import { nowIso } from '../../utils/time.js' const CORE_PROFILES = [ @@ -53,6 +57,15 @@ const CORE_PROFILES = [ }, ], }, + { + profileKey: 'kuaishou_ct_assisted', + name: '快手 cloud 履约', + executorKey: 'kuaishou_ct_assisted', + requiresClaim: false, + autoDispatch: false, + inventoryStrategy: 'external_platform', + requirements: [], + }, ] export async function ensureFulfillmentCatalogBootstrapped() { @@ -93,7 +106,10 @@ async function ensureCoreProfiles() { export async function syncConfiguredFulfillmentBindings(profileMap = {}) { const timestamp = nowIso() - const bindingsToApply = getOrderFulfillmentBindingConfigs() + const bindingsToApply = [ + ...getOrderFulfillmentBindingConfigs(), + ...mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()), + ] await query('DELETE FROM product_match_rules') await query('DELETE FROM sku_fulfillment_bindings') diff --git a/apps/backend/src/services/order/delivery-task-service.js b/apps/backend/src/services/order/delivery-task-service.js index 9b2a6f1e..cd678e14 100644 --- a/apps/backend/src/services/order/delivery-task-service.js +++ b/apps/backend/src/services/order/delivery-task-service.js @@ -41,6 +41,7 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { const requirements = await listFulfillmentProfileRequirements(profile.profile_id || profile.id) const primaryRequirement = requirements.find((requirement) => requirement.is_required !== false) || requirements[0] || null const quantity = Math.max(1, Number(item.quantity || 1)) + const fulfillmentConfig = parseJsonObject(profile.config_json) for (let index = 0; index < quantity; index += 1) { const createdAt = nowIso() @@ -84,6 +85,58 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { credentialType: String(primaryRequirement.credential_type || primaryRequirement.credentialType || 'tencent_code'), } : null, + kuaishouCloudFulfillment: isKuaishouCloudExecutor(profile.executor_key) + ? { + flowType: 'kuaishou_cloud_fulfillment', + configId: String(fulfillmentConfig.configId || '').trim(), + internalSkuCode: item.sku_code, + internalSkuName: item.sku_name, + ticket: { + code: '', + capturedAt: null, + capturedBy: null, + }, + binding: { + prepareStatus: 'pending', + cloudSourceKey: String(fulfillmentConfig.cloudtentacles?.cloudSourceKey || 'default').trim() || 'default', + skuId: Number(fulfillmentConfig.cloudtentacles?.skuId || 0) || 0, + skuName: String(fulfillmentConfig.cloudtentacles?.skuName || '').trim(), + vnKey: String(fulfillmentConfig.cloudtentacles?.vnKey || '').trim(), + vnId: 0, + vnPhone: '', + bindUrl: '', + bindPreparedAt: null, + }, + purchase: { + autoBuyEnabled: fulfillmentConfig.cloudtentacles?.autoBuyEnabled !== false, + minAssetReserve: Number(fulfillmentConfig.cloudtentacles?.minAssetReserve || 0) || 0, + usedKnapsack: false, + purchaseTriggered: false, + assetBefore: 0, + assetAfter: 0, + purchaseAt: null, + }, + dispatch: { + status: 'pending', + dispatchAt: null, + dispatchBy: null, + sendType: 0, + note: '', + }, + returnNumber: { + status: 'pending', + returnedAt: null, + returnedBy: null, + autoReturnEnabled: fulfillmentConfig.cloudtentacles?.autoReturnNumberAfterDispatch === true, + }, + consume: { + status: 'pending', + shopId: String(fulfillmentConfig.kuaishouConsume?.shopId || '').trim(), + autoConsumeEnabled: fulfillmentConfig.kuaishouConsume?.autoConsumeAfterDispatch === true, + }, + notes: String(fulfillmentConfig.notes || '').trim(), + } + : null, }), createdAt, updatedAt: createdAt, @@ -114,6 +167,27 @@ async function preparePaidTask(task) { return task } + if (isKuaishouCloudExecutor(task.executor_key)) { + if ([ + 'pending_binding_prepare', + 'waiting_binding', + 'dispatched_pending_return', + 'completed', + 'manual_review', + 'failed', + ].includes(String(task.task_status || '').trim())) { + return task + } + + return updateTask(task.id, { + task_status: 'pending_binding_prepare', + inventory_status: 'not_required', + user_action_status: 'pending', + last_error: task.last_error || '等待准备绑定资源', + updated_at: now, + }) + } + if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') { return updateTask(task.id, { task_status: 'manual_review', @@ -182,6 +256,10 @@ function resolvePaidTaskStatus(profile) { return 'paid' } + if (isKuaishouCloudExecutor(profile?.executor_key)) { + return 'pending_binding_prepare' + } + if (String(profile?.executor_key || '').trim() === 'manual_dispatch') { return 'manual_review' } @@ -206,3 +284,24 @@ function parseTaskContext(task) { return {} } } + +function parseJsonObject(value) { + if (!value) { + return {} + } + + if (typeof value === 'object' && !Array.isArray(value)) { + return value + } + + try { + const parsed = JSON.parse(String(value || '{}')) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } catch { + return {} + } +} + +function isKuaishouCloudExecutor(value) { + return String(value || '').trim() === 'kuaishou_ct_assisted' +} diff --git a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.js b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.js new file mode 100644 index 00000000..c802c595 --- /dev/null +++ b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.js @@ -0,0 +1,166 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { PROJECT_ROOT } from '../../config/runtime.js' + +const KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'kuaishou-cloud-fulfillment.json') + +export function getKuaishouCloudFulfillmentFilePath() { + return KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH +} + +export function getKuaishouCloudFulfillmentConfig() { + return loadKuaishouCloudFulfillmentConfigFromFile() +} + +export function saveKuaishouCloudFulfillmentConfig(rawValue) { + const normalized = normalizeKuaishouCloudFulfillmentConfig(rawValue) + fs.mkdirSync(path.dirname(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH), { recursive: true }) + fs.writeFileSync( + KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH, + `${JSON.stringify(normalized, null, 2)}\n`, + 'utf8', + ) + return normalized +} + +export function mapKuaishouCloudFulfillmentItemsToBindings(config = {}) { + const items = Array.isArray(config.items) ? config.items : [] + + return items + .filter((item) => item && item.enabled !== false) + .map((item) => ({ + provider: String(item.provider || 'khhao').trim() || 'khhao', + platform: String(item.platform || 'kuaishou').trim() || 'kuaishou', + shopId: String(item.shopId || '').trim(), + skuCode: String(item.internalSkuCode || '').trim(), + skuName: String(item.internalSkuName || '').trim(), + profileKey: 'kuaishou_ct_assisted', + enabled: item.enabled !== false, + priority: normalizePriority(item.priority), + config: { + flowType: 'kuaishou_cloud_fulfillment', + configId: String(item.id || '').trim(), + cloudtentacles: { + cloudSourceKey: String(item.cloudSourceKey || 'default').trim() || 'default', + skuId: normalizePositiveInteger(item.cloudSkuId), + skuName: String(item.cloudSkuName || '').trim(), + vnKey: String(item.vnKey || '').trim(), + autoBuyEnabled: item.autoBuyEnabled !== false, + minAssetReserve: normalizeNonNegativeInteger(item.minAssetReserve, 0), + autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch === true, + }, + kuaishouConsume: { + shopId: String(item.kuaishouConsumeShopId || '').trim(), + autoConsumeAfterDispatch: item.autoConsumeAfterDispatch === true, + }, + notes: String(item.notes || '').trim(), + }, + match: { + externalSkuCode: String(item.externalSkuCode || '').trim(), + externalItemId: String(item.externalItemId || '').trim(), + externalSkuName: String(item.externalSkuName || '').trim(), + config: { + resolvedSkuName: String(item.resolvedSkuName || item.internalSkuName || '').trim(), + }, + }, + })) + .filter((item) => item.skuCode && (item.match.externalSkuCode || item.match.externalItemId || item.match.externalSkuName)) +} + +function loadKuaishouCloudFulfillmentConfigFromFile() { + if (!fs.existsSync(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH)) { + return normalizeKuaishouCloudFulfillmentConfig({}) + } + + try { + const rawText = fs.readFileSync(KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH, 'utf8') + const parsed = JSON.parse(rawText) + return normalizeKuaishouCloudFulfillmentConfig(parsed) + } catch { + return normalizeKuaishouCloudFulfillmentConfig({}) + } +} + +function normalizeKuaishouCloudFulfillmentConfig(rawValue) { + const source = isPlainObject(rawValue) ? rawValue : {} + return { + enabled: source.enabled !== false, + items: Array.isArray(source.items) + ? source.items.map((item) => normalizeKuaishouCloudFulfillmentItem(item)).filter(Boolean) + : [], + } +} + +function normalizeKuaishouCloudFulfillmentItem(rawValue) { + if (!isPlainObject(rawValue)) { + return null + } + + const internalSkuCode = String(rawValue.internalSkuCode || '').trim() + const cloudSkuId = normalizePositiveInteger(rawValue.cloudSkuId) + const vnKey = String(rawValue.vnKey || '').trim() + const externalSkuCode = String(rawValue.externalSkuCode || '').trim() + const externalItemId = String(rawValue.externalItemId || '').trim() + const externalSkuName = String(rawValue.externalSkuName || '').trim() + + if (!internalSkuCode) { + return null + } + + if (!cloudSkuId || !vnKey) { + return null + } + + if (!externalSkuCode && !externalItemId && !externalSkuName) { + return null + } + + return { + id: String(rawValue.id || internalSkuCode).trim() || internalSkuCode, + enabled: rawValue.enabled !== false, + priority: normalizePriority(rawValue.priority), + provider: String(rawValue.provider || 'khhao').trim() || 'khhao', + platform: String(rawValue.platform || 'kuaishou').trim() || 'kuaishou', + shopId: String(rawValue.shopId || '').trim(), + internalSkuCode, + internalSkuName: String(rawValue.internalSkuName || '').trim() || internalSkuCode, + externalSkuCode, + externalItemId, + externalSkuName, + resolvedSkuName: String(rawValue.resolvedSkuName || rawValue.internalSkuName || '').trim(), + cloudSourceKey: String(rawValue.cloudSourceKey || 'default').trim() || 'default', + cloudSkuId, + cloudSkuName: String(rawValue.cloudSkuName || '').trim(), + vnKey, + autoBuyEnabled: rawValue.autoBuyEnabled !== false, + minAssetReserve: normalizeNonNegativeInteger(rawValue.minAssetReserve, 0), + autoReturnNumberAfterDispatch: rawValue.autoReturnNumberAfterDispatch === true, + autoConsumeAfterDispatch: rawValue.autoConsumeAfterDispatch === true, + kuaishouConsumeShopId: String(rawValue.kuaishouConsumeShopId || '').trim(), + notes: String(rawValue.notes || '').trim(), + } +} + +function normalizePositiveInteger(value) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : 0 +} + +function normalizeNonNegativeInteger(value, fallback) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback +} + +function normalizePriority(value) { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return 100 + } + + return Math.max(1, Math.round(parsed)) +} + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/types/admin-route-inputs.js b/apps/backend/src/types/admin-route-inputs.js index c3b89e9b..efbb86da 100644 --- a/apps/backend/src/types/admin-route-inputs.js +++ b/apps/backend/src/types/admin-route-inputs.js @@ -66,6 +66,10 @@ export {} * @typedef {import('./admin-write-inputs.js').AdminCloudtentaclesSourceConfigInput} AdminCloudtentaclesSourceConfigRouteBody */ +/** + * @typedef {import('./admin-write-inputs.js').AdminKuaishouCloudFulfillmentConfigInput} AdminKuaishouCloudFulfillmentConfigRouteBody + */ + /** * @typedef {import('./admin-write-inputs.js').AdminCloudtentaclesSendSmsCodeInput} AdminCloudtentaclesSendSmsCodeRouteBody */ @@ -126,6 +130,10 @@ export {} * @typedef {import('./admin-write-inputs.js').AdminTaskManualDispatchInput} AdminTaskManualDispatchRouteBody */ +/** + * @typedef {import('./admin-write-inputs.js').AdminTaskKuaishouCloudDispatchInput} AdminTaskKuaishouCloudDispatchRouteBody + */ + /** * @typedef {import('./admin-write-inputs.js').AdminManualRedeemCreateInput} AdminManualRedeemCreateRouteBody */ diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js index 531da122..543abf4a 100644 --- a/apps/backend/src/types/admin-write-inputs.js +++ b/apps/backend/src/types/admin-write-inputs.js @@ -67,6 +67,40 @@ export {} * }} AdminAgisoShopConfigSaveInput */ +/** + * @typedef {{ + * id?: string + * enabled?: boolean + * priority?: number | string + * provider?: string + * platform?: string + * shopId?: string + * internalSkuCode?: string + * internalSkuName?: string + * externalSkuCode?: string + * externalItemId?: string + * externalSkuName?: string + * resolvedSkuName?: string + * cloudSourceKey?: string + * cloudSkuId?: number | string + * cloudSkuName?: string + * vnKey?: string + * autoBuyEnabled?: boolean + * minAssetReserve?: number | string + * autoReturnNumberAfterDispatch?: boolean + * autoConsumeAfterDispatch?: boolean + * kuaishouConsumeShopId?: string + * notes?: string + * }} AdminKuaishouCloudFulfillmentItemInput + */ + +/** + * @typedef {{ + * enabled?: boolean + * items?: AdminKuaishouCloudFulfillmentItemInput[] + * }} AdminKuaishouCloudFulfillmentConfigInput + */ + /** * @typedef {{ * enabled?: boolean @@ -302,6 +336,12 @@ export {} * }} AdminTaskManualDispatchInput */ +/** + * @typedef {{ + * ticketCode?: string + * }} AdminTaskKuaishouCloudDispatchInput + */ + /** * @typedef {{ * proofValue?: string diff --git a/apps/frontend/src/components/admin/AdminStatusTag.vue b/apps/frontend/src/components/admin/AdminStatusTag.vue index 35e79cf6..11e16d84 100644 --- a/apps/frontend/src/components/admin/AdminStatusTag.vue +++ b/apps/frontend/src/components/admin/AdminStatusTag.vue @@ -8,15 +8,15 @@ const props = defineProps<{ function resolveTone(status: string) { const normalized = String(status || '').trim().toLowerCase() - if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'success', 'active', 'system_bound', 'binding_completed'].includes(normalized)) { + if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'success', 'active', 'system_bound', 'binding_completed', 'completed'].includes(normalized)) { return 'success' } - if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'support', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding', 'pending', 'waiting'].includes(normalized)) { + if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'support', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding', 'pending', 'waiting', 'waiting_binding'].includes(normalized)) { return 'primary' } - if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started', 'consumed', 'skipped'].includes(normalized)) { + if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started', 'consumed', 'skipped', 'pending_binding_prepare', 'dispatched_pending_return'].includes(normalized)) { return 'warning' } @@ -71,7 +71,11 @@ function resolveLabel(status: string) { processing: '处理中', system_bound: '系统已绑定', pending_binding: '待系统绑定', + pending_binding_prepare: '待准备绑定', not_started: '未开始', + waiting_binding: '待客户绑定', + dispatched_pending_return: '待退还号码', + completed: '已完成', waiting_user_claim: '待用户打开链接', link_opened: '已打开链接', binding_confirmed: '已提交绑定', diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index 9851df80..3e71fdc3 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -93,6 +93,11 @@ const router = createRouter({ meta: { allowedRoles: ['admin'] }, component: () => import('@/views/admin/AdminFulfillmentBindingsView.vue'), }, + { + path: 'platform-kuaishou-cloud-fulfillment', + meta: { allowedRoles: ['admin'] }, + component: () => import('@/views/admin/AdminKuaishouCloudFulfillmentView.vue'), + }, { path: 'webhook-events/:eventId', meta: { allowedRoles: ['admin', 'operator'] }, diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index 38f75f86..be7f4e95 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -17,6 +17,7 @@ import type { AdminKhhaoSyncState, AdminKhhaoOrderSyncResult, AdminKuaishouEticketConsumeResult, + AdminKuaishouCloudFulfillmentConfig, AdminKuaishouEticketDetailResult, AdminKuaishouEticketShopInfoResult, AdminKuaishouEticketSourceConfig, @@ -418,6 +419,20 @@ export function saveAdminFulfillmentBindingConfigs(payload: { bindings: Array('/api/v1/admin/platform-config/fulfillment-bindings', payload) } +export function fetchAdminKuaishouCloudFulfillmentConfig() { + return apiGet<{ + filePath: string + source: AdminKuaishouCloudFulfillmentConfig + }>('/api/v1/admin/platform-config/kuaishou-cloud-fulfillment') +} + +export function saveAdminKuaishouCloudFulfillmentConfig(payload: AdminKuaishouCloudFulfillmentConfig) { + return apiPost<{ + filePath: string + source: AdminKuaishouCloudFulfillmentConfig + }>('/api/v1/admin/platform-config/kuaishou-cloud-fulfillment', payload as unknown as Record) +} + export function fetchAdminOrders(params?: Record) { return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>('/api/v1/admin/orders', params) } @@ -485,6 +500,23 @@ export function completeAdminTaskManualDispatch( return apiPost(`/api/v1/admin/tasks/${taskId}/complete-manual-dispatch`, payload) } +export function prepareAdminTaskKuaishouCloudFulfillment(taskId: number | string) { + return apiPost(`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/prepare`, {}) +} + +export function dispatchAdminTaskKuaishouCloudFulfillment( + taskId: number | string, + payload: { + ticketCode?: string + }, +) { + return apiPost(`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/dispatch`, payload) +} + +export function returnNumberAdminTaskKuaishouCloudFulfillment(taskId: number | string) { + return apiPost(`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/return-number`, {}) +} + export function fetchAdminInventoryItems(params?: Record) { return apiGet<{ items: AdminInventoryItemListItem[]; pagination: AdminPagination }>('/api/v1/admin/inventory', params) } diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index c5f76203..9e7ab578 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -335,6 +335,36 @@ export interface AdminFulfillmentBindingConfigItem { } } +export interface AdminKuaishouCloudFulfillmentItem { + id: string + enabled: boolean + priority: number + provider: string + platform: string + shopId: string + internalSkuCode: string + internalSkuName: string + externalSkuCode: string + externalItemId: string + externalSkuName: string + resolvedSkuName: string + cloudSourceKey: string + cloudSkuId: number + cloudSkuName: string + vnKey: string + autoBuyEnabled: boolean + minAssetReserve: number + autoReturnNumberAfterDispatch: boolean + autoConsumeAfterDispatch: boolean + kuaishouConsumeShopId: string + notes: string +} + +export interface AdminKuaishouCloudFulfillmentConfig { + enabled: boolean + items: AdminKuaishouCloudFulfillmentItem[] +} + export interface AdminObservedProductItem { provider: string platform: string @@ -497,6 +527,9 @@ export interface AdminTaskOperations { canClose: boolean canMarkManualReview: boolean canCompleteManualDispatch: boolean + canPrepareKuaishouCloudFulfillment: boolean + canDispatchKuaishouCloudFulfillment: boolean + canReturnKuaishouCloudFulfillment: boolean canSupportConfirmRole: boolean canSupportRedeem: boolean canViewSensitiveTaskData: boolean @@ -652,6 +685,53 @@ export interface AdminTaskDetail { resultMessage: string }> } + kuaishouCloudFulfillment: null | { + flowType: string + configId: string + internalSkuCode: string + internalSkuName: string + ticket: { + code: string + capturedAt: string | null + } + binding: { + prepareStatus: string + cloudSourceKey: string + skuId: number + skuName: string + vnKey: string + vnId: number + vnPhone: string + bindUrl: string + bindPreparedAt: string | null + } + purchase: { + autoBuyEnabled: boolean + minAssetReserve: number + usedKnapsack: boolean + purchaseTriggered: boolean + assetBefore: number + assetAfter: number + purchaseAt: string | null + } + dispatch: { + status: string + dispatchAt: string | null + sendType: number + note: string + } + returnNumber: { + status: string + returnedAt: string | null + autoReturnEnabled: boolean + } + consume: { + status: string + shopId: string + autoConsumeEnabled: boolean + } + notes: string + } manualDispatch: null | { outcome: string deliveryReference: string diff --git a/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue b/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue index 30e40279..22efd755 100644 --- a/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue +++ b/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue @@ -65,6 +65,7 @@ type ValidationState = { const PROFILE_OPTIONS = [ { label: '腾讯领取兑换', value: 'tencent_claim_redeem' }, { label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' }, + { label: '快手 Cloud 履约', value: 'kuaishou_ct_assisted' }, { label: '人工发货', value: 'manual_review' }, ] @@ -102,6 +103,7 @@ const ruleMetrics = computed(() => { const completedCount = bindings.value.filter(isEditableBindingComplete).length const manualCount = bindings.value.filter((item) => item.profileKey.trim() === 'manual_review').length const assistedCount = bindings.value.filter((item) => item.profileKey.trim() === 'tencent_claim_assisted').length + const kuaishouCloudCount = bindings.value.filter((item) => item.profileKey.trim() === 'kuaishou_ct_assisted').length return { totalCount: bindings.value.length, @@ -109,6 +111,7 @@ const ruleMetrics = computed(() => { draftCount: Math.max(bindings.value.length - completedCount, 0), manualCount, assistedCount, + kuaishouCloudCount, } }) @@ -536,7 +539,7 @@ onMounted(loadConfigs)

履约配置

-

把外部商品统一映射到内部履约 SKU,并指定最终走自动、半自动还是人工处理。

+

把外部商品统一映射到内部履约 SKU,并指定最终走腾讯领取、快手 Cloud 或人工处理。

外部条件命中 1 个即可 @@ -603,6 +606,7 @@ onMounted(loadConfigs) 待完善 {{ ruleMetrics.draftCount }} 人工 {{ ruleMetrics.manualCount }} 半自动 {{ ruleMetrics.assistedCount }} + 快手 Cloud {{ ruleMetrics.kuaishouCloudCount }}
新增规则 diff --git a/apps/frontend/src/views/admin/AdminKuaishouCloudFulfillmentView.vue b/apps/frontend/src/views/admin/AdminKuaishouCloudFulfillmentView.vue new file mode 100644 index 00000000..f4b45488 --- /dev/null +++ b/apps/frontend/src/views/admin/AdminKuaishouCloudFulfillmentView.vue @@ -0,0 +1,802 @@ + + + + + diff --git a/apps/frontend/src/views/admin/AdminLayout.vue b/apps/frontend/src/views/admin/AdminLayout.vue index f30a14e5..5dcd3e79 100644 --- a/apps/frontend/src/views/admin/AdminLayout.vue +++ b/apps/frontend/src/views/admin/AdminLayout.vue @@ -40,6 +40,7 @@ const navItems = computed(() => { baseItems.splice(1, 0, { to: '/admin/users', label: '用户' }) baseItems.push({ to: '/admin/platform-shops', label: '平台配置' }) baseItems.push({ to: '/admin/platform-fulfillment', label: '履约配置' }) + baseItems.push({ to: '/admin/platform-kuaishou-cloud-fulfillment', label: '新履约配置' }) baseItems.push({ to: '/admin/audit-logs', label: '审计' }) } @@ -133,6 +134,7 @@ async function submitLogout() { margin-top: 28px; display: grid; gap: 10px; + align-content: start; } .admin-meta { diff --git a/apps/frontend/src/views/admin/AdminTaskDetailView.vue b/apps/frontend/src/views/admin/AdminTaskDetailView.vue index b1518234..57d1ad6e 100644 --- a/apps/frontend/src/views/admin/AdminTaskDetailView.vue +++ b/apps/frontend/src/views/admin/AdminTaskDetailView.vue @@ -8,12 +8,15 @@ import { closeAdminTask, confirmAdminTaskAssistedRole, completeAdminTaskManualDispatch, + dispatchAdminTaskKuaishouCloudFulfillment, redeemAdminTaskAssisted, fetchAdminTaskScreenshot, fetchAdminTaskDetail, markAdminTaskManualReview, + prepareAdminTaskKuaishouCloudFulfillment, regenerateAdminTaskClaimLink, releaseAdminTaskInventoryBinding, + returnNumberAdminTaskKuaishouCloudFulfillment, retryAdminTask, } from '@/services/admin' import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin' @@ -33,9 +36,14 @@ const manualDispatchForm = reactive({ deliveredCredential: '', resultMessage: '', }) +const kuaishouCloudForm = reactive({ + ticketCode: '', +}) const canManageTaskLifecycle = computed(() => hasAdminRole('operator')) const canCloseTasks = computed(() => hasAdminRole('support')) const canViewSensitiveTaskData = computed(() => Boolean(detail.value?.operations.canViewSensitiveTaskData)) +const kuaishouCloudFlow = computed(() => detail.value?.kuaishouCloudFulfillment || null) +const isKuaishouCloudTask = computed(() => detail.value?.task.executorKey === 'kuaishou_ct_assisted') const claimUrl = computed(() => { const tokenStatus = String(detail.value?.claimToken?.status || '').trim() const taskStatus = String(detail.value?.task.status || '').trim() @@ -66,6 +74,7 @@ async function loadDetail() { const response = await fetchAdminTaskDetail(String(route.params.taskId || '')) detail.value = response.data syncManualDispatchForm(response.data) + syncKuaishouCloudForm(response.data) await loadScreenshotPreview(response.data) } catch (error) { errorMessage.value = error instanceof Error ? error.message : '读取任务详情失败' @@ -133,6 +142,10 @@ function syncManualDispatchForm(taskDetail: AdminTaskDetail) { manualDispatchForm.resultMessage = taskDetail.manualDispatch?.resultMessage || taskDetail.task.resultMessage || '' } +function syncKuaishouCloudForm(taskDetail: AdminTaskDetail) { + kuaishouCloudForm.ticketCode = taskDetail.kuaishouCloudFulfillment?.ticket.code || '' +} + async function submitManualDispatch(outcome: 'delivered' | 'failed') { if (!detail.value) { return @@ -155,6 +168,68 @@ async function submitManualDispatch(outcome: 'delivered' | 'failed') { ) } +async function submitKuaishouCloudPrepare() { + if (!detail.value) { + return + } + + await runAction( + () => prepareAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId), + '绑定资源已准备完成', + `确认开始为任务 ${detail.value.task.taskNo} 准备绑定资源吗?系统会自动检查背包、余额并申请虚拟号。`, + ) +} + +async function submitKuaishouCloudDispatch() { + if (!detail.value) { + return + } + + await runAction( + () => dispatchAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId, { + ticketCode: kuaishouCloudForm.ticketCode, + }), + '已完成绑定确认并发货', + `确认客户已经完成绑定,并立即为任务 ${detail.value.task.taskNo} 执行发货吗?这个动作会把“确认绑定完成”和“发货”合并为一步。`, + ) +} + +async function submitKuaishouCloudReturnNumber() { + if (!detail.value) { + return + } + + await runAction( + () => returnNumberAdminTaskKuaishouCloudFulfillment(detail.value!.task.taskId), + '号码已退还', + `确认退还任务 ${detail.value.task.taskNo} 当前使用的虚拟号吗?退号后该流程会正式收口。`, + ) +} + +async function copyText(value: string, successMessage: string) { + const text = String(value || '').trim() + if (!text) { + showError('当前没有可复制的内容') + return + } + + try { + await navigator.clipboard.writeText(text) + showSuccess(successMessage) + } catch { + showError('复制失败,请手动复制') + } +} + +function openExternalLink(value: string) { + const text = String(value || '').trim() + if (!text) { + return + } + + window.open(text, '_blank', 'noopener,noreferrer') +} + function formatTaskEventPayload(payload: Record) { const parts = [ payload.outcome ? `结果 ${payload.outcome}` : '', @@ -166,11 +241,19 @@ function formatTaskEventPayload(payload: Record) { payload.inventoryItemId ? `库存项 #${payload.inventoryItemId}` : '', payload.previousInventoryItemId ? `旧库存 #${payload.previousInventoryItemId}` : '', payload.nextInventoryItemId ? `新库存 #${payload.nextInventoryItemId}` : '', + payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : '', + payload.vnId ? `虚拟号 #${payload.vnId}` : '', + payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : '', + payload.bindUrl ? `绑定链接 ${payload.bindUrl}` : '', + payload.sendType ? `发送类型 ${payload.sendType}` : '', + payload.note ? `备注 ${payload.note}` : '', payload.previousOutcome ? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}` : '', payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '', payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '', payload.trigger ? `触发 ${formatAutoDeliveryTrigger(String(payload.trigger || ''))}` : '', payload.reason ? `原因 ${payload.reason}` : '', + payload.purchaseTriggered === true ? '已触发购买' : '', + payload.usedKnapsack === true ? '使用背包库存' : '', payload.responseStatus ? `HTTP ${payload.responseStatus}` : '', payload.requestId ? `请求 ${payload.requestId}` : '', payload.errorMessage ? `错误 ${payload.errorMessage}` : '', @@ -349,6 +432,33 @@ onBeforeUnmount(clearScreenshotPreview) > 客服开始兑换 + + 准备绑定资源 + + + 确认绑定完成并发货 + + + 退还号码 + +
+
+
+

快手 Cloud 履约面板

+

这里集中处理绑定链接、客户卡券号、发货动作与退号收口。

+
+
+ + 复制绑定链接 + + + 打开绑定页 + +
+
+ +
+
+ 准备状态 +
+ +
+
+
+ 发货状态 +
+ +
+
+
+ 退号状态 +
+ +
+
+
+ 当前任务状态 +
+ +
+
+
+ +
+
+

客户绑定

+

客户下单后需要先完成外部绑定,客服只需要把链接发给客户,并在完成后点一次发货按钮。

+
+
绑定链接{{ kuaishouCloudFlow.binding.bindUrl || '-' }}
+
准备时间{{ formatAdminDateTime(kuaishouCloudFlow.binding.bindPreparedAt) }}
+
虚拟号 ID{{ kuaishouCloudFlow.binding.vnId || '-' }}
+
虚拟号码{{ kuaishouCloudFlow.binding.vnPhone || '-' }}
+
+
+ +
+

cloud 资源

+
+
cloudSourceKey{{ kuaishouCloudFlow.binding.cloudSourceKey || '-' }}
+
cloud SKU{{ kuaishouCloudFlow.binding.skuId ? `${kuaishouCloudFlow.binding.skuId} / ${kuaishouCloudFlow.binding.skuName || '-'}` : '-' }}
+
VN Key{{ kuaishouCloudFlow.binding.vnKey || '-' }}
+
自动购买{{ kuaishouCloudFlow.purchase.autoBuyEnabled ? '开启' : '关闭' }}
+
背包命中{{ kuaishouCloudFlow.purchase.usedKnapsack ? '是' : '否' }}
+
触发购买{{ kuaishouCloudFlow.purchase.purchaseTriggered ? '是' : '否' }}
+
+
+
+ +
+ +

“确认绑定完成并发货”已经合并为一个按钮,不再拆成两步。

+
+ +
+
+

发货结果

+
+
最近卡券号{{ kuaishouCloudFlow.ticket.code || '-' }}
+
记录时间{{ formatAdminDateTime(kuaishouCloudFlow.ticket.capturedAt) }}
+
发货时间{{ formatAdminDateTime(kuaishouCloudFlow.dispatch.dispatchAt) }}
+
发送类型{{ kuaishouCloudFlow.dispatch.sendType || '-' }}
+
结果说明{{ kuaishouCloudFlow.dispatch.note || detail.task.resultMessage || '-' }}
+
+
+ +
+

流程收口

+
+
退号时间{{ formatAdminDateTime(kuaishouCloudFlow.returnNumber.returnedAt) }}
+
自动退号{{ kuaishouCloudFlow.returnNumber.autoReturnEnabled ? '开启' : '关闭' }}
+
自动核销{{ kuaishouCloudFlow.consume.autoConsumeEnabled ? '开启' : '关闭' }}
+
核销店铺{{ kuaishouCloudFlow.consume.shopId || '-' }}
+
备注{{ kuaishouCloudFlow.notes || '-' }}
+
+
+
+
+