fix(worker-platform): 接单平台工单详情抽屉完整展示寄售手动匹配与指派订单的卖家资料

1. 修复管理后台「接单平台 -> 接单工单」抽屉中,手动匹配或指派寄售库存履约的工单因 booster_material_json 为空导致卖家资料全部显示 '-' 的问题;
2. 后端在履约及指派时将卖家角色昵称、数字ID、联系方式、手机号、备注及上架凭证截图完整写入工单 material.consignment;
3. 后端工单查询及展示映射层增加自动回退与批量库存补充解析机制,确保历史与新增的寄售工单均能准确解析出卖家角色名、ID、区服、系统与截图;
4. 前端工单抽屉卡片优化寄售订单的资料展示与阶段标签,清晰展示卖家角色名、游戏ID、联系方式(微信/QQ/手机)、提报/仓库单号及上架截图。
This commit is contained in:
yml2213
2026-09-19 14:33:56 +08:00
parent 13a9028c66
commit 2c120bd3e5
6 changed files with 358 additions and 113 deletions
@@ -333,6 +333,27 @@ export async function getConsignmentInventoryById(
return res.rows[0] || null
}
export async function listConsignmentInventoriesByIds(
ids: Array<number | string>,
): Promise<ConsignmentInventoryRow[]> {
const numericIds = [...new Set(ids.map(Number).filter((n) => Number.isFinite(n) && n > 0))]
if (numericIds.length === 0) return []
const sql = `
SELECT
ci.*,
wc.name AS category_name,
cs.submission_no AS source_submission_no,
cs.screenshots_json AS submission_screenshots_json
FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
WHERE ci.id = ANY($1::bigint[])
`
const res = await query<ConsignmentInventoryRow>(sql, [numericIds])
return res.rows
}
export async function updateConsignmentInventory(
id: number | string,
input: {
@@ -25,3 +25,4 @@ export * from './worker-cancel-request-repo.js'
export * from './after-sales-repo.js'
export * from './product-match-repo.js'
export * from './sms-code-repo.js'
export * from './consignment-repo.js'
@@ -1,15 +1,18 @@
import {
getWorkOrderStatistics,
listConsignmentInventoriesByIds,
listWorkOrderEventsByOrderId,
listWorkOrderSharesByOrderIds,
listWorkOrders,
sumPendingUnfreezeByOrderIds,
type ConsignmentInventoryRow,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import {
normalizeDateString,
normalizePage,
normalizePageSize,
safeParseJson,
} from '../admin/admin-query-utils.js'
import {
mapWorkOrderAdmin,
@@ -60,10 +63,29 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
getWorkOrderStatistics(filters),
])
const itemIds = items.map((item) => Number(item.id))
const [pendingUnfreezeByOrderId, shares] = await Promise.all([
const inventoryIds = new Set<number>()
for (const item of items) {
const mat = safeParseJson(item.material_json)
const invId = Number(mat.consignment?.inventoryId || 0)
if (invId > 0) {
inventoryIds.add(invId)
}
}
const [pendingUnfreezeByOrderId, shares, inventories] = await Promise.all([
sumPendingUnfreezeByOrderIds(itemIds),
listWorkOrderSharesByOrderIds(itemIds),
inventoryIds.size > 0
? listConsignmentInventoriesByIds([...inventoryIds])
: Promise.resolve([]),
])
const inventoriesById = new Map<number, ConsignmentInventoryRow>()
for (const inv of inventories) {
inventoriesById.set(Number(inv.id), inv)
}
const sharesByOrderId = new Map<number, typeof shares>()
for (const share of shares) {
const orderId = Number(share.work_order_id)
@@ -72,11 +94,17 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
sharesByOrderId.set(orderId, current)
}
return {
items: items.map((item) => ({
...mapWorkOrderAdmin(item, sharesByOrderId.get(Number(item.id)) || []),
pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(Number(item.id)) || 0,
})),
items: items.map((item) => {
const mat = safeParseJson(item.material_json)
const invId = Number(mat.consignment?.inventoryId || 0)
const inv = invId > 0 ? inventoriesById.get(invId) || null : null
return {
...mapWorkOrderAdmin(item, sharesByOrderId.get(Number(item.id)) || [], inv),
pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(Number(item.id)) || 0,
}
}),
pagination: { page, pageSize, total: statistics.totalCount },
statistics,
}
}
@@ -462,8 +462,15 @@ export async function fulfillWorkOrderWithConsignment(input: {
diamondLevel: inv.diamond_level,
brillianceCount: inv.brilliance_count,
buyerName: inv.buyer_name,
sellerGameId: inv.seller_game_id || '',
sellerContact: inv.seller_contact || '',
sellerPhone: inv.seller_phone || '',
workerName: inv.worker_name || '',
workerPhone: inv.worker_phone || '',
remark: inv.remark || '',
inboundBatch: inv.inbound_batch,
credentials: safeParseJson(inv.credentials_json),
screenshots: safeParseJson(inv.screenshots_json || inv.submission_screenshots_json || []),
costPriceFen: inv.cost_price_fen,
sourceSubmissionId: inv.source_submission_id ? Number(inv.source_submission_id) : null,
sourceSubmissionNo: inv.source_submission_no || '',
@@ -694,11 +701,22 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
diamondLevel: claimed.diamond_level,
brillianceCount: claimed.brilliance_count,
costPriceFen: claimed.cost_price_fen,
buyerName: claimed.buyer_name,
sellerGameId: claimed.seller_game_id || '',
sellerContact: claimed.seller_contact || '',
sellerPhone: claimed.seller_phone || '',
workerId: Number(targetWorkerId),
workerName: claimed.worker_name || '',
workerPhone: claimed.worker_phone || '',
remark: claimed.remark || '',
credentials: safeParseJson(claimed.credentials_json),
screenshots: safeParseJson(
claimed.screenshots_json || claimed.submission_screenshots_json || [],
),
sourceSubmissionId: claimed.source_submission_id
? Number(claimed.source_submission_id)
: null,
sourceSubmissionNo: claimed.source_submission_no || '',
fulfillmentType: 'worker_deposit',
assignedAt: now,
assignedBy: input.actorName,
@@ -10,6 +10,7 @@ import {
WORK_ORDER_GIFT_PHASE,
} from '../../domain/work-order-acceptance-mode.js'
import {
type ConsignmentInventoryRow,
type WorkCategoryRow,
type WorkOrderCapabilities,
type WorkOrderRow,
@@ -76,9 +77,103 @@ export function mapWorkOrderBoosterMaterial(value: unknown) {
screenshots: normalizeUploadedFiles(material.screenshots).map(refreshUploadedFileUrls),
note: String(material.note || ''),
submittedAt: String(material.submittedAt || ''),
isConsignmentSellerMaterial: false,
}
}
/** 读取好友赠送模式的打手本人资料或寄售卖家资料;截图访问链接需要读取时重签。 */
export function resolveWorkOrderEffectiveBoosterMaterial(
workOrder: WorkOrderRow,
consignmentInventory?: ConsignmentInventoryRow | null,
) {
const existingBooster = safeParseJson(workOrder.booster_material_json)
const existingFields =
existingBooster &&
typeof existingBooster.fields === 'object' &&
!Array.isArray(existingBooster.fields)
? (existingBooster.fields as Record<string, string>)
: {}
const hasExistingFields = Object.values(existingFields).some(
(v) => String(v || '').trim().length > 0,
)
const existingScreenshots = normalizeUploadedFiles(existingBooster.screenshots).map(
refreshUploadedFileUrls,
)
const hasExistingScreenshots = existingScreenshots.length > 0
if (hasExistingFields || hasExistingScreenshots) {
return {
fields: existingFields,
screenshots: existingScreenshots,
note: String(existingBooster.note || ''),
submittedAt: String(existingBooster.submittedAt || ''),
isConsignmentSellerMaterial: false,
}
}
// Fallback to consignment seller information if this is a consignment-fulfilled order
const material = safeParseJson(workOrder.material_json)
const consignment = safeParseJson(material.consignment)
const hasConsignment =
(consignment && typeof consignment === 'object' && Object.keys(consignment).length > 0) ||
Boolean(consignmentInventory)
if (hasConsignment) {
const rawScreenshots =
consignment?.screenshots ||
consignmentInventory?.screenshots_json ||
consignmentInventory?.submission_screenshots_json ||
[]
const fields: Record<string, string> = {
gameNickname: String(
consignment?.buyerName ||
consignmentInventory?.buyer_name ||
consignment?.sellerGameNickname ||
'',
).trim(),
gameId: String(
consignment?.sellerGameId || consignmentInventory?.seller_game_id || '',
).trim(),
serverZone: String(
consignment?.serverZone || consignmentInventory?.server_zone || '',
).trim(),
system: String(consignment?.system || consignmentInventory?.system || '').trim(),
sellerContact: String(
consignment?.sellerContact || consignmentInventory?.seller_contact || '',
).trim(),
sellerPhone: String(
consignment?.sellerPhone || consignmentInventory?.seller_phone || '',
).trim(),
sourceSubmissionNo: String(
consignment?.sourceSubmissionNo || consignmentInventory?.source_submission_no || '',
).trim(),
inventoryNo: String(
consignment?.inventoryNo || consignmentInventory?.inventory_no || '',
).trim(),
sourceType: 'consignment',
}
return {
fields,
screenshots: normalizeUploadedFiles(rawScreenshots).map(refreshUploadedFileUrls),
note: String(
consignment?.remark || consignmentInventory?.remark || consignment?.matchNote || '',
).trim(),
submittedAt: String(
consignment?.fulfilledAt ||
consignment?.assignedAt ||
consignmentInventory?.inbound_at ||
consignmentInventory?.created_at ||
workOrder.created_at ||
'',
),
isConsignmentSellerMaterial: true,
}
}
return mapWorkOrderBoosterMaterial(workOrder.booster_material_json)
}
/** 统一计算后台使用的工单业务状态,避免拼单订单继续直接暴露母工单 open。 */
export function resolveWorkOrderOperationalStatus(workOrder: WorkOrderRow): string {
const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0))
@@ -131,7 +226,11 @@ function resolveWorkOrderCancelSource(cancelEventType: string | null | undefined
return eventType || '未知来源'
}
export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderShareRow[]) {
export function mapWorkOrderAdmin(
workOrder: WorkOrderRow,
shares?: WorkOrderShareRow[],
consignmentInventory?: ConsignmentInventoryRow | null,
) {
const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0))
const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0))
const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
@@ -245,7 +344,10 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
cooldownMinutes: normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes),
friendAddedAt: workOrder.friend_added_at,
giftAvailableAt: workOrder.gift_available_at,
boosterMaterial: mapWorkOrderBoosterMaterial(workOrder.booster_material_json),
boosterMaterial: resolveWorkOrderEffectiveBoosterMaterial(
workOrder,
consignmentInventory,
),
},
}
: {}),
@@ -177,6 +177,16 @@ function isInternalConsignmentOrder(row: WorkOrder) {
return Boolean(consignment.inventoryId) && !row.worker
}
function isConsignmentOrder(row: WorkOrder) {
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
return Boolean(
consignment.inventoryId ||
consignment.inventoryNo ||
consignment.fulfillmentType ||
row.gift?.boosterMaterial?.fields?.inventoryNo,
)
}
/** 工单 `material.consignment`(寄售履约信息);没有寄售履约信息时返回 null。 */
function getConsignmentFulfillment(row: WorkOrder): ConsignmentFulfillmentInfo | null {
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
@@ -2093,112 +2103,177 @@ export default function WorkOrdersPanel() {
</div>
</Card>
{detailOrder.acceptanceMode === 'friend_gift' && detailOrder.gift ? (
<Card
title={
isInternalConsignmentOrder(detailOrder) ? '内部寄售赠送进度' : '好友赠送进度'
}
size="small"
extra={
<Space>
{isInternalConsignmentOrder(detailOrder) ? (
<Tag color="cyan">内部寄售匹配</Tag>
) : (
<Tag color="purple">{formatAcceptanceMode(detailOrder.acceptanceMode)}</Tag>
)}
<Tag color={resolveGiftPhaseColor(detailOrder.gift.phase)}>
{formatGiftPhase(
detailOrder.gift.phase,
isInternalConsignmentOrder(detailOrder),
)}
</Tag>
{detailOrder.capabilities?.canConfirmFriend && canManageProblem ? (
<Button
type="primary"
size="small"
onClick={() => confirmFriendAdded(detailOrder)}
>
确认加好友
</Button>
) : null}
{detailOrder.capabilities?.canCompleteGiftCooldown && canManageProblem ? (
<Button
type="primary"
size="small"
onClick={() => completeGiftCooldown(detailOrder)}
>
结束冷却
</Button>
) : null}
{detailOrder.capabilities?.canResetGiftFlow && canManageProblem ? (
<Button
size="small"
danger
onClick={() => requestResetGiftMaterial(detailOrder)}
>
重置打手资料
</Button>
) : null}
</Space>
}
>
<div className="worker-order-detail-stack">
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
<Descriptions.Item label="冷却时长">
{detailOrder.gift.cooldownMinutes > 0
? `${detailOrder.gift.cooldownMinutes} 分钟`
: `${detailOrder.gift.cooldownHours} 小时`}
</Descriptions.Item>
<Descriptions.Item label="打手资料提交">
{formatAdminDateTime(detailOrder.gift.boosterMaterial?.submittedAt)}
</Descriptions.Item>
<Descriptions.Item label="已加好友">
{formatAdminDateTime(detailOrder.gift.friendAddedAt)}
</Descriptions.Item>
<Descriptions.Item label="可赠送时间">
{detailOrder.gift.giftAvailableAt
? formatAdminDateTime(detailOrder.gift.giftAvailableAt)
: '-'}
</Descriptions.Item>
</Descriptions>
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
{(
[
['gameNickname', '游戏昵称'],
['gameId', '游戏ID'],
['serverZone', '区服'],
['system', '系统'],
] as const
).map(([key, label]) => (
<Descriptions.Item key={key} label={label}>
<span className="worker-order-detail-value">
{String(detailOrder.gift?.boosterMaterial?.fields?.[key] || '').trim() ||
'-'}
</span>
</Descriptions.Item>
))}
<Descriptions.Item label="打手备注" span={2}>
<span className="worker-order-detail-value">
{String(detailOrder.gift?.boosterMaterial?.note || '').trim() || '-'}
</span>
</Descriptions.Item>
</Descriptions>
<div>
<Typography.Text type="secondary">打手主页截图</Typography.Text>
<div style={{ marginTop: 8 }}>
{detailOrder.gift.boosterMaterial?.screenshots?.length ? (
<ImagePreviewList
files={detailOrder.gift.boosterMaterial.screenshots}
size={72}
/>
) : (
<Typography.Text type="secondary">未提交</Typography.Text>
)}
</div>
</div>
</div>
</Card>
) : null}
{detailOrder.acceptanceMode === 'friend_gift' && detailOrder.gift
? (() => {
const isConsignment =
isInternalConsignmentOrder(detailOrder) || isConsignmentOrder(detailOrder)
const consignmentInfo = getConsignmentFulfillment(detailOrder)
const sellerFields = detailOrder.gift.boosterMaterial?.fields || {}
const sellerContact =
sellerFields.sellerContact || String(consignmentInfo?.sellerContact || '')
const sellerPhone =
sellerFields.sellerPhone || String(consignmentInfo?.sellerPhone || '')
const inventoryNo =
sellerFields.inventoryNo || String(consignmentInfo?.inventoryNo || '')
const sourceSubmissionNo =
sellerFields.sourceSubmissionNo ||
String(consignmentInfo?.sourceSubmissionNo || '')
const effectiveNote = String(
detailOrder.gift?.boosterMaterial?.note || consignmentInfo?.remark || '',
).trim()
return (
<Card
title={isConsignment ? '寄售赠送进度' : '好友赠送进度'}
size="small"
extra={
<Space>
{isInternalConsignmentOrder(detailOrder) ? (
<Tag color="cyan">内部寄售匹配</Tag>
) : isConsignment ? (
<Tag color="blue">寄售指派匹配</Tag>
) : (
<Tag color="purple">
{formatAcceptanceMode(detailOrder.acceptanceMode)}
</Tag>
)}
<Tag color={resolveGiftPhaseColor(detailOrder.gift.phase)}>
{formatGiftPhase(detailOrder.gift.phase, isConsignment)}
</Tag>
{detailOrder.capabilities?.canConfirmFriend && canManageProblem ? (
<Button
type="primary"
size="small"
onClick={() => confirmFriendAdded(detailOrder)}
>
确认加好友
</Button>
) : null}
{detailOrder.capabilities?.canCompleteGiftCooldown && canManageProblem ? (
<Button
type="primary"
size="small"
onClick={() => completeGiftCooldown(detailOrder)}
>
结束冷却
</Button>
) : null}
{detailOrder.capabilities?.canResetGiftFlow && canManageProblem ? (
<Button
size="small"
danger
onClick={() => requestResetGiftMaterial(detailOrder)}
>
重置打手资料
</Button>
) : null}
</Space>
}
>
<div className="worker-order-detail-stack">
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
<Descriptions.Item label="冷却时长">
{detailOrder.gift.cooldownMinutes > 0
? `${detailOrder.gift.cooldownMinutes} 分钟`
: `${detailOrder.gift.cooldownHours} 小时`}
</Descriptions.Item>
<Descriptions.Item
label={isConsignment ? '卖家资料/提报时间' : '打手资料提交'}
>
{formatAdminDateTime(detailOrder.gift.boosterMaterial?.submittedAt) ||
(isConsignment ? '已自动带入卖家现货资料' : '-')}
</Descriptions.Item>
<Descriptions.Item label="已加好友">
{formatAdminDateTime(detailOrder.gift.friendAddedAt)}
</Descriptions.Item>
<Descriptions.Item label="可赠送时间">
{detailOrder.gift.giftAvailableAt
? formatAdminDateTime(detailOrder.gift.giftAvailableAt)
: '-'}
</Descriptions.Item>
</Descriptions>
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
<Descriptions.Item label={isConsignment ? '卖家角色昵称' : '游戏昵称'}>
<span className="worker-order-detail-value">
{String(
sellerFields.gameNickname || consignmentInfo?.buyerName || '',
).trim() || '-'}
</span>
</Descriptions.Item>
<Descriptions.Item label={isConsignment ? '卖家游戏ID' : '游戏ID'}>
<span className="worker-order-detail-value">
{String(
sellerFields.gameId || consignmentInfo?.sellerGameId || '',
).trim() || '-'}
</span>
</Descriptions.Item>
<Descriptions.Item label="区服">
<span className="worker-order-detail-value">
{String(
sellerFields.serverZone || consignmentInfo?.serverZone || '',
).trim() || '-'}
</span>
</Descriptions.Item>
<Descriptions.Item label="系统">
<span className="worker-order-detail-value">
{String(
sellerFields.system || consignmentInfo?.system || '',
).trim() || '-'}
</span>
</Descriptions.Item>
{isConsignment && (sellerContact || sellerPhone) ? (
<Descriptions.Item label="卖家联系方式" span={2}>
<span className="worker-order-detail-value">
{[
sellerContact ? `微信/QQ: ${sellerContact}` : '',
sellerPhone ? `手机: ${sellerPhone}` : '',
]
.filter(Boolean)
.join(' / ')}
</span>
</Descriptions.Item>
) : null}
{isConsignment && (inventoryNo || sourceSubmissionNo) ? (
<Descriptions.Item label="寄售单号" span={2}>
<span className="worker-order-detail-value">
{[
sourceSubmissionNo ? `提报单: ${sourceSubmissionNo}` : '',
inventoryNo ? `仓库单: ${inventoryNo}` : '',
]
.filter(Boolean)
.join(' / ')}
</span>
</Descriptions.Item>
) : null}
<Descriptions.Item
label={isConsignment ? '卖家备注' : '打手备注'}
span={2}
>
<span className="worker-order-detail-value">
{effectiveNote || '-'}
</span>
</Descriptions.Item>
</Descriptions>
<div>
<Typography.Text type="secondary">
{isConsignment ? '卖家上架/主页截图' : '打手主页截图'}
</Typography.Text>
<div style={{ marginTop: 8 }}>
{detailOrder.gift.boosterMaterial?.screenshots?.length ? (
<ImagePreviewList
files={detailOrder.gift.boosterMaterial.screenshots}
size={72}
/>
) : (
<Typography.Text type="secondary">未提交</Typography.Text>
)}
</div>
</div>
</div>
</Card>
)
})()
: null}
<Card title="验收信息" size="small">
<div className="worker-order-detail-stack">