feat(consignment): 支持多数量拆分与份额级赠送,并对齐待抢单看板
- 匹配引擎支持一条库存按剩余数量拆给多张整单/拼单,份额数量、报酬、分配数量按实际占用计算 - 「我的寄卖」改为按占用单/份额一行 + 剩余库存行,卖家按 allocation/share/workOrder 定位确认加好友与提交凭证 - 解绑/撤销/超时按 allocation 精确回补,修复单绑定字段在多占用下被误清的问题 - 拼单好友赠送改为份额级 friend_added_at / gift_available_at(迁移 146),新增后台按份额确认加好友入口与寄卖赠送列 - 待抢单匹配看板改用新引擎:覆盖全部寄卖分类、拼单余量、精确/非精确/冲突明细,去掉手枪品类写死 - 发布押金改为该提报单全部售出/作废后才释放,避免多件库存分多单时提前退款 - 补齐集成测试与单测:拆分、多占用、份额级独立冷却与凭证、看板新旧引擎对齐
This commit is contained in:
@@ -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('当前拼单份额已提交,请勿重复提交', {
|
||||
|
||||
Reference in New Issue
Block a user