421 lines
14 KiB
JavaScript
421 lines
14 KiB
JavaScript
// @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'
|
|
|
|
const MANUAL_PROVIDER = 'manual'
|
|
const MANUAL_PLATFORM = 'manual_redeem'
|
|
const MANUAL_SOURCE_TYPE = 'admin_manual_redeem'
|
|
const ASSISTED_PROFILE_KEY = 'tencent_claim_assisted'
|
|
|
|
/** @typedef {import('../../types/admin-read-inputs.js').AdminEntityIdInput} AdminEntityIdInput */
|
|
/** @typedef {import('../../types/admin-read-inputs.js').AdminViewerSessionInput} AdminViewerSessionInput */
|
|
/** @typedef {import('../../types/admin-write-inputs.js').AdminManualRedeemCreateInput} AdminManualRedeemCreateInput */
|
|
|
|
/** @param {AdminManualRedeemCreateInput} [payload] */
|
|
/** @param {AdminViewerSessionInput | null} [session] */
|
|
export async function createAdminManualRedeemTask(
|
|
payload = /** @type {AdminManualRedeemCreateInput} */ ({}),
|
|
session = null,
|
|
) {
|
|
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)
|
|
}
|
|
|
|
/** @param {AdminEntityIdInput} taskId */
|
|
/** @param {AdminViewerSessionInput | null} [session] */
|
|
export async function getAdminManualRedeemDetail(taskId, session = null) {
|
|
const task = await getRequiredManualRedeemTask(taskId, session)
|
|
const detail = await getClaimDetailForAdminTask(task.id)
|
|
return decorateManualRedeemDetail(task, detail)
|
|
}
|
|
|
|
/** @param {AdminEntityIdInput} taskId */
|
|
/** @param {{ loginType?: string, forceRecreate?: boolean }} [payload] */
|
|
/** @param {AdminViewerSessionInput | null} [session] */
|
|
export async function createAdminManualRedeemSession(taskId, payload = {}, session = null) {
|
|
const task = await getRequiredManualRedeemTask(taskId, session)
|
|
const detail = await createClaimSessionForAdminTask(task.id, payload)
|
|
return decorateManualRedeemDetail(task, detail)
|
|
}
|
|
|
|
/** @param {AdminEntityIdInput} taskId */
|
|
/** @param {AdminViewerSessionInput | null} [session] */
|
|
export async function getAdminManualRedeemSessionSummary(taskId, session = null) {
|
|
const task = await getRequiredManualRedeemTask(taskId, session)
|
|
const detail = await getClaimSessionSummaryForAdminTask(task.id)
|
|
return decorateManualRedeemDetail(task, detail)
|
|
}
|
|
|
|
/** @param {AdminEntityIdInput} taskId */
|
|
/** @param {AdminViewerSessionInput | null} [session] */
|
|
export async function reloadAdminManualRedeemSession(taskId, session = null) {
|
|
const task = await getRequiredManualRedeemTask(taskId, session)
|
|
const detail = await reloadClaimSessionForAdminTask(task.id)
|
|
return decorateManualRedeemDetail(task, detail)
|
|
}
|
|
|
|
/** @param {AdminEntityIdInput} taskId */
|
|
/** @param {AdminViewerSessionInput | null} [session] */
|
|
export async function closeAdminManualRedeemSession(taskId, session = null) {
|
|
const task = await getRequiredManualRedeemTask(taskId, session)
|
|
const detail = await closeClaimSessionForAdminTask(task.id)
|
|
return decorateManualRedeemDetail(task, detail)
|
|
}
|
|
|
|
/** @param {AdminEntityIdInput} taskId */
|
|
/** @param {AdminViewerSessionInput | null} [session] */
|
|
export async function closeAdminManualRedeemTask(taskId, session = null) {
|
|
const task = await getRequiredManualRedeemTask(taskId, session)
|
|
await closeAdminTask(task.id)
|
|
return getAdminManualRedeemDetail(task.id, session)
|
|
}
|
|
|
|
/** @param {AdminEntityIdInput} taskId */
|
|
/** @param {AdminViewerSessionInput | null} [session] */
|
|
export async function confirmAdminManualRedeemRole(taskId, session = null) {
|
|
const task = await getRequiredManualRedeemTask(taskId, session)
|
|
await confirmAdminTaskAssistedRole(task.id, session)
|
|
return getAdminManualRedeemDetail(task.id, session)
|
|
}
|
|
|
|
/** @param {AdminEntityIdInput} taskId */
|
|
/** @param {AdminViewerSessionInput | null} [session] */
|
|
export async function redeemAdminManualRedeemTask(taskId, session = null) {
|
|
const task = await getRequiredManualRedeemTask(taskId, session)
|
|
await redeemAdminTaskAssisted(task.id, session)
|
|
return getAdminManualRedeemDetail(task.id, session)
|
|
}
|
|
|
|
async function getRequiredManualRedeemTask(taskId, session = null) {
|
|
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, detail) {
|
|
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) {
|
|
return String(value || '').replace(/\s+/g, ' ').trim()
|
|
}
|