feat(consignment): 客服端凭证链可视化(提报凭证 + 赠送凭证)
问题:一件寄卖有两类凭证,客服界面只看得见第一类,还是 48x48 一张缩略图, 看不出是主页还是物品;第二类(证明"这件货真送出去了"的赠送完成截图)只在工单验收界面, 客服要证明赠送是否完成得自己跨模块翻,没有任何地方把两类凭证并排呈现。 - 两类凭证分别在哪里: 提报凭证 = 提报表 screenshots_json(卖家提报时的游戏主页截图,含物品/仓库详情) 赠送凭证 = 绑定工单的验收资料 work_orders.acceptance_json(冷却结束后上传、客服凭此验收) - 后端把凭证链从客服两个列表/详情接口一起下发:提报表/库存 → 结果库存 → 绑定工单, 新增 submissionScreenshots / giftEvidenceImages / giftEvidenceNote / giftEvidenceSubmittedAt / giftWorkOrderNo / giftWorkOrderStatus / giftEvidenceReviewStatus(none|pending|accepted|submitted); 赠送凭证图从 imageUrls 与 files[].url|mediumUrl|thumbnailUrl 两处取并去重 - 提报凭证口径统一:有来源提报单用提报单主页截图,客服手工入库现货回退库存自身截图, 前端只看 submissionScreenshots 一个字段,不会拿错 - 前端新增共享组件 ConsignmentEvidenceGallery(缩略图 + 大图预览 + 共 N 张, 预览用 Image.PreviewGroup items,被折叠的图在大图里仍能翻看);审核面板把「凭据截图」 拆成「提报凭证(游戏主页截图)」与「赠送凭证」(带待验收/已验收 Tag + 关联工单 Tooltip), 仓库面板新增「凭证」列并分组展示,两个详情弹窗同口径(含备注/提交时间/工单号/一句话说明) 真实库验证:赠送凭证解析在构造的工单验收资料上取到 3 张图(imageUrls 1 + files 2)并识别 pending_acceptance → 待客服验收;提报凭证在真实数据上取到 1 张。临时数据已清理。
This commit is contained in:
@@ -35,6 +35,12 @@ export type ConsignmentInventoryRow = {
|
||||
source_submission_id: string | number | null
|
||||
/** 唯一锚点:来源提报单号(客服手工入库的现货没有) */
|
||||
source_submission_no?: string | null
|
||||
/** 提报凭证(卖家提报时上传的物品/主页截图) */
|
||||
submission_screenshots_json?: string | unknown[] | null
|
||||
/** 赠送凭证:冷却结束后上传的赠送完成截图(存在绑定工单的验收资料里) */
|
||||
gift_evidence_json?: string | Record<string, unknown> | null
|
||||
gift_work_order_no?: string | null
|
||||
gift_work_order_status?: string | null
|
||||
worker_id?: string | number | null
|
||||
worker_name?: string
|
||||
worker_phone?: string
|
||||
@@ -74,6 +80,10 @@ export type ConsignmentSubmissionRow = {
|
||||
reviewer_name: string
|
||||
reviewed_at: string | null
|
||||
result_inventory_id: string | number | null
|
||||
/** 赠送凭证:履约方冷却结束后上传的赠送完成截图(存在绑定工单的验收资料里) */
|
||||
gift_evidence_json?: string | Record<string, unknown> | null
|
||||
gift_work_order_no?: string | null
|
||||
gift_work_order_status?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
category_name?: string
|
||||
@@ -180,10 +190,15 @@ export async function listConsignmentInventories(filters: {
|
||||
SELECT
|
||||
ci.*,
|
||||
wc.name AS category_name,
|
||||
cs.submission_no AS source_submission_no
|
||||
cs.submission_no AS source_submission_no,
|
||||
cs.screenshots_json AS submission_screenshots_json,
|
||||
wo.acceptance_json AS gift_evidence_json,
|
||||
wo.work_order_no AS gift_work_order_no,
|
||||
wo.status AS gift_work_order_status
|
||||
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
|
||||
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
|
||||
${whereClause}
|
||||
ORDER BY ci.id DESC
|
||||
LIMIT $${values.length - 1} OFFSET $${values.length}
|
||||
@@ -290,10 +305,15 @@ export async function getConsignmentInventoryById(
|
||||
SELECT
|
||||
ci.*,
|
||||
wc.name AS category_name,
|
||||
cs.submission_no AS source_submission_no
|
||||
cs.submission_no AS source_submission_no,
|
||||
cs.screenshots_json AS submission_screenshots_json,
|
||||
wo.acceptance_json AS gift_evidence_json,
|
||||
wo.work_order_no AS gift_work_order_no,
|
||||
wo.status AS gift_work_order_status
|
||||
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
|
||||
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
|
||||
WHERE ci.id = $1
|
||||
LIMIT 1
|
||||
`
|
||||
@@ -941,12 +961,18 @@ export async function listConsignmentSubmissions(filters: {
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
values.push(pageSize, offset)
|
||||
// 赠送凭证存在绑定工单的验收资料里:提报行 → 结果库存 → 绑定工单
|
||||
const listSql = `
|
||||
SELECT
|
||||
cs.*,
|
||||
wc.name AS category_name
|
||||
wc.name AS category_name,
|
||||
wo.acceptance_json AS gift_evidence_json,
|
||||
wo.work_order_no AS gift_work_order_no,
|
||||
wo.status AS gift_work_order_status
|
||||
FROM consignment_submissions cs
|
||||
LEFT JOIN work_categories wc ON wc.id = cs.category_id
|
||||
LEFT JOIN consignment_inventories rinv ON rinv.id = cs.result_inventory_id
|
||||
LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id
|
||||
${whereClause}
|
||||
ORDER BY cs.id DESC
|
||||
LIMIT $${values.length - 1} OFFSET $${values.length}
|
||||
|
||||
@@ -70,6 +70,70 @@ function pickScreenshotsJson(payload: JsonObject): unknown[] | undefined {
|
||||
return Array.isArray(value) ? value : undefined
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
const parsed = safeParseJson(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function toScreenshotList(value: unknown): string[] {
|
||||
const list = Array.isArray(value) ? value : []
|
||||
return list.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一件寄卖的**凭证链**:提报凭证(卖家提报时上传的物品/主页截图)+ 赠送凭证
|
||||
* (履约方冷却结束后上传的赠送完成截图,落在绑定工单的验收资料里,客服凭它验收)。
|
||||
*
|
||||
* 这两个凭证以前散在两处、只有提报凭证在寄卖审核界面可见,客服要证明"这件货真送出去了"
|
||||
* 得自己去工单里翻,所以这里统一从客服的两个列表接口下发。
|
||||
*/
|
||||
function mapConsignmentEvidence(source: {
|
||||
submission_screenshots_json?: unknown
|
||||
screenshots_json?: unknown
|
||||
gift_evidence_json?: string | Record<string, unknown> | null
|
||||
gift_work_order_no?: string | null
|
||||
gift_work_order_status?: string | null
|
||||
}) {
|
||||
const acceptance = toRecord(source.gift_evidence_json)
|
||||
const files = Array.isArray(acceptance?.files) ? (acceptance.files as unknown[]) : []
|
||||
const fileUrls = files.map((file) => {
|
||||
const record = toRecord(file)
|
||||
return String(record?.url || record?.mediumUrl || record?.thumbnailUrl || '').trim()
|
||||
})
|
||||
const giftImages = [
|
||||
...new Set([...toScreenshotList(acceptance?.imageUrls), ...fileUrls.filter(Boolean)]),
|
||||
]
|
||||
const workOrderStatus = String(source.gift_work_order_status || '')
|
||||
const reviewStatus =
|
||||
giftImages.length === 0
|
||||
? 'none'
|
||||
: workOrderStatus === 'accepted'
|
||||
? 'accepted'
|
||||
: workOrderStatus === 'pending_acceptance'
|
||||
? 'pending'
|
||||
: 'submitted'
|
||||
|
||||
return {
|
||||
/** 提报凭证(物品/主页截图) */
|
||||
submissionScreenshots: toScreenshotList(
|
||||
source.submission_screenshots_json ?? source.screenshots_json,
|
||||
),
|
||||
/** 赠送凭证(赠送完成截图) */
|
||||
giftEvidenceImages: giftImages,
|
||||
giftEvidenceNote: String(acceptance?.note || ''),
|
||||
giftEvidenceSubmittedAt: String(acceptance?.submittedAt || ''),
|
||||
giftWorkOrderNo: source.gift_work_order_no || '',
|
||||
giftWorkOrderStatus: workOrderStatus,
|
||||
/** none=还没交 / pending=交了待客服验收 / accepted=客服已验收 / submitted=其他在途状态 */
|
||||
giftEvidenceReviewStatus: reviewStatus,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
@@ -110,6 +174,7 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
|
||||
sourceSubmissionId: row.source_submission_id ? Number(row.source_submission_id) : null,
|
||||
/** 唯一锚点:卖家端看到的「寄卖单号」就是它,客服端也用同一个号 */
|
||||
sourceSubmissionNo: row.source_submission_no || '',
|
||||
...mapConsignmentEvidence(row),
|
||||
workerId: row.worker_id ? Number(row.worker_id) : null,
|
||||
workerName: row.worker_name || '',
|
||||
workerPhone: row.worker_phone || '',
|
||||
@@ -158,6 +223,7 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
|
||||
reviewerName: row.reviewer_name || '',
|
||||
reviewedAt: row.reviewed_at,
|
||||
resultInventoryId: row.result_inventory_id ? Number(row.result_inventory_id) : null,
|
||||
...mapConsignmentEvidence(row),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { mapConsignmentInventory, mapConsignmentSubmission } from './consignment-admin-service.js'
|
||||
|
||||
function inventoryRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
inventory_no: 'INV1',
|
||||
game_name: '和平精英',
|
||||
category_id: null,
|
||||
item_name: '苹果微信区血宴3钻2璀璨 苏尔南',
|
||||
system: '苹果',
|
||||
server_zone: '微信区',
|
||||
series: '血宴',
|
||||
diamond_level: 3,
|
||||
brilliance_count: 2,
|
||||
cost_price_fen: 11500,
|
||||
status: 'consumed',
|
||||
allocation_status: '已安排',
|
||||
source_type: 'worker_portal',
|
||||
source_submission_id: 9,
|
||||
source_submission_no: 'SUB1',
|
||||
screenshots_json: [],
|
||||
submission_screenshots_json: ['https://cdn.example.com/publish-1.png'],
|
||||
...overrides,
|
||||
} as never
|
||||
}
|
||||
|
||||
test('赠送凭证从工单验收资料的 imageUrls 与 files 两处取图并去重', () => {
|
||||
const mapped = mapConsignmentInventory(
|
||||
inventoryRow({
|
||||
gift_evidence_json: {
|
||||
note: '已赠送',
|
||||
imageUrls: ['https://cdn.example.com/a.png', 'https://cdn.example.com/a.png'],
|
||||
files: [
|
||||
{ url: 'https://cdn.example.com/b.png' },
|
||||
{ mediumUrl: 'https://cdn.example.com/c.png' },
|
||||
{ thumbnailUrl: '' },
|
||||
{},
|
||||
],
|
||||
submittedAt: '2026-09-17T10:00:00.000Z',
|
||||
},
|
||||
gift_work_order_no: 'WO1',
|
||||
gift_work_order_status: 'pending_acceptance',
|
||||
}),
|
||||
)
|
||||
|
||||
assert.deepEqual(mapped.giftEvidenceImages, [
|
||||
'https://cdn.example.com/a.png',
|
||||
'https://cdn.example.com/b.png',
|
||||
'https://cdn.example.com/c.png',
|
||||
])
|
||||
assert.equal(mapped.giftEvidenceNote, '已赠送')
|
||||
assert.equal(mapped.giftEvidenceSubmittedAt, '2026-09-17T10:00:00.000Z')
|
||||
assert.equal(mapped.giftWorkOrderNo, 'WO1')
|
||||
})
|
||||
|
||||
test('赠送凭证状态跟着绑定工单走:未交/待验收/已验收', () => {
|
||||
assert.equal(
|
||||
mapConsignmentInventory(inventoryRow({ gift_evidence_json: null })).giftEvidenceReviewStatus,
|
||||
'none',
|
||||
)
|
||||
assert.equal(
|
||||
mapConsignmentInventory(
|
||||
inventoryRow({
|
||||
gift_evidence_json: { imageUrls: ['https://cdn.example.com/a.png'] },
|
||||
gift_work_order_status: 'pending_acceptance',
|
||||
}),
|
||||
).giftEvidenceReviewStatus,
|
||||
'pending',
|
||||
)
|
||||
assert.equal(
|
||||
mapConsignmentInventory(
|
||||
inventoryRow({
|
||||
gift_evidence_json: { imageUrls: ['https://cdn.example.com/a.png'] },
|
||||
gift_work_order_status: 'accepted',
|
||||
}),
|
||||
).giftEvidenceReviewStatus,
|
||||
'accepted',
|
||||
)
|
||||
// 交了图但工单还没走到待验收(例如超时挂起),也不能说成"已验收"
|
||||
assert.equal(
|
||||
mapConsignmentInventory(
|
||||
inventoryRow({
|
||||
gift_evidence_json: { imageUrls: ['https://cdn.example.com/a.png'] },
|
||||
gift_work_order_status: 'in_progress',
|
||||
}),
|
||||
).giftEvidenceReviewStatus,
|
||||
'submitted',
|
||||
)
|
||||
})
|
||||
|
||||
test('提报凭证优先用来源提报单的截图,客服手工入库的现货回退自身截图', () => {
|
||||
assert.deepEqual(mapConsignmentInventory(inventoryRow()).submissionScreenshots, [
|
||||
'https://cdn.example.com/publish-1.png',
|
||||
])
|
||||
const manual = mapConsignmentInventory(
|
||||
inventoryRow({
|
||||
source_submission_id: null,
|
||||
submission_screenshots_json: null,
|
||||
screenshots_json: ['https://cdn.example.com/manual.png'],
|
||||
}),
|
||||
)
|
||||
assert.deepEqual(manual.submissionScreenshots, ['https://cdn.example.com/manual.png'])
|
||||
})
|
||||
|
||||
test('脏数据不炸:坏 JSON / 空串 / 非数组都退回空凭证', () => {
|
||||
for (const bad of ['', '{坏json', null, 42, []]) {
|
||||
const mapped = mapConsignmentInventory(
|
||||
inventoryRow({ gift_evidence_json: bad, submission_screenshots_json: bad }),
|
||||
)
|
||||
assert.deepEqual(mapped.giftEvidenceImages, [])
|
||||
assert.deepEqual(mapped.submissionScreenshots, [])
|
||||
assert.equal(mapped.giftEvidenceReviewStatus, 'none')
|
||||
}
|
||||
})
|
||||
|
||||
test('提报行同样带出赠送凭证(审核界面要看这件货有没有真的送出去)', () => {
|
||||
const mapped = mapConsignmentSubmission({
|
||||
id: 9,
|
||||
submission_no: 'SUB1',
|
||||
game_name: '和平精英',
|
||||
item_name: '苹果微信区血宴3钻2璀璨 苏尔南',
|
||||
system: '苹果',
|
||||
server_zone: '微信区',
|
||||
series: '血宴',
|
||||
diamond_level: 3,
|
||||
brilliance_count: 2,
|
||||
screenshots_json: ['https://cdn.example.com/publish-1.png'],
|
||||
asking_price_fen: 13000,
|
||||
worker_id: 3,
|
||||
status: 'approved',
|
||||
result_inventory_id: 1,
|
||||
gift_evidence_json: { imageUrls: ['https://cdn.example.com/gift.png'] },
|
||||
gift_work_order_no: 'WO1',
|
||||
gift_work_order_status: 'accepted',
|
||||
created_at: '2026-09-17T10:00:00.000Z',
|
||||
updated_at: '2026-09-17T10:00:00.000Z',
|
||||
} as never)
|
||||
|
||||
assert.deepEqual(mapped.screenshotsJson, ['https://cdn.example.com/publish-1.png'])
|
||||
assert.deepEqual(mapped.giftEvidenceImages, ['https://cdn.example.com/gift.png'])
|
||||
assert.equal(mapped.giftEvidenceReviewStatus, 'accepted')
|
||||
assert.equal(mapped.giftWorkOrderNo, 'WO1')
|
||||
})
|
||||
Reference in New Issue
Block a user