删除咸鱼旧链路
This commit is contained in:
@@ -1,434 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import {
|
||||
closeClaimSessionForAdminTask,
|
||||
createClaimSessionForAdminTask,
|
||||
getClaimDetailForAdminTask,
|
||||
getClaimSessionSummaryForAdminTask,
|
||||
reloadClaimSessionForAdminTask,
|
||||
} from '../claim/claim-session-service.js'
|
||||
import { reserveInventoryForTask } from '../order/inventory-service.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
|
||||
import { createOrder, findOrderByPlatformOrderId } from '../../repositories/order-repo.js'
|
||||
import { getFulfillmentProfileByKey, listFulfillmentProfileRequirements } from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { findFirstAvailableInventoryItemBySkuCode } from '../../repositories/inventory-repo.js'
|
||||
import { createTask, getTaskById, updateTask } from '../../repositories/task-repo.js'
|
||||
import { createTaskEvent } from '../../repositories/task-event-repo.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { randomId } from '../../utils/random.js'
|
||||
import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.js'
|
||||
import { createAdminViewerContext, isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js'
|
||||
|
||||
import type {
|
||||
AdminEntityIdInput,
|
||||
AdminViewerSessionInput,
|
||||
} from '../../types/admin-read-inputs.js'
|
||||
import type { AdminManualRedeemCreateInput } from '../../types/admin-write-inputs.js'
|
||||
import type { TaskRow } from '../../types/repository-rows.js'
|
||||
|
||||
const MANUAL_PROVIDER = 'manual'
|
||||
const MANUAL_PLATFORM = 'manual_redeem'
|
||||
const MANUAL_SOURCE_TYPE = 'admin_manual_redeem'
|
||||
const ASSISTED_PROFILE_KEY = 'tencent_claim_assisted'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function createAdminManualRedeemTask(
|
||||
payload: AdminManualRedeemCreateInput = {},
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
const proofValue = normalizeManualProofValue(payload.proofValue)
|
||||
const skuCode = String(payload.skuCode || '').trim()
|
||||
const skuName = String(payload.skuName || '').trim() || skuCode
|
||||
const remark = String(payload.remark || '').trim()
|
||||
|
||||
if (!proofValue) {
|
||||
throw createHttpError('请先输入唯一凭据', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_manual_redeem_missing_proof',
|
||||
})
|
||||
}
|
||||
|
||||
if (!skuCode) {
|
||||
throw createHttpError('请先选择内部履约 SKU', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_manual_redeem_missing_sku',
|
||||
})
|
||||
}
|
||||
|
||||
const existingOrder = await findOrderByPlatformOrderId({
|
||||
provider: MANUAL_PROVIDER,
|
||||
platform: MANUAL_PLATFORM,
|
||||
shopId: '',
|
||||
platformOrderId: proofValue,
|
||||
})
|
||||
|
||||
if (existingOrder) {
|
||||
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_manual_redeem_duplicate_proof',
|
||||
})
|
||||
}
|
||||
|
||||
const profile = await getFulfillmentProfileByKey(ASSISTED_PROFILE_KEY)
|
||||
if (!profile) {
|
||||
throw createHttpError('人工兑换所需的履约档案不存在,请先检查系统初始化', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_manual_redeem_profile_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const requirements = await listFulfillmentProfileRequirements(profile.id)
|
||||
const primaryRequirement = requirements.find((item) => item.is_required !== false) || requirements[0] || null
|
||||
const credentialType = String(primaryRequirement?.credential_type || primaryRequirement?.credentialType || 'tencent_code').trim() || 'tencent_code'
|
||||
const roleKey = String(primaryRequirement?.role_key || primaryRequirement?.roleKey || 'primary_code').trim() || 'primary_code'
|
||||
const availableInventory = await findFirstAvailableInventoryItemBySkuCode(
|
||||
skuCode,
|
||||
credentialType,
|
||||
viewerContext.allowedInventoryGroupCodes,
|
||||
)
|
||||
|
||||
if (!availableInventory) {
|
||||
if (viewerContext.restrictInventoryGroups && viewerContext.allowedInventoryGroupCodes?.length === 0) {
|
||||
throw createHttpError('当前客服未绑定任何库存组,无法创建人工兑换任务', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_manual_redeem_inventory_group_unbound',
|
||||
})
|
||||
}
|
||||
|
||||
throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_manual_redeem_inventory_unavailable',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const manualContext = {
|
||||
sourceType: MANUAL_SOURCE_TYPE,
|
||||
proofValue,
|
||||
remark,
|
||||
createdAt: now,
|
||||
createdBy: session
|
||||
? {
|
||||
userId: Number(session.userId || 0) || 0,
|
||||
username: String(session.username || '').trim(),
|
||||
role: String(session.role || '').trim(),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
|
||||
let order
|
||||
try {
|
||||
order = await createOrder({
|
||||
provider: MANUAL_PROVIDER,
|
||||
platform: MANUAL_PLATFORM,
|
||||
shopId: '',
|
||||
shopName: '人工兑换',
|
||||
platformOrderId: proofValue,
|
||||
orderStatus: 'manual_created',
|
||||
payStatus: 'paid',
|
||||
buyerId: '',
|
||||
buyerName: '',
|
||||
receiverContact: '',
|
||||
totalAmount: 0,
|
||||
currency: 'CNY',
|
||||
rawPayloadJson: JSON.stringify({
|
||||
manualRedeem: manualContext,
|
||||
}),
|
||||
paidAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
} catch (error) {
|
||||
if (String(error?.code || '') === '23505') {
|
||||
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_manual_redeem_duplicate_proof',
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
throw createHttpError('人工兑换订单创建失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_manual_redeem_order_create_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const orderItems = await replaceOrderItems(order.id, [
|
||||
{
|
||||
skuCode,
|
||||
skuName,
|
||||
quantity: 1,
|
||||
specJson: JSON.stringify({
|
||||
title: skuName,
|
||||
sourceType: MANUAL_SOURCE_TYPE,
|
||||
}),
|
||||
itemSnapshotJson: JSON.stringify({
|
||||
manualRedeem: manualContext,
|
||||
skuCode,
|
||||
skuName,
|
||||
}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
])
|
||||
|
||||
const orderItem = orderItems[0]
|
||||
if (!orderItem) {
|
||||
throw createHttpError('人工兑换订单商品创建失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_manual_redeem_order_item_create_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const createdTask = await createTask({
|
||||
orderId: order.id,
|
||||
orderItemId: orderItem.id,
|
||||
unitIndex: 1,
|
||||
provider: MANUAL_PROVIDER,
|
||||
platform: MANUAL_PLATFORM,
|
||||
shopId: '',
|
||||
shopName: '人工兑换',
|
||||
platformOrderId: proofValue,
|
||||
taskNo: randomId('MR'),
|
||||
profileId: Number(profile.id),
|
||||
executorKey: String(profile.executor_key || ASSISTED_PROFILE_KEY),
|
||||
taskStatus: 'paid',
|
||||
inventoryStatus: 'pending',
|
||||
deliveryStatus: 'pending',
|
||||
resultCode: '',
|
||||
resultMessage: '',
|
||||
claimToken: '',
|
||||
claimExpiresAt: null,
|
||||
automationMode: 'manual',
|
||||
requiresClaim: true,
|
||||
userActionStatus: 'pending_claim',
|
||||
attemptCount: 0,
|
||||
lastError: '',
|
||||
contextJson: JSON.stringify({
|
||||
profileKey: String(profile.profile_key || ASSISTED_PROFILE_KEY),
|
||||
profileName: String(profile.name || ''),
|
||||
skuCode,
|
||||
skuName,
|
||||
inventorySkuCode: skuCode,
|
||||
primaryRequirement: primaryRequirement
|
||||
? {
|
||||
roleKey,
|
||||
credentialType,
|
||||
}
|
||||
: null,
|
||||
inventoryGroupCode: String(availableInventory.inventory_group_code || '').trim(),
|
||||
manualRedeem: manualContext,
|
||||
}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (!createdTask) {
|
||||
throw createHttpError('人工兑换任务创建失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_manual_redeem_task_create_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const reservedInventory = await reserveInventoryForTask({
|
||||
skuCode,
|
||||
taskId: createdTask.id,
|
||||
credentialType,
|
||||
roleKey,
|
||||
inventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
|
||||
})
|
||||
|
||||
if (!reservedInventory) {
|
||||
await updateTask(createdTask.id, {
|
||||
task_status: 'waiting_inventory',
|
||||
inventory_status: 'pending',
|
||||
last_error: '库存不足,无法为人工兑换任务预占库存',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
throw createHttpError('库存刚刚被其他任务占用,请重新选择或稍后再试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_manual_redeem_inventory_race_lost',
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(createdTask.id)
|
||||
await updateTask(createdTask.id, {
|
||||
task_status: 'link_generated',
|
||||
inventory_status: 'reserved',
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
user_action_status: 'pending_claim',
|
||||
last_error: '',
|
||||
context_json: JSON.stringify({
|
||||
...parseTaskContext(createdTask),
|
||||
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
|
||||
}),
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
await createTaskEvent(createdTask.id, 'manual_redeem_created', {
|
||||
sourceType: MANUAL_SOURCE_TYPE,
|
||||
proofValue,
|
||||
skuCode,
|
||||
skuName,
|
||||
inventoryItemId: Number(reservedInventory.id || 0) || null,
|
||||
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
|
||||
createdBy: manualContext.createdBy,
|
||||
remark,
|
||||
}, nowIso())
|
||||
|
||||
return getAdminManualRedeemDetail(createdTask.id, session)
|
||||
}
|
||||
|
||||
export async function getAdminManualRedeemDetail(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
const detail = await getClaimDetailForAdminTask(task.id)
|
||||
return decorateManualRedeemDetail(task, detail)
|
||||
}
|
||||
|
||||
export async function createAdminManualRedeemSession(
|
||||
taskId: AdminEntityIdInput,
|
||||
payload: { loginType?: string, forceRecreate?: boolean } = {},
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
const detail = await createClaimSessionForAdminTask(task.id, payload)
|
||||
return decorateManualRedeemDetail(task, detail)
|
||||
}
|
||||
|
||||
export async function getAdminManualRedeemSessionSummary(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
const detail = await getClaimSessionSummaryForAdminTask(task.id)
|
||||
return decorateManualRedeemDetail(task, detail)
|
||||
}
|
||||
|
||||
export async function reloadAdminManualRedeemSession(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
const detail = await reloadClaimSessionForAdminTask(task.id)
|
||||
return decorateManualRedeemDetail(task, detail)
|
||||
}
|
||||
|
||||
export async function closeAdminManualRedeemSession(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
const detail = await closeClaimSessionForAdminTask(task.id)
|
||||
return decorateManualRedeemDetail(task, detail)
|
||||
}
|
||||
|
||||
export async function closeAdminManualRedeemTask(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
await closeAdminTask(task.id)
|
||||
return getAdminManualRedeemDetail(task.id, session)
|
||||
}
|
||||
|
||||
export async function confirmAdminManualRedeemRole(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
await confirmAdminTaskAssistedRole(task.id, session)
|
||||
return getAdminManualRedeemDetail(task.id, session)
|
||||
}
|
||||
|
||||
export async function redeemAdminManualRedeemTask(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
await redeemAdminTaskAssisted(task.id, session)
|
||||
return getAdminManualRedeemDetail(task.id, session)
|
||||
}
|
||||
|
||||
async function getRequiredManualRedeemTask(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<TaskRow> {
|
||||
const task = await getTaskById(Number(taskId))
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('人工兑换任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_manual_redeem_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const taskContext = parseTaskContext(task)
|
||||
const sourceType = String(taskContext?.manualRedeem?.sourceType || '').trim()
|
||||
|
||||
if (!isAssistedClaimTask(task) || sourceType !== MANUAL_SOURCE_TYPE) {
|
||||
throw createHttpError('当前任务不是人工兑换任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_manual_redeem_task_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
if (viewerContext.restrictInventoryGroups) {
|
||||
if (!Array.isArray(viewerContext.allowedInventoryGroupCodes) || viewerContext.allowedInventoryGroupCodes.length === 0) {
|
||||
throw createHttpError('当前客服未绑定任何库存组,无法操作人工兑换任务', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_manual_redeem_inventory_group_unbound',
|
||||
})
|
||||
}
|
||||
|
||||
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
|
||||
|
||||
if (inventoryGroupCode && !viewerContext.allowedInventoryGroupCodes.includes(inventoryGroupCode)) {
|
||||
throw createHttpError('当前客服无权操作该库存组下的人工兑换任务', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_manual_redeem_inventory_group_denied',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
function decorateManualRedeemDetail(task: TaskRow, detail: any): JsonObject {
|
||||
const taskContext = parseTaskContext(task)
|
||||
const manualRedeem = taskContext.manualRedeem && typeof taskContext.manualRedeem === 'object'
|
||||
? taskContext.manualRedeem
|
||||
: {}
|
||||
|
||||
return {
|
||||
...detail,
|
||||
claimUrl: '',
|
||||
manualRequest: {
|
||||
sourceType: String(manualRedeem.sourceType || MANUAL_SOURCE_TYPE).trim() || MANUAL_SOURCE_TYPE,
|
||||
proofValue: String(manualRedeem.proofValue || detail?.order?.platformOrderId || '').trim(),
|
||||
remark: String(manualRedeem.remark || '').trim(),
|
||||
},
|
||||
result: detail.result
|
||||
? {
|
||||
...detail.result,
|
||||
screenshotUrl: detail.result.screenshotReady ? `/api/v1/admin/tasks/${detail.task.taskId}/screenshot` : '',
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeManualProofValue(value: unknown): string {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
@@ -38,7 +38,6 @@ import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js'
|
||||
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
|
||||
import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
|
||||
import {
|
||||
buildOrderAgisoAutoDeliverySummary,
|
||||
buildOrderBindingSummary,
|
||||
createTaskBindingSummaryFromBindings,
|
||||
getRequiredTask,
|
||||
@@ -130,7 +129,6 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
|
||||
? order.raw_payload_json
|
||||
: JSON.parse(String(order.raw_payload_json || '{}')),
|
||||
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
|
||||
agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks),
|
||||
},
|
||||
items: items.map((item) => ({
|
||||
orderItemId: item.id,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
export {
|
||||
getRequiredInventoryItem,
|
||||
mapAdminInventoryListItem,
|
||||
} from './admin-inventory-read-helpers.js'
|
||||
export {
|
||||
mapAdminOrderListItem,
|
||||
summarizeOrderItems,
|
||||
} from './admin-order-read-helpers.js'
|
||||
export { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
|
||||
|
||||
export {
|
||||
buildOrderAgisoAutoDeliverySummary,
|
||||
buildOrderBindingSummary,
|
||||
createEmptyTaskBindingSummary,
|
||||
createTaskBindingSummaryFromBindings,
|
||||
getRequiredTask,
|
||||
getTaskBindingSummary,
|
||||
getTaskBindingSummaryMap,
|
||||
mapAdminTaskEvent,
|
||||
mapAdminTaskInventoryBinding,
|
||||
mapAdminTaskListItem,
|
||||
mapAdminTaskSummary,
|
||||
mapTaskActionPayload,
|
||||
} from './admin-task-read-helpers.js'
|
||||
|
||||
export {
|
||||
canRegenerateClaimLinkForViewer,
|
||||
canViewerConfirmAssistedRole,
|
||||
canViewerRedeemAssistedTask,
|
||||
createAdminViewerContext,
|
||||
getTaskPrimaryClaimTokenId,
|
||||
getTaskPrimaryInventoryItemId,
|
||||
isAssistedClaimTask,
|
||||
isManualDispatchTask,
|
||||
mapManualDispatchContext,
|
||||
mapRedeemResolutionContext,
|
||||
parseTaskContext,
|
||||
parseTaskState,
|
||||
resolveOrderItemDeliveryMode,
|
||||
resolveOrderItemTitle,
|
||||
resolveAdminTaskScreenshotUrl,
|
||||
resolveDisplayShopName,
|
||||
} from './admin-read-shared-helpers.js'
|
||||
|
||||
export {
|
||||
getAdminInventoryItems,
|
||||
getAdminInventorySkuSuggestions,
|
||||
getAdminOrderDetail,
|
||||
getAdminOrders,
|
||||
getAdminTaskDetail,
|
||||
getAdminTasks,
|
||||
getAdminTaskScreenshotPath,
|
||||
getAdminWebhookEventDetail,
|
||||
getAdminWebhookEvents,
|
||||
} from './admin-read-service.js'
|
||||
|
||||
export {
|
||||
closeAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
confirmAdminTaskAssistedRole,
|
||||
createAdminInventoryItem,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
importAdminInventoryItems,
|
||||
invalidateAdminInventoryItem,
|
||||
markAdminTaskManualReview,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
redeemAdminTaskAssisted,
|
||||
regenerateAdminTaskClaimLink,
|
||||
releaseAdminInventoryItem,
|
||||
releaseAdminTaskInventory,
|
||||
releaseAdminTaskInventoryBinding,
|
||||
replayAdminWebhookEvent,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
retryAdminTask,
|
||||
} from './admin-write-service.js'
|
||||
|
||||
export {
|
||||
getAdminAgisoShopConfigs,
|
||||
getAdminFulfillmentBindingConfigs,
|
||||
lookupAdminFulfillmentBindingOrder,
|
||||
updateAdminAgisoShopConfigs,
|
||||
updateAdminFulfillmentBindingConfigs,
|
||||
} from './platform-config/service.js'
|
||||
|
||||
export { getAdminDashboardSummary } from './admin-dashboard-service.js'
|
||||
export { getAdminMessageDeliveries } from './admin-message-delivery-service.js'
|
||||
@@ -10,13 +10,11 @@ import {
|
||||
} from './admin-read-shared-helpers.js'
|
||||
|
||||
import type {
|
||||
AdminAgisoAutoDeliveryStatus,
|
||||
AdminTaskBindingSummary,
|
||||
AdminTaskListItem,
|
||||
} from '../../types/admin-read-models.js'
|
||||
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
|
||||
import type {
|
||||
OrderRow,
|
||||
TaskEventRow,
|
||||
TaskInventoryBindingRow,
|
||||
TaskInventoryBindingSummaryRow,
|
||||
@@ -47,24 +45,11 @@ type OrderBindingSummary = {
|
||||
userBindingStatus: string
|
||||
}
|
||||
|
||||
type OrderAgisoAutoDeliverySummary = AdminAgisoAutoDeliveryStatus & {
|
||||
sourceTaskId: number | null
|
||||
sourceTaskNo: string
|
||||
totalTaskCount: number
|
||||
deliveredTaskCount: number
|
||||
}
|
||||
|
||||
type OrderAgisoAutoDeliveryCandidate = AdminAgisoAutoDeliveryStatus & {
|
||||
sourceTaskId: number | null
|
||||
sourceTaskNo: string
|
||||
}
|
||||
|
||||
export function mapAdminTaskSummary(
|
||||
task: TaskRow,
|
||||
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
|
||||
): JsonRecord {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
@@ -81,7 +66,6 @@ export function mapAdminTaskSummary(
|
||||
lastError: task.last_error,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
}
|
||||
@@ -124,7 +108,6 @@ export function mapAdminTaskListItem(
|
||||
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
||||
): AdminTaskListItem {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
@@ -148,7 +131,6 @@ export function mapAdminTaskListItem(
|
||||
redeemedAt: task.redeemed_at,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
lastError: task.last_error,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
@@ -315,63 +297,6 @@ export function buildOrderBindingSummary(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOrderAgisoAutoDeliverySummary(
|
||||
order: Partial<OrderRow> | null | undefined,
|
||||
tasks: TaskRow[] = [],
|
||||
): OrderAgisoAutoDeliverySummary | null {
|
||||
if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
|
||||
const totalTaskCount = normalizedTasks.length
|
||||
const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length
|
||||
const latest = normalizedTasks.reduce<OrderAgisoAutoDeliveryCandidate | null>((best, task) => {
|
||||
const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery)
|
||||
|
||||
if (!autoDelivery) {
|
||||
return best
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
...autoDelivery,
|
||||
sourceTaskId: Number(task.id || 0) || null,
|
||||
sourceTaskNo: String(task.task_no || '').trim(),
|
||||
}
|
||||
const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || ''))
|
||||
const bestTime = Date.parse(String(best?.updatedAt || ''))
|
||||
|
||||
if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
return best
|
||||
}, null)
|
||||
|
||||
if (latest) {
|
||||
return {
|
||||
...latest,
|
||||
totalTaskCount,
|
||||
deliveredTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting',
|
||||
trigger: '',
|
||||
reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks',
|
||||
platformOrderId: String(order?.platform_order_id || '').trim(),
|
||||
responseStatus: 0,
|
||||
errorMessage: '',
|
||||
requestId: '',
|
||||
updatedAt: null,
|
||||
sourceTaskId: null,
|
||||
sourceTaskNo: '',
|
||||
totalTaskCount,
|
||||
deliveredTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean {
|
||||
if (!task || !binding) {
|
||||
return false
|
||||
@@ -469,24 +394,6 @@ function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
|
||||
return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task)))
|
||||
}
|
||||
|
||||
function mapAgisoAutoDeliveryContext(value: unknown): AdminAgisoAutoDeliveryStatus | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = value as JsonRecord
|
||||
return {
|
||||
status: String(record.status || '').trim(),
|
||||
trigger: String(record.trigger || '').trim(),
|
||||
reason: String(record.reason || '').trim(),
|
||||
platformOrderId: String(record.platformOrderId || '').trim(),
|
||||
responseStatus: Number(record.responseStatus || 0),
|
||||
errorMessage: String(record.errorMessage || '').trim(),
|
||||
requestId: String(record.requestId || '').trim(),
|
||||
updatedAt: record.updatedAt || null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { getRequiredTask } from './admin-task-read-helpers.js'
|
||||
|
||||
import type {
|
||||
AdminEntityIdInput,
|
||||
AdminViewerSessionInput,
|
||||
} from '../../types/admin-read-inputs.js'
|
||||
|
||||
export async function getAdminTaskScreenshotPathWithTencentFallback(
|
||||
taskId: AdminEntityIdInput,
|
||||
_session: AdminViewerSessionInput | null = null,
|
||||
): Promise<string> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
|
||||
if (task.screenshot_path) {
|
||||
return task.screenshot_path
|
||||
}
|
||||
|
||||
if (task.browser_session_id) {
|
||||
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
|
||||
}
|
||||
|
||||
throw createHttpError('当前任务还没有可查看截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_screenshot_not_found',
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||
import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js'
|
||||
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js'
|
||||
import { safeParseJson } from './admin-query-utils.js'
|
||||
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
|
||||
|
||||
@@ -49,7 +48,7 @@ export async function mapAdminWebhookEvent(
|
||||
|
||||
const mapped = {
|
||||
eventId: item.id,
|
||||
provider: item.provider || 'agiso',
|
||||
provider: item.provider || '',
|
||||
platform: item.platform,
|
||||
platformRaw: pickFirstNonEmpty([
|
||||
query.fromPlatform,
|
||||
@@ -82,7 +81,7 @@ export async function mapAdminWebhookEvent(
|
||||
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
|
||||
relatedOrderId: item.related_order_id,
|
||||
createdAt: item.created_at,
|
||||
platformOrderId: resolveAgisoTradePlatformOrderId(payload),
|
||||
platformOrderId: resolveWebhookPlatformOrderId(item, payload),
|
||||
buyerId: pickFirstNonEmpty([
|
||||
payload.buyer_id,
|
||||
payload.buyerId,
|
||||
@@ -135,7 +134,44 @@ export async function mapAdminWebhookEvent(
|
||||
}
|
||||
|
||||
function extractWebhookPayload(body: JsonRecord): JsonRecord {
|
||||
return extractAgisoTradePayload(body)
|
||||
const candidates = [
|
||||
body.data,
|
||||
body.Data,
|
||||
body.payload,
|
||||
body.Payload,
|
||||
body.message,
|
||||
body.Message,
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
||||
return normalizeRecord(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
function resolveWebhookPlatformOrderId(item: WebhookEventRow, payload: JsonRecord): string {
|
||||
const fromPayload = pickFirstNonEmpty([
|
||||
payload.biz_order_id,
|
||||
payload.bizOrderId,
|
||||
payload.BizOrderId,
|
||||
payload.tid,
|
||||
payload.Tid,
|
||||
payload.order_id,
|
||||
payload.orderId,
|
||||
payload.OrderId,
|
||||
payload.oid,
|
||||
payload.Oid,
|
||||
])
|
||||
|
||||
if (fromPayload) {
|
||||
return fromPayload
|
||||
}
|
||||
|
||||
const parts = String(item.event_key || '').split(':').map((part) => part.trim()).filter(Boolean)
|
||||
return parts.find((part) => /^\d{6,}$/.test(part)) || ''
|
||||
}
|
||||
|
||||
function extractWebhookItemSources(payload: unknown): JsonRecord[] {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { closeAdminTaskWithDeps } from './admin-write-service.js'
|
||||
|
||||
test('closeAdminTaskWithDeps revokes claim link, releases reserved inventory, and closes browser session', async () => {
|
||||
const calls = {
|
||||
updateToken: [],
|
||||
releaseReserved: [],
|
||||
closeSession: [],
|
||||
updateTask: [],
|
||||
createEvent: [],
|
||||
}
|
||||
const task = {
|
||||
id: 30,
|
||||
task_status: 'link_generated',
|
||||
delivery_status: 'pending',
|
||||
inventory_status: 'reserved',
|
||||
user_action_status: 'pending_claim',
|
||||
browser_session_id: 'browser-session-30',
|
||||
primary_claim_token_id: 9,
|
||||
claim_expires_at: '2026-04-15T10:00:00.000Z',
|
||||
last_error: '',
|
||||
updated_at: '2026-04-14T10:00:00.000Z',
|
||||
}
|
||||
const now = '2026-04-14T10:16:18.000Z'
|
||||
|
||||
const result = await closeAdminTaskWithDeps(task.id, {
|
||||
getRequiredTask: async () => task,
|
||||
listTaskInventoryBindingsByTaskId: async () => ([
|
||||
{ inventory_item_id: 17, binding_status: 'reserved' },
|
||||
{ inventory_item_id: 18, binding_status: 'released' },
|
||||
{ inventory_item_id: 17, binding_status: 'reserved' },
|
||||
]),
|
||||
releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => {
|
||||
calls.releaseReserved.push({ inventoryItemId, updatedAt })
|
||||
return { id: inventoryItemId }
|
||||
},
|
||||
updateClaimToken: async (tokenId, patch) => {
|
||||
calls.updateToken.push({ tokenId, patch })
|
||||
return { id: tokenId, ...patch }
|
||||
},
|
||||
closeTencentBrowserSession: async (sessionId) => {
|
||||
calls.closeSession.push(sessionId)
|
||||
return { sessionId, closed: true }
|
||||
},
|
||||
updateTask: async (taskId, patch) => {
|
||||
calls.updateTask.push({ taskId, patch })
|
||||
return { ...task, ...patch }
|
||||
},
|
||||
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
|
||||
calls.createEvent.push({ taskId, eventType, payload, createdAt })
|
||||
return null
|
||||
},
|
||||
nowIso: () => now,
|
||||
})
|
||||
|
||||
assert.equal(calls.updateToken.length, 1)
|
||||
assert.deepEqual(calls.updateToken[0], {
|
||||
tokenId: 9,
|
||||
patch: {
|
||||
status: 'revoked',
|
||||
expired_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
})
|
||||
assert.deepEqual(calls.releaseReserved, [
|
||||
{ inventoryItemId: 17, updatedAt: now },
|
||||
])
|
||||
assert.deepEqual(calls.closeSession, ['browser-session-30'])
|
||||
assert.equal(calls.updateTask.length, 1)
|
||||
assert.equal(calls.updateTask[0].patch.task_status, 'closed')
|
||||
assert.equal(calls.updateTask[0].patch.delivery_status, 'closed')
|
||||
assert.equal(calls.updateTask[0].patch.inventory_status, 'pending')
|
||||
assert.equal(calls.updateTask[0].patch.user_action_status, 'closed')
|
||||
assert.equal(calls.updateTask[0].patch.browser_session_id, '')
|
||||
assert.equal(calls.updateTask[0].patch.claim_expires_at, now)
|
||||
assert.match(calls.updateTask[0].patch.last_error, /领取链接已失效/)
|
||||
assert.match(calls.updateTask[0].patch.last_error, /预占库存已释放/)
|
||||
assert.equal(calls.createEvent.length, 1)
|
||||
assert.deepEqual(calls.createEvent[0], {
|
||||
taskId: 30,
|
||||
eventType: 'task_closed',
|
||||
payload: {
|
||||
claimTokenRevoked: true,
|
||||
releasedInventoryItemIds: [17],
|
||||
releasedInventoryCount: 1,
|
||||
browserSessionClosed: true,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
assert.equal(result.task.status, 'closed')
|
||||
})
|
||||
|
||||
test('closeAdminTaskWithDeps rejects redeemed tasks', async () => {
|
||||
await assert.rejects(
|
||||
() => closeAdminTaskWithDeps(99, {
|
||||
getRequiredTask: async () => ({
|
||||
id: 99,
|
||||
task_status: 'redeemed',
|
||||
}),
|
||||
}),
|
||||
/已兑换任务不能关闭/,
|
||||
)
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
export {
|
||||
createAdminInventoryItem,
|
||||
importAdminInventoryItems,
|
||||
releaseAdminInventoryItem,
|
||||
invalidateAdminInventoryItem,
|
||||
} from './write/inventory.js'
|
||||
|
||||
export {
|
||||
replayAdminWebhookEvent,
|
||||
} from './write/webhook-events.js'
|
||||
|
||||
export {
|
||||
releaseAdminTaskInventory,
|
||||
releaseAdminTaskInventoryBinding,
|
||||
regenerateAdminTaskClaimLink,
|
||||
confirmAdminTaskAssistedRole,
|
||||
redeemAdminTaskAssisted,
|
||||
closeAdminTask,
|
||||
closeAdminTaskWithDeps,
|
||||
markAdminTaskManualReview,
|
||||
retryAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
} from './write/task-actions.js'
|
||||
|
||||
export {
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
} from './write/kuaishou-cloud-actions.js'
|
||||
@@ -1,65 +0,0 @@
|
||||
import { query } from '../../../db/client.js'
|
||||
import {
|
||||
getAgisoMessagingDefaults,
|
||||
getAgisoShopConfigMap,
|
||||
getAgisoShopsFilePath,
|
||||
saveAgisoMessagingConfig,
|
||||
} from '../../platforms/agiso/shop-config-service.js'
|
||||
import { resolveDisplayShopName } from '../admin-read-shared-helpers.js'
|
||||
import {
|
||||
mapAdminAgisoMessagingDefaults,
|
||||
mapAdminAgisoShopConfigItem,
|
||||
} from './domain.js'
|
||||
import { buildAdminAgisoMessagingSavePayload } from './writes.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function getAdminAgisoShopConfigs() {
|
||||
const configMap = getAgisoShopConfigMap()
|
||||
const defaults = getAgisoMessagingDefaults()
|
||||
const rowsResult = await query(
|
||||
`
|
||||
SELECT
|
||||
shop_id,
|
||||
MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name,
|
||||
MAX(created_at) AS latest_seen_at,
|
||||
COUNT(*)::int AS webhook_event_count
|
||||
FROM webhook_events
|
||||
WHERE provider = 'agiso' AND trim(shop_id) != ''
|
||||
GROUP BY shop_id
|
||||
ORDER BY latest_seen_at DESC, shop_id DESC
|
||||
`,
|
||||
)
|
||||
const rows = rowsResult.rows
|
||||
|
||||
return {
|
||||
filePath: getAgisoShopsFilePath(),
|
||||
defaults: mapAdminAgisoMessagingDefaults(defaults),
|
||||
shops: Object.entries(configMap)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
|
||||
observedShops: rows.map((row) => ({
|
||||
shopId: String(row.shop_id || '').trim(),
|
||||
detectedShopName: String(row.detected_shop_name || '').trim(),
|
||||
displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name),
|
||||
latestSeenAt: row.latest_seen_at || null,
|
||||
webhookEventCount: Number(row.webhook_event_count || 0),
|
||||
configured: Boolean(configMap[String(row.shop_id || '').trim()]),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAdminAgisoShopConfigs(payload: JsonObject = {}) {
|
||||
const saved = saveAgisoMessagingConfig(buildAdminAgisoMessagingSavePayload(payload, {
|
||||
currentDefaults: getAgisoMessagingDefaults(),
|
||||
currentMap: getAgisoShopConfigMap(),
|
||||
}))
|
||||
|
||||
return {
|
||||
filePath: getAgisoShopsFilePath(),
|
||||
defaults: mapAdminAgisoMessagingDefaults(saved.defaults),
|
||||
shops: Object.entries(saved.shops)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export function normalizeAgisoMessageTemplate(value: unknown): string {
|
||||
return String(value || "")
|
||||
.replaceAll("\\r\\n", "\n")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("\r\n", "\n");
|
||||
}
|
||||
@@ -2,101 +2,15 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
applyOptionalStringField,
|
||||
findMatchingObservedBinding,
|
||||
mapAdminAgisoMessagingDefaults,
|
||||
mapAdminAgisoShopConfigItem,
|
||||
mapAdminFulfillmentBindingConfigItem,
|
||||
mapAdminObservedProductItem,
|
||||
matchesObservedProduct,
|
||||
} from './domain.js'
|
||||
|
||||
test('mapAdminAgisoMessagingDefaults normalizes escaped newlines', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminAgisoMessagingDefaults({
|
||||
messageTemplate: ' hello\\nworld ',
|
||||
autoDeliveryMessageTemplate: ' done\\nnow ',
|
||||
}),
|
||||
{
|
||||
messageTemplate: 'hello\nworld',
|
||||
autoDeliveryMessageTemplate: 'done\nnow',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('mapAdminAgisoShopConfigItem masks secrets and reports configured flags', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminAgisoShopConfigItem('1001', {
|
||||
shopName: ' 店铺A ',
|
||||
accessToken: 'abcdef1234567890',
|
||||
enabled: true,
|
||||
messageTemplate: ' hi\\nall ',
|
||||
autoDeliveryMessageTemplate: ' ok ',
|
||||
appSecret: 'secret-1',
|
||||
apiVersion: ' v2 ',
|
||||
sendMessageEndpoint: ' /send ',
|
||||
}),
|
||||
{
|
||||
shopId: '1001',
|
||||
shopName: '店铺A',
|
||||
accessToken: 'abcdef1234567890',
|
||||
accessTokenMasked: 'abcdef****567890',
|
||||
enabled: true,
|
||||
messageTemplate: 'hi\nall',
|
||||
autoDeliveryMessageTemplate: 'ok',
|
||||
appSecretConfigured: true,
|
||||
apiVersion: 'v2',
|
||||
sendMessageEndpoint: '/send',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('applyOptionalStringField updates and clears normalized template fields', () => {
|
||||
const target = { messageTemplate: 'old' }
|
||||
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' next\\nline ' })
|
||||
assert.deepEqual(target, { messageTemplate: 'next\nline' })
|
||||
|
||||
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' ' })
|
||||
assert.deepEqual(target, {})
|
||||
})
|
||||
|
||||
test('mapAdminFulfillmentBindingConfigItem normalizes binding shape', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminFulfillmentBindingConfigItem({
|
||||
provider: ' agiso ',
|
||||
platform: ' xianyu ',
|
||||
shopId: ' shop-1 ',
|
||||
skuCode: ' sku-1 ',
|
||||
priority: '80',
|
||||
match: {
|
||||
externalSkuCode: ' ext-1 ',
|
||||
},
|
||||
}),
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '',
|
||||
skuCode: 'sku-1',
|
||||
skuName: '',
|
||||
profileKey: '',
|
||||
enabled: true,
|
||||
priority: 80,
|
||||
config: {},
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('matchesObservedProduct honors provider platform shop and external fields', () => {
|
||||
const binding = {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
match: {
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
@@ -107,8 +21,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
||||
|
||||
assert.equal(
|
||||
matchesObservedProduct(binding, {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
}),
|
||||
@@ -117,8 +31,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
||||
|
||||
assert.equal(
|
||||
matchesObservedProduct(binding, {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-2',
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
}),
|
||||
@@ -129,8 +43,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
||||
test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
||||
const bindings = [
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'sku-1',
|
||||
skuName: 'SKU 1',
|
||||
@@ -141,8 +55,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
||||
},
|
||||
]
|
||||
const observed = {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺1',
|
||||
externalItemId: '',
|
||||
@@ -154,8 +68,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
||||
|
||||
assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0])
|
||||
assert.deepEqual(mapAdminObservedProductItem(observed, bindings), {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺1',
|
||||
externalItemId: '',
|
||||
|
||||
@@ -1,70 +1,5 @@
|
||||
import { normalizeAgisoMessageTemplate } from './agiso-template.js'
|
||||
import { maskSecret } from './mappers.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export function mapAdminAgisoMessagingDefaults(defaults: JsonObject = {}) {
|
||||
return {
|
||||
messageTemplate: normalizeAgisoMessageTemplate(String(defaults.messageTemplate || '').trim()),
|
||||
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
|
||||
String(defaults.autoDeliveryMessageTemplate || '').trim(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAdminAgisoShopConfigItem(shopId: unknown, config: JsonObject = {}) {
|
||||
return {
|
||||
shopId,
|
||||
shopName: String(config.shopName || '').trim(),
|
||||
accessToken: String(config.accessToken || '').trim(),
|
||||
accessTokenMasked: maskSecret(config.accessToken),
|
||||
enabled: typeof config.enabled === 'boolean' ? config.enabled : null,
|
||||
messageTemplate: normalizeAgisoMessageTemplate(String(config.messageTemplate || '').trim()),
|
||||
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
|
||||
String(config.autoDeliveryMessageTemplate || '').trim(),
|
||||
),
|
||||
appSecretConfigured: Boolean(String(config.appSecret || '').trim()),
|
||||
apiVersion: String(config.apiVersion || '').trim(),
|
||||
sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOptionalStringField(target: JsonObject, key: string, source: JsonObject) {
|
||||
if (!source || typeof source[key] !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
const value = String(source[key] || '').trim()
|
||||
if (value) {
|
||||
target[key] = normalizeAgisoMessageTemplate(value)
|
||||
return
|
||||
}
|
||||
|
||||
delete target[key]
|
||||
}
|
||||
|
||||
export function mapAdminFulfillmentBindingConfigItem(item: JsonObject) {
|
||||
const match = item?.match || {}
|
||||
return {
|
||||
provider: String(item?.provider || '').trim(),
|
||||
platform: String(item?.platform || '').trim(),
|
||||
shopId: String(item?.shopId || '').trim(),
|
||||
shopName: String(item?.shopName || '').trim(),
|
||||
skuCode: String(item?.skuCode || '').trim(),
|
||||
skuName: String(item?.skuName || '').trim(),
|
||||
profileKey: String(item?.profileKey || '').trim(),
|
||||
enabled: item?.enabled !== false,
|
||||
priority: Number(item?.priority || 100),
|
||||
config: item?.config || {},
|
||||
match: {
|
||||
externalSkuCode: String(match.externalSkuCode || '').trim(),
|
||||
externalItemId: String(match.externalItemId || '').trim(),
|
||||
externalSkuName: String(match.externalSkuName || '').trim(),
|
||||
config: match.config || {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesObservedProduct(binding: JsonObject, observed: JsonObject) {
|
||||
const provider = String(binding?.provider || '').trim()
|
||||
const platform = String(binding?.platform || '').trim()
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { getAgisoShopConfig } from '../../platforms/agiso/shop-config-service.js'
|
||||
import {
|
||||
filterLegacyOrderFulfillmentBindings,
|
||||
getOrderFulfillmentBindingsFilePath,
|
||||
getLegacyOrderFulfillmentBindingConfigs,
|
||||
saveOrderFulfillmentBindingConfigs,
|
||||
} from '../../order/fulfillment-binding-config-service.js'
|
||||
import { enrichAgisoXianyuTradeOrder } from '../../platforms/agiso/xianyu/order-detail-service.js'
|
||||
import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js'
|
||||
import { getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import {
|
||||
buildFulfillmentLookupResult,
|
||||
normalizeFulfillmentLookupPayload,
|
||||
resolveFulfillmentLookupDetail,
|
||||
} from './fulfillment.js'
|
||||
import { listAdminObservedProducts } from './observed-products.js'
|
||||
import { validateAdminFulfillmentBindingConfigs } from './validation.js'
|
||||
import { mapAdminFulfillmentBindingConfigItem } from './domain.js'
|
||||
|
||||
import type {
|
||||
AdminFulfillmentBindingConfigSaveInput,
|
||||
AdminFulfillmentBindingLookupInput,
|
||||
} from '../../../types/admin-write-inputs.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function getAdminFulfillmentBindingConfigs() {
|
||||
const bindings = getLegacyOrderFulfillmentBindingConfigs()
|
||||
|
||||
return {
|
||||
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||
bindings: bindings.map(mapAdminFulfillmentBindingConfigItem),
|
||||
observedProducts: await listAdminObservedProducts(bindings),
|
||||
}
|
||||
}
|
||||
|
||||
export async function lookupAdminFulfillmentBindingOrder(
|
||||
payload: AdminFulfillmentBindingLookupInput = {},
|
||||
) {
|
||||
const { provider, platform, shopId, platformOrderId } = normalizeFulfillmentLookupPayload(payload)
|
||||
|
||||
const detailResult = await enrichAgisoXianyuTradeOrder({
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: '',
|
||||
platformOrderId,
|
||||
orderStatus: 'created',
|
||||
payStatus: 'unpaid',
|
||||
buyerId: '',
|
||||
buyerName: '',
|
||||
receiverContact: '',
|
||||
totalAmount: 0,
|
||||
currency: 'CNY',
|
||||
paidAt: null,
|
||||
rawPayload: {},
|
||||
items: [],
|
||||
}, {
|
||||
requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`,
|
||||
})
|
||||
|
||||
const { detail } = resolveFulfillmentLookupDetail(detailResult, platformOrderId)
|
||||
const bindings = getLegacyOrderFulfillmentBindingConfigs()
|
||||
return buildFulfillmentLookupResult({
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
platformOrderId,
|
||||
detail,
|
||||
detailResult,
|
||||
bindings,
|
||||
fallbackShopName: getAgisoShopConfig(shopId)?.shopName,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateAdminFulfillmentBindingConfigs(
|
||||
payload: AdminFulfillmentBindingConfigSaveInput = {},
|
||||
) {
|
||||
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
|
||||
const normalizedBindings = await validateAdminFulfillmentBindingConfigs(bindingsInput, {
|
||||
getFulfillmentProfileByKey,
|
||||
})
|
||||
assertLegacyBindingConfigsOnly(normalizedBindings)
|
||||
const saved = saveOrderFulfillmentBindingConfigs(normalizedBindings)
|
||||
await syncConfiguredFulfillmentBindings()
|
||||
|
||||
return {
|
||||
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||
bindings: saved.map(mapAdminFulfillmentBindingConfigItem),
|
||||
}
|
||||
}
|
||||
|
||||
function assertLegacyBindingConfigsOnly(bindings: JsonObject[] = []) {
|
||||
if (filterLegacyOrderFulfillmentBindings(bindings).length === bindings.length) {
|
||||
return
|
||||
}
|
||||
|
||||
throw createHttpError('快手履约规则请改到“快手 Cloud 新履约”页维护,旧履约规则仅保留给 Agiso 等历史场景', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_kuaishou_cloud_moved',
|
||||
})
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
buildFulfillmentLookupResult,
|
||||
normalizeFulfillmentLookupPayload,
|
||||
resolveFulfillmentLookupDetail,
|
||||
} from './fulfillment.js'
|
||||
|
||||
test('normalizeFulfillmentLookupPayload validates required fields and defaults provider/platform', () => {
|
||||
assert.deepEqual(
|
||||
normalizeFulfillmentLookupPayload({
|
||||
shopId: ' shop-1 ',
|
||||
platformOrderId: ' order-1 ',
|
||||
}),
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
platformOrderId: 'order-1',
|
||||
},
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() => normalizeFulfillmentLookupPayload({ platformOrderId: 'order-1' }),
|
||||
/请先填写店铺 ID/,
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveFulfillmentLookupDetail maps missing config into readable error', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveFulfillmentLookupDetail(
|
||||
{
|
||||
reason: 'missing_config',
|
||||
parsed: { items: [] },
|
||||
},
|
||||
'order-1',
|
||||
),
|
||||
/当前店铺缺少订单详情查询配置/,
|
||||
)
|
||||
})
|
||||
|
||||
test('buildFulfillmentLookupResult assembles order and item matching payload', () => {
|
||||
const result = buildFulfillmentLookupResult({
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
platformOrderId: 'order-1',
|
||||
detail: {
|
||||
shopName: '店铺A',
|
||||
buyerName: '买家A',
|
||||
totalAmount: 12345,
|
||||
paidAt: '2026-05-04T10:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
externalItemId: 'item-1',
|
||||
externalSkuCode: 'sku-1',
|
||||
externalSkuName: '礼包A',
|
||||
skuName: '礼包A',
|
||||
quantity: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
detailResult: {
|
||||
enriched: true,
|
||||
reason: 'ok',
|
||||
errorMessage: '',
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'internal-1',
|
||||
skuName: '内部商品',
|
||||
profileKey: 'profile-1',
|
||||
match: {
|
||||
externalSkuCode: 'sku-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
fallbackShopName: '备用店铺',
|
||||
})
|
||||
|
||||
assert.deepEqual(result, {
|
||||
order: {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺A',
|
||||
platformOrderId: 'order-1',
|
||||
buyerName: '买家A',
|
||||
totalAmountFen: 12345,
|
||||
totalAmount: '123.45',
|
||||
paidAt: '2026-05-04T10:00:00.000Z',
|
||||
enriched: true,
|
||||
enrichReason: 'ok',
|
||||
errorMessage: '',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
lineId: 'order-1:1:sku-1:item-1',
|
||||
itemTitle: '礼包A',
|
||||
quantity: 2,
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺A',
|
||||
externalItemId: 'item-1',
|
||||
externalSkuCode: 'sku-1',
|
||||
externalSkuName: '礼包A',
|
||||
latestSeenAt: null,
|
||||
orderItemCount: 2,
|
||||
configured: true,
|
||||
matchedBinding: {
|
||||
skuCode: 'internal-1',
|
||||
skuName: '内部商品',
|
||||
profileKey: 'profile-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { formatFenToAmount } from '../../../utils/money.js'
|
||||
import { isPlainObject, pickFirstNonEmpty } from './context.js'
|
||||
import { mapAdminObservedProductItem } from './domain.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export function normalizeFulfillmentLookupPayload(payload: JsonObject = {}) {
|
||||
const provider = String(payload.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(payload.platform || 'xianyu').trim() || 'xianyu'
|
||||
const shopId = String(payload.shopId || '').trim()
|
||||
const platformOrderId = String(payload.platformOrderId || '').trim()
|
||||
|
||||
if (!shopId) {
|
||||
throw createHttpError('请先填写店铺 ID', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_missing_shop_id',
|
||||
})
|
||||
}
|
||||
|
||||
if (!platformOrderId) {
|
||||
throw createHttpError('请先填写平台订单号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_missing_platform_order_id',
|
||||
})
|
||||
}
|
||||
|
||||
if (provider !== 'agiso' || platform !== 'xianyu') {
|
||||
throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_platform_not_supported',
|
||||
})
|
||||
}
|
||||
|
||||
return { provider, platform, shopId, platformOrderId }
|
||||
}
|
||||
|
||||
export function resolveFulfillmentLookupDetail(detailResult: JsonObject, platformOrderId: unknown) {
|
||||
const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {}
|
||||
const items = Array.isArray(detail.items) ? detail.items : []
|
||||
|
||||
if (items.length > 0) {
|
||||
return { detail, items }
|
||||
}
|
||||
|
||||
const detailReason = String(detailResult?.reason || '').trim()
|
||||
const detailMessage = String(detailResult?.errorMessage || '').trim()
|
||||
let message = detailMessage
|
||||
|
||||
if (!message && detailReason === 'missing_config') {
|
||||
message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址'
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
message = `未查询到订单 ${platformOrderId} 的商品明细`
|
||||
}
|
||||
|
||||
throw createHttpError(message, {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_fulfillment_lookup_order_items_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
export function buildFulfillmentLookupResult({
|
||||
provider = '',
|
||||
platform = '',
|
||||
shopId = '',
|
||||
platformOrderId = '',
|
||||
detail = {},
|
||||
detailResult = {},
|
||||
bindings = [],
|
||||
fallbackShopName = '',
|
||||
}: {
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
platformOrderId?: string
|
||||
detail?: JsonObject
|
||||
detailResult?: JsonObject
|
||||
bindings?: JsonObject[]
|
||||
fallbackShopName?: string
|
||||
} = {}) {
|
||||
const resolvedShopName = pickFirstNonEmpty([detail.shopName, fallbackShopName])
|
||||
const items = Array.isArray(detail.items) ? detail.items : []
|
||||
|
||||
return {
|
||||
order: {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: resolvedShopName,
|
||||
platformOrderId,
|
||||
buyerName: String(detail.buyerName || '').trim(),
|
||||
totalAmountFen: Number(detail.totalAmount || 0),
|
||||
totalAmount: formatFenToAmount(detail.totalAmount),
|
||||
paidAt: detail.paidAt || null,
|
||||
enriched: Boolean(detailResult?.enriched),
|
||||
enrichReason: String(detailResult?.reason || '').trim(),
|
||||
errorMessage: String(detailResult?.errorMessage || '').trim(),
|
||||
},
|
||||
items: items.map((item, index) => {
|
||||
const observed = {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: resolvedShopName,
|
||||
externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]),
|
||||
externalSkuCode: pickFirstNonEmpty([
|
||||
item?.externalSkuCode,
|
||||
item?.skuCode,
|
||||
item?.externalItemId,
|
||||
item?.itemId,
|
||||
]),
|
||||
externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]),
|
||||
latestSeenAt: null,
|
||||
orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1),
|
||||
}
|
||||
|
||||
return {
|
||||
lineId: [
|
||||
platformOrderId,
|
||||
index + 1,
|
||||
observed.externalSkuCode || 'na',
|
||||
observed.externalItemId || 'na',
|
||||
].join(':'),
|
||||
itemTitle: pickFirstNonEmpty([
|
||||
item?.skuName,
|
||||
item?.externalSkuName,
|
||||
item?.externalSkuCode,
|
||||
item?.externalItemId,
|
||||
]),
|
||||
quantity: observed.orderItemCount,
|
||||
...mapAdminObservedProductItem(observed, bindings),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
export {
|
||||
getAdminAgisoShopConfigs,
|
||||
updateAdminAgisoShopConfigs,
|
||||
} from "./agiso-service.js";
|
||||
|
||||
export {
|
||||
getAdminNinetyoneOrders,
|
||||
retryAdminNinetyoneOrder,
|
||||
failAdminNinetyoneOrder,
|
||||
} from "./ninetyone-service.js";
|
||||
|
||||
export {
|
||||
getAdminKuaishouEticketSourceConfig,
|
||||
updateAdminKuaishouEticketSourceConfig,
|
||||
queryAdminKuaishouEticketDetail,
|
||||
queryAdminKuaishouEticketShopInfo,
|
||||
consumeAdminKuaishouEticket,
|
||||
} from "./kuaishou-eticket-service.js";
|
||||
|
||||
export {
|
||||
listAdminCloudtentaclesSources,
|
||||
deleteAdminCloudtentaclesSource,
|
||||
updateAdminCloudtentaclesSourceConfig,
|
||||
sendAdminCloudtentaclesSmsCode,
|
||||
testAdminCloudtentaclesLogin,
|
||||
validateAdminCloudtentaclesSession,
|
||||
getAdminCloudtentaclesAsset,
|
||||
getAdminCloudtentaclesCategories,
|
||||
getAdminCloudtentaclesSkuList,
|
||||
buyAdminCloudtentaclesSku,
|
||||
useAdminCloudtentaclesSku,
|
||||
getAdminCloudtentaclesKnapsack,
|
||||
listAdminCloudtentaclesVirtualNumbers,
|
||||
appointAdminCloudtentaclesVirtualNumber,
|
||||
generateAdminCloudtentaclesLoginCode,
|
||||
fetchAdminCloudtentaclesVirtualNumberCode,
|
||||
verifyAdminCloudtentaclesLoginCode,
|
||||
getAdminCloudtentaclesBindUrl,
|
||||
backAdminCloudtentaclesVirtualNumber,
|
||||
runAdminCloudtentaclesFullFlow,
|
||||
} from "./cloudtentacles-service.js";
|
||||
|
||||
export {
|
||||
getAdminFulfillmentBindingConfigs,
|
||||
lookupAdminFulfillmentBindingOrder,
|
||||
updateAdminFulfillmentBindingConfigs,
|
||||
} from "./fulfillment-bindings-service.js";
|
||||
|
||||
export {
|
||||
getAdminKuaishouCloudFulfillmentConfig,
|
||||
updateAdminKuaishouCloudFulfillmentConfig,
|
||||
} from "./kuaishou-cloud-fulfillment-service.js";
|
||||
|
||||
export {
|
||||
getAdminNotificationConfig,
|
||||
updateAdminNotificationConfig,
|
||||
testAdminNotification,
|
||||
getAdminScheduledJobsConfig,
|
||||
updateAdminScheduledJobsConfig,
|
||||
runAdminScheduledJobNow,
|
||||
} from "./notification-service.js";
|
||||
@@ -1,175 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
assertAdminFulfillmentBindingsInput,
|
||||
buildAdminFulfillmentBindingUniqueKey,
|
||||
normalizeAdminFulfillmentBindingItem,
|
||||
validateAdminFulfillmentBindingConfigs,
|
||||
} from './validation.js'
|
||||
|
||||
test('assertAdminFulfillmentBindingsInput rejects non-array payloads', () => {
|
||||
assert.doesNotThrow(() => assertAdminFulfillmentBindingsInput([]))
|
||||
assert.throws(() => assertAdminFulfillmentBindingsInput({}), /履约配置格式不正确/)
|
||||
})
|
||||
|
||||
test('normalizeAdminFulfillmentBindingItem normalizes generic binding fields', () => {
|
||||
const result = normalizeAdminFulfillmentBindingItem(
|
||||
{
|
||||
provider: ' agiso ',
|
||||
platform: ' xianyu ',
|
||||
shopId: ' shop-1 ',
|
||||
shopName: ' 店铺 ',
|
||||
skuCode: 'sku-1',
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
},
|
||||
config: {
|
||||
kuaishouShop: {
|
||||
note: 'keep',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ index: 0 },
|
||||
)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺',
|
||||
skuCode: 'sku-1',
|
||||
skuName: '',
|
||||
profileKey: 'manual_review',
|
||||
enabled: true,
|
||||
priority: undefined,
|
||||
config: {
|
||||
kuaishouShop: {
|
||||
note: 'keep',
|
||||
},
|
||||
},
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
config: {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeAdminFulfillmentBindingItem rejects missing sku and match conditions', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAdminFulfillmentBindingItem(
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
},
|
||||
},
|
||||
{ index: 1 },
|
||||
),
|
||||
/第 2 条规则缺少内部履约 SKU/,
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAdminFulfillmentBindingItem(
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
skuCode: 'sku-1',
|
||||
},
|
||||
{ index: 2 },
|
||||
),
|
||||
/第 3 条规则至少需要一种外部匹配条件/,
|
||||
)
|
||||
})
|
||||
|
||||
test('buildAdminFulfillmentBindingUniqueKey uses normalized external sku name and shop id rules', () => {
|
||||
assert.equal(
|
||||
buildAdminFulfillmentBindingUniqueKey({
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-a',
|
||||
skuCode: 'sku-1',
|
||||
match: {
|
||||
externalItemId: '',
|
||||
externalSkuCode: '',
|
||||
externalSkuName: ' 礼包 A ',
|
||||
},
|
||||
}),
|
||||
'91kaquan::kuaishou::shop-a::::::礼包 a::sku-1',
|
||||
)
|
||||
})
|
||||
|
||||
test('validateAdminFulfillmentBindingConfigs rejects missing fulfillment profile', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
validateAdminFulfillmentBindingConfigs(
|
||||
[
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'sku-1',
|
||||
profileKey: 'missing-profile',
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
kuaishouEticketSource: {},
|
||||
resolveKuaishouEticketShopConfig() {
|
||||
return null
|
||||
},
|
||||
async getFulfillmentProfileByKey() {
|
||||
return null
|
||||
},
|
||||
},
|
||||
),
|
||||
/第 1 条规则使用了不存在的履约方式: missing-profile/,
|
||||
)
|
||||
})
|
||||
|
||||
test('validateAdminFulfillmentBindingConfigs rejects duplicate rules after normalization', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
validateAdminFulfillmentBindingConfigs(
|
||||
[
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'sku-1',
|
||||
profileKey: 'manual_review',
|
||||
match: {
|
||||
externalSkuCode: ' ext-1 ',
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: ' shop-1 ',
|
||||
skuCode: 'sku-1',
|
||||
profileKey: 'manual_review',
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
kuaishouEticketSource: {},
|
||||
resolveKuaishouEticketShopConfig() {
|
||||
return null
|
||||
},
|
||||
async getFulfillmentProfileByKey() {
|
||||
return { key: 'manual_review' }
|
||||
},
|
||||
},
|
||||
),
|
||||
/第 2 条规则与其它规则重复/,
|
||||
)
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { normalizeProductName } from '../../order/product-match-service.js'
|
||||
import { isPlainObject } from './context.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export function assertAdminFulfillmentBindingsInput(bindings: unknown) {
|
||||
if (Array.isArray(bindings)) {
|
||||
return
|
||||
}
|
||||
|
||||
throw createHttpError('履约配置格式不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_payload',
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeAdminFulfillmentBindingItem(
|
||||
rawBinding: unknown,
|
||||
options: JsonObject = {},
|
||||
) {
|
||||
const index = Number(options.index || 0)
|
||||
if (!isPlainObject(rawBinding)) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则格式不正确`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_item',
|
||||
})
|
||||
}
|
||||
|
||||
const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(rawBinding.platform || '').trim()
|
||||
const shopId = String(rawBinding.shopId || '').trim()
|
||||
const shopName = String(rawBinding.shopName || '').trim()
|
||||
const skuCode = String(rawBinding.skuCode || '').trim()
|
||||
const skuName = String(rawBinding.skuName || '').trim()
|
||||
const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review'
|
||||
const match = isPlainObject(rawBinding.match) ? rawBinding.match : {}
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
const config = isPlainObject(rawBinding.config) ? { ...rawBinding.config } : {}
|
||||
|
||||
if (!skuCode) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_sku_code',
|
||||
})
|
||||
}
|
||||
|
||||
if (!externalItemId && !externalSkuCode && !externalSkuName) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_match_condition',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName,
|
||||
skuCode,
|
||||
skuName,
|
||||
profileKey,
|
||||
enabled: rawBinding.enabled !== false,
|
||||
priority: rawBinding.priority,
|
||||
config,
|
||||
match: {
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
externalSkuName,
|
||||
config: isPlainObject(match.config) ? match.config : {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAdminFulfillmentBindingUniqueKey(binding) {
|
||||
return [
|
||||
String(binding?.provider || '').trim(),
|
||||
String(binding?.platform || '').trim(),
|
||||
String(binding?.shopId || '').trim(),
|
||||
String(binding?.match?.externalItemId || '').trim(),
|
||||
String(binding?.match?.externalSkuCode || '').trim(),
|
||||
normalizeProductName(String(binding?.match?.externalSkuName || '').trim()),
|
||||
String(binding?.skuCode || '').trim(),
|
||||
].join('::')
|
||||
}
|
||||
|
||||
export async function validateAdminFulfillmentBindingConfigs(
|
||||
bindings: JsonObject[],
|
||||
options: JsonObject = {},
|
||||
) {
|
||||
assertAdminFulfillmentBindingsInput(bindings)
|
||||
|
||||
const getFulfillmentProfileByKey = (
|
||||
options.getFulfillmentProfileByKey || (async () => null)
|
||||
) as (profileKey: string) => Promise<unknown>
|
||||
const seenKeys = new Set()
|
||||
const normalizedBindings: JsonObject[] = []
|
||||
|
||||
for (const [index, rawBinding] of bindings.entries()) {
|
||||
const normalized = normalizeAdminFulfillmentBindingItem(rawBinding, { index })
|
||||
|
||||
const profile = await getFulfillmentProfileByKey(normalized.profileKey)
|
||||
if (!profile) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${normalized.profileKey}`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_profile_key',
|
||||
})
|
||||
}
|
||||
|
||||
const uniqueKey = buildAdminFulfillmentBindingUniqueKey(normalized)
|
||||
if (seenKeys.has(uniqueKey)) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_fulfillment_bindings_duplicate_rule',
|
||||
})
|
||||
}
|
||||
|
||||
seenKeys.add(uniqueKey)
|
||||
normalizedBindings.push(normalized)
|
||||
}
|
||||
|
||||
return normalizedBindings
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
buildAdminAgisoMessagingSavePayload,
|
||||
buildAdminAgisoShopConfigUpdate,
|
||||
normalizeAdminCloudtentaclesSourceConfigPayload,
|
||||
} from './writes.js'
|
||||
|
||||
test('buildAdminAgisoShopConfigUpdate merges item fields and drops explicit empty templates', () => {
|
||||
assert.deepEqual(
|
||||
buildAdminAgisoShopConfigUpdate(
|
||||
{
|
||||
shopId: ' shop-1 ',
|
||||
shopName: ' ',
|
||||
accessToken: ' new-token ',
|
||||
messageTemplate: ' ',
|
||||
autoDeliveryMessageTemplate: ' done ',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
shopName: '旧店铺',
|
||||
accessToken: 'old-token',
|
||||
messageTemplate: 'old-template',
|
||||
autoDeliveryMessageTemplate: 'old-auto',
|
||||
appSecret: 'keep-secret',
|
||||
},
|
||||
),
|
||||
{
|
||||
shopId: 'shop-1',
|
||||
config: {
|
||||
accessToken: 'new-token',
|
||||
autoDeliveryMessageTemplate: 'done',
|
||||
appSecret: 'keep-secret',
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('buildAdminAgisoMessagingSavePayload updates defaults and keeps only shops with resulting access token', () => {
|
||||
assert.deepEqual(
|
||||
buildAdminAgisoMessagingSavePayload(
|
||||
{
|
||||
defaults: {
|
||||
messageTemplate: ' next\\nline ',
|
||||
autoDeliveryMessageTemplate: ' ',
|
||||
},
|
||||
shops: [
|
||||
{
|
||||
shopId: ' shop-1 ',
|
||||
shopName: '店铺一',
|
||||
accessToken: '',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
shopId: 'shop-2',
|
||||
shopName: ' 店铺二 ',
|
||||
accessToken: ' token-2 ',
|
||||
messageTemplate: ' hi ',
|
||||
apiVersion: ' v2 ',
|
||||
},
|
||||
{
|
||||
shopId: '',
|
||||
accessToken: 'skip',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
currentDefaults: {
|
||||
messageTemplate: 'old',
|
||||
autoDeliveryMessageTemplate: 'old-auto',
|
||||
},
|
||||
currentMap: {
|
||||
'shop-1': {
|
||||
accessToken: 'token-1',
|
||||
autoDeliveryMessageTemplate: 'keep-auto',
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
defaults: {
|
||||
messageTemplate: 'next\nline',
|
||||
},
|
||||
shops: {
|
||||
'shop-1': {
|
||||
accessToken: 'token-1',
|
||||
autoDeliveryMessageTemplate: 'keep-auto',
|
||||
enabled: true,
|
||||
shopName: '店铺一',
|
||||
},
|
||||
'shop-2': {
|
||||
accessToken: 'token-2',
|
||||
apiVersion: 'v2',
|
||||
messageTemplate: 'hi',
|
||||
shopName: '店铺二',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizeAdminCloudtentaclesSourceConfigPayload applies defaults', () => {
|
||||
assert.deepEqual(
|
||||
normalizeAdminCloudtentaclesSourceConfigPayload({
|
||||
enabled: false,
|
||||
baseUrl: ' ',
|
||||
username: ' user ',
|
||||
password: ' pass ',
|
||||
phone: ' 13800138000 ',
|
||||
deviceId: ' ',
|
||||
deviceType: '2',
|
||||
}),
|
||||
{
|
||||
key: 'default',
|
||||
label: '',
|
||||
enabled: false,
|
||||
baseUrl: 'https://123.207.217.176',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
phone: '13800138000',
|
||||
deviceId: '-',
|
||||
deviceType: 2,
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -1,100 +0,0 @@
|
||||
import { applyOptionalStringField } from "./domain.js";
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export function buildAdminAgisoShopConfigUpdate(item: JsonObject, current: JsonObject = {}) {
|
||||
const shopId = String(item?.shopId || "").trim();
|
||||
if (!shopId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next: JsonObject = { ...current };
|
||||
const shopName = String(item?.shopName || "").trim();
|
||||
const accessToken = String(item?.accessToken || "").trim();
|
||||
const messageTemplate = String(item?.messageTemplate || "").trim();
|
||||
const autoDeliveryMessageTemplate = String(
|
||||
item?.autoDeliveryMessageTemplate || ""
|
||||
).trim();
|
||||
const appSecret = String(item?.appSecret || "").trim();
|
||||
const apiVersion = String(item?.apiVersion || "").trim();
|
||||
const sendMessageEndpoint = String(item?.sendMessageEndpoint || "").trim();
|
||||
|
||||
if (shopName) {
|
||||
next.shopName = shopName;
|
||||
} else {
|
||||
delete next.shopName;
|
||||
}
|
||||
if (accessToken) {
|
||||
next.accessToken = accessToken;
|
||||
}
|
||||
if (messageTemplate) {
|
||||
next.messageTemplate = messageTemplate;
|
||||
} else if (typeof item?.messageTemplate === "string") {
|
||||
delete next.messageTemplate;
|
||||
}
|
||||
if (autoDeliveryMessageTemplate) {
|
||||
next.autoDeliveryMessageTemplate = autoDeliveryMessageTemplate;
|
||||
} else if (typeof item?.autoDeliveryMessageTemplate === "string") {
|
||||
delete next.autoDeliveryMessageTemplate;
|
||||
}
|
||||
if (appSecret) {
|
||||
next.appSecret = appSecret;
|
||||
}
|
||||
if (apiVersion) {
|
||||
next.apiVersion = apiVersion;
|
||||
}
|
||||
if (sendMessageEndpoint) {
|
||||
next.sendMessageEndpoint = sendMessageEndpoint;
|
||||
}
|
||||
if (typeof item?.enabled === "boolean") {
|
||||
next.enabled = item.enabled;
|
||||
}
|
||||
|
||||
if (!next.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { shopId, config: next };
|
||||
}
|
||||
|
||||
export function buildAdminAgisoMessagingSavePayload(
|
||||
payload: JsonObject = {},
|
||||
options: JsonObject = {}
|
||||
) {
|
||||
const rawItems = Array.isArray(payload.shops) ? payload.shops : [];
|
||||
const currentDefaults: JsonObject = options.currentDefaults || {};
|
||||
const currentMap: Record<string, JsonObject> = options.currentMap || {};
|
||||
const nextDefaults: JsonObject = {
|
||||
...currentDefaults,
|
||||
};
|
||||
const nextMap: Record<string, JsonObject> = {};
|
||||
|
||||
applyOptionalStringField(nextDefaults, "messageTemplate", payload.defaults);
|
||||
applyOptionalStringField(
|
||||
nextDefaults,
|
||||
"autoDeliveryMessageTemplate",
|
||||
payload.defaults
|
||||
);
|
||||
|
||||
for (const item of rawItems) {
|
||||
const shopId = String(item?.shopId || "").trim();
|
||||
if (!shopId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const updated = buildAdminAgisoShopConfigUpdate(
|
||||
item,
|
||||
currentMap[shopId] || {}
|
||||
);
|
||||
if (!updated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextMap[updated.shopId] = updated.config;
|
||||
}
|
||||
|
||||
return {
|
||||
defaults: nextDefaults,
|
||||
shops: nextMap,
|
||||
};
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
import {
|
||||
markInventoryItemDelivered,
|
||||
releaseReservedInventoryItem,
|
||||
} from '../../../repositories/inventory-repo.js'
|
||||
import { updateClaimToken } from '../../../repositories/claim-token-repo.js'
|
||||
import { listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js'
|
||||
import { getOrderById } from '../../../repositories/order-repo.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import {
|
||||
getTaskInventoryBindingById,
|
||||
listTaskInventoryBindingsByTaskId,
|
||||
} from '../../../repositories/task-inventory-binding-repo.js'
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { createTaskClaimToken } from '../../claim/claim-service.js'
|
||||
import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../../claim/claim-session-service.js'
|
||||
import { reserveInventoryForTask } from '../../order/inventory-service.js'
|
||||
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js'
|
||||
import { closeTencentBrowserSession } from '../../session/session.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import {
|
||||
createAdminViewerContext,
|
||||
getTaskPrimaryClaimTokenId,
|
||||
getTaskPrimaryInventoryItemId,
|
||||
isAssistedClaimTask,
|
||||
isManualDispatchTask,
|
||||
parseTaskContext,
|
||||
} from '../admin-read-shared-helpers.js'
|
||||
import {
|
||||
getRequiredTask,
|
||||
mapTaskActionPayload,
|
||||
} from '../admin-task-read-helpers.js'
|
||||
import {
|
||||
ensureViewerCanOperateAssistedTask,
|
||||
getTaskClaimExpiresAt,
|
||||
isRecoverableTaskSessionCloseError,
|
||||
maskCode,
|
||||
normalizeManualDispatchOutcome,
|
||||
resolveTaskInventoryGroupCodes,
|
||||
} from './shared.js'
|
||||
|
||||
import type {
|
||||
AdminEntityIdInput,
|
||||
AdminViewerSessionInput,
|
||||
} from '../../../types/admin-read-inputs.js'
|
||||
import type { AdminTaskManualDispatchInput } from '../../../types/admin-write-inputs.js'
|
||||
import type {
|
||||
AdminTaskActionResponse,
|
||||
AdminTaskBindingReleaseResponse,
|
||||
AdminTaskManualDispatchResponse,
|
||||
} from '../../../types/admin-write-models.js'
|
||||
import type { TaskRow } from '../../../types/repository-rows.js'
|
||||
import type { TaskUpdatePatch } from '../../../types/repository-inputs.js'
|
||||
|
||||
type CloseAdminTaskDeps = {
|
||||
getRequiredTask?: (taskId: AdminEntityIdInput) => Promise<TaskRow>
|
||||
listTaskInventoryBindingsByTaskId?: (taskId: AdminEntityIdInput) => Promise<any[]>
|
||||
releaseReservedInventoryItem?: (inventoryItemId: AdminEntityIdInput, updatedAt: string) => Promise<unknown>
|
||||
updateClaimToken?: (tokenId: AdminEntityIdInput, patch: Record<string, unknown>) => Promise<unknown>
|
||||
updateTask?: (taskId: AdminEntityIdInput, patch: TaskUpdatePatch) => Promise<any>
|
||||
createTaskEvent?: (taskId: AdminEntityIdInput, eventType: string, payload: Record<string, unknown>, createdAt: string) => Promise<unknown>
|
||||
closeTencentBrowserSession?: (sessionId: string) => Promise<unknown>
|
||||
nowIso?: () => string
|
||||
}
|
||||
|
||||
export async function releaseAdminTaskInventory(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task)
|
||||
|
||||
if (!primaryInventoryItemId) {
|
||||
throw createHttpError('当前任务没有预占库存项', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_no_reserved_inventory',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能释放库存项', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
await releaseReservedInventoryItem(primaryInventoryItemId, now)
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: 'waiting_inventory',
|
||||
inventory_status: 'pending',
|
||||
last_error: '已手动释放预占库存项',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseAdminTaskInventoryBinding(
|
||||
taskId: AdminEntityIdInput,
|
||||
bindingId: AdminEntityIdInput,
|
||||
): Promise<AdminTaskBindingReleaseResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const binding = await getTaskInventoryBindingById(Number(bindingId))
|
||||
|
||||
if (!binding || Number(binding.task_id) !== Number(task.id)) {
|
||||
throw createHttpError('任务库存绑定不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_inventory_binding_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (String(binding.binding_status || '').trim() !== 'reserved') {
|
||||
throw createHttpError('当前库存绑定不是预占状态,不能释放', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_inventory_binding_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能释放库存绑定', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
await releaseReservedInventoryItem(binding.inventory_item_id, now)
|
||||
|
||||
const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id)
|
||||
const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim()))
|
||||
const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved')
|
||||
const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed')
|
||||
const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending')
|
||||
const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim())
|
||||
? 'waiting_inventory'
|
||||
: task.task_status
|
||||
|
||||
if (!hasReservedBindings) {
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
if (primaryClaimTokenId) {
|
||||
await updateClaimToken(primaryClaimTokenId, {
|
||||
status: 'revoked',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: nextTaskStatus,
|
||||
inventory_status: nextInventoryStatus,
|
||||
last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createTaskEvent(task.id, 'inventory_binding_released', {
|
||||
bindingId: Number(binding.id),
|
||||
inventoryItemId: Number(binding.inventory_item_id),
|
||||
roleKey: String(binding.role_key || '').trim(),
|
||||
}, now)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
bindingId: Number(binding.id),
|
||||
inventoryItemId: Number(binding.inventory_item_id),
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateAdminTaskClaimLink(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
|
||||
if (isManualDispatchTask(task)) {
|
||||
throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_manual_dispatch_claim_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (['redeemed', 'closed'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务状态不允许重新生成领取链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_regenerate_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) {
|
||||
throw createHttpError('当前账号只能重发半自动客服任务的领取链接', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_task_regenerate_permission_denied',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
if (primaryClaimTokenId) {
|
||||
await updateClaimToken(primaryClaimTokenId, {
|
||||
status: 'revoked',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
task_status: 'link_generated',
|
||||
last_error: '',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
claimUrl: claimToken.claimUrl,
|
||||
token: claimToken.token,
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmAdminTaskAssistedRole(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm')
|
||||
await confirmClaimRoleForAdminTask(task.id)
|
||||
const updatedTask = await getRequiredTask(task.id)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function redeemAdminTaskAssisted(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem')
|
||||
await redeemClaimTaskForAdminTask(task.id)
|
||||
const updatedTask = await getRequiredTask(task.id)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeAdminTask(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
|
||||
return closeAdminTaskWithDeps(taskId)
|
||||
}
|
||||
|
||||
export async function closeAdminTaskWithDeps(
|
||||
taskId: AdminEntityIdInput,
|
||||
{
|
||||
getRequiredTask: getTask = getRequiredTask,
|
||||
listTaskInventoryBindingsByTaskId: listBindings = listTaskInventoryBindingsByTaskId,
|
||||
releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem,
|
||||
updateClaimToken: updateToken = updateClaimToken,
|
||||
updateTask: updateTaskRecord = updateTask,
|
||||
createTaskEvent: createEvent = createTaskEvent,
|
||||
closeTencentBrowserSession: closeSession = closeTencentBrowserSession,
|
||||
nowIso: getNowIso = nowIso,
|
||||
}: CloseAdminTaskDeps = {},
|
||||
): Promise<AdminTaskActionResponse> {
|
||||
const task = await getTask(taskId)
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能关闭', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_close_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const now = getNowIso()
|
||||
const bindings = await listBindings(task.id)
|
||||
const reservedBindings = bindings.filter((binding) => String(binding.binding_status || '').trim() === 'reserved')
|
||||
const releasedInventoryItemIds = Array.from(new Set(reservedBindings.map((binding) => Number(binding.inventory_item_id)).filter((id) => id > 0)))
|
||||
const hasConsumedBindings = bindings.some((binding) => String(binding.binding_status || '').trim() === 'consumed')
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
let browserSessionClosed = false
|
||||
|
||||
if (primaryClaimTokenId) {
|
||||
await updateToken(primaryClaimTokenId, {
|
||||
status: 'revoked',
|
||||
expired_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (task.browser_session_id) {
|
||||
try {
|
||||
await closeSession(task.browser_session_id)
|
||||
browserSessionClosed = true
|
||||
} catch (error) {
|
||||
if (!isRecoverableTaskSessionCloseError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const inventoryItemId of releasedInventoryItemIds) {
|
||||
await releaseReserved(inventoryItemId, now)
|
||||
}
|
||||
|
||||
const closeReasonParts = ['已手动关闭任务']
|
||||
if (primaryClaimTokenId) {
|
||||
closeReasonParts.push('领取链接已失效')
|
||||
}
|
||||
if (releasedInventoryItemIds.length > 0) {
|
||||
closeReasonParts.push('预占库存已释放')
|
||||
}
|
||||
|
||||
const updatedTask = await updateTaskRecord(task.id, {
|
||||
task_status: 'closed',
|
||||
inventory_status: hasConsumedBindings ? 'consumed' : 'pending',
|
||||
delivery_status: 'closed',
|
||||
user_action_status: 'closed',
|
||||
claim_expires_at: primaryClaimTokenId ? now : task.claim_expires_at,
|
||||
browser_session_id: '',
|
||||
last_error: task.last_error || closeReasonParts.join(','),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createEvent(task.id, 'task_closed', {
|
||||
claimTokenRevoked: Boolean(primaryClaimTokenId),
|
||||
releasedInventoryItemIds,
|
||||
releasedInventoryCount: releasedInventoryItemIds.length,
|
||||
browserSessionClosed,
|
||||
}, now)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAdminTaskManualReview(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: 'manual_review',
|
||||
delivery_status: task.delivery_status || 'pending',
|
||||
last_error: task.last_error || '已转人工处理',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryAdminTask(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const now = nowIso()
|
||||
|
||||
if (isManualDispatchTask(task)) {
|
||||
throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_manual_dispatch_retry_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务状态不允许重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_retry_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const taskContext = parseTaskContext(task)
|
||||
const primaryRequirement = taskContext.primaryRequirement || null
|
||||
const orderItems = await listOrderItemsByOrderId(task.order_id)
|
||||
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
|
||||
let reservedInventoryItemId = getTaskPrimaryInventoryItemId(task)
|
||||
let claimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
let nextStatus = 'link_generated'
|
||||
let lastError = ''
|
||||
let claimExpiresAt = getTaskClaimExpiresAt(task)
|
||||
let claimUrl = ''
|
||||
let token = ''
|
||||
|
||||
if (!reservedInventoryItemId) {
|
||||
const reserved = await reserveInventoryForTask({
|
||||
skuCode: orderItem?.sku_code || '',
|
||||
taskId: task.id,
|
||||
credentialType: primaryRequirement?.credentialType || 'tencent_code',
|
||||
roleKey: primaryRequirement?.roleKey || 'primary_code',
|
||||
inventoryGroupCodes: resolveTaskInventoryGroupCodes(task),
|
||||
})
|
||||
|
||||
if (!reserved) {
|
||||
nextStatus = 'waiting_inventory'
|
||||
lastError = '库存不足,等待可用库存凭据'
|
||||
} else {
|
||||
reservedInventoryItemId = reserved.id
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStatus === 'link_generated' && !claimTokenId) {
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
claimTokenId = claimToken.id
|
||||
claimExpiresAt = claimToken.expired_at
|
||||
claimUrl = claimToken.claimUrl
|
||||
token = claimToken.token
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: nextStatus,
|
||||
inventory_status: reservedInventoryItemId ? 'reserved' : 'pending',
|
||||
claim_token: token || task.claim_token || '',
|
||||
claim_expires_at: claimExpiresAt,
|
||||
last_error: lastError,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
const response: AdminTaskActionResponse = {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
|
||||
if (claimUrl) {
|
||||
response.claimUrl = claimUrl
|
||||
response.token = token
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export async function completeAdminTaskManualDispatch(
|
||||
taskId: AdminEntityIdInput,
|
||||
payload: AdminTaskManualDispatchInput = {},
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<AdminTaskManualDispatchResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
|
||||
if (!isManualDispatchTask(task)) {
|
||||
throw createHttpError('当前任务不是人工履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_not_manual_dispatch',
|
||||
})
|
||||
}
|
||||
|
||||
if (['redeemed', 'closed'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_manual_dispatch_already_completed',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const outcome = normalizeManualDispatchOutcome(payload.outcome)
|
||||
const resultMessage = String(payload.resultMessage || '').trim()
|
||||
const deliveryReference = String(payload.deliveryReference || '').trim()
|
||||
const deliveredCredential = String(payload.deliveredCredential || '').trim()
|
||||
const context = parseTaskContext(task)
|
||||
const inventoryItemId = getTaskPrimaryInventoryItemId(task)
|
||||
const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered'
|
||||
const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成'
|
||||
const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed'
|
||||
const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered'
|
||||
const nextInventoryStatus = outcome === 'delivered' && inventoryItemId
|
||||
? 'consumed'
|
||||
: task.inventory_status || 'not_required'
|
||||
const manualDispatch = {
|
||||
outcome,
|
||||
deliveryReference,
|
||||
deliveredCredential,
|
||||
resultMessage: resultMessage || fallbackMessage,
|
||||
completedAt: now,
|
||||
completedBy: session
|
||||
? {
|
||||
userId: Number(session.userId || 0),
|
||||
username: String(session.username || ''),
|
||||
role: String(session.role || ''),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
|
||||
if (outcome === 'delivered' && inventoryItemId) {
|
||||
await markInventoryItemDelivered(inventoryItemId, now)
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: nextTaskStatus,
|
||||
inventory_status: nextInventoryStatus,
|
||||
delivery_status: nextDeliveryStatus,
|
||||
result_code: resultCode,
|
||||
result_message: resultMessage || fallbackMessage,
|
||||
user_action_status: 'not_required',
|
||||
last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '',
|
||||
redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null,
|
||||
context_json: JSON.stringify({
|
||||
...context,
|
||||
manualDispatch,
|
||||
}),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createTaskEvent(task.id, 'manual_dispatch_completed', {
|
||||
outcome,
|
||||
resultCode,
|
||||
resultMessage: resultMessage || fallbackMessage,
|
||||
deliveryReference,
|
||||
deliveredCredentialMasked: maskCode(deliveredCredential),
|
||||
completedBy: manualDispatch.completedBy,
|
||||
}, now)
|
||||
|
||||
const taskAfterAutoDelivery = outcome === 'delivered'
|
||||
? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
|
||||
order: await getOrderById(task.order_id),
|
||||
task: updatedTask,
|
||||
trigger: 'manual_dispatch_completed',
|
||||
})).task || updatedTask
|
||||
: updatedTask
|
||||
|
||||
return {
|
||||
outcome,
|
||||
task: mapTaskActionPayload(taskAfterAutoDelivery),
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { getWebhookEventById } from '../../../repositories/webhook-event-repo.js'
|
||||
import { replayAgisoTradeWebhookEvent } from '../../order/webhook-service.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
|
||||
import type { AdminEntityIdInput } from '../../../types/admin-read-inputs.js'
|
||||
import type { AdminWebhookReplayResponse } from '../../../types/admin-write-models.js'
|
||||
|
||||
export async function replayAdminWebhookEvent(eventId: AdminEntityIdInput): Promise<AdminWebhookReplayResponse> {
|
||||
const event = await getWebhookEventById(Number(eventId))
|
||||
|
||||
if (!event) {
|
||||
throw createHttpError('Webhook 事件不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_webhook_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (String(event.provider || event.platform || '').trim() !== 'agiso') {
|
||||
throw createHttpError('当前只支持重放 agiso webhook', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_webhook_replay_not_supported',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await replayAgisoTradeWebhookEvent(event)
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
replayed: true,
|
||||
result,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user