diff --git a/apps/backend/src/services/file-storage/file-storage-service.ts b/apps/backend/src/services/file-storage/file-storage-service.ts index d1f972f5..21fa8747 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.ts @@ -214,7 +214,10 @@ export function refreshUploadedFileUrls(value: unknown) { value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} - 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') diff --git a/apps/backend/src/services/worker-platform/consignment-booster-material.test.ts b/apps/backend/src/services/worker-platform/consignment-booster-material.test.ts new file mode 100644 index 00000000..25a8cb8c --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-booster-material.test.ts @@ -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', + ) +}) diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 02753bad..d32b4b4b 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -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 = { 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(), diff --git a/apps/backend/src/services/worker-platform/worker-input-normalizers.ts b/apps/backend/src/services/worker-platform/worker-input-normalizers.ts index 761b61b6..4d8a6d07 100644 --- a/apps/backend/src/services/worker-platform/worker-input-normalizers.ts +++ b/apps/backend/src/services/worker-platform/worker-input-normalizers.ts @@ -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), diff --git a/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx index 6a9b615e..af26a340 100644 --- a/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx @@ -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({ > @@ -442,18 +435,18 @@ export default function ConsignmentManualMatchModal({ {selected.workOrderLabel || '—'} {selected.inventoryLabel || '—'} - + {selected.workerId ? ( } color="blue"> - {selected.workerName || `打手 #${selected.workerId}`} + {selected.workerName || `供货打手 #${selected.workerId}`} - (工单将进入该打手“我的接单”并冻结履约押金) + (验收后将自动为该打手发放货款) ) : ( - 该现货未关联供货打手,无法指派 + 内部库存现货(无打手关联) )} @@ -469,15 +462,6 @@ export default function ConsignmentManualMatchModal({ - {!selected.workerId ? ( - - ) : null} - {requirement.needsPartialConfirm ? ( - 确认后现货立即被占用:内部现货履约进入「待加好友」,指派打手会冻结履约押金。 + 确认后现货立即被占用并进入「待加好友」,双方加好买家好友后点确认加好友进入赠送冷却期。 如需退回请使用「撤销寄售履约」。 diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 23c6d42c..a6d56b6d 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -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 ? ( + typeof item === 'string' && item.trim(), + )} + size={72} + /> ) : ( 未提交 )} @@ -3429,49 +3438,36 @@ export default function WorkOrdersPanel() { backgroundColor: '#fff', }} > - {inv.workerId ? ( - <> - -
- 指派供货打手履约 · 自动冻结履约押金 -
- - ) : ( -
- 该现货未关联供货打手,无法走平台指派;请先关联打手 -
- )} + +
+ 免外派佣金 · 不扣打手押金 · 自动进冷静期 +
diff --git a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx index 59dbef2e..e60f796a 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx @@ -245,6 +245,17 @@ function GiftProgressCard({ order }: { order: WorkOrder }) {
+ ) : Array.isArray( + (order.material as Record | undefined)?.consignment?.screenshots, + ) && + ((order.material as Record).consignment.screenshots as unknown[]) + .length > 0 ? ( +
+ ).consignment.screenshots} + size={64} + /> +
) : null} diff --git a/apps/frontend/tests/consignment-manual-match-mount.test.ts b/apps/frontend/tests/consignment-manual-match-mount.test.ts index 68e45bf0..4059b651 100644 --- a/apps/frontend/tests/consignment-manual-match-mount.test.ts +++ b/apps/frontend/tests/consignment-manual-match-mount.test.ts @@ -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'), '库存侧关键字要透传后端') diff --git a/docs/寄卖库存优化现状与进度追踪.md b/docs/寄卖库存优化现状与进度追踪.md new file mode 100644 index 00000000..be3db0f7 --- /dev/null +++ b/docs/寄卖库存优化现状与进度追踪.md @@ -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% 通过。 + +