import { buildClaimUrl, buildConfiguredClaimUrl } from '../claim/claim-service.js' import { buildClaimIdentityAdminSummary } from '../claim/claim-identity.js' import { getClaimTokenById } from '../../repositories/claim-token-repo.js' import { listOrderItemsByOrderId, listOrderItemsByOrderIds, } from '../../repositories/order-item-repo.js' import { getOrderById, listOrders } from '../../repositories/order-repo.js' import { listOrderRefunds } from '../finance/order-refund-service.js' import { getManualFulfillmentCountsByOrderIds } from '../finance/manual-fulfillment-service.js' import { getTaskById, listTasks, listTasksByOrderId, listTasksByOrderIds, } from '../../repositories/task-repo.js' import { listTaskEventsByTaskId } from '../../repositories/task-event-repo.js' import type { JsonRecord } from '../../types/json.js' import { listKuaishouIndustryVouchersByOid, listKuaishouIndustryVouchersByOids, listKuaishouIndustryVouchersByTaskId, } from '../../repositories/kuaishou-industry-voucher-repo.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { normalizeDateQuery, normalizeDateString, normalizePage, normalizePageSize, safeParseJson, } from './admin-query-utils.js' import { canCompleteManualDispatchTaskStatus, canMarkManualReviewTaskStatus, canRetryTaskStatus, } from '../../domain/task-status.js' import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js' import { getDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.js' import { isDouyinMashidaProvider } from '../platforms/douyin-mashida/order-mapper.js' import { mergeAffiliateDashSkuMapping, resolveAffiliateDashDisplayProductName, } from '../platforms/affiliate-dash/sku-mapping.js' import { canViewerCloseTask, canRegenerateClaimLinkForViewer, createAdminViewerContext, getTaskPrimaryClaimTokenId, isManualDispatchTask, mapKuaishouCloudFulfillmentContext, mapKuaishouCloudTaskStateProjection, mapManualDispatchContext, mapRedeemResolutionContext, parseTaskContext, parseTaskState, resolveAdminTaskScreenshotUrl, resolveDisplayShopName, resolveOrderItemDeliveryMode, resolveOrderItemMatchSku, resolveOrderItemSkuNick, resolveOrderItemTitle, } from './admin-read-shared-helpers.js' import { isRetiredFulfillmentExecutor } from '../fulfillment/executors/types.js' import { mapAdminOrderListItem, normalizeOrderItemFulfillmentMode, resolveOrderFulfillmentSummary, summarizeOrderItems, } from './admin-order-read-helpers.js' import { listWorkOrdersByOrderId, listWorkOrdersByOrderIds, } from '../../repositories/worker-platform/work-order-query-repo.js' import { buildOrderFulfillmentProgress, getRequiredTask, mapAdminTaskEvent, mapAdminTaskListItem, mapAdminTaskSummary, } from './admin-task-read-helpers.js' import type { AdminOrderListResponse, AdminTaskListResponse, } from '../../types/admin/read-models.js' import type { AdminTaskDetailView } from '../../types/admin/task-detail.js' import type { AdminEntityIdInput, AdminOrderListQueryInput, AdminTaskListQueryInput, AdminViewerSessionInput, } from '../../types/admin/read-inputs.js' import type { KuaishouIndustryVoucherRow, OrderRow, TaskRow } from '../../types/repository/rows.js' export async function getAdminOrders( query: AdminOrderListQueryInput = {}, ): Promise { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) const { items, total } = await listOrders({ page, pageSize, keyword: String(query.keyword || '').trim(), provider: String(query.provider || '').trim(), executorKey: String(query.executorKey || '').trim(), fulfillmentMode: normalizeOrderFulfillmentModeFilter(query.fulfillmentMode), platformOrderId: String(query.platformOrderId || '').trim(), payStatus: String(query.payStatus || '').trim(), taskStatus: String(query.taskStatus || '').trim(), skuCode: String(query.skuCode || '').trim(), dateFrom: normalizeDateString(query.dateFrom), dateTo: normalizeDateString(query.dateTo), }) const orderIds = items.map((item) => Number(item.id)).filter((id) => id > 0) const oids = items.map((item) => item.platform_order_id).filter(Boolean) const [orderItems, tasks, vouchers, workOrders, manualCounts] = await Promise.all([ listOrderItemsByOrderIds(orderIds), listTasksByOrderIds(orderIds), listKuaishouIndustryVouchersByOids(oids), listWorkOrdersByOrderIds(orderIds), getManualFulfillmentCountsByOrderIds(orderIds), ]) const orderItemsByOrderId = groupRowsByOrderId(orderItems) const tasksByOrderId = groupRowsByOrderId(tasks) const vouchersByOid = groupRowsByOid(vouchers) const workOrdersByOrderId = groupRowsByOrderId(workOrders) return { items: items.map((item) => { const orderId = Number(item.id) const orderTasks = tasksByOrderId.get(orderId) || [] const orderVouchers = vouchersByOid.get(item.platform_order_id) || [] const orderWorkOrders = workOrdersByOrderId.get(orderId) || [] return mapAdminOrderListItem(item, { orderItems: orderItemsByOrderId.get(orderId) || [], tasks: orderTasks, vouchers: orderVouchers, workOrderCount: orderWorkOrders.length, manualConfig: manualCounts.get(orderId) || { pending: 0, unconfigured: 0 }, }) }), pagination: { page, pageSize, total }, } } const ADMIN_ORDER_FULFILLMENT_MODES = new Set([ 'work_order', 'affiliate_dash', 'legacy_platform', 'manual', 'mixed', 'pending', ]) function normalizeOrderFulfillmentModeFilter(value: unknown): string { const mode = String(value || '') .trim() .toLowerCase() return ADMIN_ORDER_FULFILLMENT_MODES.has(mode) ? mode : '' } function groupRowsByOrderId( rows: T[], ): Map { const grouped = new Map() for (const row of rows) { // pg BIGINT 常以 string 返回;统一 Number 键,避免列表挂不上 order_items →「商品 0」。 const orderId = Number(row.order_id) if (!Number.isFinite(orderId) || orderId <= 0) { continue } const group = grouped.get(orderId) || [] group.push(row) grouped.set(orderId, group) } return grouped } function groupRowsByOid(rows: T[]): Map { const grouped = new Map() for (const row of rows) { const group = grouped.get(row.oid) || [] group.push(row) grouped.set(row.oid, group) } return grouped } export async function getAdminOrderDetail( orderId: AdminEntityIdInput, session: AdminViewerSessionInput | null = null, ): Promise { const order = await getOrderById(Number(orderId)) if (!order) { throw createHttpError('订单不存在', { statusCode: 404, errorCode: 'admin_order_not_found', }) } const [items, tasks, workOrders, refunds, manualCounts] = await Promise.all([ listOrderItemsByOrderId(order.id), listTasksByOrderId(order.id), listWorkOrdersByOrderId(order.id), listOrderRefunds(order.id), getManualFulfillmentCountsByOrderIds([order.id]), ]) const manualConfig = manualCounts.get(Number(order.id)) || { pending: 0, unconfigured: 0 } const kuaishouIndustryVouchers = await listKuaishouIndustryVouchersByOid(order.platform_order_id) const itemSummary = summarizeOrderItems(items, order, kuaishouIndustryVouchers) const orderItemTitle = resolveOrderItemTitle(items[0], order, kuaishouIndustryVouchers) const orderSkuNick = resolveOrderItemSkuNick(items[0], order, kuaishouIndustryVouchers) const viewerContext = createAdminViewerContext(session) const fulfillment = resolveOrderFulfillmentSummary({ items, tasks, workOrderCount: workOrders.length, vouchers: kuaishouIndustryVouchers, }) return { order: { orderId: order.id, provider: order.provider || '', platform: order.platform, shopId: order.shop_id || '', shopName: resolveDisplayShopName(order.provider, order.shop_id, order.shop_name), platformOrderId: order.platform_order_id, orderStatus: order.order_status, payStatus: order.pay_status, buyerId: order.buyer_id, buyerName: order.buyer_name, receiverContact: order.receiver_contact, totalAmount: formatFenToAmount(order.total_amount), totalAmountFen: normalizeFen(order.total_amount), currency: order.currency, paidAt: order.paid_at, createdAt: order.created_at, updatedAt: order.updated_at, itemSummary, itemTitle: orderItemTitle, skuNick: orderSkuNick, rawPayload: order.raw_payload_json && typeof order.raw_payload_json === 'object' ? order.raw_payload_json : JSON.parse(String(order.raw_payload_json || '{}')), fulfillmentProgress: buildOrderFulfillmentProgress(tasks), claimStatus: order.claim_status || 'active', claimStatusReason: order.claim_status_reason || '', claimStatusUpdatedAt: order.claim_status_updated_at || null, fulfillmentMode: fulfillment.fulfillmentMode, manualItemCount: fulfillment.manualItemCount, pendingItemCount: fulfillment.pendingItemCount, manualPendingCount: manualConfig.pending, manualUnconfiguredCount: manualConfig.unconfigured, hasVoucher: fulfillment.hasVoucher, voucherState: fulfillment.voucherState, workOrderCount: workOrders.length, canClaimH5: fulfillment.canClaimH5, }, items: items.map((item) => { const revenueFen = Math.max(0, Number(item.revenue_amount_fen || 0)) const refundedFen = refunds .filter((refund) => refund.status === 'confirmed' && refund.orderItemId === Number(item.id)) .reduce((sum, refund) => sum + refund.amountFen, 0) return { orderItemId: Number(item.id), skuCode: item.sku_code, skuName: item.sku_name, itemTitle: resolveOrderItemTitle(item, order, kuaishouIndustryVouchers), skuNick: resolveOrderItemSkuNick(item, order, kuaishouIndustryVouchers), matchSku: resolveOrderItemMatchSku(item, order, kuaishouIndustryVouchers), quantity: item.quantity, revenueFen, refundedFen, refundableFen: Math.max(0, revenueFen - refundedFen), fulfillmentMode: normalizeOrderItemFulfillmentMode(item.fulfillment_mode), deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id), spec: item.spec_json && typeof item.spec_json === 'object' ? item.spec_json : JSON.parse(String(item.spec_json || '{}')), } }), refunds, tasks: tasks.map((task) => mapAdminTaskSummary(task)), kuaishouIndustryVouchers: kuaishouIndustryVouchers.map(mapAdminKuaishouIndustryVoucher), operations: { canResendKuaishouIndustryVoucherCode: viewerContext.canOperateKuaishouIndustryVoucher && kuaishouIndustryVouchers.length > 0, }, } } function mapAdminKuaishouIndustryVoucher(voucher: { voucher_code?: unknown oid?: unknown unit_index?: unknown status?: unknown send_callback_status?: unknown send_callback_attempt_count?: unknown send_callback_last_error?: unknown send_callback_sent_at?: unknown consume_serial_num?: unknown consume_details_json?: unknown consumed_at?: unknown destroyed_at?: unknown }) { return { oid: String(voucher.oid || '').trim(), voucherCode: String(voucher.voucher_code || '').trim(), unitIndex: Number(voucher.unit_index || 0) || 0, status: String(voucher.status || '').trim(), sendCallbackStatus: String(voucher.send_callback_status || '').trim(), sendCallbackAttemptCount: Number(voucher.send_callback_attempt_count || 0) || 0, sendCallbackLastError: String(voucher.send_callback_last_error || '').trim(), sendCallbackSentAt: voucher.send_callback_sent_at || null, consumeSerialNum: String(voucher.consume_serial_num || '').trim(), consumeDetails: normalizeAdminJsonArray(voucher.consume_details_json), consumedAt: voucher.consumed_at || null, destroyedAt: voucher.destroyed_at || null, } } export async function getAdminTasks( query: AdminTaskListQueryInput = {}, session: AdminViewerSessionInput | null = null, ): Promise { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) const { items, total } = await listTasks({ page, pageSize, keyword: String(query.keyword || '').trim(), executorKey: String(query.executorKey || '').trim(), status: String(query.status || '').trim(), platformOrderId: String(query.platformOrderId || '').trim(), taskNo: String(query.taskNo || '').trim(), skuCode: String(query.skuCode || '').trim(), roleId: String(query.roleId || '').trim(), dateFrom: normalizeDateQuery(query.dateFrom), dateTo: normalizeDateQuery(query.dateTo, true), }) const viewerContext = createAdminViewerContext(session) return { items: items.map((item) => mapAdminTaskListItem(item, viewerContext)), pagination: { page, pageSize, total }, } } export async function getAdminTaskDetail( taskId: AdminEntityIdInput, session: AdminViewerSessionInput | null = null, ): Promise { const task = await getTaskById(Number(taskId)) if (!task) { throw createHttpError('任务不存在', { statusCode: 404, errorCode: 'admin_task_not_found', }) } const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) const [order, claimToken, taskEvents, taskKuaishouIndustryVouchers, oidKuaishouIndustryVouchers] = await Promise.all([ getOrderById(task.order_id), primaryClaimTokenId ? getClaimTokenById(primaryClaimTokenId) : Promise.resolve(null), listTaskEventsByTaskId(task.id), listKuaishouIndustryVouchersByTaskId(task.id), task.platform_order_id ? listKuaishouIndustryVouchersByOid(task.platform_order_id) : Promise.resolve([]), ]) const orderItems = order ? await listOrderItemsByOrderId(order.id) : [] const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null const taskContext = parseTaskContext(task) const taskState = parseTaskState(task) const viewerContext = createAdminViewerContext(session) // 后台回显的领取链接口径按**订单来源**决定(不再按 executor_key): // 抖音码速达订单用后台配置的 `claimLinkBase`(买家领取页,走 affiliate_dash 领取流程); // 其它来源沿用全局 buildClaimUrl(orders.claimBaseUrl)。 const claimUrl = resolveAdminTaskClaimUrl(claimToken, order) const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) const cloudSourceLabelMap = buildCloudSourceLabelMap() const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment, { cloudSourceLabelMap, }) || mapKuaishouCloudTaskStateProjection(task, { cloudSourceLabelMap }) const claimIdentity = buildClaimIdentityAdminSummary(taskContext, { flowLike: taskContext.kuaishouCloudFulfillment || taskContext.kuaishouFeifei || null, taskRoleId: task.role_id, taskRoleName: task.role_name, }) const firstKuaishouIndustryVoucher = resolveKuaishouIndustryVoucherForTask( task, taskContext, taskKuaishouIndustryVouchers, oidKuaishouIndustryVouchers, ) const kuaishouIndustryVoucher = firstKuaishouIndustryVoucher ? mapAdminKuaishouIndustryVoucher(firstKuaishouIndustryVoucher) : mapKuaishouIndustryVoucherContext(taskContext.kuaishouIndustryVoucher) const hasKuaishouIndustryVoucher = Boolean( kuaishouIndustryVoucher && String(kuaishouIndustryVoucher.voucherCode || '').trim(), ) const kuaishouIndustryVoucherStatus = String(kuaishouIndustryVoucher?.status || '') .trim() .toUpperCase() const kuaishouIndustryVoucherSendCallbackStatus = String( kuaishouIndustryVoucher?.sendCallbackStatus || '', ) .trim() .toLowerCase() return { task: mapAdminTaskListItem( { ...task, sku_code: orderItem?.sku_code || '', sku_name: orderItem?.sku_name || '', claim_token: claimToken?.token || '', }, viewerContext, ), order: order ? { orderId: order.id, provider: order.provider || '', platform: order.platform, shopId: order.shop_id || '', shopName: order.shop_name || '', platformOrderId: order.platform_order_id, payStatus: order.pay_status, orderStatus: order.order_status, claimStatus: (order.claim_status as 'active' | 'paused' | 'closed') || 'active', claimStatusReason: order.claim_status_reason || null, claimStatusUpdatedAt: order.claim_status_updated_at || null, } : null, orderItem: orderItem ? { orderItemId: orderItem.id, skuCode: orderItem.sku_code, skuName: resolveAffiliateDashDisplayProductName( String(orderItem.sku_code || ''), String(orderItem.sku_name || ''), mergeAffiliateDashSkuMapping(getAffiliateDashConfig().skuMapping), ), quantity: orderItem.quantity, } : null, claimToken: claimToken ? { primaryClaimTokenId: claimToken.id, token: viewerContext.canViewSensitiveTaskData ? claimToken.token || '' : '', status: claimToken.status || '', expiredAt: claimToken.expired_at || '', claimUrl, } : null, artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {}, screenshotUrl, review: { required: false, screenshotCapturedAt: String(taskState.reviewCapturedAt || '').trim() || null, roleId: String(taskState.reviewRoleId || '').trim() || '', roleName: String(taskState.reviewRoleName || '').trim() || '', }, redeemResolution: mapRedeemResolutionContext(taskContext.redeemResolution), claimIdentity, kuaishouCloudFulfillment, kuaishouIndustryVoucher, manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext), affiliateDash: taskContext.affiliateDash || null, events: taskEvents.map(mapAdminTaskEvent), operations: { canRetry: viewerContext.canManageTaskLifecycle && !isManualDispatchTask(task) && !isRetiredFulfillmentExecutor(task.executor_key) && canRetryTaskStatus(task.task_status), canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext), canClose: canViewerCloseTask(task, viewerContext), canMarkManualReview: viewerContext.canManageTaskLifecycle && canMarkManualReviewTaskStatus(task.task_status), canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && (isManualDispatchTask(task) || isRetiredFulfillmentExecutor(task.executor_key)) && canCompleteManualDispatchTaskStatus(task.task_status), canPrepareKuaishouCloudFulfillment: false, canRefreshKuaishouCloudRoleInfo: false, canRebindKuaishouCloudRole: false, canDispatchKuaishouCloudFulfillment: false, canReturnKuaishouCloudFulfillment: false, canResendKuaishouIndustryVoucherCode: viewerContext.canOperateKuaishouIndustryVoucher && hasKuaishouIndustryVoucher, canConsumeKuaishouIndustryVoucher: viewerContext.canOperateKuaishouIndustryVoucher && hasKuaishouIndustryVoucher && kuaishouIndustryVoucherStatus !== 'CONSUMED' && kuaishouIndustryVoucherSendCallbackStatus === 'success', canViewSensitiveTaskData: viewerContext.canViewSensitiveTaskData, }, } } /** * 后台展示的领取链接。 * * - 抖音码速达订单(`provider='douyin_mashida'`):`claimLinkBase` + token(买家领取页, * 走 affiliate_dash 领取流程);未配 `claimLinkBase` 时退回全局 claim 链接,至少链接不会为空。 * - 其它来源:沿用全局 `buildClaimUrl`(orders.claimBaseUrl),历史行为不变。 * * ⚠️ 这里按 **provider** 判断,不按 executor_key:抖音皮肤单已改走 `affiliate_dash` 通道, * 按执行器判断会失效。拿不到 provider(order 为空)时按「其它来源」处理。 */ function resolveAdminTaskClaimUrl( claimToken: { token: string } | null | undefined, order: Pick | null | undefined, ): string { const token = String(claimToken?.token || '').trim() if (!token) { return '' } if (isDouyinMashidaProvider(order?.provider)) { const claimUrl = buildConfiguredClaimUrl(getDouyinMashidaSourceConfig().claimLinkBase, token) if (claimUrl) { return claimUrl } } return buildClaimUrl(token) } function mapKuaishouIndustryVoucherContext(value: unknown): JsonRecord | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null } const record = value as JsonRecord return { oid: String(record.oid || '').trim(), voucherCode: String(record.voucherCode || record.eticketId || '').trim(), unitIndex: Number(record.unitIndex || 0) || 0, status: String(record.status || '').trim(), sendCallbackStatus: String(record.sendCallbackStatus || '').trim(), sendCallbackLastError: String(record.sendCallbackLastError || '').trim(), sendCallbackAttemptCount: Number(record.sendCallbackAttemptCount || 0) || 0, sendCallbackSentAt: record.sendCallbackSentAt || null, consumeSerialNum: String(record.consumeSerialNum || '').trim(), consumeDetails: normalizeAdminJsonArray(record.consumeDetails), consumedAt: record.consumedAt || null, destroyedAt: record.destroyedAt || null, } } function normalizeAdminJsonArray(value: unknown): JsonRecord[] { if (Array.isArray(value)) { return value.filter((item): item is JsonRecord => Boolean(item && typeof item === 'object' && !Array.isArray(item)), ) } try { const parsed = JSON.parse(String(value || '[]')) return Array.isArray(parsed) ? parsed.filter((item): item is JsonRecord => Boolean(item && typeof item === 'object' && !Array.isArray(item)), ) : [] } catch { return [] } } function resolveKuaishouIndustryVoucherForTask( task: TaskRow, taskContext: JsonRecord, taskVouchers: KuaishouIndustryVoucherRow[], oidVouchers: KuaishouIndustryVoucherRow[], ): KuaishouIndustryVoucherRow | null { const linkedVoucher = taskVouchers[0] || null if (linkedVoucher) { return linkedVoucher } const voucherContext = taskContext.kuaishouIndustryVoucher && typeof taskContext.kuaishouIndustryVoucher === 'object' && !Array.isArray(taskContext.kuaishouIndustryVoucher) ? (taskContext.kuaishouIndustryVoucher as JsonRecord) : {} const voucherCode = String(voucherContext.voucherCode || voucherContext.eticketId || '').trim() if (voucherCode) { const matchedByCode = oidVouchers.find( (voucher) => String(voucher.voucher_code || '').trim() === voucherCode, ) if (matchedByCode) { return matchedByCode } } const unitIndex = Number(task.unit_index || 0) || 0 if (unitIndex > 0) { const matchedByUnit = oidVouchers.find( (voucher) => Number(voucher.unit_index || 0) === unitIndex, ) if (matchedByUnit) { return matchedByUnit } } return oidVouchers.length === 1 ? oidVouchers[0] || null : null } function buildCloudSourceLabelMap() { return new Map() } export async function getAdminTaskScreenshotPath( taskId: AdminEntityIdInput, _session: AdminViewerSessionInput | null = null, ): Promise { const task = await getRequiredTask(taskId) if (task.screenshot_path) { return task.screenshot_path } throw createHttpError('当前任务还没有可查看截图', { statusCode: 404, errorCode: 'admin_task_screenshot_not_found', }) }