feat(consignment): 支持多数量拆分与份额级赠送,并对齐待抢单看板

- 匹配引擎支持一条库存按剩余数量拆给多张整单/拼单,份额数量、报酬、分配数量按实际占用计算
- 「我的寄卖」改为按占用单/份额一行 + 剩余库存行,卖家按 allocation/share/workOrder 定位确认加好友与提交凭证
- 解绑/撤销/超时按 allocation 精确回补,修复单绑定字段在多占用下被误清的问题
- 拼单好友赠送改为份额级 friend_added_at / gift_available_at(迁移 146),新增后台按份额确认加好友入口与寄卖赠送列
- 待抢单匹配看板改用新引擎:覆盖全部寄卖分类、拼单余量、精确/非精确/冲突明细,去掉手枪品类写死
- 发布押金改为该提报单全部售出/作废后才释放,避免多件库存分多单时提前退款
- 补齐集成测试与单测:拆分、多占用、份额级独立冷却与凭证、看板新旧引擎对齐
This commit is contained in:
2026-11-06 12:23:55 +08:00
parent 3f16b15c5c
commit e0a85359b6
28 changed files with 1758 additions and 125 deletions
@@ -1,10 +1,11 @@
import {
completeWorkOrderGiftCooldown,
confirmWorkOrderFriendAdded,
confirmWorkOrderShareFriendAdded,
resetWorkOrderGiftMaterial,
} from '../../repositories/worker-platform/index.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
import { mapWorkOrderAdmin } from './mappers.js'
import { mapWorkOrderAdmin, mapWorkOrderShare } from './mappers.js'
import { getRequiredWorkOrder } from './worker-session-context-service.js'
import { nowIso } from '../../utils/time.js'
import { createHttpError } from '../../utils/http.js'
@@ -46,6 +47,45 @@ export async function confirmAdminWorkOrderFriendAdded(
return { order: mapWorkOrderAdmin(order) }
}
/** 后台按拼单份额确认加好友:只影响该卖家的份额,不动同单其他卖家。 */
export async function confirmAdminWorkOrderShareFriendAdded(
workOrderId: number | string,
shareId: number | string,
actorName = '',
) {
const result = await confirmWorkOrderShareFriendAdded({
workOrderId: Number(workOrderId),
shareId: Number(shareId),
actorType: 'admin',
actorId: actorName,
now: nowIso(),
})
if (result.failureReason) {
const failure = result.failureReason
const message =
failure === 'work_order_not_found'
? '订单不存在'
: failure === 'work_order_gift_mode_invalid'
? '该订单不是好友赠送模式'
: failure === 'share_status_invalid'
? '该份额已提交或已验收,不能重复确认'
: failure === 'share_not_consignment'
? '该份额不是寄卖份额'
: '未找到该拼单份额'
throw createHttpError(message, {
statusCode: failure === 'work_order_not_found' || failure === 'share_not_found' ? 404 : 409,
errorCode: `work_order_share_friend_${failure}`,
})
}
if (result.share) {
publishWorkOrderRealtimeChange({
workOrderId: Number(workOrderId),
workerIds: [Number(result.share.worker_id)],
})
}
return { share: result.share ? mapWorkOrderShare(result.share) : null }
}
/** 客服手动结束好友赠送冷却,让打手立即可以提交赠送凭证。 */
export async function completeAdminWorkOrderGiftCooldown(
workOrderId: number | string,
@@ -2,6 +2,7 @@ import type { PoolClient } from 'pg'
import { withTransaction, query } from '../../db/client.js'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import { isFriendGiftAcceptanceMode } from '../../domain/work-order-acceptance-mode.js'
import {
createWorkOrderEventWithClient,
getWorkOrderById,
@@ -59,7 +60,7 @@ function inventorySalePriceFen(inventory: ConsignmentInventoryRow): number {
return Math.max(0, Math.round(Number(inventory.sale_price_fen || inventory.cost_price_fen || 0)))
}
function inventoryMatchesProfile(
export function inventoryMatchesProfile(
inventory: ConsignmentInventoryRow,
profile: ConsignmentProfileSnapshot | null,
): boolean {
@@ -162,7 +163,7 @@ function resolveOrderSpecValues(workOrder: WorkOrderRow): ConsignmentSpecValues
return values
}
function evaluateInventoryMatch(
export function evaluateInventoryMatch(
workOrder: WorkOrderRow,
inventory: ConsignmentInventoryRow,
profile: ConsignmentProfileSnapshot | null,
@@ -208,7 +209,7 @@ function evaluateInventoryMatch(
}
}
async function resolveProfileForWorkOrder(
export async function resolveProfileForWorkOrder(
workOrder: WorkOrderRow,
): Promise<ConsignmentProfileSnapshot | null> {
if (workOrder.consignment_profile_id) {
@@ -327,6 +328,7 @@ function pickCandidateInventories(
const preferLowReward = options.preferLowReward !== false
return inventories
.filter((inventory) => inventoryMatchesProfile(inventory, profile))
.filter((inventory) => Number(inventory.remaining_quantity ?? 1) > 0)
.map((inventory) => ({
inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
@@ -362,11 +364,23 @@ async function applyWholeOrderMatch(
const material = parseMaterial(current.material_json)
const source = parseMaterial(material.source)
const shopName = String(source.shopName || source.shopTitle || '').trim()
// 整单也必须按订单总数量占用:数量 > 1 的非拼单订单以前只扣 1 件,
// 剩余件会留在 in_stock 且绑定字段被下一单覆盖。
const requiredQuantity = Math.max(1, Number(current.sharing_total_quantity || 1))
const unitPriceFen = inventorySalePriceFen(inventory)
// 好友赠送单要求“一份库存一次性送完”:不允许把一份库存拆给多张赠礼单,
// 否则同一个账号的加好友/冷却/赠送凭证无法按订单归属。
if (
isFriendGiftAcceptanceMode(current.acceptance_mode) &&
Number(inventory.remaining_quantity ?? 1) !== requiredQuantity
) {
return false
}
const allocation = await reserveConsignmentInventoryWithClient(client, {
inventoryId: Number(inventory.id),
workOrderId: Number(current.id),
quantity: 1,
unitPriceFen: inventorySalePriceFen(inventory),
quantity: requiredQuantity,
unitPriceFen,
now,
boundOrderId: current.order_id ? Number(current.order_id) : null,
boundPlatformOrderId: current.platform_order_id || '',
@@ -375,7 +389,8 @@ async function applyWholeOrderMatch(
if (!allocation) return false
const config = profile?.config
const grossRewardFen = inventorySalePriceFen(inventory) || Number(current.reward_amount || 0)
const grossRewardFen =
(unitPriceFen || Math.max(0, Number(current.reward_amount || 0))) * requiredQuantity
const serviceFeeFen = config ? buildServiceFeeFen(config, grossRewardFen) : 0
const netRewardFen = Math.max(0, grossRewardFen - serviceFeeFen)
const timeoutMinutes = config?.timeout.minutes ?? 30
@@ -393,7 +408,8 @@ async function applyWholeOrderMatch(
brillianceCount: inventory.brilliance_count,
profileId: profile?.profileId ?? null,
profileVersionId: profile?.versionId ?? null,
salePriceFen: inventorySalePriceFen(inventory),
salePriceFen: unitPriceFen,
quantity: requiredQuantity,
grossRewardFen,
serviceFeeFen,
netRewardFen,
@@ -517,23 +533,36 @@ async function applySharingMatch(
const unitRewardFen =
Math.max(0, Number(current.sharing_unit_reward || 0)) ||
Math.max(0, Number(current.reward_amount || 0)) / totalQuantity
const shareGrossFen = Math.max(0, Math.round(unitRewardFen))
const shareFeeFen = config ? buildServiceFeeFen(config, shareGrossFen) : 0
const shareNetFen = Math.max(0, shareGrossFen - shareFeeFen)
const shareGrossFenPerUnit = Math.max(0, Math.round(unitRewardFen))
const shareFeeFenPerUnit = config ? buildServiceFeeFen(config, shareGrossFenPerUnit) : 0
const shareNetFenPerUnit = Math.max(0, shareGrossFenPerUnit - shareFeeFenPerUnit)
const material = parseMaterial(current.material_json)
const source = parseMaterial(material.source)
const shopName = String(source.shopName || source.shopTitle || '').trim()
let createdCount = 0
let createdQuantity = 0
let firstInventoryId: number | null = null
let firstInventoryQuantity = 0
for (const { inventory } of candidates) {
if (remaining <= 0) break
if (!inventory.worker_id) continue
// 一条库存可以拆给多张订单:本次最多吃掉“订单还缺的份数”,
// 剩下的留在 remaining_quantity 里等下一单。好友赠送单必须整份匹配,避免同一份库存跨单赠礼。
const available = Math.max(0, Math.round(Number(inventory.remaining_quantity ?? 1)))
if (isFriendGiftAcceptanceMode(current.acceptance_mode) && available > remaining) continue
const take = isFriendGiftAcceptanceMode(current.acceptance_mode)
? available
: Math.min(available, remaining)
if (take <= 0) continue
const shareGrossFen = shareGrossFenPerUnit * take
const shareFeeFen = config ? buildServiceFeeFen(config, shareGrossFen) : 0
const shareNetFen = Math.max(0, shareGrossFen - shareFeeFen)
const allocation = await reserveConsignmentInventoryWithClient(client, {
inventoryId: Number(inventory.id),
workOrderId: Number(current.id),
quantity: 1,
quantity: take,
unitPriceFen: inventorySalePriceFen(inventory),
now,
boundOrderId: current.order_id ? Number(current.order_id) : null,
@@ -549,7 +578,7 @@ async function applySharingMatch(
status, acceptance_json, draft_acceptance_json, deadline_at, timeout_minutes,
timeout_policy, request_id, consignment_inventory_id, created_at, updated_at
) VALUES (
$1, $2, 1, $3, $4, 0,
$1, $2, $10, $3, $4, 0,
'joined', '{}'::jsonb, '{}'::jsonb, $5, $6,
$7, '', $8, $9, $9
)
@@ -558,13 +587,14 @@ async function applySharingMatch(
[
current.id,
Number(inventory.worker_id),
shareGrossFen,
shareGrossFenPerUnit,
shareNetFen,
deadline,
timeoutMinutes,
timeoutPolicy,
Number(inventory.id),
now,
take,
],
)
const shareId = Number(share.rows[0]?.id || 0)
@@ -574,12 +604,16 @@ async function applySharingMatch(
[shareId, now, allocation.id],
)
createdCount += 1
if (!firstInventoryId) firstInventoryId = Number(inventory.id)
remaining -= 1
createdQuantity += take
if (!firstInventoryId) {
firstInventoryId = Number(inventory.id)
firstInventoryQuantity = take
}
remaining -= take
}
if (createdCount > 0) {
active += createdCount
active += createdQuantity
const nextStatus =
active >= totalQuantity
? WORK_ORDER_STATUS.IN_PROGRESS
@@ -604,6 +638,7 @@ async function applySharingMatch(
? {
consignment: {
inventoryId: firstInventoryId,
quantity: firstInventoryQuantity,
profileId: profile?.profileId ?? null,
matchedAt: now,
matchType: manual ? 'manual_sharing' : 'auto_sharing',
@@ -642,10 +677,11 @@ async function applySharingMatch(
toStatus: nextStatus,
payloadJson: JSON.stringify({
createdCount,
createdQuantity,
activeQuantity: active,
totalQuantity,
shareRewardFen: shareNetFen,
serviceFeeFenPerShare: shareFeeFen,
shareRewardFenPerUnit: shareNetFenPerUnit,
serviceFeeFenPerUnit: shareFeeFenPerUnit,
matchType: manual ? 'manual_sharing' : 'auto_sharing',
...(manual
? {
@@ -704,7 +740,14 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
const created = await applySharingMatch({ workOrder, profile }, candidates, context)
if (created > 0) matched += 1
} else {
const chosen = candidates[0]!
// 整单必须由一条库存一次性满足订单总数量,不能只占 1 件。
const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
const friendGift = isFriendGiftAcceptanceMode(workOrder.acceptance_mode)
const chosen = candidates.find((candidate) => {
const available = Number(candidate.inventory.remaining_quantity ?? 1)
return available >= requiredQuantity && (!friendGift || available === requiredQuantity)
})
if (!chosen) continue
const applied = await applyWholeOrderMatch({ workOrder, profile }, chosen, {
matchType: 'auto',
partial: !chosen.evaluation.exact,
@@ -918,6 +961,19 @@ export async function matchConsignmentWorkOrderManually(input: {
return { matched: created > 0, matchedCount: created }
}
const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
const chosenInventory = selected[0]!.inventory
const availableQuantity = Number(chosenInventory.remaining_quantity ?? 1)
if (
availableQuantity < requiredQuantity ||
(isFriendGiftAcceptanceMode(workOrder.acceptance_mode) &&
availableQuantity !== requiredQuantity)
) {
throw createHttpError(
`该库存仅剩 ${availableQuantity} 件,订单需要 ${requiredQuantity} 件,请改选库存`,
{ statusCode: 409, errorCode: 'consignment_inventory_quantity_insufficient' },
)
}
const applied = await applyWholeOrderMatch(
{ workOrder: appliedWorkOrder, profile },
selected[0]!,
@@ -29,11 +29,14 @@ import {
resolveWorkerConsignmentPrice,
} from './consignment-spec-service.js'
import {
confirmWorkerConsignmentFriendAdded,
getWorkerConsignmentPriceSuggestion,
listWorkerConsignments,
publishWorkerConsignment,
submitWorkerConsignmentGiftEvidence,
updateWorkerConsignmentSalePrice,
} from './consignment-worker-service.js'
import type { WorkerSession } from './worker-session-context-service.js'
const TEST_DATABASE_URL = String(process.env.CONSIGNMENT_MATCHING_TEST_DATABASE_URL || '').trim()
@@ -61,6 +64,18 @@ function errorCode(error: unknown): string {
return String((error as { errorCode?: string })?.errorCode || '')
}
function workerSession(workerId: number, username: string): WorkerSession {
return {
sessionId: `ctest-session-${workerId}`,
workerId,
username,
status: 'active',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
absoluteExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
sessionVersion: 1,
}
}
async function expectError(fn: () => Promise<unknown>, code: string) {
try {
await fn()
@@ -291,6 +306,7 @@ async function main() {
fields?: Record<string, string>
sharing?: { total: number; unitReward: number }
internalDeadline?: string | null
acceptanceMode?: string
}) {
const material = {
collect: {
@@ -323,6 +339,13 @@ async function main() {
input.internalDeadline || null,
],
)
if (input.acceptanceMode) {
await query('UPDATE work_orders SET acceptance_mode = $1 WHERE id = $2', [
input.acceptanceMode,
Number(res.rows[0]!.id),
])
res.rows[0]!.acceptance_mode = input.acceptanceMode
}
return res.rows[0]!
}
@@ -924,6 +947,261 @@ async function main() {
assert(item2.workOrderId, `卖家2 workOrderId=${item2.workOrderId}`)
})
// ---------- 7.1 多数量库存 ----------
const multiInv = await createInventory({
no: `CT-INV-MULTI-${suffix}`,
workerId: sellerId,
quantity: 3,
saleFen: 6000,
capFen: 6000,
})
const multiShareWo = await createWorkOrder({
no: `CT-WO-MULTI-${suffix}`,
status: 'internal_matching',
sharing: { total: 3, unitReward: 7000 },
internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
await check('多数量库存:拼单 3 件由一份库存一次吃完,份额数量=3', async () => {
const result = await matchConsignmentWorkOrderManually({
workOrderId: Number(multiShareWo.id),
inventoryIds: [Number(multiInv.id)],
actorName: 'ctest-admin',
})
assert(result.matched, JSON.stringify(result))
const shares = await query<Record<string, any>>(
'SELECT * FROM work_order_shares WHERE work_order_id = $1 ORDER BY id',
[Number(multiShareWo.id)],
)
assert(shares.rows.length === 1, `shares=${shares.rows.length}`)
assert(Number(shares.rows[0]!.quantity) === 3, `share quantity=${shares.rows[0]!.quantity}`)
assert(
Number(shares.rows[0]!.share_reward) === 19950,
`share reward=${shares.rows[0]!.share_reward},应按 3×7000 扣 5% 服务费`,
)
const inv = await query<Record<string, any>>(
'SELECT * FROM consignment_inventories WHERE id = $1',
[Number(multiInv.id)],
)
assert(Number(inv.rows[0]!.remaining_quantity) === 0, '库存剩余必须扣到 0')
assert(inv.rows[0]!.status === 'consumed', `inventory=${inv.rows[0]!.status}`)
const allocations = await query<Record<string, any>>(
'SELECT * FROM consignment_inventory_allocations WHERE work_order_id = $1',
[Number(multiShareWo.id)],
)
assert(allocations.rows.length === 1, `allocations=${allocations.rows.length}`)
assert(Number(allocations.rows[0]!.quantity) === 3, '分配数量必须=3')
assert(Number(allocations.rows[0]!.work_order_share_id) > 0, '分配未绑定份额')
})
const splitInv = await createInventory({
no: `CT-INV-SPLIT-${suffix}`,
workerId: sellerId,
quantity: 3,
saleFen: 5000,
capFen: 5000,
})
const splitWo1 = await createWorkOrder({
no: `CT-WO-SPLIT1-${suffix}`,
status: 'unassigned',
})
const splitWo2 = await createWorkOrder({
no: `CT-WO-SPLIT2-${suffix}`,
status: 'unassigned',
})
await check('多数量库存:拆给两张订单后,卖家列表出现两条占用行 + 一条剩余行', async () => {
for (const workOrder of [splitWo1, splitWo2]) {
const result = await matchConsignmentWorkOrderManually({
workOrderId: Number(workOrder.id),
inventoryIds: [Number(splitInv.id)],
actorName: 'ctest-admin',
})
assert(result.matched, `${workOrder.work_order_no} 匹配失败`)
}
const inv = await query<Record<string, any>>(
'SELECT * FROM consignment_inventories WHERE id = $1',
[Number(splitInv.id)],
)
assert(Number(inv.rows[0]!.remaining_quantity) === 1, '两张单各占 1 件后应剩 1 件')
assert(inv.rows[0]!.status === 'in_stock', `inventory=${inv.rows[0]!.status}`)
const page = await listWorkerConsignments(sellerId, { pageSize: 50 })
const rows = page.items.filter((item) => Number(item.inventoryId) === Number(splitInv.id))
const allocationRows = rows.filter((item) => Number(item.allocationId || 0) > 0)
assert(allocationRows.length === 2, `占用行=${allocationRows.length}`)
assert(
allocationRows.every((item) => Number(item.allocatedQuantity) === 1),
'每条占用行应各占 1 件',
)
const remainderRow = rows.find((item) => !item.allocationId)
assert(remainderRow, '缺少剩余库存行')
assert(Number(remainderRow.remainingQuantity) === 1, '剩余行应显示 1 件')
})
const giftInv = await createInventory({
no: `CT-INV-GIFT-${suffix}`,
workerId: sellerId,
quantity: 3,
saleFen: 5000,
capFen: 5000,
})
const giftSplitWo = await createWorkOrder({
no: `CT-WO-GIFTSINGLE-${suffix}`,
status: 'internal_matching',
acceptanceMode: 'friend_gift',
internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
await check('多数量库存:好友赠送单必须整份匹配,不能把一份库存拆给赠礼单', async () => {
await expectError(
() =>
matchConsignmentWorkOrderManually({
workOrderId: Number(giftSplitWo.id),
inventoryIds: [Number(giftInv.id)],
actorName: 'ctest-admin',
}),
'consignment_inventory_quantity_insufficient',
)
await query("UPDATE work_orders SET status = 'cancelled' WHERE id = $1", [
Number(giftSplitWo.id),
])
})
// ---------- 7.2 拼单好友赠送:按份额独立加好友/冷却/凭证 ----------
async function publishGiftInventory(workerId: number, label: string) {
const published = await publishWorkerConsignment(workerId, {
profileId,
quantity: 1,
specValues: {
system: '苹果',
serverZone: '微信区',
series: '暗潮',
diamondLevel: '1',
brillianceCount: '1',
},
sellerContact: `wx_gift_${label}_${suffix}`,
characterName: `赠礼卖家${label}`,
characterId: `GIFT${label}${suffix}`.slice(0, 20),
screenshotsJson: ['https://example.com/gift-item.png'],
askingPriceFen: 9000,
contactSupporterId: contactId,
})
const approved = await approveAdminConsignmentSubmission(
Number(published.id),
{ costPriceFen: 7000, buyerName: `gift-${label}` },
'ctest-admin',
)
return {
submissionId: Number(published.id),
inventoryId: Number(approved.inventory.id),
}
}
const giftA = await publishGiftInventory(sellerId, 'a')
const giftB = await publishGiftInventory(seller2Id, 'b')
const giftShareWo = await createWorkOrder({
no: `CT-WO-GIFTSHARE-${suffix}`,
status: 'internal_matching',
sharing: { total: 2, unitReward: 7000 },
acceptanceMode: 'friend_gift',
internalDeadline: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
})
await check('拼单好友赠送:A 确认加好友只影响 A 的份额,B 仍待加好友', async () => {
const matched = await matchConsignmentWorkOrderManually({
workOrderId: Number(giftShareWo.id),
inventoryIds: [giftA.inventoryId, giftB.inventoryId],
actorName: 'ctest-admin',
})
assert(matched.matched && matched.matchedCount === 2, JSON.stringify(matched))
const pageA = await listWorkerConsignments(sellerId, { pageSize: 50 })
const rowA = pageA.items.find(
(item) =>
Number(item.inventoryId) === giftA.inventoryId && Number(item.allocationId || 0) > 0,
)
assert(rowA, '卖家 A 看不到拼单占用行')
assert(rowA.canConfirmFriend, `A canConfirmFriend=${rowA.canConfirmFriend}`)
assert(Number(rowA.shareId || 0) > 0, 'A 行缺少 shareId')
await confirmWorkerConsignmentFriendAdded(sellerId, giftA.submissionId, 'seller-a', {
shareId: Number(rowA.shareId),
})
const shares = await query<Record<string, any>>(
'SELECT * FROM work_order_shares WHERE work_order_id = $1 ORDER BY id',
[Number(giftShareWo.id)],
)
const shareA = shares.rows.find(
(share) => Number(share.consignment_inventory_id) === giftA.inventoryId,
)
const shareB = shares.rows.find(
(share) => Number(share.consignment_inventory_id) === giftB.inventoryId,
)
assert(shareA?.friend_added_at, 'A 份额没有写加好友时间')
assert(!shareB?.friend_added_at, 'A 确认加好友不能影响 B')
const pageB = await listWorkerConsignments(seller2Id, { pageSize: 50 })
const rowB = pageB.items.find(
(item) =>
Number(item.inventoryId) === giftB.inventoryId && Number(item.allocationId || 0) > 0,
)
assert(rowB, '卖家 B 看不到拼单占用行')
assert(rowB.canConfirmFriend, 'B 仍应可以独立确认加好友')
assert(!rowB.canSubmitEvidence, 'B 尚未加好友,不应能提交赠送凭证')
assert(!rowB.friendAddedAt, 'B 的加好友时间被 A 污染')
})
await check('拼单好友赠送:A 冷却未结束时提交被拒绝,到期后可独立提交', async () => {
const pageA = await listWorkerConsignments(sellerId, { pageSize: 50 })
const rowA = pageA.items.find(
(item) =>
Number(item.inventoryId) === giftA.inventoryId && Number(item.allocationId || 0) > 0,
)
assert(rowA?.shareId, 'A 行缺少 shareId')
const shareId = Number(rowA.shareId)
await expectError(
() =>
submitWorkerConsignmentGiftEvidence(
sellerId,
giftA.submissionId,
{ shareId, imageUrls: ['https://example.com/gift-a.png'] },
workerSession(sellerId, 'seller-a'),
),
'consignment_share_gift_cooldown',
)
await query(
"UPDATE work_order_shares SET gift_available_at = NOW() - INTERVAL '1 minute' WHERE id = $1",
[shareId],
)
await submitWorkerConsignmentGiftEvidence(
sellerId,
giftA.submissionId,
{ shareId, imageUrls: ['https://example.com/gift-a.png'] },
workerSession(sellerId, 'seller-a'),
)
const share = await query<Record<string, any>>(
'SELECT * FROM work_order_shares WHERE id = $1',
[shareId],
)
assert(share.rows[0]!.status === 'submitted', `A share=${share.rows[0]!.status}`)
const order = await query<Record<string, any>>('SELECT * FROM work_orders WHERE id = $1', [
Number(giftShareWo.id),
])
assert(
order.rows[0]!.status === 'in_progress',
`A 提交后母单不能回退成大厅状态,实际 ${order.rows[0]!.status}`,
)
const pageB = await listWorkerConsignments(seller2Id, { pageSize: 50 })
const rowB = pageB.items.find(
(item) =>
Number(item.inventoryId) === giftB.inventoryId && Number(item.allocationId || 0) > 0,
)
assert(rowB?.canConfirmFriend, 'A 提交后 B 仍应能确认加好友')
assert(!rowB?.canSubmitEvidence, 'A 提交不能把 B 的按钮也点亮')
})
// ---------- 8. 超时回大厅 ----------
const expireWo = await createWorkOrder({
no: `CT-WO-EXPIRE-${suffix}`,
@@ -8,9 +8,14 @@ import {
scoreConsignmentMatchDetails,
} from './consignment-matching-service.js'
import {
buildPendingConsignmentMatchPairs,
buildPendingPistolMatchPairs,
type PendingPistolMatchPair,
} from './consignment-pending-match-service.js'
import {
DEFAULT_CONSIGNMENT_PROFILE_CONFIG,
type ConsignmentProfileSnapshot,
} from './consignment-profile-service.js'
function buildInventory(overrides: Partial<ConsignmentInventoryRow> = {}): ConsignmentInventoryRow {
return {
@@ -71,6 +76,57 @@ function pairByInventory(result: { pairs: PendingPistolMatchPair[] }, inventoryI
return result.pairs.find((pair) => pair.inventoryId === inventoryId)
}
const CUSTOM_SPEC = {
key: 'weaponSkin',
label: '枪皮',
type: 'text' as const,
required: true,
options: [],
placeholder: '',
helpText: '',
matchMode: 'exact' as const,
wildcardValues: [],
showInItemName: true,
sortOrder: 1,
}
function buildProfile(
overrides: Partial<ConsignmentProfileSnapshot> = {},
): ConsignmentProfileSnapshot {
return {
profileId: 99,
categoryId: 9,
categoryName: '自定义品类',
versionId: 1,
versionNo: 1,
config: { ...DEFAULT_CONSIGNMENT_PROFILE_CONFIG, specs: [CUSTOM_SPEC] },
...overrides,
}
}
function buildCustomOrder(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow {
return buildOrder({
id: 101,
work_order_no: 'WK101',
product_name: '地铁逃生 龙纹枪皮',
material_json: JSON.stringify({ collect: { fields: { weaponSkin: '龙纹' } } }),
...overrides,
})
}
function buildCustomInventory(
overrides: Partial<ConsignmentInventoryRow> = {},
): ConsignmentInventoryRow {
return buildInventory({
id: 201,
category_id: 9,
profile_id: 99,
item_name: '地铁逃生 龙纹枪皮',
spec_values_json: { weaponSkin: '龙纹' },
...overrides,
})
}
test('细粒度评分:全部规格一致为 100 分', () => {
const order = extractPeacekeeperSpecs('苹果微信区 苏尔南手枪 暗潮 1钻')
const scored = scoreConsignmentMatchDetails(order, buildInventory())
@@ -245,3 +301,82 @@ test('匹配看板:配对明细包含平台号/工单号/商品名/寄售单
assert.equal(pair.itemName, '苹果微信区 暗潮 1钻 苏尔南')
assert.equal(pair.score, 100)
})
test('看板新口径:自定义分类按寄卖配置出现,并带出逐字段明细', () => {
const result = buildPendingConsignmentMatchPairs({
orders: [buildCustomOrder()],
inventories: [buildCustomInventory(), buildInventory({ id: 202, category_id: 1 })],
profileByOrderId: new Map([[101, buildProfile()]]),
})
assert.equal(result.pendingOrderCount, 1)
assert.equal(result.pairCount, 1)
const pair = result.pairs[0]!
assert.equal(pair.inventoryId, 201)
assert.equal(pair.matchType, 'exact')
assert.equal(pair.profileId, 99)
assert.equal(pair.matchDetails.find((detail) => detail.key === 'weaponSkin')?.status, 'match')
})
test('看板新口径:自定义规格冲突默认隐藏,勾选后按 0 分展示', () => {
const conflictInventory = buildCustomInventory({
id: 203,
spec_values_json: { weaponSkin: '虎纹' },
})
const hidden = buildPendingConsignmentMatchPairs({
orders: [buildCustomOrder()],
inventories: [conflictInventory],
profileByOrderId: new Map([[101, buildProfile()]]),
})
assert.equal(hidden.pairCount, 0)
assert.equal(hidden.conflictCount, 0)
const included = buildPendingConsignmentMatchPairs({
orders: [buildCustomOrder()],
inventories: [conflictInventory],
profileByOrderId: new Map([[101, buildProfile()]]),
includeConflicts: true,
})
assert.equal(included.pairCount, 1)
assert.equal(included.conflictCount, 1)
assert.equal(included.pairs[0]?.matchType, 'conflict')
assert.equal(included.pairs[0]?.score, 0)
})
test('看板新口径:拼单订单展示剩余份数,而不是直接排除', () => {
const order = buildCustomOrder({
sharing_enabled: true,
sharing_total_quantity: 5,
active_share_quantity: 2,
})
const result = buildPendingConsignmentMatchPairs({
orders: [order],
inventories: [buildCustomInventory()],
profileByOrderId: new Map([[101, buildProfile()]]),
})
assert.equal(result.pairCount, 1)
assert.equal(result.pairs[0]?.remainingQuantity, 3)
assert.equal(result.pairs[0]?.totalQuantity, 5)
})
test('看板新口径:库存 profile 与订单配置不一致时跳过', () => {
const otherProfileInventory = buildCustomInventory({ id: 204, profile_id: 100 })
const result = buildPendingConsignmentMatchPairs({
orders: [buildCustomOrder()],
inventories: [otherProfileInventory],
profileByOrderId: new Map([[101, buildProfile()]]),
})
assert.equal(result.pairCount, 0)
assert.equal(result.inStockInventoryCount, 0)
})
test('看板新口径:无寄卖配置的订单仍按旧手枪口径兜底', () => {
const result = buildPendingConsignmentMatchPairs({
orders: [buildOrder({ id: 301 })],
inventories: [buildInventory({ id: 302 })],
profileByOrderId: new Map([[301, null]]),
})
assert.equal(result.pairCount, 1)
assert.equal(result.pairs[0]?.profileId, null)
assert.equal(result.pairs[0]?.score, 100)
})
@@ -10,6 +10,12 @@ import {
extractPeacekeeperSpecs,
scoreConsignmentMatchDetails,
} from './consignment-matching-service.js'
import {
evaluateInventoryMatch,
inventoryMatchesProfile,
resolveProfileForWorkOrder,
} from './consignment-match-engine-service.js'
import type { ConsignmentProfileSnapshot } from './consignment-profile-service.js'
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
export type PendingPistolMatchPair = {
@@ -171,14 +177,243 @@ export function buildPendingPistolMatchPairs(input: {
}
}
/** 后台「寄售仓库 → 匹配」:待抢单手枪单 × 在库待匹配现货。 */
export async function listPendingPistolMatches(
export type PendingConsignmentMatchDetail = {
key: string
label: string
orderValue: string
inventoryValue: string
status: 'match' | 'partial' | 'conflict' | 'unknown' | 'ignored'
}
/** 看板配对:在旧字段基础上补充规格明细、订单剩余数量与命中的寄卖配置。 */
export type PendingConsignmentMatchPair = PendingPistolMatchPair & {
matchDetails: PendingConsignmentMatchDetail[]
remainingQuantity: number
totalQuantity: number
profileId: number | null
}
export type PendingConsignmentMatchResult = {
pendingOrderCount: number
inStockInventoryCount: number
pairCount: number
exactCount: number
partialCount: number
conflictCount: number
truncated: boolean
pairs: PendingConsignmentMatchPair[]
}
function scoreFromDetails(details: PendingConsignmentMatchDetail[]): number {
const effective = details.filter((detail) => detail.status !== 'ignored')
if (effective.length === 0) return 0
const matched = effective.reduce((sum, detail) => {
if (detail.status === 'match') return sum + 1
if (detail.status === 'partial') return sum + 0.85
if (detail.status === 'unknown') return sum + 0.5
return sum
}, 0)
return Math.round((matched / effective.length) * 100)
}
function buildEvaluationReason(evaluation: ReturnType<typeof evaluateInventoryMatch>): string {
if (evaluation.exact) return '规格完全一致'
const parts = [...evaluation.conflicts, ...evaluation.unknowns.map((item) => `订单缺少:${item}`)]
if (parts.length > 0) {
return `${evaluation.conflicts.length > 0 ? '不匹配' : '待核对'}:${parts.join(';')}`
}
if (evaluation.partial) return '规格高于要求(非精确)'
return '需人工核对'
}
function scoreLegacy(
order: WorkOrderRow,
inventory: ConsignmentInventoryRow,
): ReturnType<typeof scoreConsignmentMatchDetails> {
const fields = resolveCollectSubmittedFieldValues(order)
const specs = extractPeacekeeperSpecs(order.product_name, {
system: String(fields.system || ''),
serverZone: String(fields.serverZone || ''),
series: String(fields.series || ''),
})
return scoreConsignmentMatchDetails(specs, inventory)
}
/**
* 新口径的待抢单看板:与内部匹配引擎完全一致。
*
* - 有寄卖配置的工单:按 profile 过滤库存、跑 `evaluateInventoryMatch`,支持自定义规格;
* - 拼单工单:按 `sharing_total_quantity - active_share_quantity` 展示剩余份数;
* - 没有寄卖配置的工单:保留旧手枪分类器兜底,避免配置缺失时看板直接空掉。
*/
export function buildPendingConsignmentMatchPairs(input: {
orders: WorkOrderRow[]
inventories: ConsignmentInventoryRow[]
profileByOrderId: Map<number, ConsignmentProfileSnapshot | null>
keyword?: string | undefined
includeConflicts?: boolean | undefined
limit?: number | undefined
ordersTruncated?: boolean | undefined
}): PendingConsignmentMatchResult {
const keyword = String(input.keyword || '')
.trim()
.toLowerCase()
const includeConflicts = input.includeConflicts === true
const limit = Math.min(
Math.max(Math.floor(Number(input.limit) || DEFAULT_RESULT_LIMIT), 1),
MAX_RESULT_LIMIT,
)
const preparedOrders: Array<{
order: WorkOrderRow
profile: ConsignmentProfileSnapshot | null
remainingQuantity: number
totalQuantity: number
}> = []
for (const order of input.orders) {
if (String(order.status || '') !== WORK_ORDER_STATUS.OPEN) continue
const profile = input.profileByOrderId.get(Number(order.id)) ?? null
const totalQuantity = Math.max(1, Number(order.sharing_total_quantity || 1))
const activeQuantity = Math.max(0, Number(order.active_share_quantity || 0))
if (!profile) {
// 兜底:没有寄卖配置的订单仍按旧手枪口径展示,且保持“已有份额不参与”的旧规则。
if (activeQuantity > 0) continue
const fields = resolveCollectSubmittedFieldValues(order)
const text = [
String(order.product_name || ''),
String(fields.system || ''),
String(fields.serverZone || ''),
String(fields.series || ''),
].join(' ')
if (detectItemCategory(text) !== 'pistol_suernan') continue
}
const remainingQuantity =
profile && order.sharing_enabled ? Math.max(0, totalQuantity - activeQuantity) : 1
if (remainingQuantity <= 0) continue
preparedOrders.push({ order, profile, remainingQuantity, totalQuantity })
}
const inventories = input.inventories.filter((inventory) => {
if (inventory.status !== 'in_stock' || inventory.allow_matching !== true) return false
return Number(inventory.remaining_quantity ?? 1) > 0
})
const pairs: PendingConsignmentMatchPair[] = []
const matchedInventoryIds = new Set<number>()
for (const { order, profile, remainingQuantity, totalQuantity } of preparedOrders) {
for (const inventory of inventories) {
let matchType: 'exact' | 'partial' | 'conflict'
let score: number
let reason: string
let conflicts: string[]
let matchDetails: PendingConsignmentMatchDetail[]
if (profile) {
if (!inventoryMatchesProfile(inventory, profile)) continue
const evaluation = evaluateInventoryMatch(order, inventory, profile)
if (evaluation.details.length === 0) {
// 内置五字段沿用旧评分,保证存量口径与 0-100 分不变。
const legacy = scoreLegacy(order, inventory)
matchType = legacy.matchType
score = legacy.score
reason = legacy.reason
conflicts = legacy.conflicts
matchDetails = []
} else {
matchType = evaluation.exact
? 'exact'
: evaluation.conflicts.length > 0
? 'conflict'
: 'partial'
score = scoreFromDetails(evaluation.details)
reason = buildEvaluationReason(evaluation)
conflicts = evaluation.conflicts
matchDetails = evaluation.details
}
} else {
if (
detectItemCategory(
`${inventory.item_name} ${inventory.series || ''} ${inventory.category_name || ''}`,
) !== 'pistol_suernan'
) {
continue
}
const legacy = scoreLegacy(order, inventory)
matchType = legacy.matchType
score = legacy.score
reason = legacy.reason
conflicts = legacy.conflicts
matchDetails = []
}
matchedInventoryIds.add(Number(inventory.id))
if (matchType === 'conflict' && !includeConflicts) continue
const pair: PendingConsignmentMatchPair = {
workOrderId: Number(order.id),
workOrderNo: String(order.work_order_no || ''),
platformOrderId: String(order.platform_order_id || ''),
productName: String(order.product_name || ''),
workOrderCreatedAt: order.created_at ? String(order.created_at) : null,
inventoryId: Number(inventory.id),
consignmentNo: String(inventory.source_submission_no || inventory.inventory_no || ''),
inventoryNo: String(inventory.inventory_no || ''),
itemName: String(inventory.item_name || ''),
inventoryInboundAt: inventory.inbound_at
? String(inventory.inbound_at)
: inventory.created_at
? String(inventory.created_at)
: null,
matchType,
score,
reason,
conflicts,
matchDetails,
remainingQuantity,
totalQuantity,
profileId: profile?.profileId ?? null,
}
if (keyword && !buildPairHaystack(pair).includes(keyword)) continue
pairs.push(pair)
}
}
const exactCount = pairs.filter((pair) => pair.matchType === 'exact').length
const partialCount = pairs.filter((pair) => pair.matchType === 'partial').length
const conflictCount = pairs.filter((pair) => pair.matchType === 'conflict').length
pairs.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score
if (right.workOrderId !== left.workOrderId) {
return right.workOrderId - left.workOrderId
}
const leftTime = Date.parse(left.inventoryInboundAt || '') || left.inventoryId
const rightTime = Date.parse(right.inventoryInboundAt || '') || right.inventoryId
if (leftTime !== rightTime) return leftTime - rightTime
return left.inventoryId - right.inventoryId
})
return {
pendingOrderCount: preparedOrders.length,
inStockInventoryCount: matchedInventoryIds.size,
pairCount: pairs.length,
exactCount,
partialCount,
conflictCount,
truncated: pairs.length > limit || input.ordersTruncated === true,
pairs: pairs.slice(0, limit),
}
}
/** 后台「寄售仓库 → 匹配」:待抢单 × 在库待匹配现货。 */
export async function listPendingConsignmentMatches(
input: {
keyword?: string | undefined
includeConflicts?: boolean | undefined
limit?: number | undefined
} = {},
): Promise<PendingPistolMatchResult> {
): Promise<PendingConsignmentMatchResult> {
const { items, total } = await listWorkOrders({
statuses: [WORK_ORDER_STATUS.OPEN],
page: 1,
@@ -188,12 +423,32 @@ export async function listPendingPistolMatches(
})
const inventories = await listActiveConsignmentInventoriesForMatching()
return buildPendingPistolMatchPairs({
const profileCache = new Map<string, Promise<ConsignmentProfileSnapshot | null>>()
const profileByOrderId = new Map<number, ConsignmentProfileSnapshot | null>()
await Promise.all(
items.map(async (order) => {
const cacheKey = order.consignment_profile_id
? `profile:${Number(order.consignment_profile_id)}`
: `category:${Number(order.category_id || 0)}`
let pendingProfile = profileCache.get(cacheKey)
if (!pendingProfile) {
pendingProfile = resolveProfileForWorkOrder(order)
profileCache.set(cacheKey, pendingProfile)
}
profileByOrderId.set(Number(order.id), await pendingProfile)
}),
)
return buildPendingConsignmentMatchPairs({
orders: items,
inventories,
profileByOrderId,
keyword: input.keyword,
includeConflicts: input.includeConflicts,
limit: input.limit,
ordersTruncated: total > items.length,
})
}
/** 兼容旧调用方:看板语义已改为按寄卖配置匹配,不再局限于手枪。 */
export const listPendingPistolMatches = listPendingConsignmentMatches
@@ -84,8 +84,11 @@ test('我的寄卖以提报单号为唯一锚点聚合,不再返回 submission
test('列表阶段由统一状态机推导,工单信息批量取而不是逐件查', async () => {
const serviceSource = await readServiceSource()
assert.ok(serviceSource.includes('deriveConsignmentItemStage('))
assert.ok(serviceSource.includes('listWorkOrdersByIds(boundWorkOrderIds)'))
assert.ok(serviceSource.includes('listWorkOrdersByIds([...new Set(boundWorkOrderIds)])'))
assert.ok(serviceSource.includes('hasInternalConsignmentFulfillment(workOrder)'))
// 一条库存拆给多张订单时,阶段要按占用行/份额推导,不能只看库存状态
assert.ok(serviceSource.includes('buildAllocationItem('))
assert.ok(serviceSource.includes('listWorkOrderSharesByOrderIds('))
})
test('卖家确认加好友复用既有链路,归属只认"这张提报单是这个卖家的"', async () => {
@@ -106,13 +109,16 @@ test('赠送截图复用打手验收提交链路(冷却校验、必须带图
assert.ok(serviceSource.includes("errorCode: 'consignment_item_not_found'"))
})
test('卖家动作只按提报单号定位:手动入库停用后,按库存号的入口必须不存在', async () => {
test('卖家动作按提报单号 + 占用记录定位:不再保留按库存号的旧入口', async () => {
const serviceSource = await readServiceSource()
// 手动入库已停用 → 每件货必然由提报单产出 → 不该再保留 inventoryId 入口(含"一个 id 两义"的旧设计)
assert.ok(!serviceSource.includes('confirmWorkerConsignmentInventoryFriendAdded'))
assert.ok(!serviceSource.includes('submitWorkerConsignmentInventoryGiftEvidence'))
assert.ok(!serviceSource.includes('inventoryId: number | string'))
assert.ok(serviceSource.includes('resolveSellerConsignmentWorkOrder(workerId, submissionId)'))
// 一库存可拆给多张订单:必须按提报单 + allocation/share 定位,不能再只看 bound_work_order_id
assert.ok(serviceSource.includes('resolveSellerConsignmentWorkOrder('))
assert.ok(serviceSource.includes('listActiveConsignmentAllocationsForInventory('))
assert.ok(serviceSource.includes("errorCode: 'consignment_allocation_selection_required'"))
})
test('没有提报单的兜底行不给卖家操作能力,也不暴露内部仓库号', async () => {
@@ -1,5 +1,6 @@
import { withTransaction } from '../../db/client.js'
import { freezeConsignmentPublishDepositWithClient } from '../../repositories/worker-platform/consignment-deposit-repo.js'
import { listActiveConsignmentAllocationsForInventory } from '../../repositories/worker-platform/consignment-allocation-repo.js'
import {
createConsignmentSubmissionWithClient,
getConsignmentInventoryById,
@@ -11,10 +12,15 @@ import {
updateConsignmentInventory,
type WorkerConsignmentGroup,
} from '../../repositories/worker-platform/consignment-repo.js'
import { getWorkerUserById } from '../../repositories/worker-platform/index.js'
import {
getWorkerUserById,
getWorkOrderById,
confirmWorkOrderShareFriendAdded,
} from '../../repositories/worker-platform/index.js'
import { listEnabledWorkProductRulesForSuggestion } from '../../repositories/worker-platform/product-match-repo.js'
import { listWorkOrdersByIds } from '../../repositories/worker-platform/work-order-query-repo.js'
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
import { listWorkOrderSharesByOrderIds } from '../../repositories/worker-platform/work-order-share-repo.js'
import type { WorkOrderRow, WorkOrderShareRow } from '../../repositories/worker-platform/types.js'
import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js'
import { canWorkerPublishConsignment } from '../../domain/consignment-whitelist.js'
import { resolveEnabledConsignmentContact } from './consignment-contact-service.js'
@@ -86,15 +92,8 @@ function parseSpecValues(value: unknown): ConsignmentSpecValues {
return result
}
function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | null) {
if (!boundWo) {
return {
giftEvidenceImages: [] as string[],
giftEvidenceNote: '',
giftEvidenceSubmittedAt: null as string | null,
}
}
const acceptance = toRecord(boundWo.acceptance_json)
function extractConsignmentGiftEvidenceFromAcceptance(value: unknown) {
const acceptance = toRecord(value)
const files = Array.isArray(acceptance?.files) ? (acceptance.files as unknown[]) : []
const fileUrls = files.map((file) => {
const record = toRecord(file)
@@ -119,6 +118,28 @@ function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | nu
}
}
function extractConsignmentGiftEvidenceFromWorkOrder(boundWo?: WorkOrderRow | null) {
if (!boundWo) {
return {
giftEvidenceImages: [] as string[],
giftEvidenceNote: '',
giftEvidenceSubmittedAt: null as string | null,
}
}
return extractConsignmentGiftEvidenceFromAcceptance(boundWo.acceptance_json)
}
function extractConsignmentGiftEvidenceFromShare(share?: WorkOrderShareRow | null) {
if (!share) {
return {
giftEvidenceImages: [] as string[],
giftEvidenceNote: '',
giftEvidenceSubmittedAt: null as string | null,
}
}
return extractConsignmentGiftEvidenceFromAcceptance(share.acceptance_json)
}
export type ConsignmentSellerInfo = {
characterName: string
characterId: string
@@ -564,11 +585,23 @@ export async function listWorkerConsignments(
const submissionById = new Map(submissions.map((sub) => [Number(sub.id), sub]))
const inventoryById = new Map(inventories.map((inv) => [Number(inv.id), inv]))
const boundWorkOrderIds = inventories
.map((inv) => Number(inv.bound_work_order_id || 0))
.filter((id) => id > 0)
const workOrders = await listWorkOrdersByIds(boundWorkOrderIds)
const boundWorkOrderIds = [
...inventories.map((inv) => Number(inv.bound_work_order_id || 0)),
...page.rows.map((row) => Number(row.workOrderId || 0)),
].filter((id) => id > 0)
const workOrders = await listWorkOrdersByIds([...new Set(boundWorkOrderIds)])
const workOrderById = new Map(workOrders.map((wo) => [Number(wo.id), wo]))
const shareOrderIds = [
...new Set(
page.rows
.filter((row) => Number(row.shareId || 0) > 0 && Number(row.workOrderId || 0) > 0)
.map((row) => Number(row.workOrderId)),
),
]
const shares = shareOrderIds.length > 0 ? await listWorkOrderSharesByOrderIds(shareOrderIds) : []
const shareById = new Map<number, WorkOrderShareRow>(
shares.map((share) => [Number(share.id), share]),
)
const viewer = { role: 'seller', workerId: Number(workerId) } as const
const resolveStage = (
@@ -664,6 +697,93 @@ export async function listWorkerConsignments(
}
}
const buildAllocationItem = (
row: (typeof page.rows)[number],
sub: (typeof submissions)[number] | undefined,
inv: (typeof inventories)[number] | undefined,
wo: WorkOrderRow,
share: WorkOrderShareRow | undefined,
) => {
const submissionStatus = String(sub?.status || 'approved')
// 这一行代表“被本单占用的那部分”,即使库存还有剩余,也必须按已出库推导阶段。
const stage = deriveConsignmentItemStage({
submissionStatus,
inventoryStatus: 'consumed',
viewer,
share: share
? {
status: share.status,
friend_added_at: share.friend_added_at ?? null,
gift_available_at: share.gift_available_at ?? null,
}
: null,
workOrder: {
status: String(wo.status || ''),
acceptance_mode: wo.acceptance_mode,
gift_phase: wo.gift_phase,
gift_available_at: wo.gift_available_at,
friend_added_at: wo.friend_added_at,
internalStock: hasInternalConsignmentFulfillment(wo),
assignedWorkerId: Number(wo.assigned_worker_id || 0) || null,
},
})
const showingMatchedBuyer = !['open', 'unassigned', 'cancelled'].includes(
String(wo.status || ''),
)
const evidence = share
? extractConsignmentGiftEvidenceFromShare(share)
: showingMatchedBuyer
? extractConsignmentGiftEvidenceFromWorkOrder(wo)
: { giftEvidenceImages: [], giftEvidenceNote: '', giftEvidenceSubmittedAt: '' }
const contactSupporter = inv?.contact_supporter_id ? inv : sub
return {
consignmentNo: sub?.submission_no || '客服手工入库',
submissionId: sub?.id ?? null,
inventoryId: inv?.id ?? null,
allocationId: row.allocationId,
shareId: row.shareId,
workOrderId: Number(wo.id),
itemName: sub?.item_name || inv?.item_name || '',
system: sub?.system || inv?.system || '',
serverZone: sub?.server_zone || inv?.server_zone || '',
series: sub?.series || inv?.series || '',
diamondLevel: sub?.diamond_level || inv?.diamond_level || 1,
brillianceCount: sub?.brilliance_count ?? inv?.brilliance_count ?? 0,
specValues: parseSpecValues(inv?.spec_values_json ?? sub?.spec_values_json),
specText: inv?.spec_text || sub?.spec_text || '',
askingPriceFen: Number(sub?.asking_price_fen ?? inv?.cost_price_fen ?? 0),
salePriceFen: Number(
inv?.sale_price_fen ?? sub?.requested_sale_price_fen ?? sub?.asking_price_fen ?? 0,
),
priceCapFen: Number(inv?.price_cap_fen ?? sub?.price_cap_fen ?? 0),
quantity: Math.max(1, Number(inv?.quantity ?? sub?.quantity ?? 1)),
allocatedQuantity: row.rowQuantity,
remainingQuantity: inv ? Math.max(0, Number(inv.remaining_quantity ?? 0)) : 0,
listedAt: inv?.listed_at || null,
canEditPrice: false,
priceDropSuggestion: null,
depositAmountFen: Number(sub?.deposit_amount_fen ?? inv?.deposit_amount_fen ?? 0),
...mapConsignmentContactSupporter(contactSupporter || {}),
reviewNote: sub?.review_note || '',
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: row.createdAt,
...stage,
giftAvailableAt: share?.gift_available_at ?? wo.gift_available_at ?? null,
friendAddedAt: share?.friend_added_at ?? wo.friend_added_at ?? null,
sellerInfo: sub
? extractConsignmentSellerInfoFromSubmission(sub)
: inv
? extractConsignmentSellerInfoFromInventory(inv)
: null,
buyerInfo: showingMatchedBuyer ? extractConsignmentBuyerInfoFromWorkOrder(wo) : null,
}
}
const buildOrphanInventoryItem = (inv: (typeof inventories)[number]) => {
// 手动入库已停用:没有提报单的货不该再出现,这里只做兜底展示(不显示内部仓库号、不给操作能力)
const boundWo = inv.bound_work_order_id
@@ -728,6 +848,13 @@ export async function listWorkerConsignments(
.map((row) => {
const sub = row.submissionId ? submissionById.get(Number(row.submissionId)) : undefined
const inv = row.inventoryId ? inventoryById.get(Number(row.inventoryId)) : undefined
if (row.allocationId && inv) {
const wo = workOrderById.get(Number(row.workOrderId || 0))
if (wo) {
const share = row.shareId ? shareById.get(Number(row.shareId)) : undefined
return buildAllocationItem(row, sub, inv, wo, share)
}
}
if (sub) return buildSubmissionItem(sub, inv)
return inv ? buildOrphanInventoryItem(inv) : null
})
@@ -749,10 +876,17 @@ export async function listWorkerConsignments(
* 两个动作本质上就是工单赠送流程里的既有环节,这里只做**归属校验**后复用同一套仓库/服务:
* 谁在履约谁才能操作,避免卖家去动别人的订单。
*/
type ConsignmentActionTarget = {
workOrderId?: number | string | undefined
shareId?: number | string | undefined
allocationId?: number | string | undefined
}
async function resolveSellerConsignmentWorkOrder(
workerId: number,
submissionId: number | string,
): Promise<{ workOrderId: number; inventoryId: number }> {
target: ConsignmentActionTarget = {},
): Promise<{ workOrderId: number; inventoryId: number; shareId: number | null }> {
const submission = await getConsignmentSubmissionById(submissionId)
if (!submission || Number(submission.worker_id) !== Number(workerId)) {
throw createHttpError('寄卖单不存在或不属于当前账号', {
@@ -770,7 +904,34 @@ async function resolveSellerConsignmentWorkOrder(
})
}
const workOrderId = Number(inventory.bound_work_order_id || 0)
// 一条库存可能拆给多张订单:优先按前端传来的订单/份额/占用记录定位;
// 没有传且只有一笔占用时自动选中,多笔时必须让卖家明确选哪一单。
const allocations = await listActiveConsignmentAllocationsForInventory(inventory.id)
const allocationIdInput = Number(target.allocationId || 0)
const shareIdInput = Number(target.shareId || 0)
const workOrderIdInput = Number(target.workOrderId || 0)
let allocation = allocations[0] || null
if (allocationIdInput > 0) {
allocation = allocations.find((item) => Number(item.id) === allocationIdInput) ?? null
} else if (shareIdInput > 0) {
allocation =
allocations.find((item) => Number(item.work_order_share_id || 0) === shareIdInput) ?? null
} else if (workOrderIdInput > 0) {
allocation = allocations.find((item) => Number(item.work_order_id) === workOrderIdInput) ?? null
} else if (allocations.length > 1) {
throw createHttpError('该寄卖单有多笔订单占用,请指定要操作的订单', {
statusCode: 400,
errorCode: 'consignment_allocation_selection_required',
})
}
if ((allocationIdInput > 0 || shareIdInput > 0 || workOrderIdInput > 0) && !allocation) {
throw createHttpError('未找到该寄卖单对应的订单占用,请刷新后重试', {
statusCode: 404,
errorCode: 'consignment_allocation_not_found',
})
}
const workOrderId = Number(allocation?.work_order_id || inventory.bound_work_order_id || 0)
if (!workOrderId) {
throw createHttpError('该寄卖单当前没有关联订单,无需操作', {
statusCode: 409,
@@ -788,7 +949,11 @@ async function resolveSellerConsignmentWorkOrder(
// 归属只认"这张提报单就是这个卖家的"(上面已校验),不再要求订单接单人等于卖家:
// 寄卖的货是卖家自己的,赠送由卖家完成;内部现货履约的单子根本没有接单打手,
// 按接单人卡会把卖家自己的货挡在外面。客服侧保留同样的按钮,谁先点谁生效。
return { workOrderId, inventoryId: Number(inventory.id) }
return {
workOrderId,
inventoryId: Number(inventory.id),
shareId: allocation?.work_order_share_id ? Number(allocation.work_order_share_id) : null,
}
}
/**
@@ -801,8 +966,43 @@ export async function confirmWorkerConsignmentFriendAdded(
workerId: number,
submissionId: number | string,
workerName = '',
target: ConsignmentActionTarget = {},
) {
const { workOrderId } = await resolveSellerConsignmentWorkOrder(workerId, submissionId)
const { workOrderId, shareId } = await resolveSellerConsignmentWorkOrder(
workerId,
submissionId,
target,
)
if (shareId) {
const result = await confirmWorkOrderShareFriendAdded({
workOrderId,
shareId,
actorType: 'worker',
actorId: `worker:${workerId}`,
now: nowIso(),
})
if (result.failureReason) {
const failure = result.failureReason
const message =
failure === 'work_order_not_found'
? '关联订单不存在'
: failure === 'work_order_gift_mode_invalid'
? '该订单不是好友赠送模式'
: failure === 'share_status_invalid'
? '该拼单份额已提交或已验收,不能重复确认'
: failure === 'share_not_consignment'
? '该拼单份额不是寄卖份额'
: '未找到该拼单份额'
throw createHttpError(message, {
statusCode: failure === 'work_order_not_found' || failure === 'share_not_found' ? 404 : 409,
errorCode: `consignment_share_${failure}`,
})
}
return {
order: await getWorkOrderById(workOrderId),
share: result.share,
}
}
return confirmAdminWorkOrderFriendAdded(workOrderId, workerName || `worker:${workerId}`)
}
@@ -818,8 +1018,15 @@ export async function submitWorkerConsignmentGiftEvidence(
payload: JsonObject,
session: WorkerSession,
) {
const { workOrderId } = await resolveSellerConsignmentWorkOrder(workerId, submissionId)
return submitWorkerOrderAcceptance(workOrderId, payload, session, { asConsignmentSupplier: true })
const { workOrderId, shareId } = await resolveSellerConsignmentWorkOrder(workerId, submissionId, {
workOrderId: payload.workOrderId as number | string | undefined,
shareId: payload.shareId as number | string | undefined,
allocationId: payload.allocationId as number | string | undefined,
})
return submitWorkerOrderAcceptance(workOrderId, payload, session, {
asConsignmentSupplier: true,
...(shareId ? { consignmentShareId: shareId } : {}),
})
}
/**
@@ -950,6 +950,12 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) {
cancelNote: share.cancel_note || '',
/** 被撤掉的份数:撤份额累计,单人撤单为整份份数;0 = 未撤过 */
cancelQuantity: Math.max(0, Number(share.cancel_quantity || 0)),
/** 寄卖拼单:本份额的加好友/赠送冷却状态 */
consignmentInventoryId: share.consignment_inventory_id
? Number(share.consignment_inventory_id)
: null,
friendAddedAt: share.friend_added_at || null,
giftAvailableAt: share.gift_available_at || null,
worker: share.worker_id
? {
workerId: Number(share.worker_id),
@@ -13,6 +13,7 @@ import {
acceptWorkOrderShareAndSettle,
countWorkOrderPendingSharingSubmissions,
createWorkOrderEvent,
getWorkOrderShareById,
listWorkOrderSharesByWorkerInOrder,
markWorkOrderAcceptanceSubmitted,
settleOverdueWorkOrder,
@@ -79,12 +80,30 @@ export async function submitWorkerOrderAcceptance(
* 调用方必须先校验"这张提报单就是这个卖家的",这里就不再按接单人来卡。
*/
asConsignmentSupplier?: boolean
/** 寄卖拼单:明确落到某一条份额上,按份额写验收/赠送凭证 */
consignmentShareId?: number
} = {},
) {
requireActiveWorkerSession(session)
let workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId)
const worker = await getRequiredWorker(session.workerId)
if (options.consignmentShareId) {
const share = await getWorkOrderShareById(options.consignmentShareId, workOrder.id)
if (
!share ||
Number(share.worker_id) !== workerId ||
!Number(share.consignment_inventory_id || 0)
) {
throw createHttpError('未找到该寄卖拼单份额,请刷新后重试', {
statusCode: 404,
errorCode: 'consignment_share_not_found',
})
}
return submitWorkerSharingAcceptance(workOrder, share, payload, session, {
consignmentGift: true,
})
}
if (!options.asConsignmentSupplier) {
const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload)
if (target.error === 'required') {
@@ -591,6 +610,7 @@ async function submitWorkerSharingAcceptance(
sharingShare: WorkOrderShareRow,
payload: JsonObject = {},
session: WorkerSession,
options: { consignmentGift?: boolean } = {},
) {
if (workOrder.status === WORK_ORDER_STATUS.ACCEPTED) {
throw createHttpError('该拼单已验收完成', {
@@ -630,7 +650,25 @@ async function submitWorkerSharingAcceptance(
})
}
const worker = await getRequiredWorker(session.workerId)
const canAutoAcceptWithoutEvidence = resolveWorkerPermissions(worker).autoAcceptWithoutEvidence
// 寄卖拼单的好友赠送:每个卖家独立确认加好友、独立冷却、独立提交凭证。
if (options.consignmentGift && isFriendGiftAcceptanceMode(workOrder.acceptance_mode)) {
const friendAddedAt = Date.parse(String(sharingShare.friend_added_at || ''))
if (!Number.isFinite(friendAddedAt)) {
throw createHttpError('请先在「我的寄卖」确认已加好买家好友', {
statusCode: 409,
errorCode: 'consignment_share_friend_required',
})
}
const giftAvailableAt = Date.parse(String(sharingShare.gift_available_at || ''))
if (!Number.isFinite(giftAvailableAt) || giftAvailableAt > Date.now()) {
throw createHttpError('赠送冷却未结束,暂不能提交赠送凭证', {
statusCode: 409,
errorCode: 'consignment_share_gift_cooldown',
})
}
}
const canAutoAcceptWithoutEvidence =
!options.consignmentGift && resolveWorkerPermissions(worker).autoAcceptWithoutEvidence
const { files, imageUrls, note } = resolveAcceptanceImages(
payload,
sharingShare.draft_acceptance_json,
@@ -664,6 +702,7 @@ async function submitWorkerSharingAcceptance(
shareId: Number(sharingShare.id),
acceptanceJson: JSON.stringify(acceptance),
now,
preserveWorkOrderStatus: options.consignmentGift === true,
})
if (result.failureReason === 'share_status_invalid') {
throw createHttpError('当前拼单份额已提交,请勿重复提交', {