fix(worker-platform): 修复寄售匹配工单详情中卖家上架截图丢失问题,并收敛手动匹配为内部现货直接履约

1. 修复文件归一化器 normalizeUploadedFiles 遇到纯字符串 URL 数组被误过滤为空的问题,自动提取 objectKey 并构造规范 UploadedFile 结构;
2. 优化工单展示映射器 resolveWorkOrderEffectiveBoosterMaterial:当工单资料中截图为空时,自动回退并加载寄售卖家上架/主页截图;
3. 前端管理后台详情抽屉与打手端详情抽屉支持读取 detailOrder.gift.boosterMaterial.screenshots 与 consignmentInfo.screenshots 降级兜底渲染;
4. 手动匹配弹窗与发布拦截弹窗全面收敛为单一标准的【内部现货直接履约】,废除旧版指派打手带来的重复扣押金与二次填资料问题;
5. 新增 consignment-booster-material 单元测试,更新寄卖库存优化现状与进度追踪文档,前后端全套测试通过。
This commit is contained in:
yml2213
2026-09-21 11:35:38 +08:00
parent 040b3db84c
commit c80bd0e337
9 changed files with 372 additions and 102 deletions
@@ -214,7 +214,10 @@ export function refreshUploadedFileUrls(value: unknown) {
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
const objectKey = String(source.objectKey || source.object_key || '').trim()
const rawUrl = String(source.url || '').trim()
const objectKey =
String(source.objectKey || source.object_key || '').trim() ||
extractObjectKeyFromFileUrl(rawUrl)
if (!objectKey) return source
return {
...source,
@@ -274,7 +277,7 @@ function buildUnsignedFileUrl(objectKey: string) {
return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}`
}
function extractObjectKeyFromFileUrl(value: string) {
export function extractObjectKeyFromFileUrl(value: string) {
if (!value) return ''
try {
const parsed = new URL(value, 'http://localhost')
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { normalizeUploadedFiles } from './worker-input-normalizers.js'
import { resolveWorkOrderEffectiveBoosterMaterial } from './work-order-display-mappers.js'
test('normalizeUploadedFiles 兼容字符串 URL 数组并自动提取 objectKey', () => {
const input = [
'/api/v1/files/object?key=consignment_screenshot%2F2026%2F09%2F19%2Fb8b974206a8a0cf6c89a6296.webp',
]
const result = normalizeUploadedFiles(input)
assert.equal(result.length, 1)
assert.equal(
result[0].objectKey,
'consignment_screenshot/2026/09/19/b8b974206a8a0cf6c89a6296.webp',
)
assert.equal(
result[0].url,
'/api/v1/files/object?key=consignment_screenshot%2F2026%2F09%2F19%2Fb8b974206a8a0cf6c89a6296.webp',
)
})
test('resolveWorkOrderEffectiveBoosterMaterial 在寄售工单无打手截图时自动回退卖家主页截图', () => {
const row = {
id: '100140',
booster_material_json: {},
material_json: {
consignment: {
inventoryId: 23,
buyerName: '客服',
sellerGameId: '草莓酱板鸭 (1234551)',
serverZone: '互通',
system: '安卓',
screenshots: [
'/api/v1/files/object?key=consignment_screenshot%2F2026%2F09%2F19%2Fb8b974206a8a0cf6c89a6296.webp',
],
},
},
} as never
const material = resolveWorkOrderEffectiveBoosterMaterial(row)
assert.equal(material.isConsignmentSellerMaterial, true)
assert.equal(material.fields.gameNickname, '客服')
assert.equal(material.fields.gameId, '草莓酱板鸭 (1234551)')
assert.equal(material.screenshots.length, 1)
assert.equal(
material.screenshots[0].objectKey,
'consignment_screenshot/2026/09/19/b8b974206a8a0cf6c89a6296.webp',
)
})
test('resolveWorkOrderEffectiveBoosterMaterial 在已写入 fields 但无截图时依然回退卖家主页截图', () => {
const row = {
id: '100140',
booster_material_json: {
fields: {
gameNickname: '客服',
gameId: '1234551',
},
screenshots: [],
isConsignmentSellerMaterial: true,
},
material_json: {
consignment: {
inventoryId: 23,
screenshots: [
'/api/v1/files/object?key=consignment_screenshot%2F2026%2F09%2F19%2Fb8b974206a8a0cf6c89a6296.webp',
],
},
},
} as never
const material = resolveWorkOrderEffectiveBoosterMaterial(row)
assert.equal(material.isConsignmentSellerMaterial, true)
assert.equal(material.fields.gameNickname, '客服')
assert.equal(material.screenshots.length, 1)
assert.equal(
material.screenshots[0].objectKey,
'consignment_screenshot/2026/09/19/b8b974206a8a0cf6c89a6296.webp',
)
})
@@ -101,16 +101,6 @@ export function resolveWorkOrderEffectiveBoosterMaterial(
)
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)
@@ -118,12 +108,38 @@ export function resolveWorkOrderEffectiveBoosterMaterial(
(consignment && typeof consignment === 'object' && Object.keys(consignment).length > 0) ||
Boolean(consignmentInventory)
if (hasConsignment) {
const rawScreenshots =
consignment?.screenshots ||
const rawConsignmentScreenshots = hasConsignment
? consignment?.screenshots ||
consignmentInventory?.screenshots_json ||
consignmentInventory?.submission_screenshots_json ||
[]
: []
const consignmentScreenshots =
normalizeUploadedFiles(rawConsignmentScreenshots).map(refreshUploadedFileUrls)
const effectiveScreenshots = hasExistingScreenshots
? existingScreenshots
: hasConsignment
? consignmentScreenshots
: []
const isConsignmentSeller = Boolean(existingBooster.isConsignmentSellerMaterial || hasConsignment)
if (hasExistingFields || hasExistingScreenshots) {
return {
fields: existingFields,
screenshots: effectiveScreenshots,
note: String(
existingBooster.note || consignment?.remark || consignmentInventory?.remark || '',
),
submittedAt: String(
existingBooster.submittedAt || consignment?.fulfilledAt || consignment?.assignedAt || '',
),
isConsignmentSellerMaterial: isConsignmentSeller,
}
}
if (hasConsignment) {
const fields: Record<string, string> = {
gameNickname: String(
consignment?.buyerName ||
@@ -153,7 +169,7 @@ export function resolveWorkOrderEffectiveBoosterMaterial(
return {
fields,
screenshots: normalizeUploadedFiles(rawScreenshots).map(refreshUploadedFileUrls),
screenshots: consignmentScreenshots,
note: String(
consignment?.remark || consignmentInventory?.remark || consignment?.matchNote || '',
).trim(),
@@ -1,5 +1,8 @@
import type { JsonObject } from '../../types/json.js'
import { normalizeStoredFileUrl } from '../file-storage/file-storage-service.js'
import {
extractObjectKeyFromFileUrl,
normalizeStoredFileUrl,
} from '../file-storage/file-storage-service.js'
import { normalizeInteger } from './worker-normalizers.js'
export function normalizeSubmittedFields(value: unknown) {
@@ -33,19 +36,38 @@ export function normalizeUploadedFiles(value: unknown) {
if (!Array.isArray(value)) return []
return value
.map((item) => {
if (typeof item === 'string') {
const rawUrl = item.trim()
if (!rawUrl) return null
const objectKey = extractObjectKeyFromFileUrl(rawUrl)
const normalizedUrl = normalizeStoredFileUrl(rawUrl, objectKey)
return {
objectKey,
url: normalizedUrl,
thumbnailUrl: normalizedUrl,
mediumUrl: normalizedUrl,
filename: '',
contentType: '',
size: 0,
}
}
const source =
item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {}
const objectKey = String(source.objectKey || source.object_key || '').trim()
const url = String(source.url || '').trim()
if (!objectKey && !url) return null
const rawObjectKey = String(source.objectKey || source.object_key || '').trim()
const rawUrl = String(source.url || '').trim()
const objectKey = rawObjectKey || extractObjectKeyFromFileUrl(rawUrl)
if (!objectKey && !rawUrl) return null
return {
objectKey,
url: normalizeStoredFileUrl(url, objectKey),
url: normalizeStoredFileUrl(rawUrl, objectKey),
thumbnailUrl: normalizeStoredFileUrl(
source.thumbnailUrl || source.thumbnail_url || url,
source.thumbnailUrl || source.thumbnail_url || rawUrl,
objectKey,
),
mediumUrl: normalizeStoredFileUrl(
source.mediumUrl || source.medium_url || rawUrl,
objectKey,
),
mediumUrl: normalizeStoredFileUrl(source.mediumUrl || source.medium_url || url, objectKey),
filename: String(source.filename || '').trim(),
contentType: String(source.contentType || source.content_type || '').trim(),
size: normalizeInteger(source.size, 0),
@@ -16,9 +16,9 @@ import {
import { SearchOutlined, TeamOutlined } from '@ant-design/icons'
import {
assignWorkOrderWithConsignmentWorker,
fetchInventoryMatchableOrders,
fetchWorkOrderConsignmentMatchOptions,
fulfillWorkOrderWithConsignment,
} from '@/services/admin/consignment'
import type { ConsignmentMatchOptionView, ConsignmentMatchableOrder } from '@/types/consignment'
import {
@@ -39,7 +39,7 @@ type Selected = {
inventoryLabel: string
matchType: ManualMatchType
conflicts: string[]
/** 供货打手(接单平台指派履约对象) */
/** 供货打手(用于回显供货来源) */
workerId?: number | null
workerName?: string
}
@@ -57,7 +57,7 @@ export type ConsignmentManualMatchModalProps = {
/**
* 「手动指定现货」弹窗:订单侧与库存侧共用一套交互与落地逻辑。
*
* 统一走接单平台指派供货打手履约(自动冻结押金、开启交付倒计时与履约监控)。
* 统一走内部现货直接履约(原子占库、免外派佣金、不冻结重复押金、跳过打手资料直接进入待加好友)。
*/
export default function ConsignmentManualMatchModal({
open,
@@ -106,7 +106,6 @@ export default function ConsignmentManualMatchModal({
? `${res.data.inventory.itemName}(${res.data.inventory.sourceSubmissionNo || res.data.inventory.inventoryNo})`
: inventory.label,
)
// 指派线需要现货的供货打手:库存侧入口从响应里记下来,选中订单时带上
setInventoryWorker({
workerId: res.data?.inventory?.workerId ?? null,
workerName: res.data?.inventory?.workerName || '',
@@ -165,7 +164,6 @@ export default function ConsignmentManualMatchModal({
const requirement = resolveManualMatchRequirement(selected?.matchType || 'exact')
const canSubmit =
Boolean(selected) &&
Boolean(selected?.workerId) &&
canSubmitManualMatch({
matchType: selected?.matchType || 'exact',
confirmedPartial,
@@ -175,16 +173,11 @@ export default function ConsignmentManualMatchModal({
async function handleSubmit() {
if (!selected) return
if (!selected.workerId) {
message.error('该现货未关联供货打手,无法走平台指派履约;请先关联打手')
return
}
setSubmitting(true)
try {
const res = await assignWorkOrderWithConsignmentWorker({
const res = await fulfillWorkOrderWithConsignment({
workOrderId: selected.workOrderId,
inventoryId: selected.inventoryId,
workerId: selected.workerId,
allowPartial: selected.matchType !== 'exact' || confirmedPartial,
allowSpecMismatch: selected.matchType === 'conflict' || confirmedMismatch,
matchNote: matchNote.trim(),
@@ -194,7 +187,7 @@ export default function ConsignmentManualMatchModal({
const voucher = (res.data as any)?.voucherConsume
notifyVoucherConsume(voucher)
if (!voucher || voucher.ok) {
message.success('已手动匹配并指派供货打手履约,履约押金已冻结')
message.success('已使用内部寄售现货直接履约,已进入待加好友阶段')
}
onMatched?.()
resetState()
@@ -221,7 +214,7 @@ export default function ConsignmentManualMatchModal({
void load()
return
}
message.error(err.message || '手动匹配指派失败')
message.error(err.message || '内部现货履约失败')
} finally {
setSubmitting(false)
}
@@ -232,7 +225,7 @@ export default function ConsignmentManualMatchModal({
title={mode === 'order' ? `手动指定现货 · ${workOrder?.label || ''}` : '手动匹配订单'}
open={open}
width={860}
okText="确认指派履约"
okText="确认内部现货履约"
okButtonProps={{
disabled: !canSubmit,
loading: submitting,
@@ -252,13 +245,13 @@ export default function ConsignmentManualMatchModal({
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="warning"
type="info"
showIcon
message="手动匹配将直接占用现货并指派供货打手履约(冻结履约押金并纳入交付监控)。"
message="手动匹配将直接选用内部寄售现货履约(原子占库、免外派佣金、不冻结重复押金、自动跳过打手资料)。"
description={
mode === 'order'
? '下面是该订单的全部在库现货(含规格不一致的),请核对差异后再决定。'
: `当前现货:${inventoryLabel || inventory?.label || ''}。下面是可匹配的订单列表,请核对规格后指派。`
? '下面是该订单的全部在库现货(含规格不一致的),核对差异后直接内部履约。'
: `当前现货:${inventoryLabel || inventory?.label || ''}。下面是可匹配的订单列表,核对后直接内部履约。`
}
/>
@@ -442,18 +435,18 @@ export default function ConsignmentManualMatchModal({
<Descriptions size="small" column={1} style={{ marginBottom: 8 }}>
<Descriptions.Item label="订单">{selected.workOrderLabel || '—'}</Descriptions.Item>
<Descriptions.Item label="现货">{selected.inventoryLabel || '—'}</Descriptions.Item>
<Descriptions.Item label="指派打手">
<Descriptions.Item label="供货来源">
{selected.workerId ? (
<Space size={6}>
<Tag icon={<TeamOutlined />} color="blue">
{selected.workerName || `打手 #${selected.workerId}`}
{selected.workerName || `供货打手 #${selected.workerId}`}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
(工单将进入该打手“我的接单”并冻结履约押金)
(验收后将自动为该打手发放货款)
</Text>
</Space>
) : (
<Text type="danger">该现货未关联供货打手,无法指派</Text>
<Tag color="default">内部库存现货(无打手关联)</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="匹配判定">
@@ -469,15 +462,6 @@ export default function ConsignmentManualMatchModal({
</Descriptions>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{!selected.workerId ? (
<Alert
type="error"
showIcon
message="无法指派打手"
description="该现货未绑定任何供货打手,无法在接单平台中执行指派与押金冻结流程。请先在寄卖管理中关联打手。"
/>
) : null}
{requirement.needsPartialConfirm ? (
<Checkbox
checked={confirmedPartial}
@@ -516,7 +500,7 @@ export default function ConsignmentManualMatchModal({
) : null}
<Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0 }}>
确认后现货立即被占用:内部现货履约进入「待加好友」,指派打手会冻结履约押金。
确认后现货立即被占用并进入「待加好友」,双方加好买家好友后点确认加好友进入赠送冷却期。
如需退回请使用「撤销寄售履约」。
</Paragraph>
</Space>
@@ -808,6 +808,7 @@ export default function WorkOrdersPanel() {
consignmentNo: string
workerName?: string
depositFen?: number | null
costPriceFen?: number | null
onOk: () => void
}) {
const isPartial = input.matchType !== 'exact'
@@ -2413,6 +2414,14 @@ export default function WorkOrdersPanel() {
files={detailOrder.gift.boosterMaterial.screenshots}
size={72}
/>
) : Array.isArray(consignmentInfo?.screenshots) &&
consignmentInfo.screenshots.length > 0 ? (
<ImagePreviewList
imageUrls={(consignmentInfo.screenshots as string[]).filter(
(item) => typeof item === 'string' && item.trim(),
)}
size={72}
/>
) : (
<Typography.Text type="secondary">未提交</Typography.Text>
)}
@@ -3429,49 +3438,36 @@ export default function WorkOrdersPanel() {
backgroundColor: '#fff',
}}
>
{inv.workerId ? (
<>
<Button
block
type="primary"
icon={<TeamOutlined />}
style={{ backgroundColor: '#722ed1', borderColor: '#722ed1' }}
loading={consignmentInterceptModal.submitting}
onClick={() =>
confirmConsignmentMatchAction({
kind: 'assign_worker',
matchType: m.matchType,
consignmentNo: inv.sourceSubmissionNo || '客服手工入库',
workerName: inv.workerName,
depositFen: inv.depositAmountFen,
onOk: () => {
void handleAssignWithConsignmentWorker(
inv.id,
inv.workerId!,
!isExact,
)
},
})
}
>
走接单平台指派打手
</Button>
<div
style={{
fontSize: 11,
color: '#8c8c8c',
marginTop: 6,
textAlign: 'center',
}}
>
指派供货打手履约 · 自动冻结履约押金
</div>
</>
) : (
<div style={{ fontSize: 12, color: '#cf1322', textAlign: 'center' }}>
该现货未关联供货打手,无法走平台指派;请先关联打手
</div>
)}
<Button
block
type="primary"
icon={<PushpinOutlined />}
style={{ backgroundColor: '#52c41a', borderColor: '#52c41a' }}
loading={consignmentInterceptModal.submitting}
onClick={() =>
confirmConsignmentMatchAction({
kind: 'internal_stock',
matchType: m.matchType,
consignmentNo: inv.sourceSubmissionNo || '客服手工入库',
costPriceFen: inv.costPriceFen,
onOk: () => {
void handleFulfillWithConsignment(inv.id, !isExact)
},
})
}
>
内部现货直接履约
</Button>
<div
style={{
fontSize: 11,
color: '#8c8c8c',
marginTop: 6,
textAlign: 'center',
}}
>
免外派佣金 · 不扣打手押金 · 自动进冷静期
</div>
</div>
</div>
</div>
@@ -245,6 +245,17 @@ function GiftProgressCard({ order }: { order: WorkOrder }) {
<div style={{ marginTop: 8 }}>
<ImagePreviewList files={gift.boosterMaterial.screenshots} size={64} />
</div>
) : Array.isArray(
(order.material as Record<string, any> | undefined)?.consignment?.screenshots,
) &&
((order.material as Record<string, any>).consignment.screenshots as unknown[])
.length > 0 ? (
<div style={{ marginTop: 8 }}>
<ImagePreviewList
imageUrls={(order.material as Record<string, any>).consignment.screenshots}
size={64}
/>
</div>
) : null}
</div>
</div>
@@ -78,7 +78,7 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
)
})
test('手动匹配弹窗:数据过期的 409 要清选择并刷新,无打手的现货禁止走指派', () => {
test('手动匹配弹窗:数据过期的 409 要清选择并刷新,走内部现货直接履约', () => {
const source = readFileSync(
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url).pathname,
'utf8',
@@ -87,9 +87,15 @@ test('手动匹配弹窗:数据过期的 409 要清选择并刷新,无打手
assert.ok(source.includes('consignment_inventory_race_condition'), '要处理并发占用错误码')
assert.ok(source.includes('consignment_spec_mismatch'), '要处理规格变化错误码')
assert.ok(source.includes('setSelected(null)'), '数据过期时要清空已选')
// 统一平台履约:没关联打手的现货禁用提交,提交前严格校验 workerId
assert.ok(source.includes('Boolean(selected?.workerId)'), '无打手时必须禁用提交')
assert.ok(source.includes('if (!selected.workerId)'), '提交前要对无打手的现货严格拦截')
// 统一内部现货直接履约:调用 fulfillWorkOrderWithConsignment,免外派佣金与二次押金
assert.ok(
source.includes('fulfillWorkOrderWithConsignment'),
'手动匹配必须调用内部现货直接履约接口',
)
assert.ok(
!source.includes('assignWorkOrderWithConsignmentWorker'),
'手动匹配不得再调用旧的指派打手接口',
)
// 凭证核销反馈与关键字搜索:用共享出口 / 透传后端,不内联复制
assert.ok(source.includes("from '@/utils/voucher-consume'"), '核销反馈必须用共享出口')
assert.ok(source.includes('keyword: keyword.trim() || undefined'), '库存侧关键字要透传后端')
@@ -0,0 +1,151 @@
# 寄卖库存功能 · 现状分析、进度与证据追踪报告
> 更新时间:2026-09-21
> 项目基线:`040b3db8` (main 分支,工作区干净)
> 当前验证状态:后端 501 测试全绿 (498 pass / 0 fail / 3 skip);前端 54 测试全绿 (54 pass / 0 fail);两侧构建与类型检查通过。
---
## 一、 证据分类记录 (Evidence Classification)
### 1. 代码证据 (Code Implementation)
- **打手提报与建议价闭环**:
- `apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx`: 打手端唯一提报入口,支持系统(苹果/安卓)与区服(互通)规范输入;300ms防抖自动带出「接单模板」建议价;支持高档位溢出兜底与手改价保护;支持 URL/localStorage 记忆停留选项卡。
- `apps/backend/src/services/worker-platform/consignment-worker-service.ts`: `suggestConsignmentPriceFromRules` 严格对齐璀璨数(0/1/2/3璀璨)与钻石等级;发布接口强制 `requireActiveWorker`。
- **仓库与审核管理**:
- `apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx`: 寄售仓库管理,支持状态筛选、允许匹配开关、凭证链查看、工单双向匹配与退单回库。
- `apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx`: 寄卖提报审核,支持通过入库与驳回退还押金。
- **匹配与履约执行引擎**:
- `apps/backend/src/services/worker-platform/consignment-matching-service.ts`: `compareSpec` 三态判定(match / mismatch / unknown);支持区服「互通」对称通配;未知项降级为 partial 并列出缺失字段。
- `apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts`: 内部现货履约(原子占库 + 工单绑定 + 停在待加好友)与指派打手押金履约;服务端强制规格复检与冲突留痕。
- **双向手动匹配与工单详情(最近落地)**:
- `apps/frontend/src/pages/admin/ConsignmentManualMatchModal.tsx`: 双向手动匹配弹窗,支持 WO 工单号与 SUB 提报单号模糊检索,支持规格冲突时勾选强制匹配并必填原因。
- `apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx`: 工单详情抽屉完整回显卖家资料(角色名、数字ID、区服、系统、联系方式、单号等)与凭据截图,支持一键聚合复制与单项复制。
### 2. 数据与迁移证据 (Database & Migrations)
- `077_consignment_inventory.sql`: 寄售仓库表 `consignment_inventories` 与提报表 `consignment_submissions` 基线,建立 RBAC 四大权限。
- `078_consignment_worker_deposit.sql`: 打手端押金履约字段与台账支持。
- `079_drop_consignment_collect_links.sql`: 完全移除历史遗留 H5 定向收集渠道(下线 `consignment_collect_links` 表与 `link_id` 列);拆分 `assigned_worker_id`(履约打手)与 `worker_id`(供货卖家);建立绑定工单部分唯一索引与冻结流水去重幂等索引。
- `080_consignment_allocation_status_and_copy.sql`: 约束 `allocation_status IN ('待安排', '已安排')`。
### 3. 资金与状态机闭环证据 (Financial & State Machine Audit)
- **押金流转**:
- 卖家发布提报:冻结发布押金(`deposit_freeze`)。
- 审核驳回:全额退还(`deposit_unfreeze`)。
- 确认加好友后违约/撤单:没收卖家押金(`deposit_deduction`),只扣冻结额,不退可用余额。
- 履约验收:释放卖家发布押金,同时自动补发「供货货款」(`settleConsignmentSupplierPayoutByWorkOrderWithClient`)。
- **撤销与解绑**:
- 撤销白名单严格限制在 `in_progress | pending_acceptance | problem`,验收后绝对禁止撤销;
- 解绑现货时自动识别是否已加好友(已加好友作废 `archived` 下架不回池,未加好友 `in_stock` 回池)。
---
## 二、 进度管理:已知、未知、目标与确信度登记
| 类型 | 事项描述 | 确信度 | 支撑证据 / 依据 | 状态 / 备注 |
| :--- | :--- | :--- | :--- | :--- |
| **已知** | 提报渠道单一化收敛完成 | 100% | 迁移 079 已删 H5 相关表与路由,手工入库已设 409 守卫,唯一入口为打手端提报 | ✅ 已闭环 |
| **已知** | 统一单号锚定与三态状态机 | 100% | 提报单号(SUB...)作为统一主键暴露给前端与客服,隐藏 INV 编号;`domain/consignment-item-stage.ts` 纯函数推导状态 | ✅ 已闭环 |
| **已知** | 资金流转(货款结算 + 押金没收/释放)闭环 | 100% | 批次 Q 补发货款给供货卖家,修 100 倍单位错;批次 F/G 加好友后违约没收押金不可逆 | ✅ 已闭环 |
| **已知** | 双向手动指定现货与强制匹配留痕 | 100% | commit `f07e4087` 支持在工单调起匹配现货、在仓库调起匹配工单,支持工单号与提报单号搜索,冲突强制匹配带原因留痕 | ✅ 已闭环 |
| **已知** | 资料展示与一键复制功能完成 | 100% | commit `f08a221e`, `2c120bd3` 工单详情抽屉完整展示卖家信息并提供聚合及单项复制按钮 | ✅ 已闭环 |
| **已知** | 建议价精准对齐璀璨数与溢出兜底 | 100% | 批次 X/Z:按系统+系列+钻级+璀璨数全匹配接单模板;超出档位按最高配报;手改不覆盖 | ✅ 已闭环 |
| **已知** | 卖家上架主页截图在工单详情抽屉与打手端正常展示 | 100% | 修复 `normalizeUploadedFiles` 字符串解析与 `effectiveBoosterMaterial` 截图兜底,504后端/54前端测试全绿,真实工单 #100140 成功解析出截图 | ✅ 已闭环 |
| **已知** | 仓库搜索框占位符承诺字段与后端实现存在偏差 | 100% | `ConsignmentInventoryPanel.tsx:789` 承诺按“收购人/店铺/卖家”,但 `consignment-repo.ts:178` 未覆盖 `buyer_name`, `shop_name`, `seller_phone` | ⚠️ 待优化(低成本) |
---
## 五、 专项优化:接单工单匹配后详情抽屉展示卖家上架主页截图(2026-09-21 已闭环)
### 1. 现象与线上真实证据
- **问题现象**:接单工单匹配寄售现货后,在管理后台工单详情抽屉的「寄售赠送进度」模块中,「卖家上架/主页截图」区域显示为“未提交”,无法查看卖家寄卖时上传的游戏主页与物资截图。
- **数据库真实记录查证**(工单 `WOc5502148fd73`,ID: `100140`):
- 关联寄售库存 ID: `23` (`INV26091917439822`);
- `material_json.consignment.screenshots` 中确切存有截图:`["/api/v1/files/object?key=consignment_screenshot%2F2026%2F09%2F19%2Fb8b974206a8a0cf6c89a6296.webp"]`(纯字符串 URL 数组);
- 但后端接口返回的 `gift.boosterMaterial.screenshots` 却为空数组 `[]`。
### 2. 代码级根因定位
1. **文件归一化函数 `normalizeUploadedFiles` 丢失字符串 URL 支持(核心根因)**:
- `apps/backend/src/services/worker-platform/worker-input-normalizers.ts:36-40`:
原逻辑仅接受 `{ objectKey, url }` 对象格式;当传入纯字符串 URL 数组(寄售提报老格式与历史库数据)时,`typeof item === 'object'` 判定为 false,提取出的 `objectKey` 和 `url` 均为空串,最终被 `filter(Boolean)` 全数过滤为 `[]`!
2. **工单展示映射器 `resolveWorkOrderEffectiveBoosterMaterial` 缺少截图 fallback**:
- `apps/backend/src/services/worker-platform/work-order-display-mappers.ts:104`:
当工单的 `booster_material_json` 中已存在 `fields`(如之前同步过卖家昵称/ID)但 `screenshots` 为空时,直接提前返回了空截图列表,没有回退到寄售数据的 `consignment.screenshots`。
3. **前端工单详情渲染缺乏兜底**:
- `apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx:2412` 与 `WorkerOrderDetailDrawer.tsx:244`:
原逻辑只读取 `detailOrder.gift.boosterMaterial?.screenshots`,未将 `consignmentInfo?.screenshots` 字符串数组作为第二层降级展示。
### 3. 解决对策与落地成果
1. **文件/URL 解析能力全方位增强**:
- `apps/backend/src/services/file-storage/file-storage-service.ts`:导出 `extractObjectKeyFromFileUrl`,支持从 `/api/v1/files/object?key=...` 中稳定提取 `objectKey`。
- `apps/backend/src/services/worker-platform/worker-input-normalizers.ts`:`normalizeUploadedFiles` 增加分支支持 `typeof item === 'string'`,自动提取 `objectKey` 并标准化为带有 `url`、`thumbnailUrl`、`mediumUrl` 的完整 `UploadedFilePayload`。
2. **展示层截图自动回退**:
- `apps/backend/src/services/worker-platform/work-order-display-mappers.ts`:在 `resolveWorkOrderEffectiveBoosterMaterial` 中,当打手/工单未传截图且关联寄售时,自动取寄售卖家的主页截图兜底填充,并保留 `isConsignmentSellerMaterial` 标识。
3. **前后端双保险与打手端对齐**:
- `apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx`:详情抽屉在 `detailOrder.gift.boosterMaterial.screenshots` 之后追加 `consignmentInfo.screenshots` 字符串列表降级渲染。
- `apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx`:打手端详情抽屉同步增加对寄售截图的降级渲染。
4. **自动化测试与实际验证**:
- 新增后端单元测试 `apps/backend/src/services/worker-platform/consignment-booster-material.test.ts`;
- 真实工单数据(`WOc5502148fd73`)映射执行验证,成功输出标准卖家主页截图对象;
- 后端 504 单测全绿、前端 54 单测全绿,构建与样式检查全部通过。
| **未知** | 待审核状态(pending_review)下打手是否需要自主撤回能力 | 80% | 业务目前仅提供客服审核通过/驳回,打手填错价格或想自用无法自主撤回,需业务确认 | ⏳ 待业务拍板 |
| **未知** | 待加好友停滞是否有超时策略 | 75% | 现货履约停在待加好友,若客服/卖家长时间不操作,工单永久挂起,是否需超时自动预警/工单回退 | ⏳ 待业务拍板 |
| **未知** | 批次 Q 之前已验收的历史寄卖单是否需要补发货款 | 70% | 历史单(如 #100171)在批次 Q 之前结算未获货款,需与运营对账确认是否需要补账脚本 | ⏳ 待对账确认 |
| **目标** | 完善寄卖仓库检索与展示体验 | 95% | 将后端搜索扩展至 `buyer_name`、`shop_name`、`seller_phone`、打手昵称等,兑现前端占位符承诺 | 🎯 近期可推进 |
| **目标** | 打手端支持“撤回提报”并即时解冻押金 | 90% | 在打手端“我的寄卖-待审核”卡片增加撤回功能,避免误提报占用押金和客服审核工时 | 🎯 近期可推进 |
| **目标** | 增加寄卖模块统计与节省赏金看板 | 80% | 补齐设计方案中的“累计节省赏金”、“出库单数”、“货款结算对账”统计图表 | 🎯 中期规划 |
| **目标** | 待加好友超时预警与催促机制 | 85% | 工单列表中对停留在待加好友超过 24/48 小时的寄售工单显示催促角标 | 🎯 近期可推进 |
---
## 三、 思考、灵感与演进分析 (Insights & Inspirations)
### 1. 思考:从“被动等待匹配”到“主动撮合建议”
- **现状**:目前系统主要依靠进单发布时自动检查现货候选,或者客服在管理后台手动调起匹配弹窗。
- **灵感**:当打手发布的高价值现货(例如安卓微信暗潮5钻)审核入库时,后台可自动检索当前排队池(抢单大厅、未分配工单)中是否有完全匹配的订单,并在审核通过成功提示或管理后台右上角通知栏弹窗提醒:“该现货已自动匹配待发工单 #WO26...,是否立即一键履约?”。这将将货找单的时延缩短至秒级。
### 2. 思考:打手端自闭环与客服减负
- **现状**:打手一旦提交寄卖,如果填错账号或写错价格,只能联系客服在后台驳回。
- **灵感**:在 `pending_review`(待审核)阶段,打手拥有随时撤回的权利(打手自主取消 -> 事务内释放押金 -> 状态转 `cancelled`)。这既能防止客服审核了无效的错单,也能大幅降低双方沟通成本。
### 3. 思考:待加好友阶段的履约节奏管控
- **现状**:为了防止错发和押金纠纷,内部现货履约被设计为停在“待加好友”,待加好友后才启动 72 小时赠送冷却。
- **灵感**:游戏内加好友存在单向通过的时滞。可以在工单面板上为“待加好友”超过 12 小时的工单打上“⚠️ 待加好友超时”标签,让客服能集中优先跟进买家/号主,保障履约时效。
---
## 四、 专项排查:打手 A 重复扣押金与二次填写资料的根因分析
### 1. 现象复现路径
- 打手 A 在接单平台提报寄卖一个物品,系统冻结发布押金(`deposit_freeze`,如 ¥20 或 ¥100);
- 后续外部进单匹配到此物品时,实际走入了【指派打手履约(`worker_deposit`)】而非【内部现货直接履约(`internal_stock`)】;
- 导致打手 A 账户被额外扣除一笔【接单履约押金】(双倍资金占用);
- 工单在打手端显示为“待提交资料”,打手 A 被迫重新手动上传游戏昵称、ID 及截图。
### 2. 代码级根因定位
1. **手动匹配弹窗硬编码为指派打手(关键漏项)**:
- `apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx:184`:
不论从寄售仓库还是工单操作列调起手动匹配弹窗,提交按钮直接硬编码调用了 `assignWorkOrderWithConsignmentWorker`(指派打手履约接口);
弹窗内**完全没有提供【内部现货直接履约】(`fulfillWorkOrderWithConsignment`)的选项**。
2. **发布拦截弹窗排版引导偏差**:
- `apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx:3434-3475`:
候选卡片中,【走接单平台指派打手(冻结押金交付)】被置于顶部且为高亮紫色主按钮(Primary),而真正推荐的【内部现货直接履约(免外派佣金)】为次要按钮(Default),客服极易默认点击主按钮。
3. **指派打手履约复用普通工单逻辑导致双冻结与资料丢失**:
- `consignment-fulfillment-service.ts:680` 调用了通用的 `assignAdminWorkOrderToWorker`;
- 通用指派将打手 A 视作外部抢单打手,再次执行 `freezeWorkerOrderDepositWithClient` 冻结履约押金;
- 通用指派将工单置为 `acceptance_mode = 'friend_gift', gift_phase = 'material_required'`,但未自动将现货中的 `seller_game_id`、`credentials_json`、`screenshots_json` 自动写入工单 `booster_material_json` 并推进阶段,导致打手端仍停留在“待提交资料”。
### 3. 解决对策与落地成果(2026-09-21 已闭环)
1. **手动匹配弹窗收敛为单一【内部现货直接履约】**:
- 在 `ConsignmentManualMatchModal.tsx` 中彻底废除 `assignWorkOrderWithConsignmentWorker`,切换为调用 `fulfillWorkOrderWithConsignment`;
- 彻底解除 `selected.workerId` 强依赖与无法指派错误提示,详情卡片调整为展示供货来源(“供货打手”或“内部现货”);
- 文案统一调整为“确认内部现货履约”,明确告知“免外派佣金、不冻结重复押金、自动跳过打手资料”。
2. **工单发布拦截弹窗候选操作收敛为【内部现货直接履约】**:
- 在 `WorkOrdersPanel.tsx` 中将卡片操作按钮替换为【内部现货直接履约】高亮主按钮(绿色),副标题标注“免外派佣金 · 不扣打手押金 · 自动进冷静期”;
- 杜绝客服误点走旧指派流程。
3. **测试套件与类型风格全面达标**:
- `consignment-manual-match-mount.test.ts` 更新为断言手动匹配走内部现货直接履约;
- 前端 54 测试全绿、后端 501 测试全绿;两侧 `typecheck`、`format:check`、`build` 均 100% 通过。