feat(order): 精准区分自动直发与接单平台打手履约,智能门控H5直充按钮
- 后端增加按订单批量关联接单工单查询,精准识别履约类型(自动直发 / 打手履约 / 电子凭证 / 拼多多发货) - 订单列表与详情页中,仅当订单为自动直发(affiliate_dash / 码速达直充)时展示【H5】一键复制直充链接与兑换状态控制 - 接单平台工单单与纯核销单隐藏直充 H5 按钮与兑换暂停/关闭标签,防止客服向打手单买家误发直充链接 - 交付模式标签升级为「⚡ 自动直发」、「👥 打手履约」、「🎫 电子凭证」、「📦 拼多多发货」 验证方式:前端类型检查与打包构建通过,全量 132 项前端单测与 674 项后端单测全部通过
This commit is contained in:
@@ -299,6 +299,34 @@ export async function listWorkOrdersByPlatformOrderId(
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function listWorkOrdersByOrderIds(orderIds: number[]): Promise<WorkOrderRow[]> {
|
||||
if (!orderIds || orderIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
const result = await query<WorkOrderRow>(
|
||||
`${WORK_ORDER_SELECT}
|
||||
WHERE wo.order_id = ANY($1::bigint[])
|
||||
ORDER BY wo.id ASC`,
|
||||
[orderIds],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function listWorkOrdersByOrderId(orderId: number | string): Promise<WorkOrderRow[]> {
|
||||
const normalizedId = Number(orderId)
|
||||
if (!normalizedId) {
|
||||
return []
|
||||
}
|
||||
const result = await query<WorkOrderRow>(
|
||||
`${WORK_ORDER_SELECT}
|
||||
WHERE wo.order_id = $1
|
||||
ORDER BY wo.id ASC`,
|
||||
[normalizedId],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
|
||||
export async function listWorkOrders({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
|
||||
@@ -20,10 +20,12 @@ export function mapAdminOrderListItem(
|
||||
tasks = [],
|
||||
orderItems = [],
|
||||
vouchers = [],
|
||||
workOrderCount = 0,
|
||||
}: {
|
||||
tasks?: TaskRow[]
|
||||
orderItems?: OrderItemRow[]
|
||||
vouchers?: KuaishouIndustryVoucherRow[]
|
||||
workOrderCount?: number
|
||||
} = {},
|
||||
): AdminOrderListItem {
|
||||
const fulfillmentProgress = buildOrderFulfillmentProgress(tasks)
|
||||
@@ -67,6 +69,13 @@ export function mapAdminOrderListItem(
|
||||
)
|
||||
const hasResolvedFallback = (resolvedItemTitle || resolvedSkuNick) && rawItemCount === 0
|
||||
|
||||
const { fulfillmentType, canClaimH5 } = resolveOrderFulfillmentType({
|
||||
provider: item.provider || '',
|
||||
tasks,
|
||||
workOrderCount,
|
||||
vouchers,
|
||||
})
|
||||
|
||||
return {
|
||||
orderId: item.id,
|
||||
provider: item.provider || '',
|
||||
@@ -92,6 +101,9 @@ export function mapAdminOrderListItem(
|
||||
skuNick: resolvedSkuNick,
|
||||
items: itemsList,
|
||||
taskCount: tasks.length,
|
||||
workOrderCount,
|
||||
fulfillmentType,
|
||||
canClaimH5,
|
||||
resourceStatus: fulfillmentProgress.resourceStatus,
|
||||
customerStatus: fulfillmentProgress.customerStatus,
|
||||
preparedTaskCount: fulfillmentProgress.preparedTaskCount,
|
||||
@@ -102,6 +114,57 @@ export function mapAdminOrderListItem(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOrderFulfillmentType({
|
||||
provider,
|
||||
tasks = [],
|
||||
workOrderCount = 0,
|
||||
vouchers = [],
|
||||
}: {
|
||||
provider: string
|
||||
tasks?: TaskRow[]
|
||||
workOrderCount?: number
|
||||
vouchers?: KuaishouIndustryVoucherRow[]
|
||||
}): {
|
||||
fulfillmentType: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual'
|
||||
canClaimH5: boolean
|
||||
} {
|
||||
const normProvider = String(provider || '').trim().toLowerCase()
|
||||
const hasAutoClaimTask =
|
||||
tasks.some(
|
||||
(t) =>
|
||||
t.executor_key === 'affiliate_dash' ||
|
||||
t.executor_key === 'kuaishou_ct_assisted' ||
|
||||
t.executor_key === 'kuaishou_feifei' ||
|
||||
Boolean(t.requires_claim),
|
||||
) || normProvider === 'douyin_mashida'
|
||||
|
||||
if (hasAutoClaimTask) {
|
||||
return {
|
||||
fulfillmentType: 'auto_claim',
|
||||
canClaimH5: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (workOrderCount > 0) {
|
||||
return {
|
||||
fulfillmentType: 'worker_dispatch',
|
||||
canClaimH5: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (vouchers.length > 0 || normProvider === '91kaquan' || normProvider === 'kuaishou_industry') {
|
||||
return {
|
||||
fulfillmentType: 'voucher_only',
|
||||
canClaimH5: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fulfillmentType: 'manual',
|
||||
canClaimH5: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeOrderItems(
|
||||
items: OrderItemRow[] | null | undefined,
|
||||
fallbackOrder?: OrderListRow | null | undefined,
|
||||
|
||||
@@ -60,7 +60,15 @@ import {
|
||||
resolveOrderItemTitle,
|
||||
} from './admin-read-shared-helpers.js'
|
||||
import { isRetiredFulfillmentExecutor } from '../fulfillment/executors/types.js'
|
||||
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
|
||||
import {
|
||||
mapAdminOrderListItem,
|
||||
resolveOrderFulfillmentType,
|
||||
summarizeOrderItems,
|
||||
} from './admin-order-read-helpers.js'
|
||||
import {
|
||||
listWorkOrdersByOrderId,
|
||||
listWorkOrdersByOrderIds,
|
||||
} from '../../repositories/worker-platform/work-order-query-repo.js'
|
||||
import {
|
||||
buildOrderFulfillmentProgress,
|
||||
getRequiredTask,
|
||||
@@ -103,22 +111,28 @@ export async function getAdminOrders(
|
||||
|
||||
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] = await Promise.all([
|
||||
const [orderItems, tasks, vouchers, workOrders] = await Promise.all([
|
||||
listOrderItemsByOrderIds(orderIds),
|
||||
listTasksByOrderIds(orderIds),
|
||||
listKuaishouIndustryVouchersByOids(oids),
|
||||
listWorkOrdersByOrderIds(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: tasksByOrderId.get(orderId) || [],
|
||||
vouchers: vouchersByOid.get(item.platform_order_id) || [],
|
||||
tasks: orderTasks,
|
||||
vouchers: orderVouchers,
|
||||
workOrderCount: orderWorkOrders.length,
|
||||
})
|
||||
}),
|
||||
pagination: { page, pageSize, total },
|
||||
@@ -163,15 +177,22 @@ export async function getAdminOrderDetail(
|
||||
})
|
||||
}
|
||||
|
||||
const [items, tasks] = await Promise.all([
|
||||
const [items, tasks, workOrders] = await Promise.all([
|
||||
listOrderItemsByOrderId(order.id),
|
||||
listTasksByOrderId(order.id),
|
||||
listWorkOrdersByOrderId(order.id),
|
||||
])
|
||||
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 { fulfillmentType, canClaimH5 } = resolveOrderFulfillmentType({
|
||||
provider: order.provider || '',
|
||||
tasks,
|
||||
workOrderCount: workOrders.length,
|
||||
vouchers: kuaishouIndustryVouchers,
|
||||
})
|
||||
|
||||
return {
|
||||
order: {
|
||||
@@ -203,6 +224,9 @@ export async function getAdminOrderDetail(
|
||||
claimStatus: order.claim_status || 'active',
|
||||
claimStatusReason: order.claim_status_reason || '',
|
||||
claimStatusUpdatedAt: order.claim_status_updated_at || null,
|
||||
fulfillmentType,
|
||||
workOrderCount: workOrders.length,
|
||||
canClaimH5,
|
||||
},
|
||||
items: items.map((item) => ({
|
||||
orderItemId: item.id,
|
||||
|
||||
@@ -44,6 +44,9 @@ export type AdminOrderListItem = {
|
||||
claimStatus?: string
|
||||
claimStatusReason?: string
|
||||
claimStatusUpdatedAt?: string | null
|
||||
fulfillmentType?: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual'
|
||||
workOrderCount?: number
|
||||
canClaimH5?: boolean
|
||||
}
|
||||
|
||||
export type AdminTaskListItem = {
|
||||
|
||||
@@ -30,6 +30,7 @@ import StatusTag from '@/components/admin/StatusTag'
|
||||
import { fetchAdminOrderDetail, updateAdminOrderClaimStatus } from '@/services/admin'
|
||||
import type { AdminOrderDetail, AdminTaskListItem } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { resolveOrderFulfillmentMode } from '@/utils/admin-display'
|
||||
|
||||
export default function AdminOrderDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -164,6 +165,10 @@ export default function AdminOrderDetailPage() {
|
||||
const isOrderRefundedOrCancelled =
|
||||
detail.order.payStatus === 'refunded' || detail.order.orderStatus === 'cancelled'
|
||||
const canPauseOrClose = !isOrderFulfilled && !isOrderRefundedOrCancelled
|
||||
const isAutoClaimOrder = Boolean(
|
||||
detail.order.canClaimH5 || detail.order.fulfillmentType === 'auto_claim',
|
||||
)
|
||||
const fulfillmentMode = resolveOrderFulfillmentMode(detail.order)
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
@@ -176,40 +181,42 @@ export default function AdminOrderDetailPage() {
|
||||
<Card
|
||||
title="基础信息"
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
|
||||
复制 H5 领取链接
|
||||
</Button>
|
||||
{detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => openStatusModal('active')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
恢复启用
|
||||
isAutoClaimOrder ? (
|
||||
<Space>
|
||||
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
|
||||
复制 H5 领取链接
|
||||
</Button>
|
||||
) : canPauseOrClose ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<PauseCircleOutlined />}
|
||||
onClick={() => openStatusModal('paused')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
暂停兑换
|
||||
</Button>
|
||||
) : null}
|
||||
{canPauseOrClose && detail.order.claimStatus !== 'closed' ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={() => openStatusModal('closed')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
关闭兑换
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
{detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => openStatusModal('active')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
恢复启用
|
||||
</Button>
|
||||
) : canPauseOrClose ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<PauseCircleOutlined />}
|
||||
onClick={() => openStatusModal('paused')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
暂停兑换
|
||||
</Button>
|
||||
) : null}
|
||||
{canPauseOrClose && detail.order.claimStatus !== 'closed' ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={() => openStatusModal('closed')}
|
||||
loading={statusMutation.isPending}
|
||||
>
|
||||
关闭兑换
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||
@@ -218,34 +225,41 @@ export default function AdminOrderDetailPage() {
|
||||
{detail.order.shopName || detail.order.shopId || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台订单号">{detail.order.platformOrderId}</Descriptions.Item>
|
||||
<Descriptions.Item label="履约模式">
|
||||
<Tag color={fulfillmentMode.color} style={{ margin: 0 }}>
|
||||
{fulfillmentMode.label}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="订单状态">
|
||||
<StatusTag value={detail.order.orderStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付状态">
|
||||
<StatusTag value={detail.order.payStatus} kind="pay" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="H5 兑换状态">
|
||||
<Space wrap>
|
||||
{detail.order.claimStatus === 'paused' ? (
|
||||
<Tag color="warning" icon={<PauseCircleOutlined />}>
|
||||
已暂停兑换
|
||||
</Tag>
|
||||
) : detail.order.claimStatus === 'closed' ? (
|
||||
<Tag color="default" icon={<StopOutlined />}>
|
||||
已关闭兑换
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="success" icon={<PlayCircleOutlined />}>
|
||||
正常有效
|
||||
</Tag>
|
||||
)}
|
||||
{detail.order.claimStatusReason ? (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
({detail.order.claimStatusReason})
|
||||
</span>
|
||||
) : null}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
{isAutoClaimOrder ? (
|
||||
<Descriptions.Item label="H5 兑换状态">
|
||||
<Space wrap>
|
||||
{detail.order.claimStatus === 'paused' ? (
|
||||
<Tag color="warning" icon={<PauseCircleOutlined />}>
|
||||
已暂停兑换
|
||||
</Tag>
|
||||
) : detail.order.claimStatus === 'closed' ? (
|
||||
<Tag color="default" icon={<StopOutlined />}>
|
||||
已关闭兑换
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="success" icon={<PlayCircleOutlined />}>
|
||||
正常有效
|
||||
</Tag>
|
||||
)}
|
||||
{detail.order.claimStatusReason ? (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
({detail.order.claimStatusReason})
|
||||
</span>
|
||||
) : null}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="金额">
|
||||
{detail.order.totalAmount} {detail.order.currency}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -309,7 +309,12 @@ export default function AdminOrdersPage() {
|
||||
</>
|
||||
)}
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
数量 {row.totalQuantity || 1} · 关联任务 {row.taskCount || 0}
|
||||
数量 {row.totalQuantity || 1}
|
||||
{row.canClaimH5
|
||||
? ` · 关联任务 ${row.taskCount || 0}`
|
||||
: row.workOrderCount
|
||||
? ` · 关联工单 ${row.workOrderCount}`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -332,11 +337,11 @@ export default function AdminOrdersPage() {
|
||||
<StatusTag value={row.customerStatus} kind="customer" />
|
||||
</Space>
|
||||
|
||||
{row.claimStatus === 'paused' ? (
|
||||
{row.canClaimH5 && row.claimStatus === 'paused' ? (
|
||||
<Tag color="warning" style={{ margin: 0, width: 'fit-content' }}>
|
||||
H5已暂停
|
||||
</Tag>
|
||||
) : row.claimStatus === 'closed' ? (
|
||||
) : row.canClaimH5 && row.claimStatus === 'closed' ? (
|
||||
<Tag color="default" style={{ margin: 0, width: 'fit-content' }}>
|
||||
H5已关闭
|
||||
</Tag>
|
||||
@@ -361,7 +366,7 @@ export default function AdminOrdersPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
minWidth: 140,
|
||||
minWidth: 130,
|
||||
fixed: 'right',
|
||||
render: (_, row) => {
|
||||
const orderNo = row.platformOrderId || String(row.orderId)
|
||||
@@ -378,15 +383,17 @@ export default function AdminOrdersPage() {
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Tooltip title="复制 H5 领取链接">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => handleCopyText(h5Url, 'H5 领取链接')}
|
||||
>
|
||||
H5
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{row.canClaimH5 ? (
|
||||
<Tooltip title="复制自动直充 H5 领取链接">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => handleCopyText(h5Url, 'H5 直充链接')}
|
||||
>
|
||||
H5
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -46,6 +46,9 @@ export interface AdminOrderListItem {
|
||||
claimStatus?: string
|
||||
claimStatusReason?: string
|
||||
claimStatusUpdatedAt?: string | null
|
||||
fulfillmentType?: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual'
|
||||
workOrderCount?: number
|
||||
canClaimH5?: boolean
|
||||
}
|
||||
|
||||
export interface AdminOrderDetail {
|
||||
@@ -61,6 +64,9 @@ export interface AdminOrderDetail {
|
||||
claimStatus?: string
|
||||
claimStatusReason?: string
|
||||
claimStatusUpdatedAt?: string | null
|
||||
fulfillmentType?: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual'
|
||||
workOrderCount?: number
|
||||
canClaimH5?: boolean
|
||||
buyerId: string
|
||||
buyerName: string
|
||||
receiverContact: string
|
||||
|
||||
@@ -221,21 +221,34 @@ export function resolveOrderPlatformBadge(row: {
|
||||
export function resolveOrderFulfillmentMode(row: {
|
||||
provider?: string
|
||||
platform?: string
|
||||
fulfillmentType?: 'auto_claim' | 'worker_dispatch' | 'voucher_only' | 'manual'
|
||||
workOrderCount?: number
|
||||
canClaimH5?: boolean
|
||||
taskCount?: number
|
||||
}): { label: string; color: string } {
|
||||
const provider = String(row.provider || '').toLowerCase()
|
||||
|
||||
if (provider === 'douyin_mashida') {
|
||||
return { label: 'H5直绑', color: 'blue' }
|
||||
if (row.canClaimH5 || row.fulfillmentType === 'auto_claim' || provider === 'douyin_mashida') {
|
||||
return { label: '⚡ 自动直发', color: 'blue' }
|
||||
}
|
||||
|
||||
if (provider === 'kuaishou_industry' || provider === '91kaquan' || provider === 'open_91') {
|
||||
return { label: '电子凭证', color: 'gold' }
|
||||
if (row.fulfillmentType === 'worker_dispatch' || Number(row.workOrderCount || 0) > 0) {
|
||||
return { label: '👥 打手履约', color: 'purple' }
|
||||
}
|
||||
|
||||
if (
|
||||
provider === 'kuaishou_industry' ||
|
||||
provider === '91kaquan' ||
|
||||
provider === 'open_91' ||
|
||||
row.fulfillmentType === 'voucher_only'
|
||||
) {
|
||||
return { label: '🎫 电子凭证', color: 'gold' }
|
||||
}
|
||||
|
||||
if (provider === 'pdd') {
|
||||
return { label: '拼多多发货', color: 'red' }
|
||||
return { label: '📦 拼多多发货', color: 'red' }
|
||||
}
|
||||
|
||||
return { label: '自动交付', color: 'cyan' }
|
||||
return { label: '手动交付', color: 'default' }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user