feat(worker-platform): 寄卖价格改由接单模板只读定价,我的寄卖支持服务端搜索与分页

1. 寄售价不再由卖家填写:打手端改为只读展示模板价(与履约押金同款只读框),服务端在发布时按同一套规则复算金额,保证展示口径与落库口径一致,直连接口也无法随意定价;
2. 模板缺档按「最接近的已配置档位」报价、距离相同取更低档:后台只配了 1/3/7 钻时,号主出 2 钻报 1 钻价、出 6 钻报 7 钻价,不再因为中间档没配价而拿不到价格;
3. 我的寄卖列表把分组、关键字、翻页全部下沉到 SQL:新增 listWorkerConsignmentPage 用 UNION ALL 合成「提报单行 + 无提报单库存兜底行」,CASE 推导 待审核/履约中/已完成 分组,返回当前分组 total 与三个分组的角标计数;
4. 关键字覆盖寄卖单号、规格名、系统/区服/系列/钻级、卖家角色名与数字 ID、联系方式、买家昵称与游戏 ID(读取 work_orders.material_json.collect.fields 的多种历史键名);
5. 前端搜索框 300ms 防抖后才请求服务端、换分组或换关键字自动回到第 1 页、列表底部增加分页器,并移除原来固定 pageSize 200 的隐形截断;
6. 新增仓库层与匹配层单测,其中包含 SQL 分组 CASE 与 domain/consignment-item-stage.ts 的 group 映射交叉断言,防止两处状态机漂移;后端 516 项、前端 66 项测试全部通过。
This commit is contained in:
yml2213
2026-10-01 11:07:28 +08:00
parent ef491660e7
commit 1636616ec1
10 changed files with 853 additions and 265 deletions
@@ -418,21 +418,80 @@ test('发布寄卖建议价:按 系统+系列+钻级 从接单模板带出(
assert.equal(hit.suggestedPriceFen, 12000, '多条命中取最高价(12000 > 9999)')
assert.equal(hit.matchedRules.length, 2)
// 系统不符不命中:苹果的 1钻 没配价
// 该端只配了更高档:苹果在暗潮下只有 5钻,1钻没有任何更低档可退 → 落到唯一已配档(5钻)
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '苹果', series: '暗潮', diamondLevel: 1 }),
null,
suggestConsignmentPriceFromRules(rules, { system: '苹果', series: '暗潮', diamondLevel: 1 })
?.suggestedPriceFen,
30000,
)
// 钻级不符不命中:2钻规则没价格、6钻是另一档
// 该端在该系列下完全没有模板才返回 null(苹果+怒嚎:怒嚎只有安卓配过价)
// 中间钻级没配价 → 退到最接近的已配置钻级:2钻距 1钻(1) 比 6钻(4) 更近 → 报 1钻价
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 2 }),
null,
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 2 })
?.suggestedPriceFen,
12000,
'2钻未配价时取最接近的 1钻',
)
// 超出后台最大已配钻级 → 落到最大档
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 7 })
?.suggestedPriceFen,
50000,
'7钻超出配置上限时落到 6钻',
)
// 系列不符不命中
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '怒嚎', diamondLevel: 1 }),
null,
)
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '苹果', series: '怒嚎', diamondLevel: 1 }),
null,
)
})
test('发布寄卖建议价:钻级/璀璨距离相同时取更低档,不给低配报高价', () => {
const rules = [
{ ruleKey: 'd1', productName: '安卓暗潮1钻', rewardAmountFen: 10000 },
{ ruleKey: 'd3', productName: '安卓暗潮3钻', rewardAmountFen: 30000 },
]
// 2钻到 1钻/3钻 距离都是 1 → 取更低的 1钻
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 2 })
?.suggestedPriceFen,
10000,
)
// 低于最小已配钻级 → 落到最小档
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 1 })
?.suggestedPriceFen,
10000,
)
// 璀璨档缺失:3钻只配了 0璀 与 3璀,号主出 1璀 → 距离 1 与 2 → 取 0璀
const brillianceRules = [
{ ruleKey: 'b0', productName: '安卓暗潮3钻', rewardAmountFen: 30000 },
{ ruleKey: 'b3', productName: '安卓暗潮3钻3璀', rewardAmountFen: 45000 },
]
assert.equal(
suggestConsignmentPriceFromRules(brillianceRules, {
system: '安卓',
series: '暗潮',
diamondLevel: 3,
brillianceCount: 1,
})?.suggestedPriceFen,
30000,
'1璀未配价时取最接近的 0璀',
)
assert.equal(
suggestConsignmentPriceFromRules(brillianceRules, {
system: '安卓',
series: '暗潮',
diamondLevel: 3,
brillianceCount: 5,
})?.suggestedPriceFen,
45000,
'超出最大璀璨档时落到 3璀',
)
})
test('发布寄卖建议价:模板写「通用/双端」时两端都命中', () => {
@@ -551,7 +610,7 @@ test('发布寄卖建议价:按璀璨数精准区分价格,不会同钻级
assert.equal(hit5?.suggestedPriceFen, 28000)
})
test('发布寄卖建议价:号主钻级比后台配置高时,按已配置的最高钻级报价(7钻报6钻价,低钻不报高钻)', () => {
test('发布寄卖建议价:号主钻级比后台配置高时按最高档,缺档时取最接近档(距离相同取更低档)', () => {
const rules = [
{ ruleKey: 'rule-1z', productName: '安卓怒嚎1钻', rewardAmountFen: 10000 },
{ ruleKey: 'rule-6z-0c', productName: '安卓怒嚎6钻', rewardAmountFen: 35000 },
@@ -579,16 +638,17 @@ test('发布寄卖建议价:号主钻级比后台配置高时,按已配置
assert.equal(hit7z5c?.suggestedPriceFen, 42000)
assert.equal(hit7z5c?.matchedRules[0].ruleKey, 'rule-6z-2c')
// 低配不能充高配:号主出 2钻(未超出 6钻 上限,但未单独配置 2钻):不命中返回 null,绝不误报 6钻
// 号主出 2钻:后台只配了 1钻 与 6钻,2钻到 1钻(距离1) 比到 6钻(距离4) 更近 → 报 1钻价,绝不误报 6钻
const hit2z = suggestConsignmentPriceFromRules(rules, {
system: '安卓',
series: '怒嚎',
diamondLevel: 2,
brillianceCount: 0,
})
assert.equal(hit2z, null)
assert.equal(hit2z?.suggestedPriceFen, 10000)
assert.equal(hit2z?.matchedRules[0].ruleKey, 'rule-1z')
// 号主出 1钻,但如果后台只有 3钻 以上规则,不命中返回 null
// 号主出 1钻,后台只有 3钻 规则:没有任何更低档可退,只能落到唯一的最低已配档(3钻)
const onlyHighRules = [{ ruleKey: 'rule-3z', productName: '安卓怒嚎3钻', rewardAmountFen: 20000 }]
assert.equal(
suggestConsignmentPriceFromRules(onlyHighRules, {
@@ -596,8 +656,8 @@ test('发布寄卖建议价:号主钻级比后台配置高时,按已配置
series: '怒嚎',
diamondLevel: 1,
brillianceCount: 0,
}),
null,
})?.suggestedPriceFen,
20000,
)
})
@@ -623,12 +623,12 @@ export type ConsignmentPriceSuggestionRule = {
*
* 模板的商品名(如「安卓暗潮1钻」)用与订单同一套规格解析器拆解:
* 系统(含通用通配)、系列必须一致;区服不参与比对。
* 钻级与璀璨数支持高配向下兜底匹配(号主出得比配置高时,报价当前已配置的最高规格):
* 1. 钻级对齐:优先精确匹配号主钻级;若号主钻级高于后台配置上限(如号主出7钻但后台只配了1~6钻),
* 按后台已配置的最大钻级(6钻)报价。低钻不能配高钻(号主出1钻不报6钻价)。
* 2. 璀璨对齐:在该钻级下,优先精确匹配号主璀璨;若号主璀璨高于后台配置上限(如号主出3~5璀但后台只配了1~2璀),
* 按后台该钻级已配置的最大璀璨档位报价。
* 3. 命中多条(如通用与专属端同存)取最高价,卖家仍可手动修改。
* 钻级与璀璨数按「最接近的已配置档位」兜底,保证规格选齐后一定给得出价格:
* 1. 钻级对齐:优先精确匹配号主钻级;该钻级没配价时取最接近的已配置钻级,
* 距离相同取更低档(号主出2钻、后台只配了1钻与3钻 → 报1钻价)。低钻不能配高钻。
* 2. 璀璨对齐:在该钻级下,优先精确匹配号主璀璨;该璀璨档没配价时同样取最接近的已配置档位,距离相同取更低档。
* 3. 命中多条(如通用与专属端同存)取最高价。
* 只有当「该系列在该端下完全没有配置过任何有价模板」时才返回 null。
*/
export function suggestConsignmentPriceFromRules(
rules: ConsignmentPriceSuggestionRule[],
@@ -671,10 +671,11 @@ export function suggestConsignmentPriceFromRules(
if (seriesCandidates.length === 0) return null
// 2. 钻级对齐:
// 查找当前系统与系列已配置的最大钻级;若号主出的钻级比后台配置高(如后台只设置了6个价格,号主出7个),
// 按后台已配置的最大钻级报价;未超出配置上限时必须精确对齐钻级(低钻不报高钻,中间未配价的钻级不向下误报)
const maxConfiguredDiamond = Math.max(...seriesCandidates.map((item) => item.diamondLevel))
const targetDiamond = wantedDiamond > maxConfiguredDiamond ? maxConfiguredDiamond : wantedDiamond
// 优先精确对齐;该钻级没配价时退到「最接近的已配置钻级」(如后台只配了 1/3/7 钻、号主出 2 钻 → 取 1 钻),
// 距离相同取更低档,避免把低钻按高钻价报出去;号主钻级高于后台已配最大档时自然落到最大档。
const configuredDiamonds = Array.from(new Set(seriesCandidates.map((item) => item.diamondLevel)))
const targetDiamond = pickNearestConfiguredTier(configuredDiamonds, wantedDiamond)
if (targetDiamond === null) return null
const targetDiamondCandidates = seriesCandidates.filter(
(item) => item.diamondLevel === targetDiamond,
@@ -682,25 +683,20 @@ export function suggestConsignmentPriceFromRules(
if (targetDiamondCandidates.length === 0) return null
// 3. 璀璨对齐:
// 若未指定璀璨(null),取 targetDiamond 下所有规则
// 若指定了璀璨:比后台配置高(如后台该钻级只配置到 1~2 璀,号主出 3/4/5 等),按已配置的最大璀璨报价;
// 未超出配置上限时严格精确对齐璀璨档位(0璀对0璀、1璀对1璀,不拿高璀价格报给低璀)
// 若未指定璀璨(null),取 targetDiamond 下所有规则;
// 若指定了璀璨:优先精确对齐,该璀璨档没配价时退到最接近的已配置档位,距离相同取更低档。
let matchedItems: typeof targetDiamondCandidates
if (wantedBrilliance === null) {
matchedItems = targetDiamondCandidates
} else {
const maxConfiguredBrilliance = Math.max(
...targetDiamondCandidates.map((item) => item.brillianceCount),
const configuredBrilliance = Array.from(
new Set(targetDiamondCandidates.map((item) => item.brillianceCount)),
)
const targetBrilliance = pickNearestConfiguredTier(configuredBrilliance, wantedBrilliance)
if (targetBrilliance === null) return null
matchedItems = targetDiamondCandidates.filter(
(item) => item.brillianceCount === targetBrilliance,
)
if (wantedBrilliance > maxConfiguredBrilliance) {
matchedItems = targetDiamondCandidates.filter(
(item) => item.brillianceCount === maxConfiguredBrilliance,
)
} else {
matchedItems = targetDiamondCandidates.filter(
(item) => item.brillianceCount === wantedBrilliance,
)
}
}
if (matchedItems.length === 0) return null
@@ -708,3 +704,26 @@ export function suggestConsignmentPriceFromRules(
const matchedRules = matchedItems.map((item) => item.rule)
return { suggestedPriceFen, matchedRules }
}
/**
* 从「已配置档位」里挑最接近目标的一档。
* 距离相同时取更低档:宁可少报也不要把低配按高配价格报出去。
*/
function pickNearestConfiguredTier(configured: number[], wanted: number): number | null {
const tiers = configured
.map((value) => Math.round(Number(value)))
.filter((value) => Number.isFinite(value))
.sort((a, b) => a - b)
if (tiers.length === 0) return null
let best = tiers[0]!
let bestDistance = Math.abs(best - wanted)
for (const tier of tiers) {
const distance = Math.abs(tier - wanted)
if (distance < bestDistance) {
best = tier
bestDistance = distance
}
}
return best
}
@@ -95,3 +95,15 @@ test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截',
'未开通必须返回专门的 consignment_permission_required 错误码',
)
})
test('寄售价以接单模板为准:服务端复算模板价,不信任前端传来的金额', async () => {
const serviceSource = await readServiceSource()
assert.ok(
serviceSource.includes('getWorkerConsignmentPriceSuggestion({'),
'发布时必须按同一套模板规则复算价格',
)
assert.ok(
serviceSource.includes('templatePriceFen > 0 ? templatePriceFen : submittedPriceFen'),
'模板命中时用模板价,只有完全没配模板时才回落到调用方传来的金额',
)
})
@@ -4,8 +4,10 @@ import {
createConsignmentSubmissionWithClient,
getConsignmentInventoryBySubmissionId,
getConsignmentSubmissionById,
listConsignmentInventories,
listConsignmentSubmissions,
listConsignmentInventoriesByIds,
listConsignmentSubmissionsByIds,
listWorkerConsignmentPage,
type WorkerConsignmentGroup,
} from '../../repositories/worker-platform/consignment-repo.js'
import { getWorkerUserById } from '../../repositories/worker-platform/index.js'
import { listEnabledWorkProductRulesForSuggestion } from '../../repositories/worker-platform/product-match-repo.js'
@@ -85,7 +87,7 @@ export type ConsignmentSellerInfo = {
}
function extractConsignmentSellerInfoFromSubmission(
sub: Awaited<ReturnType<typeof listConsignmentSubmissions>>['items'][number],
sub: Awaited<ReturnType<typeof listConsignmentSubmissionsByIds>>[number],
): ConsignmentSellerInfo {
const content = toRecord(sub.content_json) || {}
let characterName = String(content.characterName || '').trim()
@@ -114,7 +116,7 @@ function extractConsignmentSellerInfoFromSubmission(
}
function extractConsignmentSellerInfoFromInventory(
inv: Awaited<ReturnType<typeof listConsignmentInventories>>['items'][number],
inv: Awaited<ReturnType<typeof listConsignmentInventoriesByIds>>[number],
): ConsignmentSellerInfo {
let characterName = ''
let characterId = ''
@@ -244,13 +246,18 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
})
}
const askingPriceFen = Math.max(100, Math.round(Number(payload.askingPriceFen || 0)))
if (askingPriceFen < 100) {
throw createHttpError('寄售价格不能少于1元', {
statusCode: 400,
errorCode: 'invalid_asking_price',
})
}
// 寄售价以「接单模板」为准:服务端按同一套规则复算,不信任前端传来的金额
// (卖家端已把该字段改成只读展示,这里再兜一道,避免直连接口随意定价)。
// 只有该规格在模板里完全没配过价时才回落到调用方传来的值。
const priceSuggestion = await getWorkerConsignmentPriceSuggestion({
system,
series,
diamondLevel,
brillianceCount,
})
const templatePriceFen = Math.max(0, Math.round(Number(priceSuggestion.suggestedPriceFen || 0)))
const submittedPriceFen = Math.max(100, Math.round(Number(payload.askingPriceFen || 0)))
const askingPriceFen = templatePriceFen > 0 ? templatePriceFen : submittedPriceFen
const depositAmountFen = getConsignmentPublishDepositFen()
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
@@ -348,26 +355,45 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
* 卖家看到同一件货两行、两个号。现在**以提报单号为唯一锚点**聚合:一个提报单一条记录,
* 库存与绑定工单的信息挂上去,阶段由统一状态机推导(三套底层状态 → 一个卖家看得懂的阶段)。
*/
export async function listWorkerConsignments(workerId: number) {
const [submissionsRes, inventoriesRes] = await Promise.all([
listConsignmentSubmissions({ workerId, pageSize: 200 }),
listConsignmentInventories({ workerId, pageSize: 200 }),
])
/**
* 「我的寄卖」列表:服务端搜索 + 分组 + 分页。
*
* 分组、关键字、翻页全部在 SQL 完成(见 listWorkerConsignmentPage),
* 这里只负责按行标识回填完整数据并推导展示阶段——阶段口径仍然只由
* domain/consignment-item-stage.ts 决定,避免两处状态机漂移。
*/
export async function listWorkerConsignments(
workerId: number,
options: {
group?: WorkerConsignmentGroup | undefined
keyword?: string | undefined
page?: number | undefined
pageSize?: number | undefined
} = {},
) {
const page = await listWorkerConsignmentPage({
workerId,
group: options.group,
keyword: options.keyword,
page: options.page,
pageSize: options.pageSize,
})
const publishDepositFen = getConsignmentPublishDepositFen()
const inventoryBySubmission = new Map<number, (typeof inventoriesRes.items)[number]>()
const orphanInventories: typeof inventoriesRes.items = []
for (const inv of inventoriesRes.items) {
const submissionId = Number(inv.source_submission_id || 0)
if (submissionId > 0 && !inventoryBySubmission.has(submissionId)) {
inventoryBySubmission.set(submissionId, inv)
} else {
// 客服手工入库的现货没有提报单,用库存号自己当锚点
orphanInventories.push(inv)
}
}
const submissionIds = page.rows
.map((row) => row.submissionId)
.filter((id): id is number => Number(id) > 0)
const inventoryIds = page.rows
.map((row) => row.inventoryId)
.filter((id): id is number => Number(id) > 0)
const [submissions, inventories] = await Promise.all([
listConsignmentSubmissionsByIds(submissionIds),
listConsignmentInventoriesByIds(inventoryIds),
])
const submissionById = new Map(submissions.map((sub) => [Number(sub.id), sub]))
const inventoryById = new Map(inventories.map((inv) => [Number(inv.id), inv]))
const boundWorkOrderIds = [...inventoryBySubmission.values(), ...orphanInventories]
const boundWorkOrderIds = inventories
.map((inv) => Number(inv.bound_work_order_id || 0))
.filter((id) => id > 0)
const workOrders = await listWorkOrdersByIds(boundWorkOrderIds)
@@ -376,7 +402,7 @@ export async function listWorkerConsignments(workerId: number) {
const viewer = { role: 'seller', workerId: Number(workerId) } as const
const resolveStage = (
submissionStatus: string,
inv: (typeof inventoriesRes.items)[number] | undefined,
inv: (typeof inventories)[number] | undefined,
) => {
const workOrder = inv?.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
@@ -399,98 +425,101 @@ export async function listWorkerConsignments(workerId: number) {
})
}
const items = [
...submissionsRes.items.map((sub) => {
const inv = inventoryBySubmission.get(Number(sub.id))
const boundWo = inv?.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
: undefined
const stage = resolveStage(String(sub.status || ''), inv)
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
return {
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
consignmentNo: sub.submission_no,
submissionId: sub.id,
inventoryId: inv?.id ?? null,
workOrderId: inv?.bound_work_order_id ?? null,
itemName: sub.item_name,
system: sub.system,
serverZone: sub.server_zone,
series: sub.series,
diamondLevel: sub.diamond_level,
brillianceCount: sub.brilliance_count,
askingPriceFen: sub.asking_price_fen,
depositAmountFen: sub.deposit_amount_fen,
reviewNote: sub.review_note,
// 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图
screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : [])
.filter((url): url is string => typeof url === 'string' && url.trim().length > 0)
.map((url) => refreshStoredFileUrl(url)),
giftEvidenceImages: evidence.giftEvidenceImages,
giftEvidenceNote: evidence.giftEvidenceNote,
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
createdAt: sub.created_at,
...stage,
/** 冷却截止时间,前端拿它显示倒计时 */
giftAvailableAt: inv?.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.gift_available_at ?? null)
: null,
friendAddedAt: inv?.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.friend_added_at ?? null)
: null,
sellerInfo: extractConsignmentSellerInfoFromSubmission(sub),
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
}
}),
...orphanInventories.map((inv) => {
// 手动入库已停用:没有提报单的货不该再出现,这里只做兜底展示(不显示内部仓库号、不给操作能力)
const boundWo = inv.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
: undefined
const stage = resolveStage('approved', inv)
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
return {
consignmentNo: '客服手工入库',
...(stage.canConfirmFriend || stage.canSubmitEvidence
? { canConfirmFriend: false, canSubmitEvidence: false }
: {}),
submissionId: null,
inventoryId: inv.id,
workOrderId: inv.bound_work_order_id ?? null,
itemName: inv.item_name,
system: inv.system,
serverZone: inv.server_zone,
series: inv.series,
diamondLevel: inv.diamond_level,
brillianceCount: inv.brilliance_count,
askingPriceFen: inv.cost_price_fen,
depositAmountFen: inv.deposit_amount_fen,
reviewNote: '',
screenshotsJson: [],
giftEvidenceImages: evidence.giftEvidenceImages,
giftEvidenceNote: evidence.giftEvidenceNote,
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
createdAt: inv.created_at,
...stage,
giftAvailableAt: inv.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.gift_available_at ?? null)
: null,
friendAddedAt: inv.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.friend_added_at ?? null)
: null,
sellerInfo: extractConsignmentSellerInfoFromInventory(inv),
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
}
}),
].sort((a, b) => Date.parse(String(b.createdAt || '')) - Date.parse(String(a.createdAt || '')))
const buildSubmissionItem = (
sub: (typeof submissions)[number],
inv: (typeof inventories)[number] | undefined,
) => {
const boundWo = inv?.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
: undefined
const stage = resolveStage(String(sub.status || ''), inv)
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
return {
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
consignmentNo: sub.submission_no,
submissionId: sub.id,
inventoryId: inv?.id ?? null,
workOrderId: inv?.bound_work_order_id ?? null,
itemName: sub.item_name,
system: sub.system,
serverZone: sub.server_zone,
series: sub.series,
diamondLevel: sub.diamond_level,
brillianceCount: sub.brilliance_count,
askingPriceFen: sub.asking_price_fen,
depositAmountFen: sub.deposit_amount_fen,
reviewNote: sub.review_note,
// 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图
screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : [])
.filter((url): url is string => typeof url === 'string' && url.trim().length > 0)
.map((url) => refreshStoredFileUrl(url)),
giftEvidenceImages: evidence.giftEvidenceImages,
giftEvidenceNote: evidence.giftEvidenceNote,
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
createdAt: sub.created_at,
...stage,
/** 冷却截止时间,前端拿它显示倒计时 */
giftAvailableAt: boundWo?.gift_available_at ?? null,
friendAddedAt: boundWo?.friend_added_at ?? null,
sellerInfo: extractConsignmentSellerInfoFromSubmission(sub),
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
}
}
const buildOrphanInventoryItem = (inv: (typeof inventories)[number]) => {
// 手动入库已停用:没有提报单的货不该再出现,这里只做兜底展示(不显示内部仓库号、不给操作能力)
const boundWo = inv.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
: undefined
const stage = resolveStage('approved', inv)
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
return {
consignmentNo: '客服手工入库',
...(stage.canConfirmFriend || stage.canSubmitEvidence
? { canConfirmFriend: false, canSubmitEvidence: false }
: {}),
submissionId: null,
inventoryId: inv.id,
workOrderId: inv.bound_work_order_id ?? null,
itemName: inv.item_name,
system: inv.system,
serverZone: inv.server_zone,
series: inv.series,
diamondLevel: inv.diamond_level,
brillianceCount: inv.brilliance_count,
askingPriceFen: inv.cost_price_fen,
depositAmountFen: inv.deposit_amount_fen,
reviewNote: '',
screenshotsJson: [],
giftEvidenceImages: evidence.giftEvidenceImages,
giftEvidenceNote: evidence.giftEvidenceNote,
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
createdAt: inv.created_at,
...stage,
giftAvailableAt: boundWo?.gift_available_at ?? null,
friendAddedAt: boundWo?.friend_added_at ?? null,
sellerInfo: extractConsignmentSellerInfoFromInventory(inv),
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
}
}
// 顺序完全由服务端分页决定,这里不再排序
const items = page.rows
.map((row) => {
const sub = row.submissionId ? submissionById.get(Number(row.submissionId)) : undefined
const inv = row.inventoryId ? inventoryById.get(Number(row.inventoryId)) : undefined
if (sub) return buildSubmissionItem(sub, inv)
return inv ? buildOrphanInventoryItem(inv) : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
return {
publishDepositFen,
items,
stageCounts: items.reduce<Record<string, number>>((acc, item) => {
acc[item.stage] = (acc[item.stage] || 0) + 1
return acc
}, {}),
total: page.total,
page: Math.max(1, Number(options.page) || 1),
pageSize: Math.max(1, Number(options.pageSize) || 10),
counts: page.counts,
}
}