feat(consignment): 内部现货占库进入赠送冷静期并闭环发布押金

打手发布冻结履约押金,驳回/作废退还,验收释放;内部匹配履约立刻占用库存并跳过打手资料进入冷静期,撤销回库时补冻押金。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-15 20:03:11 +08:00
co-authored by Cursor
parent 4a6215e816
commit fd1eb5815b
24 changed files with 1662 additions and 462 deletions
@@ -0,0 +1,262 @@
import type { PoolClient } from 'pg'
import { withTransaction } from '../../db/client.js'
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, toJsonString } from './shared.js'
/**
* 查询寄卖提报押金尚未释放的金额(分)
*/
export async function getOutstandingConsignmentDepositFen(
client: PoolClient,
workerId: number,
submissionId: number | string,
): Promise<number> {
const res = await client.query<{ outstanding: number }>(
`
SELECT (
COALESCE(SUM(CASE WHEN ledger_type = 'deposit_freeze' THEN ABS(amount) ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN ledger_type = 'deposit_release' THEN ABS(amount) ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN ledger_type = 'deposit_deduction' THEN ABS(amount) ELSE 0 END), 0)
)::int AS outstanding
FROM worker_wallet_ledgers
WHERE worker_id = $1
AND COALESCE(payload_json->>'kind', '') = 'consignment_publish'
AND COALESCE(payload_json->>'consignmentSubmissionId', '') = $2
`,
[workerId, String(submissionId)],
)
return Math.max(0, Number(res.rows[0]?.outstanding || 0))
}
export function getConsignmentDepositErrorCode(error: unknown): string | null {
if (!(error instanceof Error)) return null
const code = String((error as Error & { code?: string }).code || error.message || '').trim()
if (
code === 'consignment_deposit_insufficient' ||
code === 'consignment_deposit_balance_inconsistent'
) {
return code
}
return null
}
/**
* 寄卖发布成功:从可用余额冻结履约押金
*/
export async function freezeConsignmentPublishDepositWithClient(
client: PoolClient,
input: {
workerId: number
amountFen: number
submissionId: number | string
submissionNo: string
now: string
note?: string
},
): Promise<{ availableAfter: number; frozenAfter: number }> {
const amount = Math.max(0, Math.round(Number(input.amountFen || 0)))
if (amount <= 0) {
const wallet = await getWorkerWalletWithClient(client, input.workerId)
return {
availableAfter: Number(wallet?.available_amount || 0),
frozenAfter: Number(wallet?.frozen_deposit_amount || 0),
}
}
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, input.workerId)
const available = Number(wallet?.available_amount || 0)
if (available < amount) {
const err = new Error('consignment_deposit_insufficient')
;(err as Error & { code?: string }).code = 'consignment_deposit_insufficient'
throw err
}
const nextAvailable = available - amount
const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + amount
await client.query(
`
UPDATE worker_wallets
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
WHERE worker_id = $4
`,
[nextAvailable, nextFrozen, input.now, input.workerId],
)
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_freeze', $2, $3, $4, NULL, $5, $6::jsonb, $7)
`,
[
input.workerId,
-amount,
nextAvailable,
nextFrozen,
input.note || `寄卖发布冻结押金 ${input.submissionNo}`,
toJsonString({
kind: 'consignment_publish',
consignmentSubmissionId: String(input.submissionId),
submissionNo: input.submissionNo,
}),
input.now,
],
)
return { availableAfter: nextAvailable, frozenAfter: nextFrozen }
}
/**
* 寄卖驳回或履约完成后:释放发布时冻结的押金
*/
export async function releaseConsignmentPublishDepositWithClient(
client: PoolClient,
input: {
workerId: number
submissionId: number | string
note: string
now: string
},
): Promise<number> {
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, input.workerId)
// 锁住钱包后再读取流水,串行化同一打手的并发释放,避免重复退款或挪用其他押金。
const outstanding = await getOutstandingConsignmentDepositFen(
client,
input.workerId,
input.submissionId,
)
if (outstanding <= 0) return 0
const frozen = Number(wallet?.frozen_deposit_amount || 0)
if (frozen < outstanding) {
const error = new Error('consignment_deposit_balance_inconsistent')
;(error as Error & { code?: string }).code = 'consignment_deposit_balance_inconsistent'
throw error
}
const releaseAmount = outstanding
const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount
const nextFrozen = Math.max(0, frozen - releaseAmount)
await client.query(
`
UPDATE worker_wallets
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
WHERE worker_id = $4
`,
[nextAvailable, nextFrozen, input.now, input.workerId],
)
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_release', $2, $3, $4, NULL, $5, $6::jsonb, $7)
`,
[
input.workerId,
releaseAmount,
nextAvailable,
nextFrozen,
input.note,
toJsonString({
kind: 'consignment_publish',
consignmentSubmissionId: String(input.submissionId),
}),
input.now,
],
)
return releaseAmount
}
async function getConsignmentSubmissionDepositTarget(
client: PoolClient,
submissionId: number | string,
): Promise<{
workerId: number
depositAmountFen: number
submissionNo: string
} | null> {
const submission = await client.query<{
worker_id: number | string | null
deposit_amount_fen: number | string | null
submission_no: string
}>(
`
SELECT worker_id, deposit_amount_fen, submission_no
FROM consignment_submissions
WHERE id = $1
FOR SHARE
`,
[submissionId],
)
const row = submission.rows[0]
const workerId = Number(row?.worker_id || 0)
if (!workerId) return null
return {
workerId,
depositAmountFen: Math.max(0, Math.round(Number(row?.deposit_amount_fen || 0))),
submissionNo: String(row?.submission_no || ''),
}
}
export async function releaseConsignmentPublishDepositBySubmissionWithClient(
client: PoolClient,
input: {
submissionId: number | string
note: string
now: string
},
): Promise<number> {
const target = await getConsignmentSubmissionDepositTarget(client, input.submissionId)
if (!target) return 0
return releaseConsignmentPublishDepositWithClient(client, {
workerId: target.workerId,
submissionId: input.submissionId,
note: input.note,
now: input.now,
})
}
/** 回库或重新挂卖时:若发布押金已释放则按提报金额重新冻结,已冻结则保持。 */
export async function ensureConsignmentPublishDepositFrozenBySubmissionWithClient(
client: PoolClient,
input: {
submissionId: number | string
note: string
now: string
},
): Promise<number> {
const target = await getConsignmentSubmissionDepositTarget(client, input.submissionId)
if (!target) return 0
await ensureWorkerWalletWithClient(client, target.workerId, input.now)
await getWorkerWalletWithClient(client, target.workerId)
const outstanding = await getOutstandingConsignmentDepositFen(
client,
target.workerId,
input.submissionId,
)
if (outstanding > 0) return outstanding
if (target.depositAmountFen <= 0) return 0
await freezeConsignmentPublishDepositWithClient(client, {
workerId: target.workerId,
amountFen: target.depositAmountFen,
submissionId: input.submissionId,
submissionNo: target.submissionNo,
note: input.note,
now: input.now,
})
return target.depositAmountFen
}
export async function releaseConsignmentPublishDeposit(input: {
workerId: number
submissionId: number | string
note: string
now: string
}): Promise<number> {
return withTransaction((client) => releaseConsignmentPublishDepositWithClient(client, input))
}
@@ -542,33 +542,66 @@ export async function updateConsignmentInventory(
return res.rows[0] || null
}
export async function archiveConsignmentInventoryWithClient(
client: PoolClient,
id: number | string,
now: string,
): Promise<ConsignmentInventoryRow | null> {
const res = await client.query<ConsignmentInventoryRow>(
`
UPDATE consignment_inventories
SET
status = 'archived',
allow_matching = FALSE,
updated_at = $1
WHERE id = $2
RETURNING *
`,
[now, id],
)
return res.rows[0] || null
}
export async function returnConsignmentInventoryToStockWithClient(
client: PoolClient,
id: number | string,
reason: string,
now: string,
): Promise<ConsignmentInventoryRow | null> {
const res = await client.query<ConsignmentInventoryRow>(
`
UPDATE consignment_inventories
SET
status = 'in_stock',
allocation_status = '待安排',
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
consumed_at = NULL,
allow_matching = TRUE,
remark = CASE
WHEN $1 <> '' THEN remark || ' [退库原因: ' || $1 || ']'
ELSE remark
END,
updated_at = $2
WHERE id = $3 AND status = 'consumed'
RETURNING *
`,
[reason, now, id],
)
return res.rows[0] || null
}
/** 退回复库(将已出库物品恢复为在库待匹配) */
export async function returnConsignmentInventoryToStock(
id: number | string,
reason: string,
now: string,
): Promise<ConsignmentInventoryRow | null> {
const sql = `
UPDATE consignment_inventories
SET
status = 'in_stock',
allocation_status = '待安排',
bound_order_id = NULL,
bound_work_order_id = NULL,
bound_platform_order_id = '',
shop_name = '',
consumed_at = NULL,
allow_matching = TRUE,
remark = CASE
WHEN $1 <> '' THEN remark || ' [退库原因: ' || $1 || ']'
ELSE remark
END,
updated_at = $2
WHERE id = $3 AND status = 'consumed'
RETURNING *
`
const res = await query<ConsignmentInventoryRow>(sql, [reason, now, id])
return res.rows[0] || null
return withTransaction((client) =>
returnConsignmentInventoryToStockWithClient(client, id, reason, now),
)
}
/** 查询当前所有可供匹配的在库物品(status = 'in_stock' 且 allow_matching = TRUE) */
@@ -819,6 +852,36 @@ export async function createConsignmentSubmission(input: {
depositAmountFen?: number
now: string
}): Promise<ConsignmentSubmissionRow> {
return withTransaction((client) => createConsignmentSubmissionWithClient(client, input))
}
export async function createConsignmentSubmissionWithClient(
client: PoolClient,
input: {
submissionNo: string
linkId?: number | null
gameName?: string
categoryId?: number | null
itemName: string
system?: string
serverZone?: string
series?: string
diamondLevel?: number
brillianceCount?: number
sellerGameId?: string
sellerPaymentInfo?: string
sellerPhone?: string
contactInfo?: string
contentJson?: Record<string, unknown>
screenshotsJson?: unknown[]
askingPriceFen?: number
workerId?: number | null
workerName?: string
workerPhone?: string
depositAmountFen?: number
now: string
},
): Promise<ConsignmentSubmissionRow> {
const sql = `
INSERT INTO consignment_submissions (
submission_no, link_id, game_name, category_id, item_name,
@@ -837,7 +900,7 @@ export async function createConsignmentSubmission(input: {
)
RETURNING *
`
const res = await query<ConsignmentSubmissionRow>(sql, [
const res = await client.query<ConsignmentSubmissionRow>(sql, [
input.submissionNo,
input.linkId || null,
input.gameName || '和平精英',
@@ -870,6 +933,18 @@ export async function rejectConsignmentSubmission(input: {
reviewerName: string
now: string
}): Promise<ConsignmentSubmissionRow | null> {
return withTransaction((client) => rejectConsignmentSubmissionWithClient(client, input))
}
export async function rejectConsignmentSubmissionWithClient(
client: PoolClient,
input: {
submissionId: number | string
reviewNote: string
reviewerName: string
now: string
},
): Promise<ConsignmentSubmissionRow | null> {
const sql = `
UPDATE consignment_submissions
SET
@@ -881,7 +956,7 @@ export async function rejectConsignmentSubmission(input: {
WHERE id = $4 AND status = 'pending_review'
RETURNING *
`
const res = await query<ConsignmentSubmissionRow>(sql, [
const res = await client.query<ConsignmentSubmissionRow>(sql, [
input.reviewNote,
input.reviewerName,
input.now,
@@ -1,8 +1,10 @@
import type { PoolClient } from 'pg'
import { withTransaction } from '../../db/client.js'
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
import type { WorkOrderRow } from './types.js'
import {
DEFAULT_GIFT_COOLDOWN_HOURS,
normalizeWorkOrderAcceptanceMode,
normalizeWorkOrderGiftCooldownHours,
normalizeWorkOrderGiftCooldownMinutes,
@@ -10,6 +12,20 @@ import {
WORK_ORDER_GIFT_PHASE,
} from '../../domain/work-order-acceptance-mode.js'
export const CONSIGNMENT_FULFILLMENT_TYPE = {
INTERNAL_STOCK: 'internal_stock',
WORKER_DEPOSIT: 'worker_deposit',
} as const
const GIFT_COUNTDOWN_BLOCKED_STATUSES = new Set(['accepted', 'cancelled', 'pending_acceptance'])
const GIFT_COUNTDOWN_ALLOWED_STATUSES = new Set([
'unassigned',
'pending_material',
'in_progress',
'problem',
'open',
])
/**
* 工单脱离打手(回大厅、取消指派、恢复未分配等)时重置好友赠送进度,
* 下一个打手重新走“提交资料 → 确认好友 → 冷却”流程。
@@ -22,6 +38,136 @@ export const RESET_GIFT_FLOW_SQL = `
gift_available_at = NULL
`
export function parseWorkOrderMaterial(workOrder: {
material_json?: unknown
}): Record<string, unknown> {
if (typeof workOrder.material_json === 'string') {
try {
const parsed = JSON.parse(workOrder.material_json) as unknown
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
return {}
}
return {}
}
if (workOrder.material_json && typeof workOrder.material_json === 'object') {
return workOrder.material_json as Record<string, unknown>
}
return {}
}
export function parseWorkOrderConsignment(workOrder: {
material_json?: unknown
}): Record<string, unknown> {
const material = parseWorkOrderMaterial(workOrder)
return material.consignment &&
typeof material.consignment === 'object' &&
!Array.isArray(material.consignment)
? (material.consignment as Record<string, unknown>)
: {}
}
export function hasInternalConsignmentFulfillment(workOrder: {
assigned_worker_id?: number | null
material_json?: unknown
}): boolean {
if (Number(workOrder.assigned_worker_id || 0) > 0) return false
const consignment = parseWorkOrderConsignment(workOrder)
if (!consignment.inventoryId) return false
if (String(consignment.fulfillmentType || '') === CONSIGNMENT_FULFILLMENT_TYPE.WORKER_DEPOSIT) {
return false
}
if (Number(consignment.workerId || 0) > 0) return false
return true
}
/** 开始好友赠送冷却:内部寄售跳过打手资料,直接进入等 N 天。 */
export async function startWorkOrderGiftCountdownWithClient(
client: PoolClient,
workOrder: WorkOrderRow,
input: {
now: string
actorName?: string | undefined
eventType?: string | undefined
materialJson?: unknown
extraPayload?: Record<string, unknown>
forceFriendGift?: boolean
},
): Promise<WorkOrderRow | null> {
if (GIFT_COUNTDOWN_BLOCKED_STATUSES.has(workOrder.status)) return null
let cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours)
let cooldownMinutes = normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes)
if (input.forceFriendGift && cooldownHours === 0 && cooldownMinutes === 0) {
cooldownHours = DEFAULT_GIFT_COOLDOWN_HOURS
}
const nextStatus = ['problem'].includes(workOrder.status) ? workOrder.status : 'in_progress'
const clearAcceptedAt = ['accepted', 'completed'].includes(workOrder.status)
const materialJson =
input.materialJson === undefined
? null
: typeof input.materialJson === 'string'
? input.materialJson
: JSON.stringify(input.materialJson)
const materialForInternalCheck =
input.materialJson === undefined ? workOrder.material_json : input.materialJson
await client.query(
`
UPDATE work_orders
SET
status = $1,
acceptance_mode = CASE WHEN $7 THEN 'friend_gift' ELSE acceptance_mode END,
gift_cooldown_hours = CASE WHEN $7 THEN $3 ELSE gift_cooldown_hours END,
gift_phase = 'friend_countdown',
friend_added_at = $2::timestamptz,
gift_available_at = $2::timestamptz + ($3::int * INTERVAL '1 hour') + ($4::int * INTERVAL '1 minute'),
deadline_at = CASE
WHEN timeout_minutes > 0
THEN $2::timestamptz + ($3::int * INTERVAL '1 hour') + ($4::int * INTERVAL '1 minute') + (timeout_minutes * INTERVAL '1 minute')
ELSE NULL
END,
accepted_at = CASE WHEN $5 THEN NULL ELSE accepted_at END,
material_json = COALESCE($8::jsonb, material_json),
updated_at = $2::timestamptz
WHERE id = $6
`,
[
nextStatus,
input.now,
cooldownHours,
cooldownMinutes,
clearAcceptedAt,
workOrder.id,
Boolean(input.forceFriendGift),
materialJson,
],
)
await createWorkOrderEventWithClient(client, {
workOrderId: Number(workOrder.id),
actorType: 'admin',
actorId: input.actorName || '',
eventType: input.eventType || 'friend_confirmed',
fromStatus: workOrder.status,
toStatus: nextStatus,
payloadJson: JSON.stringify({
cooldownHours,
cooldownMinutes,
friendAddedAt: input.now,
internalConsignment: hasInternalConsignmentFulfillment({
...workOrder,
material_json: materialForInternalCheck,
}),
skippedWorkerMaterial: Boolean(input.forceFriendGift),
giftPhase: WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN,
...(input.extraPayload || {}),
}),
now: input.now,
})
return getWorkOrderByIdWithClient(client, workOrder.id)
}
/** 打手提交本人资料(昵称/ID/区服/系统 + 主页截图),进入待确认好友阶段。 */
export async function submitWorkOrderGiftMaterial(input: {
workOrderId: number
@@ -102,6 +248,7 @@ export async function confirmWorkOrderFriendAdded(input: {
| 'work_order_not_found'
| 'work_order_mode_invalid'
| 'work_order_gift_phase_invalid'
| 'work_order_status_invalid'
| null
}> {
return withTransaction(async (client) => {
@@ -117,46 +264,26 @@ export async function confirmWorkOrderFriendAdded(input: {
) {
return { order: null, failureReason: 'work_order_mode_invalid' }
}
if (workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED) {
if (!GIFT_COUNTDOWN_ALLOWED_STATUSES.has(workOrder.status)) {
return { order: null, failureReason: 'work_order_status_invalid' }
}
const internalConsignment = hasInternalConsignmentFulfillment(workOrder)
const canStartFromWorkerMaterial =
workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED
const canStartFromInternalStock =
internalConsignment &&
(workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED ||
workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED)
if (!canStartFromWorkerMaterial && !canStartFromInternalStock) {
return { order: null, failureReason: 'work_order_gift_phase_invalid' }
}
const cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours)
const cooldownMinutes = normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes)
// 参数必须显式转型:未知类型参数与 interval 做运算时 PostgreSQL 无法唯一解析操作符。
await client.query(
`
UPDATE work_orders
SET
gift_phase = 'friend_countdown',
friend_added_at = $1::timestamptz,
gift_available_at = $1::timestamptz + ($2::int * INTERVAL '1 hour') + ($3::int * INTERVAL '1 minute'),
deadline_at = CASE
WHEN timeout_minutes > 0
THEN $1::timestamptz + ($2::int * INTERVAL '1 hour') + ($3::int * INTERVAL '1 minute') + (timeout_minutes * INTERVAL '1 minute')
ELSE NULL
END,
updated_at = $1::timestamptz
WHERE id = $4
`,
[input.now, cooldownHours, cooldownMinutes, input.workOrderId],
)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
actorType: 'admin',
actorId: input.actorName || '',
eventType: 'friend_confirmed',
fromStatus: workOrder.status,
toStatus: workOrder.status,
payloadJson: JSON.stringify({
cooldownHours,
cooldownMinutes,
friendAddedAt: input.now,
}),
now: input.now,
})
return {
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
order: await startWorkOrderGiftCountdownWithClient(client, workOrder, {
now: input.now,
actorName: input.actorName,
eventType: internalConsignment ? 'consignment_gift_countdown_started' : 'friend_confirmed',
}),
failureReason: null,
}
})
@@ -15,6 +15,7 @@ import {
} from './work-order-deposit-repo.js'
import { maybeUpgradeWorkerLevelWithClient } from './worker-ranking-repo.js'
import type { WorkOrderRow, WorkOrderShareRow } from './types.js'
import { releaseConsignmentPublishDepositBySubmissionWithClient } from './consignment-deposit-repo.js'
export async function acceptWorkOrderAndSettle(input: {
workOrderId: number
@@ -76,6 +77,31 @@ export async function acceptWorkOrderAndSettle(input: {
[input.now, input.workOrderId],
)
const material =
typeof workOrder.material_json === 'string'
? (() => {
try {
return JSON.parse(workOrder.material_json) as Record<string, unknown>
} catch {
return {}
}
})()
: workOrder.material_json && typeof workOrder.material_json === 'object'
? (workOrder.material_json as Record<string, unknown>)
: {}
const consignment =
material.consignment && typeof material.consignment === 'object'
? (material.consignment as Record<string, unknown>)
: null
const sourceSubmissionId = consignment?.sourceSubmissionId
if (sourceSubmissionId) {
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
submissionId: String(sourceSubmissionId),
note: `寄卖工单验收完成释放发布押金 #${input.workOrderId}`,
now: input.now,
})
}
if (shares.length > 0) {
// 单独验收过的份额已经完成资金结算,统一验收时只处理尚未结算的份额。
const unsettledShares = shares.filter((share) => share.status !== 'accepted')
@@ -24,6 +24,11 @@ import {
} from './mappers.js'
import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js'
import { getRequiredWorkOrder } from './worker-session-context-service.js'
import {
resolveWorkOrderGiftPhase,
WORK_ORDER_GIFT_PHASE,
} from '../../domain/work-order-acceptance-mode.js'
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
export async function acceptAdminWorkOrder(workOrderId: number | string, actorName = '') {
let workOrder = await getRequiredWorkOrder(workOrderId)
@@ -33,15 +38,28 @@ export async function acceptAdminWorkOrder(workOrderId: number | string, actorNa
errorCode: 'work_order_accept_status_invalid',
})
}
const now = nowIso()
// 历史拼单订单可能仍保留 open,验收前先补齐母工单生命周期状态。
if (workOrder.status === WORK_ORDER_STATUS.OPEN) {
const promoted = await updateWorkOrder(workOrder.id, {
status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
updated_at: nowIso(),
updated_at: now,
})
if (promoted) workOrder = promoted
}
// 内部寄售好友赠送:冷却结束后无需打手交图,直接进入待验收再结算。
if (
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
hasInternalConsignmentFulfillment(workOrder) &&
resolveWorkOrderGiftPhase(workOrder) === WORK_ORDER_GIFT_PHASE.GIFT_READY
) {
const promoted = await updateWorkOrder(workOrder.id, {
status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
submitted_at: now,
updated_at: now,
})
if (promoted) workOrder = promoted
}
const now = nowIso()
const { order: updated, failureReason } = await acceptWorkOrderAndSettle({
workOrderId: workOrder.id,
...resolveAcceptanceFinanceOptions(),
@@ -26,6 +26,12 @@ export async function confirmAdminWorkOrderFriendAdded(
errorCode: 'work_order_friend_confirm_phase_invalid',
})
}
if (failureReason === 'work_order_status_invalid') {
throw createHttpError('当前订单状态不能开始赠送冷却', {
statusCode: 409,
errorCode: 'work_order_friend_confirm_status_invalid',
})
}
if (!order) {
throw createHttpError('确认加好友失败,订单可能已变化', {
statusCode: 409,
@@ -1,3 +1,4 @@
import { withTransaction } from '../../db/client.js'
import {
type ConsignmentCollectLinkRow,
type ConsignmentInventoryRow,
@@ -11,8 +12,9 @@ import {
listConsignmentCollectLinks,
listConsignmentInventories,
listConsignmentSubmissions,
rejectConsignmentSubmission,
returnConsignmentInventoryToStock,
rejectConsignmentSubmissionWithClient,
archiveConsignmentInventoryWithClient,
returnConsignmentInventoryToStockWithClient,
updateConsignmentCollectLink,
updateConsignmentInventory,
} from '../../repositories/worker-platform/consignment-repo.js'
@@ -25,6 +27,31 @@ import { createHttpError } from '../../utils/http.js'
import { generateConsignmentInventoryNo, randomToken } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import {
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
getConsignmentDepositErrorCode,
releaseConsignmentPublishDepositBySubmissionWithClient,
releaseConsignmentPublishDepositWithClient,
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never {
const code = getConsignmentDepositErrorCode(error)
if (code === 'consignment_deposit_insufficient') {
throw createHttpError(
restock
? '库存回库需要重新冻结履约押金,打手可用余额不足,请先充值后再操作'
: '可用余额不足,无法冻结寄卖履约押金',
{ statusCode: 409, errorCode: 'consignment_deposit_insufficient' },
)
}
if (code === 'consignment_deposit_balance_inconsistent') {
throw createHttpError('寄卖押金账户异常,冻结额与流水不一致,请核对后再操作', {
statusCode: 409,
errorCode: 'consignment_deposit_balance_inconsistent',
})
}
throw error
}
export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
return {
@@ -321,15 +348,29 @@ export async function toggleAdminConsignmentMatching(id: number | string, allowM
/** 物品下架作废 */
export async function archiveAdminConsignmentInventory(id: number | string) {
const now = nowIso()
const updated = await updateConsignmentInventory(id, {
status: 'archived',
allowMatching: false,
now,
})
if (!updated) {
throw createHttpError('寄卖物品不存在', { statusCode: 404, errorCode: 'consignment_not_found' })
try {
const updated = await withTransaction(async (client) => {
const archived = await archiveConsignmentInventoryWithClient(client, id, now)
if (!archived) return null
if (archived.source_submission_id) {
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
submissionId: archived.source_submission_id,
note: `寄卖库存作废退还押金 ${archived.inventory_no}`,
now,
})
}
return archived
})
if (!updated) {
throw createHttpError('寄卖物品不存在', {
statusCode: 404,
errorCode: 'consignment_not_found',
})
}
return mapConsignmentInventory(updated)
} catch (error) {
throwConsignmentDepositHttpError(error, false)
}
return mapConsignmentInventory(updated)
}
/** 已出库物品退回复库 */
@@ -340,14 +381,29 @@ export async function returnAdminConsignmentInventory(
) {
const now = nowIso()
const reason = String(payload.reason || '').trim()
const updated = await returnConsignmentInventoryToStock(id, reason, now)
if (!updated) {
throw createHttpError('只有已出库物品可以退回复库', {
statusCode: 409,
errorCode: 'consignment_not_returnable',
try {
const updated = await withTransaction(async (client) => {
const returned = await returnConsignmentInventoryToStockWithClient(client, id, reason, now)
if (!returned) return null
if (returned.source_submission_id) {
await ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, {
submissionId: returned.source_submission_id,
note: `寄卖退回复库重新冻结押金 ${returned.inventory_no}`,
now,
})
}
return returned
})
if (!updated) {
throw createHttpError('只有已出库物品可以退回复库', {
statusCode: 409,
errorCode: 'consignment_not_returnable',
})
}
return mapConsignmentInventory(updated)
} catch (error) {
throwConsignmentDepositHttpError(error, true)
}
return mapConsignmentInventory(updated)
}
// ---------------- 收集链接管理 ----------------
@@ -536,11 +592,22 @@ export async function rejectAdminConsignmentSubmission(
})
}
const now = nowIso()
const updated = await rejectConsignmentSubmission({
submissionId: id,
reviewNote: note,
reviewerName: actorName,
now,
const updated = await withTransaction(async (client) => {
const rejected = await rejectConsignmentSubmissionWithClient(client, {
submissionId: id,
reviewNote: note,
reviewerName: actorName,
now,
})
if (rejected?.worker_id) {
await releaseConsignmentPublishDepositWithClient(client, {
workerId: Number(rejected.worker_id),
submissionId: rejected.id,
note: `寄卖提报驳回退还押金 ${rejected.submission_no}`,
now,
})
}
return rejected
})
if (!updated) {
throw createHttpError('提报单不存在或已被处理', {
@@ -8,8 +8,17 @@ import {
updateConsignmentInventory,
} from '../../repositories/worker-platform/consignment-repo.js'
import {
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
getConsignmentDepositErrorCode,
releaseConsignmentPublishDepositBySubmissionWithClient,
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
import {
CONSIGNMENT_FULFILLMENT_TYPE,
RESET_GIFT_FLOW_SQL,
createWorkOrderEvent,
createWorkOrderEventWithClient,
getWorkOrderById,
startWorkOrderGiftCountdownWithClient,
updateWorkOrder,
type WorkOrderRow,
} from '../../repositories/worker-platform/index.js'
@@ -28,6 +37,26 @@ import {
findMatchingPeacekeeperInventories,
} from './consignment-matching-service.js'
import { mapConsignmentInventory } from './consignment-admin-service.js'
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never {
const code = getConsignmentDepositErrorCode(error)
if (code === 'consignment_deposit_insufficient') {
throw createHttpError(
restock
? '库存回库需要重新冻结履约押金,打手可用余额不足,请先充值后再撤销'
: '可用余额不足,无法冻结寄卖履约押金',
{ statusCode: 409, errorCode: 'consignment_deposit_insufficient' },
)
}
if (code === 'consignment_deposit_balance_inconsistent') {
throw createHttpError('寄卖押金账户异常,冻结额与流水不一致,请核对后再操作', {
statusCode: 409,
errorCode: 'consignment_deposit_balance_inconsistent',
})
}
throw error
}
export type ConsignmentMatchResultView = Omit<ConsignmentMatchResult, 'inventory'> & {
inventory: ReturnType<typeof mapConsignmentInventory>
@@ -47,18 +76,14 @@ export async function checkWorkOrderInventoryMatches(workOrderId: number | strin
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
}
const material = safeParseJson(workOrder.material_json)
const collect = safeParseJson(material.collect)
const fields = safeParseJson(collect.fields)
const system = String(fields.system || '').trim()
const serverZone = String(fields.serverZone || '').trim()
const fields = resolveCollectSubmittedFieldValues(workOrder)
const inventories = await listActiveConsignmentInventoriesForMatching()
const matches = findMatchingPeacekeeperInventories(workOrder.product_name, inventories, {
system,
serverZone,
system: fields.system,
serverZone: fields.serverZone,
series: fields.series,
})
const exactCount = matches.filter((m) => m.matchType === 'exact').length
@@ -70,6 +95,8 @@ export async function checkWorkOrderInventoryMatches(workOrderId: number | strin
partialCount,
matches: matches.map((m) => ({
...m,
matchScore: m.score,
matchReasons: m.reason ? [m.reason] : [],
inventory: mapConsignmentInventory(m.inventory),
})),
}
@@ -125,8 +152,20 @@ export async function fulfillWorkOrderWithConsignment(input: {
const now = nowIso()
// 开启原子事务扣减库存并流转工单
// 开启原子事务:占库 + 工单进入赠送冷静期。押金留到验收再释放。
const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => {
const lockedOrderRes = await client.query<WorkOrderRow>(
'SELECT * FROM work_orders WHERE id = $1 AND status = $2 FOR UPDATE',
[workOrder.id, workOrder.status],
)
const lockedOrder = lockedOrderRes.rows[0]
if (!lockedOrder) {
throw createHttpError('工单状态已发生变化,履约失败', {
statusCode: 409,
errorCode: 'work_order_conflict',
})
}
const invSql = `
UPDATE consignment_inventories
SET
@@ -142,9 +181,9 @@ export async function fulfillWorkOrderWithConsignment(input: {
RETURNING *
`
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
workOrder.order_id || null,
workOrder.id,
workOrder.platform_order_id || '',
lockedOrder.order_id || null,
lockedOrder.id,
lockedOrder.platform_order_id || '',
shopName,
now,
inventory.id,
@@ -173,53 +212,34 @@ export async function fulfillWorkOrderWithConsignment(input: {
inboundBatch: inv.inbound_batch,
credentials: safeParseJson(inv.credentials_json),
costPriceFen: inv.cost_price_fen,
sourceSubmissionId: inv.source_submission_id ? Number(inv.source_submission_id) : null,
fulfillmentType: CONSIGNMENT_FULFILLMENT_TYPE.INTERNAL_STOCK,
fulfilledAt: now,
fulfilledBy: input.actorName,
},
}
const woSql = `
UPDATE work_orders
SET
status = 'completed',
material_json = $1::jsonb,
accepted_at = $2,
updated_at = $2
WHERE id = $3 AND status = $4
RETURNING *
`
const woRes = await client.query<WorkOrderRow>(woSql, [
JSON.stringify(updatedMaterial),
const wo = await startWorkOrderGiftCountdownWithClient(client, lockedOrder, {
now,
workOrder.id,
workOrder.status,
])
const wo = woRes.rows[0]
if (!wo) {
throw createHttpError('工单状态已发生变化,履约失败', {
statusCode: 409,
errorCode: 'work_order_conflict',
})
}
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'operator',
actorId: input.actorName || 'admin',
actorName: input.actorName,
eventType: 'consignment_fulfilled',
fromStatus: workOrder.status,
toStatus: 'completed',
payloadJson: JSON.stringify({
materialJson: updatedMaterial,
forceFriendGift: true,
extraPayload: {
inventoryId: inv.id,
inventoryNo: inv.inventory_no,
itemName: inv.item_name,
costPriceFen: inv.cost_price_fen,
buyerName: inv.buyer_name,
shopName,
}),
now,
},
})
if (!wo) {
throw createHttpError('工单状态已发生变化,履约失败', {
statusCode: 409,
errorCode: 'work_order_conflict',
})
}
return { updatedOrder: wo, updatedInventory: inv }
})
@@ -315,6 +335,9 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
costPriceFen: inventory.cost_price_fen,
workerId: Number(targetWorkerId),
workerName: inventory.worker_name || '',
sourceSubmissionId: inventory.source_submission_id
? Number(inventory.source_submission_id)
: null,
fulfillmentType: 'worker_deposit',
assignedAt: now,
assignedBy: input.actorName,
@@ -388,14 +411,15 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
const now = nowIso()
const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => {
let inv: ConsignmentInventoryRow | null = null
try {
const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => {
let inv: ConsignmentInventoryRow | null = null
// 1. 处理关联库存解绑与状态流转
if (inventoryId) {
if (input.action === 'archive_inventory') {
// 物品异常作废
const invSql = `
// 1. 处理关联库存解绑与状态流转
if (inventoryId) {
if (input.action === 'archive_inventory') {
// 物品异常作废
const invSql = `
UPDATE consignment_inventories
SET
status = 'archived',
@@ -409,15 +433,15 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
WHERE id = $3
RETURNING *
`
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
input.reason || '异常作废',
now,
inventoryId,
])
inv = invRes.rows[0] || null
} else {
// 恢复在库 (in_stock)
const invSql = `
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
input.reason || '异常作废',
now,
inventoryId,
])
inv = invRes.rows[0] || null
} else {
// 恢复在库 (in_stock)
const invSql = `
UPDATE consignment_inventories
SET
status = 'in_stock',
@@ -433,72 +457,93 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
WHERE id = $3
RETURNING *
`
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
input.reason || '撤单回库',
now,
inventoryId,
])
inv = invRes.rows[0] || null
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
input.reason || '撤单回库',
now,
inventoryId,
])
inv = invRes.rows[0] || null
}
}
}
// 2. 处理工单状态流转
let nextOrderStatus: WorkOrderStatus = WORK_ORDER_STATUS.UNASSIGNED
if (input.action === 'cancel_order') {
nextOrderStatus = WORK_ORDER_STATUS.CANCELLED
}
const submissionId = consignmentInfo.sourceSubmissionId || inv?.source_submission_id
if (submissionId) {
if (input.action === 'archive_inventory') {
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
submissionId,
note: `寄卖履约作废退还押金 ${inv?.inventory_no || submissionId}`,
now,
})
} else {
await ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, {
submissionId,
note: `寄卖撤销回库重新冻结押金 ${inv?.inventory_no || submissionId}`,
now,
})
}
}
const { consignment: _removed, ...cleanMaterial } = material
const archiveHistory = [
...(Array.isArray(material.consignmentHistory) ? material.consignmentHistory : []),
{
...consignmentInfo,
revokedAt: now,
revokedBy: input.actorName,
revokeReason: input.reason,
},
]
// 2. 处理工单状态流转
let nextOrderStatus: WorkOrderStatus = WORK_ORDER_STATUS.UNASSIGNED
if (input.action === 'cancel_order') {
nextOrderStatus = WORK_ORDER_STATUS.CANCELLED
}
const woSql = `
const { consignment: _removed, ...cleanMaterial } = material
const archiveHistory = [
...(Array.isArray(material.consignmentHistory) ? material.consignmentHistory : []),
{
...consignmentInfo,
revokedAt: now,
revokedBy: input.actorName,
revokeReason: input.reason,
},
]
const woSql = `
UPDATE work_orders
SET
status = $1,
material_json = $2::jsonb,
${RESET_GIFT_FLOW_SQL},
updated_at = $3
WHERE id = $4
RETURNING *
`
const woRes = await client.query<WorkOrderRow>(woSql, [
nextOrderStatus,
JSON.stringify({ ...cleanMaterial, consignmentHistory: archiveHistory }),
now,
workOrder.id,
])
const wo = woRes.rows[0]!
const woRes = await client.query<WorkOrderRow>(woSql, [
nextOrderStatus,
JSON.stringify({ ...cleanMaterial, consignmentHistory: archiveHistory }),
now,
workOrder.id,
])
const wo = woRes.rows[0]!
// 3. 记录事件
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'operator',
actorId: input.actorName || 'admin',
eventType: 'consignment_revoked',
fromStatus: workOrder.status,
toStatus: nextOrderStatus,
payloadJson: JSON.stringify({
action: input.action,
reason: input.reason,
inventoryId,
}),
now,
// 3. 记录事件
await createWorkOrderEventWithClient(client, {
workOrderId: workOrder.id,
actorType: 'operator',
actorId: input.actorName || 'admin',
eventType: 'consignment_revoked',
fromStatus: workOrder.status,
toStatus: nextOrderStatus,
payloadJson: JSON.stringify({
action: input.action,
reason: input.reason,
inventoryId,
}),
now,
})
return { updatedOrder: wo, updatedInventory: inv }
})
return { updatedOrder: wo, updatedInventory: inv }
})
publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true })
publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true })
return {
order: mapWorkOrderAdmin(updatedOrder),
inventory: updatedInventory ? mapConsignmentInventory(updatedInventory) : null,
return {
order: mapWorkOrderAdmin(updatedOrder),
inventory: updatedInventory ? mapConsignmentInventory(updatedInventory) : null,
}
} catch (error) {
throwConsignmentDepositHttpError(error, input.action !== 'archive_inventory')
}
}
@@ -69,6 +69,28 @@ test('extractPeacekeeperSpecs extracts specs accurately', () => {
assert.equal(spec3.brillianceCount, 7)
})
test('extractPeacekeeperSpecs parses compact 苹果微信暗潮1钻 and aliases', () => {
const spec = extractPeacekeeperSpecs('苹果微信暗潮1钻')
assert.equal(spec.system, '苹果')
assert.equal(spec.serverZone, '微信区')
assert.equal(spec.series, '暗潮')
assert.equal(spec.diamondLevel, 1)
assert.equal(spec.brillianceCount, 0)
const aliased = extractPeacekeeperSpecs('暗潮1钻', { system: 'iOS', serverZone: '微信' })
assert.equal(aliased.system, '苹果')
assert.equal(aliased.serverZone, '微信区')
assert.equal(aliased.series, '暗潮')
assert.equal(aliased.diamondLevel, 1)
const productWins = extractPeacekeeperSpecs('苹果微信暗潮1钻', {
system: '安卓',
serverZone: 'QQ区',
})
assert.equal(productWins.system, '苹果')
assert.equal(productWins.serverZone, '微信区')
})
test('matchPeacekeeperOrderWithInventory matches exact spec', () => {
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
const inv = buildInventory({
@@ -142,37 +164,79 @@ test('matchPeacekeeperOrderWithInventory rejects lower tier for higher tier orde
assert.equal(matched, null)
})
test('findMatchingPeacekeeperInventories ranks exact before partial and lower cost first', () => {
test('findMatchingPeacekeeperInventories ranks exact first, then lower price, then earlier inbound', () => {
const invExactHigh = buildInventory({
id: 1,
series: '暗潮',
diamond_level: 1,
brilliance_count: 0,
cost_price_fen: 12000,
inbound_at: '2026-09-01T10:00:00.000Z',
})
const invExactLow = buildInventory({
const invExactLowLate = buildInventory({
id: 2,
series: '暗潮',
diamond_level: 1,
brilliance_count: 0,
cost_price_fen: 9500,
inbound_at: '2026-09-03T10:00:00.000Z',
})
const invPartial = buildInventory({
const invExactLowEarly = buildInventory({
id: 4,
series: '暗潮',
diamond_level: 1,
brilliance_count: 0,
cost_price_fen: 9500,
inbound_at: '2026-09-02T10:00:00.000Z',
})
const invPartialCheapest = buildInventory({
id: 3,
series: '暗潮',
diamond_level: 1,
brilliance_count: 1,
cost_price_fen: 8000,
inbound_at: '2026-09-01T08:00:00.000Z',
})
const matches = findMatchingPeacekeeperInventories('安卓微信区 暗潮 1钻', [
invExactHigh,
invPartial,
invExactLow,
invPartialCheapest,
invExactLowLate,
invExactLowEarly,
])
assert.equal(matches.length, 3)
assert.equal(matches[0]!.inventory.id, 2) // exact low cost
assert.equal(matches[1]!.inventory.id, 1) // exact high cost
assert.equal(matches[2]!.inventory.id, 3) // partial
assert.equal(matches.length, 4)
assert.equal(matches[0]!.inventory.id, 4) // 完全匹配 + 低价 + 更早
assert.equal(matches[1]!.inventory.id, 2) // 完全匹配 + 同价更晚
assert.equal(matches[2]!.inventory.id, 1) // 完全匹配但更贵
assert.equal(matches[3]!.inventory.id, 3) // 高配充低配,即使更便宜也排后
})
test('findMatchingPeacekeeperInventories matches 苹果微信暗潮1钻 to 苹果/微信区 inventory', () => {
const inv = buildInventory({
item_name: '苹果微信暗潮1钻苏尔南',
system: '苹果',
server_zone: '微信区',
series: '暗潮',
diamond_level: 1,
brilliance_count: 0,
})
const aliasedInv = buildInventory({
id: 2,
item_name: '苹果微信暗潮1钻苏尔南',
system: '苹果 ',
server_zone: '微信',
series: '暗潮',
diamond_level: 1,
brilliance_count: 0,
})
const matches = findMatchingPeacekeeperInventories('苹果微信暗潮1钻', [inv, aliasedInv], {
system: 'iOS',
serverZone: '微信',
})
assert.equal(matches.length, 2)
assert.equal(matches[0]!.matchType, 'exact')
assert.equal(matches[1]!.matchType, 'exact')
})
@@ -19,75 +19,114 @@ export type ParsedOrderSpecs = {
rawText: string
}
/**
* 提取订单或文本中的《和平精英》规格(系统、区服、暗潮/怒嚎/血宴、钻级、璀璨)
*/
export function extractPeacekeeperSpecs(
rawText: string,
extraMeta?: { system?: string; serverZone?: string; series?: string; diamondLevel?: number },
): ParsedOrderSpecs {
const text = String(rawText || '')
function extractPeacekeeperSystem(value: string): string {
const text = String(value || '')
.trim()
.toLowerCase()
if (!text) return ''
if (text.includes('通用') || text.includes('双端') || text.includes('全端')) return '通用'
if (text.includes('苹果') || text.includes('ios') || text.includes('iphone')) return '苹果'
if (text.includes('安卓') || text.includes('android')) return '安卓'
return ''
}
// 1. 系统识别
let system = (extraMeta?.system || '').trim()
if (!system) {
if (text.includes('苹果') || text.includes('ios') || text.includes('iphone')) {
system = '苹果'
} else if (text.includes('安卓') || text.includes('android')) {
system = '安卓'
}
}
function extractPeacekeeperServerZone(value: string): string {
const text = String(value || '')
.trim()
.toLowerCase()
if (!text) return ''
if (text.includes('通用') || text.includes('全区') || text.includes('不限')) return '通用'
if (text.includes('微信') || text === 'wx' || text.includes('wx区')) return '微信区'
if (text.includes('qq') || text.includes('手q') || text.includes('企鹅')) return 'QQ区'
return ''
}
// 2. 大区识别
let serverZone = (extraMeta?.serverZone || '').trim()
if (!serverZone) {
if (text.includes('微信') || text.includes('wx')) {
serverZone = '微信区'
} else if (text.includes('qq') || text.includes('手q')) {
serverZone = 'QQ区'
}
}
function extractPeacekeeperSeries(value: string): string {
const text = String(value || '').trim()
if (!text) return ''
if (text.includes('暗潮')) return '暗潮'
if (text.includes('怒嚎') || text.includes('怒号')) return '怒嚎'
if (text.includes('血宴')) return '血宴'
return ''
}
// 3. 词条系列识别 (暗潮 / 怒嚎 / 血宴)
let series = (extraMeta?.series || '').trim()
if (!series) {
if (text.includes('暗潮')) {
series = '暗潮'
} else if (text.includes('怒嚎') || text.includes('怒号')) {
series = '怒嚎'
} else if (text.includes('血宴')) {
series = '血宴'
}
}
export function normalizePeacekeeperSystem(value: string): string {
return extractPeacekeeperSystem(value) || String(value || '').trim()
}
// 4. 钻级识别 (1~7钻)
let diamondLevel = extraMeta?.diamondLevel || 1
const diamondMatch = text.match(/([1-7])\s*钻/)
if (diamondMatch?.[1]) {
diamondLevel = Number(diamondMatch[1])
}
export function normalizePeacekeeperServerZone(value: string): string {
return extractPeacekeeperServerZone(value) || String(value || '').trim()
}
// 5. 璀璨数量识别 (0~7璀璨)
let brillianceCount = 0
export function normalizePeacekeeperSeries(value: string): string {
return extractPeacekeeperSeries(value) || String(value || '').trim()
}
function parseSpecsFromText(rawText: string): Omit<ParsedOrderSpecs, 'rawText'> {
const text = String(rawText || '').trim()
const diamondMatch = text.toLowerCase().match(/([1-7])\s*钻/)
const brillianceZero = /无璀璨|不带璀璨|0\s*璀璨/.test(text)
const brillianceMatch = text.match(/([1-7])\s*璀璨/)
if (brillianceMatch?.[1]) {
let brillianceCount = 0
if (brillianceZero) {
brillianceCount = 0
} else if (brillianceMatch?.[1]) {
brillianceCount = Number(brillianceMatch[1])
} else if (text.includes('璀璨')) {
brillianceCount = 1
}
return {
system,
serverZone,
series,
diamondLevel,
system: extractPeacekeeperSystem(text),
serverZone: extractPeacekeeperServerZone(text),
series: extractPeacekeeperSeries(text),
diamondLevel: diamondMatch?.[1] ? Number(diamondMatch[1]) : 0,
brillianceCount,
}
}
/**
* 提取订单或文本中的《和平精英》规格(系统、区服、暗潮/怒嚎/血宴、钻级、璀璨)
* 商品名优先,收集资料只补空白项;系统/区服别名会归一化(微信→微信区、ios→苹果)。
*/
export function extractPeacekeeperSpecs(
rawText: string,
extraMeta?: {
system?: string | undefined
serverZone?: string | undefined
series?: string | undefined
diamondLevel?: number | undefined
},
): ParsedOrderSpecs {
const fromText = parseSpecsFromText(rawText)
const fromMeta = parseSpecsFromText(
[
extraMeta?.system,
extraMeta?.serverZone,
extraMeta?.series,
extraMeta?.diamondLevel ? `${extraMeta.diamondLevel}钻` : '',
]
.filter(Boolean)
.join(' '),
)
return {
system: fromText.system || fromMeta.system,
serverZone: fromText.serverZone || fromMeta.serverZone,
series: fromText.series || fromMeta.series,
diamondLevel: fromText.diamondLevel || fromMeta.diamondLevel || extraMeta?.diamondLevel || 1,
brillianceCount: fromText.brillianceCount,
rawText,
}
}
function sameSpec(orderValue: string, inventoryValue: string): boolean {
if (!orderValue || !inventoryValue || inventoryValue === '通用') return true
return orderValue === inventoryValue
}
/**
* 单品匹配度比对
*/
@@ -100,25 +139,16 @@ export function matchPeacekeeperOrderWithInventory(
return null
}
// 硬约束:系统必须一致(若指定)
if (order.system && inventory.system && inventory.system !== '通用') {
if (order.system !== inventory.system) {
return null
}
}
const invSystem = normalizePeacekeeperSystem(inventory.system)
const invZone = normalizePeacekeeperServerZone(inventory.server_zone)
const invSeries = normalizePeacekeeperSeries(inventory.series)
// 硬约束:区服必须一致(若指定)
if (order.serverZone && inventory.server_zone && inventory.server_zone !== '通用') {
if (order.serverZone !== inventory.server_zone) {
return null
}
}
if (!sameSpec(order.system, invSystem)) return null
if (!sameSpec(order.serverZone, invZone)) return null
// 硬约束:系列必须完全一致(暗潮 只能配 暗潮,不能拿 怒嚎 充数)
if (order.series && inventory.series) {
if (order.series !== inventory.series) {
return null
}
if (order.series && invSeries && order.series !== invSeries) {
return null
}
const invDiamond = Number(inventory.diamond_level || 1)
@@ -135,7 +165,7 @@ export function matchPeacekeeperOrderWithInventory(
inventory,
matchType: 'exact',
score: 100,
matchedSeries: inventory.series || order.series,
matchedSeries: invSeries || order.series,
matchedDiamond: invDiamond,
matchedBrilliance: invBrilliance,
reason: '规格完全一致',
@@ -155,7 +185,7 @@ export function matchPeacekeeperOrderWithInventory(
inventory,
matchType: 'partial',
score: invDiamond > order.diamondLevel ? 70 : 80,
matchedSeries: inventory.series || order.series,
matchedSeries: invSeries || order.series,
matchedDiamond: invDiamond,
matchedBrilliance: invBrilliance,
reason: `属于高配充低配:${reasons.join(',')}`,
@@ -168,7 +198,12 @@ export function matchPeacekeeperOrderWithInventory(
export function findMatchingPeacekeeperInventories(
rawRequirementText: string,
inventories: ConsignmentInventoryRow[],
extraMeta?: { system?: string; serverZone?: string; series?: string; diamondLevel?: number },
extraMeta?: {
system?: string | undefined
serverZone?: string | undefined
series?: string | undefined
diamondLevel?: number | undefined
},
): ConsignmentMatchResult[] {
const orderSpecs = extractPeacekeeperSpecs(rawRequirementText, extraMeta)
const results: ConsignmentMatchResult[] = []
@@ -180,15 +215,23 @@ export function findMatchingPeacekeeperInventories(
}
}
// 排序:
// 1. 全部匹配优先 (score DESC)
// 2. 收货成本从低到高 (cost_price_fen ASC)
// 3. 先入库优先 (id ASC)
// 排序(多条都符合需求时):
// 1. 完全匹配优先于高配充低配
// 2. 同档内收购价越低越好
// 3. 同价则入库时间越早越好
return results.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
if (a.inventory.cost_price_fen !== b.inventory.cost_price_fen) {
return a.inventory.cost_price_fen - b.inventory.cost_price_fen
}
const priceDiff =
Number(a.inventory.cost_price_fen || 0) - Number(b.inventory.cost_price_fen || 0)
if (priceDiff !== 0) return priceDiff
const aTime =
Date.parse(a.inventory.inbound_at || a.inventory.created_at || '') || Number(a.inventory.id)
const bTime =
Date.parse(b.inventory.inbound_at || b.inventory.created_at || '') || Number(b.inventory.id)
if (aTime !== bTime) return aTime - bTime
return Number(a.inventory.id) - Number(b.inventory.id)
})
}
@@ -7,6 +7,7 @@ import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { generateConsignmentSubmissionNo } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import { getConsignmentPublishDepositFen } from './worker-finance-config-service.js'
export async function getPublicConsignmentLink(token: string) {
const cleanToken = String(token || '').trim()
@@ -79,9 +80,7 @@ export async function submitPublicConsignment(token: string, payload: JsonObject
? (payload.content as Record<string, unknown>)
: {}
const characterName = String(
contentPayload.characterName || payload.characterName || '',
).trim()
const characterName = String(contentPayload.characterName || payload.characterName || '').trim()
const characterId = String(contentPayload.characterId || payload.characterId || '').trim()
if (!characterName) {
throw createHttpError('请填写游戏内角色名', {
@@ -145,9 +144,7 @@ export async function submitPublicConsignment(token: string, payload: JsonObject
Math.min(7, Number(payload.brillianceCount ?? link.brilliance_count ?? 0)),
)
const sellerGameId = String(
payload.sellerGameId || `${characterName} (${characterId})`,
).trim()
const sellerGameId = String(payload.sellerGameId || `${characterName} (${characterId})`).trim()
const submission = await createConsignmentSubmission({
submissionNo,
@@ -177,8 +174,8 @@ export async function submitPublicConsignment(token: string, payload: JsonObject
workerName,
workerPhone,
depositAmountFen: Math.max(
100,
Math.round(Number(payload.depositAmountFen || askingPriceFen || 10000)),
0,
Math.round(Number(payload.depositAmountFen ?? getConsignmentPublishDepositFen())),
),
now,
})
@@ -1,5 +1,7 @@
import { withTransaction } from '../../db/client.js'
import { freezeConsignmentPublishDepositWithClient } from '../../repositories/worker-platform/consignment-deposit-repo.js'
import {
createConsignmentSubmission,
createConsignmentSubmissionWithClient,
listConsignmentInventories,
listConsignmentSubmissions,
} from '../../repositories/worker-platform/consignment-repo.js'
@@ -8,6 +10,11 @@ import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { generateConsignmentSubmissionNo } from '../../utils/random.js'
import { nowIso } from '../../utils/time.js'
import { getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepositFen } from './worker-finance-config-service.js'
export function getConsignmentPublishDepositFen() {
return getConfiguredConsignmentPublishDepositFen()
}
export async function publishWorkerConsignment(workerId: number, payload: JsonObject) {
const worker = await getWorkerUserById(workerId)
@@ -15,11 +22,38 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
throw createHttpError('打手账户不存在', { statusCode: 404, errorCode: 'worker_not_found' })
}
const system = String(payload.system || '苹果').trim()
const serverZone = String(payload.serverZone || '微信区').trim()
const series = String(payload.series || '暗潮').trim()
const diamondLevel = Math.max(1, Math.min(7, Number(payload.diamondLevel || 1)))
const brillianceCount = Math.max(0, Math.min(7, Number(payload.brillianceCount || 0)))
const system = String(payload.system || '').trim()
const serverZone = String(payload.serverZone || '').trim()
const series = String(payload.series || '').trim()
if (!system || !serverZone || !series) {
throw createHttpError('请完整填写操作系统、区服与枪系', {
statusCode: 400,
errorCode: 'specs_required',
})
}
if (
payload.diamondLevel === undefined ||
payload.diamondLevel === null ||
payload.diamondLevel === ''
) {
throw createHttpError('请选择钻石等级', {
statusCode: 400,
errorCode: 'diamond_level_required',
})
}
const diamondLevel = Math.max(1, Math.min(7, Number(payload.diamondLevel)))
if (
payload.brillianceCount === undefined ||
payload.brillianceCount === null ||
payload.brillianceCount === ''
) {
throw createHttpError('请选择璀璨数', {
statusCode: 400,
errorCode: 'brilliance_required',
})
}
const brillianceCount = Math.max(0, Math.min(7, Number(payload.brillianceCount)))
const contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim()
if (!contactInfo) {
@@ -60,65 +94,88 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
})
}
const depositAmountFen = Math.max(
100,
Math.round(Number(payload.depositAmountFen || askingPriceFen || 10000)),
)
const depositAmountFen = getConsignmentPublishDepositFen()
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
const briStr = brillianceCount > 0 ? `${brillianceCount}璀璨` : ''
const defaultItemName = `${system}${serverZone}${series}${diamondLevel}钻${briStr} 苏尔南`
const itemName = String(payload.itemName || defaultItemName).trim()
const submissionNo = generateConsignmentSubmissionNo()
const now = nowIso()
const submission = await createConsignmentSubmission({
submissionNo,
linkId: null,
gameName: '和平精英',
categoryId: null,
itemName,
system,
serverZone,
series,
diamondLevel,
brillianceCount,
sellerGameId: characterId ? `${characterName} (${characterId})` : characterName,
sellerPaymentInfo: '走打手接单平台钱包',
sellerPhone,
contactInfo,
contentJson: {
characterName,
characterId,
sellerNote: String(payload.sellerNote || '').trim(),
channel: 'worker_portal',
},
screenshotsJson: screenshots,
askingPriceFen,
workerId: Number(worker.id),
workerName: worker.display_name || worker.username,
workerPhone: sellerPhone || worker.phone || '',
depositAmountFen,
now,
})
try {
const submission = await withTransaction(async (client) => {
const created = await createConsignmentSubmissionWithClient(client, {
submissionNo,
linkId: null,
gameName: '和平精英',
categoryId: null,
itemName,
system,
serverZone,
series,
diamondLevel,
brillianceCount,
sellerGameId: characterId ? `${characterName} (${characterId})` : characterName,
sellerPaymentInfo: '走打手接单平台钱包',
sellerPhone,
contactInfo,
contentJson: {
characterName,
characterId,
sellerNote: String(payload.sellerNote || '').trim(),
channel: 'worker_portal',
publishDepositFen: depositAmountFen,
},
screenshotsJson: screenshots,
askingPriceFen,
workerId: Number(worker.id),
workerName: worker.display_name || worker.username,
workerPhone: sellerPhone || worker.phone || '',
depositAmountFen,
now,
})
return {
id: submission.id,
submissionNo: submission.submission_no,
itemName: submission.item_name,
system: submission.system,
serverZone: submission.server_zone,
series: submission.series,
diamondLevel: submission.diamond_level,
brillianceCount: submission.brilliance_count,
askingPriceFen: submission.asking_price_fen,
status: submission.status,
workerId: submission.worker_id,
workerName: submission.worker_name,
workerPhone: submission.worker_phone,
depositAmountFen: submission.deposit_amount_fen,
createdAt: submission.created_at,
await freezeConsignmentPublishDepositWithClient(client, {
workerId: Number(worker.id),
amountFen: depositAmountFen,
submissionId: created.id,
submissionNo: created.submission_no,
now,
})
return created
})
return {
id: submission.id,
submissionNo: submission.submission_no,
itemName: submission.item_name,
system: submission.system,
serverZone: submission.server_zone,
series: submission.series,
diamondLevel: submission.diamond_level,
brillianceCount: submission.brilliance_count,
askingPriceFen: submission.asking_price_fen,
status: submission.status,
workerId: submission.worker_id,
workerName: submission.worker_name,
workerPhone: submission.worker_phone,
depositAmountFen: submission.deposit_amount_fen,
createdAt: submission.created_at,
}
} catch (error) {
if (
error instanceof Error &&
((error as Error & { code?: string }).code === 'consignment_deposit_insufficient' ||
error.message === 'consignment_deposit_insufficient')
) {
const yuan = (depositAmountFen / 100).toFixed(0)
throw createHttpError(`可用余额不足,请先联系客服充值至少 ¥${yuan} 履约押金后再发布寄售`, {
statusCode: 409,
errorCode: 'consignment_deposit_insufficient',
})
}
throw error
}
}
@@ -127,8 +184,10 @@ export async function listWorkerConsignments(workerId: number) {
listConsignmentSubmissions({ workerId, pageSize: 100 }),
listConsignmentInventories({ workerId, pageSize: 100 }),
])
const publishDepositFen = getConsignmentPublishDepositFen()
return {
publishDepositFen,
submissions: submissionsRes.items.map((sub) => ({
id: sub.id,
submissionNo: sub.submission_no,
@@ -144,6 +203,7 @@ export async function listWorkerConsignments(workerId: number) {
resultInventoryId: sub.result_inventory_id,
screenshotsJson: sub.screenshots_json,
contentJson: sub.content_json,
depositAmountFen: sub.deposit_amount_fen,
createdAt: sub.created_at,
})),
inventories: inventoriesRes.items.map((inv) => ({
@@ -15,6 +15,7 @@ import {
type WorkOrderRow,
type WorkOrderShareRow,
} from '../../repositories/worker-platform/index.js'
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
import type { JsonObject } from '../../types/json.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import {
@@ -141,6 +142,8 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
const acceptanceMode = normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode)
const isFriendGift = acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
const giftPhase = String(workOrder.gift_phase || '').trim()
const resolvedGiftPhase = resolveWorkOrderGiftPhase(workOrder)
const internalConsignment = hasInternalConsignmentFulfillment(workOrder)
const hallAvailable =
workOrder.status === WORK_ORDER_STATUS.OPEN &&
(!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity)
@@ -166,7 +169,12 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED ||
workOrder.status === WORK_ORDER_STATUS.OPEN ||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS,
canAccept: status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
canAccept:
status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE ||
(isFriendGift &&
internalConsignment &&
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
resolvedGiftPhase === WORK_ORDER_GIFT_PHASE.GIFT_READY),
canMarkProblem:
status === WORK_ORDER_STATUS.IN_PROGRESS || status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
canDelete:
@@ -189,8 +197,15 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
canConfirmFriend:
isFriendGift &&
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
giftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED,
((workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
giftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED) ||
(internalConsignment &&
giftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED &&
[
WORK_ORDER_STATUS.UNASSIGNED,
WORK_ORDER_STATUS.PENDING_MATERIAL,
WORK_ORDER_STATUS.IN_PROGRESS,
].includes(workOrder.status as never))),
canCompleteGiftCooldown:
isFriendGift &&
[WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(
@@ -200,6 +215,7 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
!isWorkOrderGiftAvailable(workOrder),
canResetGiftFlow:
isFriendGift &&
!internalConsignment &&
[WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(
workOrder.status as never,
) &&
@@ -10,6 +10,8 @@ import {
export type WorkerFinanceConfig = {
depositUnfreezeDays: number
rewardUnfreezeEnabled: boolean
/** 苏尔南寄卖发布所需履约押金(分),默认 2000 = ¥20 */
consignmentPublishDepositFen: number
recharge: {
enabled: boolean
channelName: string
@@ -36,6 +38,11 @@ export function getWorkerFinanceConfig(): WorkerFinanceConfig {
return refreshWorkerFinanceConfig(config)
}
export function getConsignmentPublishDepositFen(): number {
const value = Number(getWorkerFinanceConfig().consignmentPublishDepositFen)
return Number.isFinite(value) ? Math.max(0, Math.round(value)) : 2000
}
export async function saveWorkerFinanceConfig(rawValue: unknown): Promise<WorkerFinanceConfig> {
const saved = await saveAppConfigEntry({
configKey: APP_CONFIG_KEYS.workerFinance,
@@ -58,6 +65,12 @@ export function normalizeWorkerFinanceConfig(rawValue: unknown): WorkerFinanceCo
return {
depositUnfreezeDays: normalizeRangeInteger(source.depositUnfreezeDays, 3, 0, 30),
rewardUnfreezeEnabled: source.rewardUnfreezeEnabled !== false,
consignmentPublishDepositFen: normalizeRangeInteger(
source.consignmentPublishDepositFen ?? source.consignment_publish_deposit_fen,
2000,
0,
1_000_000,
),
recharge: {
enabled: typeof recharge.enabled === 'boolean' ? recharge.enabled : true,
channelName: String(recharge.channelName || recharge.channel_name || '').trim(),
@@ -83,6 +96,7 @@ export function createDefaultWorkerFinanceConfig(): WorkerFinanceConfig {
return {
depositUnfreezeDays: 3,
rewardUnfreezeEnabled: true,
consignmentPublishDepositFen: 2000,
recharge: {
enabled: true,
channelName: '',
@@ -105,6 +119,7 @@ function refreshWorkerFinanceConfig(config: WorkerFinanceConfig): WorkerFinanceC
return {
depositUnfreezeDays: config.depositUnfreezeDays,
rewardUnfreezeEnabled: config.rewardUnfreezeEnabled,
consignmentPublishDepositFen: config.consignmentPublishDepositFen,
recharge: {
...config.recharge,
qrCodeImage: config.recharge.qrCodeImage
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { PoolClient } from 'pg'
import type {
WorkOrderRow,
@@ -51,6 +52,96 @@ import {
validateWorkerPassword,
} from './index.js'
import { resolveAfterSalesRecoveryAllocation } from '../../repositories/worker-platform/after-sales-recovery.js'
import { normalizeWorkerFinanceConfig } from './worker-finance-config-service.js'
import {
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
releaseConsignmentPublishDepositWithClient,
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
test('寄卖发布押金配置保留显式零值并回退非法值', () => {
assert.equal(
normalizeWorkerFinanceConfig({ consignmentPublishDepositFen: 0 }).consignmentPublishDepositFen,
0,
)
assert.equal(
normalizeWorkerFinanceConfig({ consignmentPublishDepositFen: 'invalid' })
.consignmentPublishDepositFen,
2000,
)
})
test('寄卖押金释放先锁钱包再读流水,冻结额不足时拒绝部分退款', async () => {
const statements: string[] = []
const client = {
query: async (sql: string) => {
statements.push(sql)
if (sql.includes('SELECT * FROM worker_wallets')) {
return { rows: [{ available_amount: 0, frozen_deposit_amount: 10 }] }
}
if (sql.includes('COALESCE(SUM')) {
return { rows: [{ outstanding: 20 }] }
}
return { rows: [] }
},
} as unknown as PoolClient
await assert.rejects(
releaseConsignmentPublishDepositWithClient(client, {
workerId: 1,
submissionId: 2,
note: 'test',
now: '2026-09-15T00:00:00.000Z',
}),
(error: unknown) =>
error instanceof Error &&
(error as Error & { code?: string }).code === 'consignment_deposit_balance_inconsistent',
)
const walletLockIndex = statements.findIndex(
(sql) => sql.includes('SELECT * FROM worker_wallets') && sql.includes('FOR UPDATE'),
)
const outstandingIndex = statements.findIndex((sql) => sql.includes('COALESCE(SUM'))
assert.ok(walletLockIndex >= 0)
assert.ok(outstandingIndex > walletLockIndex)
assert.equal(
statements.some(
(sql) => sql.includes('INSERT INTO worker_wallet_ledgers') && sql.includes('deposit_release'),
),
false,
)
})
test('寄卖回库时若押金仍冻结则不再重复扣款', async () => {
const statements: string[] = []
const client = {
query: async (sql: string) => {
statements.push(sql)
if (sql.includes('FROM consignment_submissions')) {
return { rows: [{ worker_id: 1, deposit_amount_fen: 2000, submission_no: 'CS1' }] }
}
if (sql.includes('SELECT * FROM worker_wallets')) {
return { rows: [{ available_amount: 0, frozen_deposit_amount: 2000 }] }
}
if (sql.includes('COALESCE(SUM')) {
return { rows: [{ outstanding: 2000 }] }
}
return { rows: [] }
},
} as unknown as PoolClient
const frozen = await ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, {
submissionId: 2,
note: 'restock',
now: '2026-09-15T00:00:00.000Z',
})
assert.equal(frozen, 2000)
assert.equal(
statements.some(
(sql) => sql.includes('INSERT INTO worker_wallet_ledgers') && sql.includes('deposit_freeze'),
),
false,
)
})
test('售后追缴优先扣减待解冻押金', () => {
assert.deepEqual(
@@ -1238,6 +1329,77 @@ test('好友赠送冷却中才允许客服手动结束冷却', () => {
assert.equal(completed.capabilities.canCompleteGiftCooldown, false)
})
test('内部寄售跳过打手资料:未完成冷却不可验收,冷却结束可验收', () => {
const stuckAccepted = mapWorkOrderAdmin(
buildWorkOrderRow({
status: 'accepted',
assigned_worker_id: null,
acceptance_mode: 'friend_gift',
gift_phase: 'material_required',
material_json: { consignment: { inventoryId: 88, fulfillmentType: 'internal_stock' } },
}),
)
assert.equal(stuckAccepted.capabilities.canConfirmFriend, false)
assert.equal(stuckAccepted.capabilities.canAccept, false)
const pendingStart = mapWorkOrderAdmin(
buildWorkOrderRow({
status: 'unassigned',
assigned_worker_id: null,
acceptance_mode: 'friend_gift',
gift_phase: 'material_required',
material_json: { consignment: { inventoryId: 88, fulfillmentType: 'internal_stock' } },
}),
)
assert.equal(pendingStart.capabilities.canConfirmFriend, true)
assert.equal(pendingStart.capabilities.canResetGiftFlow, false)
const cooling = mapWorkOrderAdmin(
buildWorkOrderRow({
status: 'in_progress',
assigned_worker_id: null,
acceptance_mode: 'friend_gift',
gift_phase: 'friend_countdown',
gift_available_at: new Date(Date.now() + 60_000).toISOString(),
material_json: { consignment: { inventoryId: 88, fulfillmentType: 'internal_stock' } },
}),
)
assert.equal(cooling.gift?.phase, 'friend_countdown')
assert.equal(cooling.capabilities.canCompleteGiftCooldown, true)
assert.equal(cooling.capabilities.canAccept, false)
assert.equal(cooling.capabilities.canResetGiftFlow, false)
const ready = mapWorkOrderAdmin(
buildWorkOrderRow({
status: 'in_progress',
assigned_worker_id: null,
acceptance_mode: 'friend_gift',
gift_phase: 'friend_countdown',
gift_available_at: new Date(Date.now() - 60_000).toISOString(),
material_json: { consignment: { inventoryId: 88, fulfillmentType: 'internal_stock' } },
}),
)
assert.equal(ready.gift?.phase, 'gift_ready')
assert.equal(ready.capabilities.canAccept, true)
assert.equal(ready.capabilities.canCompleteGiftCooldown, false)
})
test('取消指派后的打手寄售不会被当成内部现货', () => {
const unassignedWorkerStock = mapWorkOrderAdmin(
buildWorkOrderRow({
status: 'unassigned',
assigned_worker_id: null,
acceptance_mode: 'friend_gift',
gift_phase: 'material_required',
material_json: {
consignment: { inventoryId: 88, fulfillmentType: 'worker_deposit', workerId: 12 },
},
}),
)
assert.equal(unassignedWorkerStock.capabilities.canConfirmFriend, false)
assert.equal(unassignedWorkerStock.capabilities.canAccept, false)
})
test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
const waitingForClaim = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'open' }))
assert.equal(waitingForClaim.status, 'open')