feat(consignment): 苏尔南寄卖接入打手端押金履约

打手可直接提报寄卖,关联 worker 与履约押金后指派交枪;后台去掉旧收集链接管理,提报审核与库存/工单流程对齐接单平台。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-15 16:00:33 +08:00
co-authored by Cursor
parent 7672360875
commit 4a6215e816
23 changed files with 2218 additions and 677 deletions
@@ -0,0 +1,25 @@
-- 078_consignment_worker_deposit.sql —— 寄卖库存支持关联接单平台打手账号与押金履约模式
ALTER TABLE consignment_inventories
ADD COLUMN IF NOT EXISTS worker_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS worker_name TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS worker_phone TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS deposit_amount_fen INTEGER NOT NULL DEFAULT 0;
ALTER TABLE consignment_submissions
ADD COLUMN IF NOT EXISTS worker_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS worker_name TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS worker_phone TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS deposit_amount_fen INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_consignment_inventories_worker_id
ON consignment_inventories(worker_id)
WHERE worker_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_consignment_submissions_worker_id
ON consignment_submissions(worker_id)
WHERE worker_id IS NOT NULL;
COMMENT ON COLUMN consignment_inventories.worker_id IS '关联接单平台的打手ID(用于押金冻结与履约指派)';
COMMENT ON COLUMN consignment_inventories.deposit_amount_fen IS '该寄卖手枪建议/约定的履约押金金额(分)';
COMMENT ON COLUMN consignment_submissions.worker_id IS '提交人或关联的接单平台打手ID';
@@ -32,6 +32,10 @@ export type ConsignmentInventoryRow = {
shop_name: string
source_type: 'manual' | 'collection_link' | 'recycle'
source_submission_id: string | number | null
worker_id?: string | number | null
worker_name?: string
worker_phone?: string
deposit_amount_fen?: number
inbound_at: string
consumed_at: string | null
created_at: string
@@ -83,6 +87,10 @@ export type ConsignmentSubmissionRow = {
content_json: string | Record<string, unknown>
screenshots_json: string | unknown[]
asking_price_fen: number
worker_id?: string | number | null
worker_name?: string
worker_phone?: string
deposit_amount_fen?: number
status: 'pending_review' | 'approved' | 'rejected'
review_note: string
reviewer_name: string
@@ -121,6 +129,7 @@ export async function listConsignmentInventories(filters: {
series?: string | undefined
diamondLevel?: number | undefined
allowMatching?: boolean | undefined
workerId?: number | undefined
keyword?: string | undefined
page?: number | undefined
pageSize?: number | undefined
@@ -132,6 +141,10 @@ export async function listConsignmentInventories(filters: {
values.push(filters.status)
conditions.push(`ci.status = $${values.length}`)
}
if (filters.workerId) {
values.push(filters.workerId)
conditions.push(`ci.worker_id = $${values.length}`)
}
if (filters.buyerName) {
values.push(filters.buyerName)
conditions.push(`ci.buyer_name = $${values.length}`)
@@ -329,6 +342,10 @@ export async function createConsignmentInventory(input: {
allowMatching?: boolean
sourceType?: 'manual' | 'collection_link' | 'recycle'
sourceSubmissionId?: number | null
workerId?: number | null
workerName?: string
workerPhone?: string
depositAmountFen?: number
now: string
}): Promise<ConsignmentInventoryRow> {
const sql = `
@@ -338,14 +355,16 @@ export async function createConsignmentInventory(input: {
inbound_batch, seller_contact, seller_game_id, seller_payment_info,
seller_phone, credentials_json, screenshots_json, remark,
allow_matching, status, allocation_status, source_type,
source_submission_id, inbound_at, created_at, updated_at
source_submission_id, worker_id, worker_name, worker_phone, deposit_amount_fen,
inbound_at, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13, $14, $15,
$16, $17::jsonb, $18::jsonb, $19,
$20, 'in_stock', '待安排', $21,
$22, $23, $23, $23
$22, $23, $24, $25, $26,
$27, $27, $27
)
RETURNING *
`
@@ -372,6 +391,10 @@ export async function createConsignmentInventory(input: {
input.allowMatching !== false,
input.sourceType || 'manual',
input.sourceSubmissionId || null,
input.workerId || null,
input.workerName || '',
input.workerPhone || '',
input.depositAmountFen || 0,
input.now,
]
const res = await query<ConsignmentInventoryRow>(sql, values)
@@ -400,6 +423,10 @@ export async function updateConsignmentInventory(
allowMatching?: boolean | undefined
status?: 'in_stock' | 'consumed' | 'archived' | undefined
allocationStatus?: string | undefined
workerId?: number | null | undefined
workerName?: string | undefined
workerPhone?: string | undefined
depositAmountFen?: number | undefined
now: string
},
): Promise<ConsignmentInventoryRow | null> {
@@ -482,6 +509,22 @@ export async function updateConsignmentInventory(
values.push(input.allocationStatus)
updates.push(`allocation_status = $${values.length}`)
}
if (input.workerId !== undefined) {
values.push(input.workerId)
updates.push(`worker_id = $${values.length}`)
}
if (input.workerName !== undefined) {
values.push(input.workerName)
updates.push(`worker_name = $${values.length}`)
}
if (input.workerPhone !== undefined) {
values.push(input.workerPhone)
updates.push(`worker_phone = $${values.length}`)
}
if (input.depositAmountFen !== undefined) {
values.push(input.depositAmountFen)
updates.push(`deposit_amount_fen = $${values.length}`)
}
if (updates.length === 0) return getConsignmentInventoryById(id)
@@ -681,6 +724,7 @@ export async function updateConsignmentCollectLink(
export async function listConsignmentSubmissions(filters: {
status?: string | undefined
linkId?: number | undefined
workerId?: number | undefined
keyword?: string | undefined
page?: number | undefined
pageSize?: number | undefined
@@ -696,6 +740,10 @@ export async function listConsignmentSubmissions(filters: {
values.push(filters.linkId)
conditions.push(`cs.link_id = $${values.length}`)
}
if (filters.workerId) {
values.push(filters.workerId)
conditions.push(`cs.worker_id = $${values.length}`)
}
if (filters.keyword) {
values.push(`%${filters.keyword.trim()}%`)
conditions.push(
@@ -765,6 +813,10 @@ export async function createConsignmentSubmission(input: {
contentJson?: Record<string, unknown>
screenshotsJson?: unknown[]
askingPriceFen?: number
workerId?: number | null
workerName?: string
workerPhone?: string
depositAmountFen?: number
now: string
}): Promise<ConsignmentSubmissionRow> {
const sql = `
@@ -773,13 +825,15 @@ export async function createConsignmentSubmission(input: {
system, server_zone, series, diamond_level, brilliance_count,
seller_game_id, seller_payment_info, seller_phone, contact_info,
content_json, screenshots_json, asking_price_fen,
worker_id, worker_name, worker_phone, deposit_amount_fen,
status, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12, $13, $14,
$15::jsonb, $16::jsonb, $17,
'pending_review', $18, $18
$18, $19, $20, $21,
'pending_review', $22, $22
)
RETURNING *
`
@@ -801,6 +855,10 @@ export async function createConsignmentSubmission(input: {
toJsonString(input.contentJson || {}),
toJsonString(input.screenshotsJson || []),
input.askingPriceFen || 0,
input.workerId || null,
input.workerName || '',
input.workerPhone || '',
input.depositAmountFen || 0,
input.now,
])
return res.rows[0]!
@@ -852,6 +910,10 @@ export async function approveConsignmentSubmissionWithInbound(input: {
credentialsJson?: Record<string, unknown> | undefined
screenshotsJson?: unknown[] | undefined
reviewNote?: string | undefined
workerId?: number | null | undefined
workerName?: string | undefined
workerPhone?: string | undefined
depositAmountFen?: number | undefined
now: string
}): Promise<{ submission: ConsignmentSubmissionRow; inventory: ConsignmentInventoryRow }> {
return withTransaction(async (client: PoolClient) => {
@@ -863,6 +925,16 @@ export async function approveConsignmentSubmissionWithInbound(input: {
if (!submission) throw new Error('提报单不存在')
if (submission.status !== 'pending_review') throw new Error('提报单已被处理')
const workerId = input.workerId !== undefined ? input.workerId : submission.worker_id || null
const workerName =
input.workerName !== undefined ? input.workerName : submission.worker_name || ''
const workerPhone =
input.workerPhone !== undefined ? input.workerPhone : submission.worker_phone || ''
const depositAmountFen =
input.depositAmountFen !== undefined
? input.depositAmountFen
: submission.deposit_amount_fen || 0
const invSql = `
INSERT INTO consignment_inventories (
inventory_no, game_name, category_id, item_name, system, server_zone,
@@ -870,14 +942,16 @@ export async function approveConsignmentSubmissionWithInbound(input: {
inbound_batch, seller_contact, seller_game_id, seller_payment_info,
seller_phone, credentials_json, screenshots_json, remark,
allow_matching, status, allocation_status, source_type,
source_submission_id, inbound_at, created_at, updated_at
source_submission_id, worker_id, worker_name, worker_phone, deposit_amount_fen,
inbound_at, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13, $14, $15,
$16, $17::jsonb, $18::jsonb, $19,
TRUE, 'in_stock', '待安排', 'collection_link',
$20, $21, $21, $21
$20, $21, $22, $23, $24,
$25, $25, $25
)
RETURNING *
`
@@ -902,6 +976,10 @@ export async function approveConsignmentSubmissionWithInbound(input: {
toJsonString(input.screenshotsJson || submission.screenshots_json || []),
input.reviewNote || '',
submission.id,
workerId,
workerName,
workerPhone,
depositAmountFen,
input.now,
]
const invRes = await client.query<ConsignmentInventoryRow>(invSql, invValues)
@@ -914,15 +992,23 @@ export async function approveConsignmentSubmissionWithInbound(input: {
result_inventory_id = $1,
reviewer_name = $2,
review_note = $3,
reviewed_at = $4,
updated_at = $4
WHERE id = $5
worker_id = COALESCE($4, worker_id),
worker_name = COALESCE($5, worker_name),
worker_phone = COALESCE($6, worker_phone),
deposit_amount_fen = COALESCE($7, deposit_amount_fen),
reviewed_at = $8,
updated_at = $8
WHERE id = $9
RETURNING *
`
const updateSubRes = await client.query<ConsignmentSubmissionRow>(updateSubSql, [
inventory.id,
input.buyerName,
input.reviewNote || '',
workerId,
workerName,
workerPhone,
depositAmountFen,
input.now,
submission.id,
])
@@ -17,6 +17,7 @@ import {
updateAdminConsignmentLink,
} from '../../../services/worker-platform/consignment-admin-service.js'
import {
assignWorkOrderWithConsignmentWorker,
checkWorkOrderInventoryMatches,
fulfillWorkOrderWithConsignment,
revokeWorkOrderConsignmentFulfillment,
@@ -253,6 +254,25 @@ router.post(
),
)
router.post(
'/worker-platform/consignment/assign-worker',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler(
(req) =>
assignWorkOrderWithConsignmentWorker({
workOrderId: String(req.body?.workOrderId || ''),
inventoryId: String(req.body?.inventoryId || ''),
workerId: req.body?.workerId ? Number(req.body.workerId) : null,
actorName: req.adminSession?.username || 'admin',
}),
{
successMessage: '已指派打手,押金已冻结,工单流转进行中!',
errorMessage: '指派打手履约失败',
scope: '[admin/consignment/assign-worker]',
},
),
)
router.post(
'/worker-platform/consignment/revoke',
requireAdminRoles(['admin', 'operator', 'support']),
+3
View File
@@ -5,6 +5,7 @@ import workerAuthSessionRoutes from './worker/auth-session.js'
import workerProfileRoutes from './worker/profile.js'
import workerHallRoutes from './worker/hall.js'
import workerOrdersRoutes from './worker/orders.js'
import workerConsignmentRoutes from './worker/consignment.js'
import { requireWorkerSession } from './worker/session.js'
import { buildNotFoundPayload } from '../utils/http.js'
@@ -22,6 +23,8 @@ router.use(workerHallRoutes)
router.use(workerOrdersRoutes)
router.use(workerConsignmentRoutes)
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req))
})
@@ -0,0 +1,40 @@
import { Router } from 'express'
import {
listWorkerConsignments,
publishWorkerConsignment,
} from '../../services/worker-platform/consignment-worker-service.js'
import { sendRouteError } from '../../utils/http.js'
import { getRequiredWorkerSession } from './session.js'
const router = Router()
/**
* POST /api/v1/worker/consignment/publish
* 打手在接单平台直接提报苏尔南手枪寄卖
*/
router.post('/consignment/publish', async (req, res) => {
try {
const session = getRequiredWorkerSession(req)
const result = await publishWorkerConsignment(session.workerId, req.body || {})
res.json({ ok: true, data: result })
} catch (error) {
sendRouteError(res, error, '提交寄卖失败', '[worker/consignment/publish]')
}
})
/**
* GET /api/v1/worker/consignment/my-items
* 打手查看自己在接单平台寄卖的手枪列表与状态
*/
router.get('/consignment/my-items', async (req, res) => {
try {
const session = getRequiredWorkerSession(req)
const result = await listWorkerConsignments(session.workerId)
res.json({ ok: true, data: result })
} catch (error) {
sendRouteError(res, error, '获取我的寄卖列表失败', '[worker/consignment/my-items]')
}
})
export default router
@@ -16,6 +16,10 @@ import {
updateConsignmentCollectLink,
updateConsignmentInventory,
} from '../../repositories/worker-platform/consignment-repo.js'
import {
getWorkerUserById,
getWorkerUserByPhone,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { generateConsignmentInventoryNo, randomToken } from '../../utils/random.js'
@@ -60,6 +64,10 @@ export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
boundPlatformOrderId: row.bound_platform_order_id || '',
sourceType: row.source_type,
sourceSubmissionId: row.source_submission_id ? Number(row.source_submission_id) : null,
workerId: row.worker_id ? Number(row.worker_id) : null,
workerName: row.worker_name || '',
workerPhone: row.worker_phone || '',
depositAmountFen: Number(row.deposit_amount_fen || 0),
inboundAt: row.inbound_at || row.created_at,
consumedAt: row.consumed_at,
createdAt: row.created_at,
@@ -126,6 +134,10 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
? (safeParseJson(row.screenshots_json) as unknown[])
: [],
askingPriceFen: Number(row.asking_price_fen || 0),
workerId: row.worker_id ? Number(row.worker_id) : null,
workerName: row.worker_name || '',
workerPhone: row.worker_phone || '',
depositAmountFen: Number(row.deposit_amount_fen || 0),
status: row.status,
reviewNote: row.review_note || '',
reviewerName: row.reviewer_name || '',
@@ -204,8 +216,8 @@ export async function createAdminConsignmentInventory(payload: JsonObject, actor
system: String(payload.system || '').trim(),
serverZone: String(payload.serverZone || '').trim(),
series: String(payload.series || '').trim(),
diamondLevel: Math.max(1, Math.min(6, Number(payload.diamondLevel || 1))),
brillianceCount: Math.max(0, Math.min(6, Number(payload.brillianceCount || 0))),
diamondLevel: Math.max(1, Math.min(7, Number(payload.diamondLevel || 1))),
brillianceCount: Math.max(0, Math.min(7, Number(payload.brillianceCount || 0))),
costPriceFen,
buyerName: String(payload.buyerName || actorName || '').trim(),
inboundBatch: String(payload.inboundBatch || '').trim(),
@@ -221,6 +233,10 @@ export async function createAdminConsignmentInventory(payload: JsonObject, actor
remark: String(payload.remark || '').trim(),
allowMatching: payload.allowMatching !== false,
sourceType: 'manual',
workerId: payload.workerId ? Number(payload.workerId) : null,
workerName: String(payload.workerName || '').trim(),
workerPhone: String(payload.workerPhone || '').trim(),
depositAmountFen: payload.depositAmountFen ? Number(payload.depositAmountFen) : 0,
now,
})
@@ -238,9 +254,14 @@ export async function updateAdminConsignmentInventory(
system: payload.system !== undefined ? String(payload.system).trim() : undefined,
serverZone: payload.serverZone !== undefined ? String(payload.serverZone).trim() : undefined,
series: payload.series !== undefined ? String(payload.series).trim() : undefined,
diamondLevel: payload.diamondLevel !== undefined ? Number(payload.diamondLevel) : undefined,
diamondLevel:
payload.diamondLevel !== undefined
? Math.max(1, Math.min(7, Number(payload.diamondLevel)))
: undefined,
brillianceCount:
payload.brillianceCount !== undefined ? Number(payload.brillianceCount) : undefined,
payload.brillianceCount !== undefined
? Math.max(0, Math.min(7, Number(payload.brillianceCount)))
: undefined,
costPriceFen:
payload.costPriceFen !== undefined
? Math.max(0, Math.round(Number(payload.costPriceFen)))
@@ -265,6 +286,16 @@ export async function updateAdminConsignmentInventory(
remark: payload.remark !== undefined ? String(payload.remark).trim() : undefined,
allowMatching: payload.allowMatching !== undefined ? Boolean(payload.allowMatching) : undefined,
status: payload.status !== undefined ? (payload.status as any) : undefined,
workerId:
payload.workerId !== undefined
? payload.workerId
? Number(payload.workerId)
: null
: undefined,
workerName: payload.workerName !== undefined ? String(payload.workerName).trim() : undefined,
workerPhone: payload.workerPhone !== undefined ? String(payload.workerPhone).trim() : undefined,
depositAmountFen:
payload.depositAmountFen !== undefined ? Number(payload.depositAmountFen) : undefined,
now,
})
@@ -428,6 +459,29 @@ export async function approveAdminConsignmentSubmission(
Math.round(Number(payload.costPriceFen ?? submission.asking_price_fen ?? 0)),
)
let workerId = payload.workerId
? Number(payload.workerId)
: submission.worker_id
? Number(submission.worker_id)
: null
let workerName = String(payload.workerName || submission.worker_name || '').trim()
let workerPhone = String(payload.workerPhone || submission.worker_phone || '').trim()
const phoneToLookup = String(payload.sellerPhone || submission.seller_phone || '').trim()
if (!workerId && phoneToLookup) {
const matchedWorker = await getWorkerUserByPhone(phoneToLookup)
if (matchedWorker) {
workerId = Number(matchedWorker.id)
workerName = matchedWorker.display_name || matchedWorker.username || ''
workerPhone = matchedWorker.phone || phoneToLookup
}
}
const depositAmountFen =
payload.depositAmountFen !== undefined
? Number(payload.depositAmountFen)
: Number(submission.deposit_amount_fen || 0)
const res = await approveConsignmentSubmissionWithInbound({
submissionId: id,
inventoryNo,
@@ -439,23 +493,27 @@ export async function approveAdminConsignmentSubmission(
series: String(payload.series || submission.series).trim(),
diamondLevel: Math.max(
1,
Math.min(6, Number(payload.diamondLevel ?? submission.diamond_level ?? 1)),
Math.min(7, Number(payload.diamondLevel ?? submission.diamond_level ?? 1)),
),
brillianceCount: Math.max(
0,
Math.min(6, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0)),
Math.min(7, Number(payload.brillianceCount ?? submission.brilliance_count ?? 0)),
),
costPriceFen,
sellerContact: String(payload.sellerContact || submission.contact_info).trim(),
sellerGameId: String(payload.sellerGameId || submission.seller_game_id).trim(),
sellerPaymentInfo: String(payload.sellerPaymentInfo || submission.seller_payment_info).trim(),
sellerPhone: String(payload.sellerPhone || submission.seller_phone).trim(),
sellerPhone: phoneToLookup,
credentialsJson:
payload.credentials && typeof payload.credentials === 'object'
? (payload.credentials as Record<string, unknown>)
: undefined,
screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : undefined,
reviewNote: String(payload.reviewNote || '').trim(),
workerId,
workerName,
workerPhone,
depositAmountFen,
now,
})
@@ -5,16 +5,19 @@ import {
type ConsignmentInventoryRow,
getConsignmentInventoryById,
listActiveConsignmentInventoriesForMatching,
updateConsignmentInventory,
} from '../../repositories/worker-platform/consignment-repo.js'
import {
createWorkOrderEvent,
getWorkOrderById,
updateWorkOrder,
type WorkOrderRow,
} from '../../repositories/worker-platform/index.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
import { assignAdminWorkOrderToWorker } from './admin-work-order-assignment-service.js'
import { mapWorkOrderAdmin } from './mappers.js'
import {
consumeIndustryVouchersBeforeWorkOrderPublish,
@@ -246,6 +249,122 @@ export async function fulfillWorkOrderWithConsignment(input: {
}
}
/**
* 选用内部寄售库存并走接单平台指派打手(冻结押金担保履约)
*/
export async function assignWorkOrderWithConsignmentWorker(input: {
workOrderId: number | string
inventoryId: number | string
workerId?: number | string | null
actorName: string
}): Promise<{
order: ReturnType<typeof mapWorkOrderAdmin>
inventory: ReturnType<typeof mapConsignmentInventory>
voucherConsume: WorkOrderVoucherConsumeResult
}> {
const workOrder = await getWorkOrderById(input.workOrderId)
if (!workOrder) {
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
}
const inventory = await getConsignmentInventoryById(input.inventoryId)
if (!inventory) {
throw createHttpError('寄卖物品不存在', {
statusCode: 404,
errorCode: 'consignment_inventory_not_found',
})
}
if (inventory.status !== 'in_stock' || !inventory.allow_matching) {
throw createHttpError('该寄卖物品不可用或已被暂停匹配', {
statusCode: 409,
errorCode: 'consignment_inventory_unavailable',
})
}
const targetWorkerId = input.workerId || inventory.worker_id
if (!targetWorkerId) {
throw createHttpError('该寄卖物品未关联打手,请在指派弹窗中选择打手账号', {
statusCode: 400,
errorCode: 'consignment_worker_required',
})
}
// 1. 调用接单平台统一指派流程(进行打手可用性校验、余额与押金冻结、电子凭证核销、工单状态流转到 in_progress)
const assignResult = await assignAdminWorkOrderToWorker(
workOrder.id,
{ workerId: targetWorkerId },
input.actorName,
)
const now = nowIso()
const material = safeParseJson(workOrder.material_json)
// 2. 将寄售手枪信息写入工单 material
const updatedMaterial = {
...material,
consignment: {
inventoryId: Number(inventory.id),
inventoryNo: inventory.inventory_no,
itemName: inventory.item_name,
system: inventory.system,
serverZone: inventory.server_zone,
series: inventory.series,
diamondLevel: inventory.diamond_level,
brillianceCount: inventory.brilliance_count,
costPriceFen: inventory.cost_price_fen,
workerId: Number(targetWorkerId),
workerName: inventory.worker_name || '',
fulfillmentType: 'worker_deposit',
assignedAt: now,
assignedBy: input.actorName,
},
}
const rewardAmountYuan =
Number(inventory.cost_price_fen || 0) > 0
? Number((Number(inventory.cost_price_fen) / 100).toFixed(2))
: Number(workOrder.reward_amount || 0)
await updateWorkOrder(workOrder.id, {
material_json: updatedMaterial,
reward_amount: rewardAmountYuan,
updated_at: now,
})
// 3. 将寄卖库存置为 consumed 已出库/已安排打手
const updatedInventory = await updateConsignmentInventory(inventory.id, {
status: 'consumed',
allocationStatus: '已指派打手(押金履约中)',
workerId: Number(targetWorkerId),
now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: input.actorName,
eventType: 'consignment_worker_assigned',
fromStatus: workOrder.status,
toStatus: WORK_ORDER_STATUS.IN_PROGRESS,
payloadJson: JSON.stringify({
inventoryId: inventory.id,
inventoryNo: inventory.inventory_no,
workerId: targetWorkerId,
costPriceFen: inventory.cost_price_fen,
}),
now,
})
const refreshedOrder = await getWorkOrderById(workOrder.id)
return {
order: mapWorkOrderAdmin(refreshedOrder || (assignResult.order as any)),
inventory: mapConsignmentInventory(updatedInventory!),
voucherConsume: assignResult.voucherConsume,
}
}
/**
* 撤销寄售履约(用于选错重选、买家退款或物品异常)
*/
@@ -61,6 +61,12 @@ test('extractPeacekeeperSpecs extracts specs accurately', () => {
assert.equal(spec2.series, '怒嚎')
assert.equal(spec2.diamondLevel, 2)
assert.equal(spec2.brillianceCount, 1)
const spec3 = extractPeacekeeperSpecs('安卓区 血宴 7钻 7璀璨')
assert.equal(spec3.system, '安卓')
assert.equal(spec3.series, '血宴')
assert.equal(spec3.diamondLevel, 7)
assert.equal(spec3.brillianceCount, 7)
})
test('matchPeacekeeperOrderWithInventory matches exact spec', () => {
@@ -62,16 +62,16 @@ export function extractPeacekeeperSpecs(
}
}
// 4. 钻级识别 (1~6钻)
// 4. 钻级识别 (1~7钻)
let diamondLevel = extraMeta?.diamondLevel || 1
const diamondMatch = text.match(/([1-6])\s*钻/)
const diamondMatch = text.match(/([1-7])\s*钻/)
if (diamondMatch?.[1]) {
diamondLevel = Number(diamondMatch[1])
}
// 5. 璀璨数量识别 (0~6璀璨)
// 5. 璀璨数量识别 (0~7璀璨)
let brillianceCount = 0
const brillianceMatch = text.match(/([1-6])\s*璀璨/)
const brillianceMatch = text.match(/([1-7])\s*璀璨/)
if (brillianceMatch?.[1]) {
brillianceCount = Number(brillianceMatch[1])
} else if (text.includes('璀璨')) {
@@ -2,6 +2,7 @@ import {
createConsignmentSubmission,
getConsignmentCollectLinkByToken,
} from '../../repositories/worker-platform/consignment-repo.js'
import { getWorkerUserByPhone } from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { generateConsignmentSubmissionNo } from '../../utils/random.js'
@@ -65,19 +66,89 @@ export async function submitPublicConsignment(token: string, payload: JsonObject
).trim()
const contactInfo = String(payload.contactInfo || payload.sellerContact || '').trim()
if (!contactInfo) {
throw createHttpError('请填写联系方式(微信号或手机号)', {
throw createHttpError('请填写微信号或QQ号以便联系与核实', {
statusCode: 400,
errorCode: 'contact_info_required',
})
}
const contentPayload =
payload.contentJson && typeof payload.contentJson === 'object'
? (payload.contentJson as Record<string, unknown>)
: payload.content && typeof payload.content === 'object'
? (payload.content as Record<string, unknown>)
: {}
const characterName = String(
contentPayload.characterName || payload.characterName || '',
).trim()
const characterId = String(contentPayload.characterId || payload.characterId || '').trim()
if (!characterName) {
throw createHttpError('请填写游戏内角色名', {
statusCode: 400,
errorCode: 'character_name_required',
})
}
if (!characterId) {
throw createHttpError('请填写角色数字ID', {
statusCode: 400,
errorCode: 'character_id_required',
})
}
const screenshots = Array.isArray(payload.screenshotsJson)
? payload.screenshotsJson
: Array.isArray(payload.screenshots)
? payload.screenshots
: []
if (screenshots.length === 0) {
throw createHttpError('请上传游戏主页截图以供核验', {
statusCode: 400,
errorCode: 'screenshot_required',
})
}
const now = nowIso()
const submissionNo = generateConsignmentSubmissionNo()
const askingPriceFen = Math.max(
0,
100,
Math.round(Number(payload.askingPriceFen || link.preset_price_fen || 0)),
)
const sellerPhone = String(payload.sellerPhone || '').trim()
if (!sellerPhone) {
throw createHttpError('请填写联系电话', {
statusCode: 400,
errorCode: 'seller_phone_required',
})
}
let workerId: number | null = null
let workerName = ''
let workerPhone = ''
if (sellerPhone) {
const matchedWorker = await getWorkerUserByPhone(sellerPhone)
if (matchedWorker) {
workerId = Number(matchedWorker.id)
workerName = matchedWorker.display_name || matchedWorker.username || ''
workerPhone = matchedWorker.phone || sellerPhone
}
}
const diamondLevel = Math.max(
1,
Math.min(7, Number(payload.diamondLevel || link.diamond_level || 1)),
)
const brillianceCount = Math.max(
0,
Math.min(7, Number(payload.brillianceCount ?? link.brilliance_count ?? 0)),
)
const sellerGameId = String(
payload.sellerGameId || `${characterName} (${characterId})`,
).trim()
const submission = await createConsignmentSubmission({
submissionNo,
linkId: Number(link.id),
@@ -87,18 +158,28 @@ export async function submitPublicConsignment(token: string, payload: JsonObject
system: String(payload.system || link.system || '').trim(),
serverZone: String(payload.serverZone || link.server_zone || '').trim(),
series: String(payload.series || link.series || '').trim(),
diamondLevel: Number(payload.diamondLevel || link.diamond_level || 1),
brillianceCount: Number(payload.brillianceCount ?? link.brilliance_count ?? 0),
sellerGameId: String(payload.sellerGameId || '').trim(),
sellerPaymentInfo: String(payload.sellerPaymentInfo || '').trim(),
sellerPhone: String(payload.sellerPhone || '').trim(),
diamondLevel,
brillianceCount,
sellerGameId,
sellerPaymentInfo: String(payload.sellerPaymentInfo || '走接单平台钱包结算').trim(),
sellerPhone,
contactInfo,
contentJson:
payload.content && typeof payload.content === 'object'
? (payload.content as Record<string, unknown>)
: (payload as Record<string, unknown>),
screenshotsJson: Array.isArray(payload.screenshots) ? payload.screenshots : [],
contentJson: {
...contentPayload,
characterName,
characterId,
sellerNote: String(contentPayload.sellerNote || payload.sellerNote || '').trim(),
channel: 'collect_link',
},
screenshotsJson: screenshots,
askingPriceFen,
workerId,
workerName,
workerPhone,
depositAmountFen: Math.max(
100,
Math.round(Number(payload.depositAmountFen || askingPriceFen || 10000)),
),
now,
})
@@ -0,0 +1,166 @@
import {
createConsignmentSubmission,
listConsignmentInventories,
listConsignmentSubmissions,
} from '../../repositories/worker-platform/consignment-repo.js'
import { getWorkerUserById } from '../../repositories/worker-platform/index.js'
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'
export async function publishWorkerConsignment(workerId: number, payload: JsonObject) {
const worker = await getWorkerUserById(workerId)
if (!worker) {
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 contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim()
if (!contactInfo) {
throw createHttpError('请填写微信号或QQ号以便联系与核实', {
statusCode: 400,
errorCode: 'contact_required',
})
}
const characterName = String(payload.characterName || '').trim()
const characterId = String(payload.characterId || '').trim()
if (!characterName) {
throw createHttpError('请填写游戏内角色名', {
statusCode: 400,
errorCode: 'character_name_required',
})
}
if (!characterId) {
throw createHttpError('请填写角色数字ID', {
statusCode: 400,
errorCode: 'character_id_required',
})
}
const screenshots = Array.isArray(payload.screenshotsJson) ? payload.screenshotsJson : []
if (screenshots.length === 0) {
throw createHttpError('请上传游戏主页截图以供核验', {
statusCode: 400,
errorCode: 'screenshot_required',
})
}
const askingPriceFen = Math.max(100, Math.round(Number(payload.askingPriceFen || 0)))
if (askingPriceFen < 100) {
throw createHttpError('寄售价格不能少于1元', {
statusCode: 400,
errorCode: 'invalid_asking_price',
})
}
const depositAmountFen = Math.max(
100,
Math.round(Number(payload.depositAmountFen || askingPriceFen || 10000)),
)
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,
})
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,
}
}
export async function listWorkerConsignments(workerId: number) {
const [submissionsRes, inventoriesRes] = await Promise.all([
listConsignmentSubmissions({ workerId, pageSize: 100 }),
listConsignmentInventories({ workerId, pageSize: 100 }),
])
return {
submissions: submissionsRes.items.map((sub) => ({
id: sub.id,
submissionNo: sub.submission_no,
itemName: sub.item_name,
system: sub.system,
serverZone: sub.server_zone,
series: sub.series,
diamondLevel: sub.diamond_level,
brillianceCount: sub.brilliance_count,
askingPriceFen: sub.asking_price_fen,
status: sub.status,
reviewNote: sub.review_note,
resultInventoryId: sub.result_inventory_id,
screenshotsJson: sub.screenshots_json,
contentJson: sub.content_json,
createdAt: sub.created_at,
})),
inventories: inventoriesRes.items.map((inv) => ({
id: inv.id,
inventoryNo: inv.inventory_no,
itemName: inv.item_name,
system: inv.system,
serverZone: inv.server_zone,
series: inv.series,
diamondLevel: inv.diamond_level,
brillianceCount: inv.brilliance_count,
costPriceFen: inv.cost_price_fen,
status: inv.status,
allocationStatus: inv.allocation_status,
boundPlatformOrderId: inv.bound_platform_order_id,
depositAmountFen: inv.deposit_amount_fen,
createdAt: inv.created_at,
})),
}
}