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
@@ -0,0 +1,13 @@
-- 146_consignment_share_gift.sql
--
-- 拼单寄卖:每个卖家的库存对应一条 work_order_share,好友赠送的“加好友时间/冷却截止”
-- 必须落在份额上,不能再用 work_orders.friend_added_at 这种整单字段。
-- 这样 A 确认加好友只影响 A 的份额,B 仍可独立确认、独立提交赠送凭证。
ALTER TABLE work_order_shares
ADD COLUMN IF NOT EXISTS friend_added_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS gift_available_at TIMESTAMPTZ;
COMMENT ON COLUMN work_order_shares.friend_added_at IS
'寄卖拼单份额:卖家确认加好友时间(冷却起点);非寄卖份额为 NULL';
COMMENT ON COLUMN work_order_shares.gift_available_at IS
'寄卖拼单份额:赠送冷却结束时间;到点后按时间实时推导为可赠送';
@@ -205,3 +205,61 @@ test('标准验收的寄卖单:履约中 / 待验收,按钮文案是提交
})
assert.equal(pending.stage, CONSIGNMENT_ITEM_STAGE.PENDING_ACCEPTANCE)
})
test('拼单寄卖份额:加好友/冷却/提交按份额独立推导', () => {
const waiting = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder(),
share: { status: 'joined', friend_added_at: null, gift_available_at: null },
viewer: SELLER,
now: NOW,
})
assert.equal(waiting.stage, CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND)
assert.equal(waiting.canConfirmFriend, true)
assert.equal(waiting.canSubmitEvidence, false)
const cooldown = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder(),
share: {
status: 'joined',
friend_added_at: '2026-09-17T10:00:00.000Z',
gift_available_at: '2026-09-17T13:00:00.000Z',
},
viewer: SELLER,
now: NOW,
})
assert.equal(cooldown.stage, CONSIGNMENT_ITEM_STAGE.GIFT_COOLDOWN)
assert.equal(cooldown.canSubmitEvidence, false)
const ready = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder(),
share: {
status: 'joined',
friend_added_at: '2026-09-17T10:00:00.000Z',
gift_available_at: '2026-09-17T11:00:00.000Z',
},
viewer: SELLER,
now: NOW,
})
assert.equal(ready.stage, CONSIGNMENT_ITEM_STAGE.GIFT_READY)
assert.equal(ready.canSubmitEvidence, true)
const submitted = deriveConsignmentItemStage({
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: friendGiftOrder(),
share: {
status: 'submitted',
friend_added_at: '2026-09-17T10:00:00.000Z',
gift_available_at: '2026-09-17T11:00:00.000Z',
},
viewer: SELLER,
now: NOW,
})
assert.equal(submitted.stage, CONSIGNMENT_ITEM_STAGE.GIFT_SUBMITTED)
})
@@ -92,10 +92,18 @@ export type ConsignmentItemWorkOrderInput = {
assignedWorkerId?: number | null
}
export type ConsignmentItemShareInput = {
status: string
friend_added_at?: string | null
gift_available_at?: string | null
}
export type DeriveConsignmentItemStageInput = {
submissionStatus: string
inventoryStatus?: string | null
workOrder?: ConsignmentItemWorkOrderInput | null
/** 拼单寄卖:这一行对应的卖家份额;有值时按份额推导,不再看整单赠送阶段 */
share?: ConsignmentItemShareInput | null
/** 当前查看者身份:卖家端要判断"是不是我在履约"才能给出按钮 */
viewer?: { role: 'seller'; workerId: number } | { role: 'admin' }
/** 便于测试注入"现在" */
@@ -186,6 +194,46 @@ export function deriveConsignmentItemStage(
const viewerWorkerId = input.viewer?.role === 'seller' ? Number(input.viewer.workerId) : 0
const selfFulfilling =
viewerWorkerId > 0 && Number(workOrder.assignedWorkerId || 0) === viewerWorkerId
const nowMs = input.now ?? Date.now()
// 拼单寄卖:每个卖家一条份额,加好友/冷却/提交凭证互不影响。
if (input.share) {
const shareStatus = String(input.share.status || '').trim()
if (shareStatus === 'accepted') {
return build(CONSIGNMENT_ITEM_STAGE.COMPLETED, '该份额已验收结算')
}
if (shareStatus === 'cancelled') {
return build(CONSIGNMENT_ITEM_STAGE.CANCELLED, '该份额已取消')
}
if (shareStatus === 'submitted') {
return isFriendGift
? build(CONSIGNMENT_ITEM_STAGE.GIFT_SUBMITTED, '赠送凭证已提交,等待客服审核')
: build(CONSIGNMENT_ITEM_STAGE.PENDING_ACCEPTANCE, '验收材料已提交,等待客服验收')
}
if (isFriendGift) {
const friendAddedAt = Date.parse(String(input.share.friend_added_at || ''))
if (!Number.isFinite(friendAddedAt)) {
return build(
CONSIGNMENT_ITEM_STAGE.MATCHED_WAITING_FRIEND,
'请先加上买家好友,加好后点「确认加好友」:确认即进入赠送冷却期,未完成赠送押金不退',
{ canConfirmFriend: true },
)
}
const availableAt = Date.parse(String(input.share.gift_available_at || ''))
if (!Number.isFinite(availableAt) || availableAt > nowMs) {
return build(CONSIGNMENT_ITEM_STAGE.GIFT_COOLDOWN, '本份额的赠送冷却未结束,请耐心等待')
}
return build(
CONSIGNMENT_ITEM_STAGE.GIFT_READY,
'本份额冷却已结束,请用账号赠送并上传赠送截图给客服审核(未完成赠送押金不退)',
{ canSubmitEvidence: true, evidenceButtonLabel: '提交赠送截图' },
)
}
return build(CONSIGNMENT_ITEM_STAGE.FULFILLING, '请提交本份额的验收材料,等待客服验收', {
canSubmitEvidence: true,
evidenceButtonLabel: '提交验收材料',
})
}
if (isFriendGift) {
const now = input.now ?? Date.now()
@@ -1,5 +1,7 @@
import type { PoolClient } from 'pg'
import { query } from '../../db/client.js'
export type ConsignmentAllocationRow = {
id: number
inventory_id: number
@@ -134,6 +136,34 @@ async function releaseAllocationRows(
`,
[now, row.inventory_id],
)
} else {
// 还有其它占用:绑定字段必须指向剩下的最新一张工单,否则客服侧会看到已释放的旧单。
await client.query(
`
UPDATE consignment_inventories ci
SET
bound_order_id = wo.order_id,
bound_work_order_id = wo.id,
bound_platform_order_id = COALESCE(wo.platform_order_id, ''),
shop_name = COALESCE(
NULLIF(wo.material_json -> 'source' ->> 'shopName', ''),
NULLIF(wo.material_json -> 'source' ->> 'shopTitle', ''),
''
),
allocation_status = '已安排',
updated_at = $2
FROM work_orders wo
WHERE ci.id = $1
AND wo.id = (
SELECT work_order_id
FROM consignment_inventory_allocations
WHERE inventory_id = $1 AND status IN ('reserved', 'consumed')
ORDER BY id DESC
LIMIT 1
)
`,
[row.inventory_id, now],
)
}
}
}
@@ -144,14 +174,58 @@ export async function releaseConsignmentAllocationForShareWithClient(
shareId: number,
now: string,
): Promise<number> {
const result = await client.query<ConsignmentAllocationRow>(
`SELECT * FROM consignment_inventory_allocations WHERE work_order_share_id = $1 AND status = 'reserved' FOR UPDATE`,
const result = await client.query<ConsignmentAllocationRow & { friend_added_at: string | null }>(
`SELECT ca.*, wos.friend_added_at
FROM consignment_inventory_allocations ca
LEFT JOIN work_order_shares wos ON wos.id = ca.work_order_share_id
WHERE ca.work_order_share_id = $1 AND ca.status = 'reserved'
FOR UPDATE OF ca`,
[shareId],
)
if (result.rows.length === 0) return 0
// 已确认加好友的赠礼份额:货可能已经送给买家,撤销时只能作废,不能回池二次出售。
if (result.rows.some((row) => Boolean(row.friend_added_at))) {
for (const row of result.rows) {
await client.query(
`
UPDATE consignment_inventories
SET
status = 'archived',
remaining_quantity = 0,
allow_matching = FALSE,
allocation_status = '待安排',
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
consumed_at = NULL,
updated_at = $1
WHERE id = $2
`,
[now, row.inventory_id],
)
await client.query(
`UPDATE consignment_inventory_allocations SET status = 'released', updated_at = $1 WHERE id = $2`,
[now, row.id],
)
}
return result.rows.length
}
await releaseAllocationRows(client, result.rows, now)
return result.rows.length
}
/** 供其它释放路径复用:按分配行回补数量/释放绑定。 */
export async function releaseConsignmentAllocationRowsWithClient(
client: PoolClient,
rows: ConsignmentAllocationRow[],
now: string,
): Promise<void> {
await releaseAllocationRows(client, rows, now)
}
/** 释放整个工单占用的全部库存(整单取消 / 退回大厅 / 撤销履约)。 */
export async function releaseConsignmentAllocationsForWorkOrderWithClient(
client: PoolClient,
@@ -177,3 +251,22 @@ export async function markConsignmentAllocationsConsumedWithClient(
[now, workOrderId],
)
}
/**
* 一条库存当前的有效占用(一库存可拆给多张订单)。
* 卖家操作必须据此定位具体订单/份额,不能再只看 bound_work_order_id。
*/
export async function listActiveConsignmentAllocationsForInventory(
inventoryId: number | string,
): Promise<ConsignmentAllocationRow[]> {
const result = await query<ConsignmentAllocationRow>(
`
SELECT *
FROM consignment_inventory_allocations
WHERE inventory_id = $1 AND status IN ('reserved', 'consumed')
ORDER BY id DESC
`,
[Number(inventoryId)],
)
return result.rows
}
@@ -248,6 +248,37 @@ export async function releaseConsignmentPublishDepositBySubmissionWithClient(
now: string
},
): Promise<number> {
// 一条提报单可能对应多件库存 / 一份库存拆给多张订单:只有全部售出或作废后才能退发布押金。
const active = await client.query<{ active: boolean }>(
`
SELECT (
EXISTS (
SELECT 1 FROM consignment_inventories ci
WHERE ci.source_submission_id = $1
AND ci.status = 'in_stock'
AND ci.remaining_quantity > 0
)
OR EXISTS (
SELECT 1
FROM consignment_inventory_allocations ca
JOIN consignment_inventories ci2 ON ci2.id = ca.inventory_id
LEFT JOIN work_order_shares wos ON wos.id = ca.work_order_share_id
LEFT JOIN work_orders wo ON wo.id = ca.work_order_id
WHERE ci2.source_submission_id = $1
AND ca.status = 'reserved'
AND (
(ca.work_order_share_id IS NOT NULL
AND COALESCE(wos.status, 'joined') NOT IN ('accepted', 'cancelled'))
OR (ca.work_order_share_id IS NULL
AND COALESCE(wo.status, '') NOT IN ('accepted', 'completed', 'cancelled'))
)
)
) AS active
`,
[input.submissionId],
)
if (active.rows[0]?.active === true) return 0
const target = await getConsignmentSubmissionDepositTarget(client, input.submissionId)
if (!target) return 0
return releaseConsignmentPublishDepositWithClient(client, {
@@ -152,11 +152,24 @@ test('我的寄卖:关键字必须覆盖卖家找单的口径(单号/规格/
assert.ok(body.includes(field), `关键字查询缺少可搜字段:${field}`)
}
// 兜底行(没有归属提报单的库存)里不能引用提报单别名,否则真库直接 42703 报错
const orphanStart = body.indexOf('orphanKeywordFilter = `')
const orphanEnd = body.indexOf('`', orphanStart + 'orphanKeywordFilter = `'.length)
const orphanBody = body.slice(orphanStart, orphanEnd)
assert.ok(!orphanBody.includes('cs.'), '兜底库存行的关键字条件引用了提报单别名 cs.')
// 待审核行还没有库存,关键字条件不能引用 ci./wo.,否则真库直接 42703 报错
const pendingStart = body.indexOf('pendingKeywordFilter = `')
assert.ok(pendingStart > 0, '缺少待审核行的关键字条件')
const pendingEnd = body.indexOf('`', pendingStart + 'pendingKeywordFilter = `'.length)
const pendingBody = body.slice(pendingStart, pendingEnd)
assert.ok(
!pendingBody.includes('ci.') && !pendingBody.includes('wo.'),
'待审核行没有库存/工单别名,关键字条件不能引用 ci./wo.',
)
// 一库存拆给多张订单后,基表必须同时产出“占用行”和“剩余库存行”
assert.ok(body.includes('ca.id AS allocation_id'), '缺少占用行')
assert.ok(body.includes('ca.quantity AS row_quantity'), '占用行必须带出本单数量')
assert.ok(body.includes("ca.status IN ('reserved', 'consumed')"), '占用行口径错误')
assert.ok(
body.includes('SELECT 1 FROM consignment_inventory_allocations ca0'),
'剩余库存行必须排除已有占用的库存',
)
})
test('我的寄卖:分页与分组过滤必须走 SQL,不能再把全量记录捞回前端', async () => {
@@ -1,5 +1,10 @@
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import {
releaseConsignmentAllocationRowsWithClient,
releaseConsignmentAllocationsForWorkOrderWithClient,
type ConsignmentAllocationRow,
} from './consignment-allocation-repo.js'
import { toJsonString } from './shared.js'
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
@@ -883,7 +888,63 @@ export async function releaseConsignmentInventoryBindingForWorkOrderWithClient(
}>('SELECT acceptance_mode, friend_added_at FROM work_orders WHERE id = $1', [workOrderId])
const order = orderRes.rows[0]
if (!order) return null
const archiveOnly = isWorkOrderGiftDepositForfeitable(order)
const confirmedShare = await client.query<{ id: number }>(
`SELECT ca.id
FROM consignment_inventory_allocations ca
JOIN work_order_shares wos ON wos.id = ca.work_order_share_id
WHERE ca.work_order_id = $1 AND wos.friend_added_at IS NOT NULL
LIMIT 1`,
[Number(workOrderId)],
)
const archiveOnly = isWorkOrderGiftDepositForfeitable(order) || Boolean(confirmedShare.rows[0])
if (archiveOnly) {
// 已确认加好友的份额作废下架;未确认的份额正常回库。整单级 friend_added_at 只作为兜底。
const allocationResult = await client.query<
ConsignmentAllocationRow & { friend_added_at: string | null }
>(
`SELECT ca.*, wos.friend_added_at
FROM consignment_inventory_allocations ca
LEFT JOIN work_order_shares wos ON wos.id = ca.work_order_share_id
WHERE ca.work_order_id = $1 AND ca.status IN ('reserved', 'consumed')
FOR UPDATE OF ca`,
[Number(workOrderId)],
)
for (const row of allocationResult.rows) {
if (row.friend_added_at) {
await client.query(
`
UPDATE consignment_inventories
SET
status = 'archived',
remaining_quantity = 0,
allow_matching = FALSE,
allocation_status = '待安排',
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
consumed_at = NULL,
updated_at = $1
WHERE id = $2
`,
[input.now, row.inventory_id],
)
await client.query(
`UPDATE consignment_inventory_allocations SET status = 'released', updated_at = $1 WHERE id = $2`,
[input.now, row.id],
)
} else {
await releaseConsignmentAllocationRowsWithClient(client, [row], input.now)
}
}
} else {
// 普通回库:先按分配记录回补数量,再走下面的单绑定兜底(兼容没有分配记录的历史数据)。
await releaseConsignmentAllocationsForWorkOrderWithClient(
client,
Number(workOrderId),
input.now,
)
}
const res = await client.query<ConsignmentInventoryRow>(
archiveOnly
@@ -891,6 +952,7 @@ export async function releaseConsignmentInventoryBindingForWorkOrderWithClient(
UPDATE consignment_inventories
SET
status = 'archived',
remaining_quantity = 0,
allocation_status = '待安排',
assigned_worker_id = NULL,
bound_order_id = NULL,
@@ -1064,6 +1126,12 @@ export type WorkerConsignmentGroup = 'all' | 'reviewing' | 'active' | 'finished'
export type WorkerConsignmentRowKey = {
submissionId: number | null
inventoryId: number | null
/** 一条库存拆分给多张订单时,按占用记录拆行;库存剩余行/待审核行没有 */
allocationId: number | null
shareId: number | null
workOrderId: number | null
/** 本行对应的数量:占用行为占用数,库存剩余行为剩余数,待审核行为 1 */
rowQuantity: number
createdAt: string
}
@@ -1096,13 +1164,27 @@ export async function listWorkerConsignmentPage(input: {
}): Promise<WorkerConsignmentPage> {
const values: unknown[] = [input.workerId]
const keyword = String(input.keyword || '').trim()
let submissionKeywordFilter = ''
let orphanKeywordFilter = ''
let pendingKeywordFilter = ''
let inventoryKeywordFilter = ''
if (keyword) {
values.push(`%${keyword}%`)
const p = `$${values.length}`
// 提报单行:提报单 + 它产出的库存 + 绑定工单上的买家资料
submissionKeywordFilter = `
// 待审核行还没有库存,只能搜提报单字段
pendingKeywordFilter = `
AND (
cs.submission_no ILIKE ${p}
OR cs.item_name ILIKE ${p}
OR cs.spec_text ILIKE ${p}
OR cs.system ILIKE ${p}
OR cs.server_zone ILIKE ${p}
OR cs.series ILIKE ${p}
OR cs.diamond_level::text || '钻' ILIKE ${p}
OR cs.seller_game_id ILIKE ${p}
OR cs.contact_info ILIKE ${p}
OR cs.seller_phone ILIKE ${p}
)`
// 库存行 / 占用行:提报单 + 库存 + 绑定工单上的买家资料
inventoryKeywordFilter = `
AND (
cs.submission_no ILIKE ${p}
OR cs.item_name ILIKE ${p}
@@ -1127,63 +1209,77 @@ export async function listWorkerConsignmentPage(input: {
OR wo.material_json -> 'collect' -> 'fields' ->> 'characterId' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'uid' ILIKE ${p}
)`
// 兜底行(没有归属提报单的库存):只能搜库存与买家资料,不能引用提报单别名
orphanKeywordFilter = `
AND (
ci.inventory_no ILIKE ${p}
OR ci.item_name ILIKE ${p}
OR ci.system ILIKE ${p}
OR ci.server_zone ILIKE ${p}
OR ci.series ILIKE ${p}
OR ci.diamond_level::text || '钻' ILIKE ${p}
OR ci.seller_game_id ILIKE ${p}
OR ci.seller_contact ILIKE ${p}
OR ci.seller_phone ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'gameNickname' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'roleName' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'nickname' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'gameId' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'characterId' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'uid' ILIKE ${p}
)`
}
// 提报单行(连带它产出的库存与绑定工单)与「没有归属提报单的库存」兜底行
// 三类行:待审核提报单(无库存)、库存剩余数量、库存占用记录(一库存可拆给多张订单)
const baseSql = `
WITH base AS (
SELECT
cs.id AS submission_id,
ci.id AS inventory_id,
NULL::bigint AS inventory_id,
NULL::bigint AS allocation_id,
NULL::bigint AS share_id,
NULL::bigint AS work_order_id,
1::integer AS row_quantity,
cs.created_at AS sort_at,
CASE
WHEN cs.status = 'pending_review' THEN 'reviewing'
WHEN cs.status = 'rejected' THEN 'finished'
ELSE 'active'
END AS group_key
FROM consignment_submissions cs
WHERE cs.worker_id = $1
AND NOT EXISTS (
SELECT 1 FROM consignment_inventories ci0 WHERE ci0.source_submission_id = cs.id
)
${pendingKeywordFilter}
UNION ALL
SELECT
cs.id AS submission_id,
ci.id AS inventory_id,
NULL::bigint AS allocation_id,
NULL::bigint AS share_id,
NULL::bigint AS work_order_id,
GREATEST(ci.remaining_quantity, 0) AS row_quantity,
ci.created_at AS sort_at,
CASE
WHEN cs.status = 'rejected' THEN 'finished'
WHEN ci.status = 'archived' THEN 'finished'
ELSE 'active'
END AS group_key
FROM consignment_inventories ci
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
WHERE ci.worker_id = $1
AND (
ci.remaining_quantity > 0
OR NOT EXISTS (
SELECT 1 FROM consignment_inventory_allocations ca0
WHERE ca0.inventory_id = ci.id AND ca0.status IN ('reserved', 'consumed')
)
)
${inventoryKeywordFilter}
UNION ALL
SELECT
cs.id AS submission_id,
ci.id AS inventory_id,
ca.id AS allocation_id,
ca.work_order_share_id AS share_id,
ca.work_order_id AS work_order_id,
ca.quantity AS row_quantity,
ca.created_at AS sort_at,
CASE
WHEN cs.status = 'rejected' THEN 'finished'
WHEN ci.status = 'archived' THEN 'finished'
WHEN wo.status IN ('cancelled', 'accepted', 'completed') THEN 'finished'
ELSE 'active'
END AS group_key
FROM consignment_submissions cs
LEFT JOIN consignment_inventories ci ON ci.source_submission_id = cs.id
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
WHERE cs.worker_id = $1
${submissionKeywordFilter}
UNION ALL
SELECT
NULL AS submission_id,
ci.id AS inventory_id,
ci.created_at AS sort_at,
CASE
WHEN ci.status = 'archived' THEN 'finished'
WHEN wo.status IN ('cancelled', 'accepted', 'completed') THEN 'finished'
ELSE 'active'
END AS group_key
FROM consignment_inventories ci
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
FROM consignment_inventory_allocations ca
JOIN consignment_inventories ci ON ci.id = ca.inventory_id
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
JOIN work_orders wo ON wo.id = ca.work_order_id
WHERE ci.worker_id = $1
AND NOT EXISTS (
SELECT 1 FROM consignment_submissions cs2
WHERE cs2.id = ci.source_submission_id AND cs2.worker_id = $1
)
${orphanKeywordFilter}
AND ca.status IN ('reserved', 'consumed')
${inventoryKeywordFilter}
)
`
@@ -1226,13 +1322,20 @@ export async function listWorkerConsignmentPage(input: {
const listRes = await query<{
submission_id: string | number | null
inventory_id: string | number | null
allocation_id: string | number | null
share_id: string | number | null
work_order_id: string | number | null
row_quantity: number | string
sort_at: string
}>(
`${baseSql}
SELECT submission_id, inventory_id, sort_at
SELECT submission_id, inventory_id, allocation_id, share_id, work_order_id, row_quantity, sort_at
FROM base
${groupFilter}
ORDER BY sort_at DESC, COALESCE(submission_id, 0) DESC, COALESCE(inventory_id, 0) DESC
ORDER BY sort_at DESC,
COALESCE(submission_id, 0) DESC,
COALESCE(inventory_id, 0) DESC,
COALESCE(allocation_id, 0) DESC
LIMIT $${listValues.length - 1} OFFSET $${listValues.length}`,
listValues,
)
@@ -1241,6 +1344,10 @@ export async function listWorkerConsignmentPage(input: {
rows: listRes.rows.map((row) => ({
submissionId: row.submission_id == null ? null : Number(row.submission_id),
inventoryId: row.inventory_id == null ? null : Number(row.inventory_id),
allocationId: row.allocation_id == null ? null : Number(row.allocation_id),
shareId: row.share_id == null ? null : Number(row.share_id),
workOrderId: row.work_order_id == null ? null : Number(row.work_order_id),
rowQuantity: Math.max(0, Number(row.row_quantity || 0)),
createdAt: row.sort_at,
})),
total,
@@ -383,6 +383,9 @@ export type WorkOrderShareRow = {
request_id?: string
/** 拼单份额对应的寄卖库存;非寄卖份额为 NULL */
consignment_inventory_id?: number | null
/** 寄卖拼单份额:卖家确认加好友时间 / 赠送冷却结束时间 */
friend_added_at?: string | null
gift_available_at?: string | null
worker_username?: string
worker_display_name?: string
}
@@ -2,6 +2,11 @@ import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import { resolveShareReduceTargetQuantity } from '../../domain/work-order-share-quantity.js'
import {
isFriendGiftAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
} from '../../domain/work-order-acceptance-mode.js'
import {
ensureWorkerWalletWithClient,
getWorkerWalletWithClient,
@@ -136,6 +141,95 @@ export async function getWorkOrderShareById(
return result.rows[0] || null
}
/**
* 寄卖拼单份额:卖家确认已加买家好友。
* 冷却起止只写这一条份额,不影响同单其他卖家。
*/
export async function confirmWorkOrderShareFriendAdded(input: {
workOrderId: number
shareId: number
actorType?: 'admin' | 'worker'
actorId?: string
now: string
}): Promise<{
share: WorkOrderShareRow | null
failureReason:
| 'work_order_not_found'
| 'work_order_gift_mode_invalid'
| 'share_not_found'
| 'share_status_invalid'
| 'share_not_consignment'
| null
}> {
return withTransaction(async (client) => {
const orderResult = await client.query<WorkOrderRow>(
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
[Number(input.workOrderId)],
)
const workOrder = orderResult.rows[0] || null
if (!workOrder) return { share: null, failureReason: 'work_order_not_found' }
if (!isFriendGiftAcceptanceMode(workOrder.acceptance_mode)) {
return { share: null, failureReason: 'work_order_gift_mode_invalid' }
}
const lockResult = await client.query<{ id: number }>(
`SELECT id FROM work_order_shares
WHERE id = $1 AND work_order_id = $2
FOR UPDATE`,
[Number(input.shareId), Number(input.workOrderId)],
)
if (!lockResult.rows[0]) return { share: null, failureReason: 'share_not_found' }
const shareResult = await client.query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
[Number(input.shareId)],
)
const share = shareResult.rows[0] || null
if (!share) return { share: null, failureReason: 'share_not_found' }
if (share.status !== 'joined') return { share, failureReason: 'share_status_invalid' }
if (!Number(share.consignment_inventory_id || 0)) {
return { share, failureReason: 'share_not_consignment' }
}
const cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours)
const cooldownMinutes = normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes)
await client.query(
`
UPDATE work_order_shares
SET friend_added_at = $1::timestamptz,
gift_available_at = $1::timestamptz
+ ($2::int * INTERVAL '1 hour')
+ ($3::int * INTERVAL '1 minute'),
updated_at = $1::timestamptz
WHERE id = $4
`,
[input.now, cooldownHours, cooldownMinutes, Number(input.shareId)],
)
await createWorkOrderEventWithClient(client, {
workOrderId: Number(input.workOrderId),
actorType: input.actorType || 'worker',
actorId: input.actorId || '',
eventType: 'consignment_share_gift_countdown_started',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({
shareId: Number(input.shareId),
workerId: Number(share.worker_id),
inventoryId: Number(share.consignment_inventory_id),
cooldownHours,
cooldownMinutes,
friendAddedAt: input.now,
}),
now: input.now,
})
const updated = await client.query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
[Number(input.shareId)],
)
return { share: updated.rows[0] || null, failureReason: null }
})
}
/** 普通拼单使用指定份数,立即抢单则在事务中承接全部剩余份数。 */
export function resolveWorkOrderShareJoinQuantity(input: {
quantity: number
@@ -546,6 +640,8 @@ export async function submitWorkOrderShareAcceptance(input: {
shareId: number
acceptanceJson: string
now: string
/** 寄卖拼单:母单是 in_progress,提交份额后不能按大厅规则把母单改回 open */
preserveWorkOrderStatus?: boolean
}): Promise<{
share: WorkOrderShareRow | null
failureReason: 'share_not_found' | 'share_status_invalid' | 'work_order_status_invalid' | null
@@ -562,9 +658,6 @@ export async function submitWorkOrderShareAcceptance(input: {
if (!workOrder) {
return { share: null, failureReason: 'share_not_found' }
}
if (!['open', 'pending_acceptance'].includes(workOrder.status)) {
return { share: null, failureReason: 'work_order_status_invalid' }
}
const lockResult = await client.query<{ id: number }>(
`
@@ -587,6 +680,12 @@ export async function submitWorkOrderShareAcceptance(input: {
if (!share) {
return { share: null, failureReason: 'share_not_found' }
}
const consignmentShare = Number(share.consignment_inventory_id || 0) > 0
const allowedStatuses = ['open', 'pending_acceptance']
if (consignmentShare || input.preserveWorkOrderStatus) allowedStatuses.push('in_progress')
if (!allowedStatuses.includes(workOrder.status)) {
return { share: null, failureReason: 'work_order_status_invalid' }
}
if (share.status !== 'joined') {
return { share, failureReason: 'share_status_invalid' }
}
@@ -599,7 +698,9 @@ export async function submitWorkOrderShareAcceptance(input: {
[input.acceptanceJson, input.now, Number(share.id)],
)
await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now)
if (!consignmentShare && !input.preserveWorkOrderStatus) {
await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now)
}
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
@@ -612,6 +713,7 @@ export async function submitWorkOrderShareAcceptance(input: {
workerId: input.workerId,
shareId: Number(share.id),
quantity: share.quantity,
consignmentShare,
}),
now: input.now,
})
@@ -42,7 +42,7 @@ import {
listInventoryMatchableOrders,
listWorkOrderInventoryMatchOptions,
} from '../../../services/worker-platform/consignment-fulfillment-service.js'
import { listPendingPistolMatches } from '../../../services/worker-platform/consignment-pending-match-service.js'
import { listPendingConsignmentMatches } from '../../../services/worker-platform/consignment-pending-match-service.js'
import {
listConsignmentMatchCandidatesForWorkOrder,
matchConsignmentWorkOrderManually,
@@ -366,20 +366,20 @@ router.post(
// ---------------- 匹配与履约 ----------------
// 全局待抢单匹配看板:只看非拼单的待抢单工单 × 在库待匹配现货,只读。
// 全局待抢单匹配看板:按寄卖配置匹配待抢单工单 × 在库待匹配现货,只读。
router.get(
'/worker-platform/consignment/pending-pistol-matches',
requireAdminPermission('consignment.view'),
createJsonHandler(
(req) =>
listPendingPistolMatches({
listPendingConsignmentMatches({
keyword: String(req.query.keyword || ''),
includeConflicts: String(req.query.includeConflicts || '') === 'true',
limit: Number(req.query.limit || 0) || undefined,
}),
{
successMessage: 'ok',
errorMessage: '读取待匹配手枪订单失败',
errorMessage: '读取待匹配订单失败',
scope: '[admin/consignment/pending-pistol-matches]',
},
),
@@ -8,6 +8,7 @@ import {
cancelAdminWorkOrder,
completeAdminWorkOrderGiftCooldown,
confirmAdminWorkOrderFriendAdded,
confirmAdminWorkOrderShareFriendAdded,
createAdminMockWorkOrder,
deleteAdminMockCustomShop,
deleteAdminWorkOrder,
@@ -529,6 +530,30 @@ router.post(
),
)
router.post(
'/worker-platform/orders/:workOrderId/shares/:shareId/confirm-friend',
requireAdminPermission('worker_order.manage_problem'),
createJsonHandler(
(req) =>
confirmAdminWorkOrderShareFriendAdded(
String(req.params.workOrderId || ''),
String(req.params.shareId || ''),
req.adminSession?.displayName || req.adminSession?.username || '',
),
{
successMessage: '已确认该份额加好友,赠送冷却开始',
errorMessage: '确认份额加好友失败',
scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/confirm-friend]',
audit: (req, data) => ({
action: 'work_order_share_friend_confirmed',
targetType: 'work_order_share',
targetId: String(req.params.shareId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
router.post(
'/worker-platform/orders/:workOrderId/reset-gift-material',
requireAdminPermission('worker_order.manage_problem'),
@@ -147,6 +147,11 @@ router.post(
session.workerId,
String(req.params.submissionId || ''),
session.username || '',
{
workOrderId: req.body?.workOrderId,
shareId: req.body?.shareId,
allocationId: req.body?.allocationId,
},
)
res.json({ ok: true, data: result })
} catch (error) {
@@ -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('当前拼单份额已提交,请勿重复提交', {
@@ -95,6 +95,11 @@ export default function ConsignmentPendingMatchModal({
<Text type="secondary" style={{ fontSize: 11 }}>
录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'}
</Text>
{Number(record.totalQuantity || 1) > 1 ? (
<Text type="secondary" style={{ fontSize: 11 }}>
拼单剩余 {record.remainingQuantity ?? 0} / {record.totalQuantity} 份
</Text>
) : null}
</Space>
),
},
@@ -166,6 +171,15 @@ export default function ConsignmentPendingMatchModal({
{record.conflicts.join(';')}
</Text>
) : null}
{(record.matchDetails || [])
.filter((detail) => detail.status !== 'match' && detail.status !== 'ignored')
.slice(0, 4)
.map((detail) => (
<Text key={detail.key} type="secondary" style={{ fontSize: 11 }}>
{detail.label}:订单「{detail.orderValue || '-'}」/ 库存「
{detail.inventoryValue || '-'}」
</Text>
))}
</Space>
),
},
@@ -173,7 +187,7 @@ export default function ConsignmentPendingMatchModal({
return (
<Modal
title="匹配 · 待抢单手枪 × 在库现货"
title="匹配 · 待抢单 × 在库现货"
open={open}
width="min(1320px, 94vw)"
footer={null}
@@ -184,8 +198,8 @@ export default function ConsignmentPendingMatchModal({
<Alert
type="info"
showIcon
message="只查询非拼单的待抢单手枪单 × 在库待匹配现货,不直接改单。"
description="分数 0-100:系统 20 / 区服 20 / 系列 20 / 钻级 25 / 璀璨 15。非精确项仍需按现有规则确认;不按录单、入库时间先后过滤。"
message="按寄卖配置匹配全部待抢单 × 在库待匹配现货,不直接改单。"
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);拼单订单按剩余份数展示。未配置寄卖的订单仍按旧手枪口径兜底。"
/>
<Space wrap size={12}>
@@ -52,6 +52,7 @@ import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/
import {
acceptAdminWorkOrder,
acceptAdminWorkOrderShare,
confirmAdminWorkOrderShareFriendAdded,
acceptAdminWorkOrders,
assignAdminWorkOrderToWorker,
cancelAdminWorkOrder,
@@ -3734,6 +3735,22 @@ export default function WorkOrdersPanel() {
</Space>
),
},
{
title: '寄卖赠送',
width: 120,
render: (_, share) => {
if (!share.consignmentInventoryId) return '-'
if (share.status === 'accepted') return <Tag color="green">已完成</Tag>
if (share.status === 'submitted')
return <Tag color="processing">待审核</Tag>
if (!share.friendAddedAt) return <Tag>待加好友</Tag>
const availableAt = Date.parse(String(share.giftAvailableAt || ''))
if (Number.isFinite(availableAt) && availableAt > Date.now()) {
return <Tag color="orange">冷却中</Tag>
}
return <Tag color="green">待赠送</Tag>
},
},
{
title: '问题',
width: 120,
@@ -3906,6 +3923,29 @@ export default function WorkOrdersPanel() {
)
) : null
const consignmentGiftAction =
sharingOrder?.acceptanceMode === 'friend_gift' &&
share.consignmentInventoryId &&
share.status === 'joined' &&
!share.friendAddedAt ? (
<Button
size="small"
title="只确认该卖家的份额,其他卖家的冷却不受影响"
onClick={() => {
void runAction(
() =>
confirmAdminWorkOrderShareFriendAdded(
sharingOrder.workOrderId,
share.shareId,
),
'已确认该份额加好友,赠送冷却开始',
)
}}
>
确认加好友
</Button>
) : null
return (
<Space size={6} className="admin-share-actions">
{share.status === 'submitted' ? (
@@ -3969,6 +4009,7 @@ export default function WorkOrdersPanel() {
撤份额
</Button>
) : null}
{consignmentGiftAction}
{problemAction}
</Space>
)
@@ -530,6 +530,14 @@ export default function WorkerConsignmentPage() {
return null
}
function resolveActionTarget(item: WorkerConsignmentRecord) {
return {
workOrderId: item.workOrderId,
shareId: item.shareId ?? null,
allocationId: item.allocationId ?? null,
}
}
function handleConfirmFriend(item: WorkerConsignmentRecord) {
const submissionId = resolveActionSubmissionId(item)
if (submissionId == null) return
@@ -554,7 +562,7 @@ export default function WorkerConsignmentPage() {
centered: true,
onOk: async () => {
try {
await confirmWorkerConsignmentFriendAdded(submissionId)
await confirmWorkerConsignmentFriendAdded(submissionId, resolveActionTarget(item))
message.success('已确认加好友,赠送冷却开始')
await myConsignmentsQuery.refetch()
} catch (err: any) {
@@ -578,7 +586,8 @@ export default function WorkerConsignmentPage() {
}
async function handleEvidenceSubmit() {
const submissionId = evidenceItem ? resolveActionSubmissionId(evidenceItem) : null
if (!evidenceItem) return
const submissionId = resolveActionSubmissionId(evidenceItem)
if (submissionId == null) return
const urls = evidenceFiles.map((file) => file.url).filter(Boolean)
if (urls.length === 0) {
@@ -594,7 +603,10 @@ export default function WorkerConsignmentPage() {
imageUrls: urls,
...(note ? { note } : {}),
}
await submitWorkerConsignmentGiftEvidence(submissionId, payload)
await submitWorkerConsignmentGiftEvidence(submissionId, {
...resolveActionTarget(evidenceItem),
...payload,
})
message.success('赠送截图已提交,等待客服验收')
closeEvidenceModal()
await myConsignmentsQuery.refetch()
@@ -1137,7 +1149,7 @@ export default function WorkerConsignmentPage() {
<div className="worker-consignment-record-list">
{records.map((record) => (
<ConsignmentRecordCard
key={record.consignmentNo}
key={`${record.submissionId ?? 'x'}-${record.inventoryId ?? 'x'}-${record.allocationId ?? 'stock'}-${record.workOrderId ?? 0}`}
record={record}
now={cooldownNow}
onConfirmFriend={handleConfirmFriend}
@@ -1526,6 +1538,11 @@ function ConsignmentRecordCard({
寄售价{' '}
<strong>¥{((record.salePriceFen ?? record.askingPriceFen) / 100).toFixed(2)}</strong>
</span>
{record.allocationId && Number(record.allocatedQuantity || 0) > 0 ? (
<span>
本单占用 <strong>{record.allocatedQuantity} 件</strong>
</span>
) : null}
{Number(record.quantity || 1) > 1 ? (
<span>
数量{' '}
@@ -981,6 +981,14 @@ export function acceptAdminWorkOrderShare(workOrderId: number, shareId: number)
)
}
/** 寄卖拼单:按份额确认已加好友,只影响该卖家的冷却。 */
export function confirmAdminWorkOrderShareFriendAdded(workOrderId: number, shareId: number) {
return apiPost<{ share: WorkOrderShare | null }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/confirm-friend`,
{},
)
}
export function cancelAdminWorkOrderShare(
workOrderId: number,
shareId: number,
+18 -3
View File
@@ -447,7 +447,13 @@ export type WorkerConsignmentRecord = {
consignmentNo: string
submissionId: number | null
inventoryId: number | null
/** 一库存拆给多张订单时,本行对应的占用记录 */
allocationId?: number | null
/** 拼单份额 id(本行对应的卖家份额) */
shareId?: number | null
workOrderId: number | null
/** 本行占用的数量;库存剩余行为剩余数量 */
allocatedQuantity?: number
itemName: string
system: string
serverZone: string
@@ -558,21 +564,30 @@ export function fetchWorkerConsignments(params: WorkerConsignmentListParams = {}
)
}
export type WorkerConsignmentActionTarget = {
workOrderId?: number | null
shareId?: number | null
allocationId?: number | null
}
/**
* 卖家确认已在游戏内加好买家好友 → 进入赠送冷却期。
* 注意:确认后若未完成赠送,履约押金不退。
*/
export function confirmWorkerConsignmentFriendAdded(submissionId: number | string) {
export function confirmWorkerConsignmentFriendAdded(
submissionId: number | string,
target: WorkerConsignmentActionTarget = {},
) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/worker/consignment/items/${submissionId}/confirm-friend`,
{},
target,
)
}
/** 卖家提交赠送完成截图(后端复用打手验收链路,工单转「待验收」交客服审核)。 */
export function submitWorkerConsignmentGiftEvidence(
submissionId: number | string,
payload: { imageUrls: string[]; note?: string },
payload: { imageUrls: string[]; note?: string } & WorkerConsignmentActionTarget,
) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/worker/consignment/items/${submissionId}/gift-evidence`,
+15 -1
View File
@@ -300,7 +300,7 @@ export type WorkOrderInventoryMatchCheck = {
matches: ConsignmentMatchResult[]
}
/** 后台「寄售仓库 → 匹配」:待抢单手枪 × 在库待匹配现货的配对明细 */
/** 后台「寄售仓库 → 匹配」:待抢单 × 在库待匹配现货的配对明细 */
export type PendingPistolMatchPair = {
workOrderId: number
workOrderNo: string
@@ -316,6 +316,20 @@ export type PendingPistolMatchPair = {
score: number
reason: string
conflicts: string[]
/** 自定义规格逐字段对比;内置五字段为空,沿用旧评分原因 */
matchDetails?: Array<{
key: string
label: string
orderValue: string
inventoryValue: string
status: 'match' | 'partial' | 'conflict' | 'unknown' | 'ignored'
}>
/** 拼单订单还差多少份;非拼单为 1 */
remainingQuantity?: number
/** 拼单订单总份数;非拼单为 1 */
totalQuantity?: number
/** 命中的寄卖配置 id;无配置时为 null(旧手枪兜底) */
profileId?: number | null
}
export type PendingPistolMatchResult = {
@@ -608,6 +608,10 @@ export type WorkOrderShare = {
cancelNote?: string
/** 被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过) */
cancelQuantity?: number
/** 寄卖拼单:本份额对应的库存与加好友/赠送冷却状态 */
consignmentInventoryId?: number | null
friendAddedAt?: string | null
giftAvailableAt?: string | null
/** 份额级问题单:只影响该份额持有人,母单状态不变 */
problemMarked?: boolean
problemNote?: string