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