feat(consignment): 寄卖配置价格/匹配/超时/收货字段全面改造
- 价格:固定价格改为模板价固定下调,移除绝对固定价;没有模板价时统一阻止发布 - 配置面板:按下拉选项动态显隐,超时与降价提示拆成两张卡片,超时单位改为小时(默认 0.5) - 匹配:新增关闭匹配并设为默认,移除自动匹配开关;关闭后新单直接进大厅 - 超时:新增重新匹配退/扣押金,回内部匹配池并排除原卖家与原库存(迁移 147) - 日志:新增 consignment_match_logs,自动/手动匹配成功、失败、跳过统一留痕,后台新增匹配日志抽屉 - 打手端:顶部分类下拉替换多 tab,默认苏尔南手枪;联系方式与游戏凭据改为动态字段(默认 7 项,手机号必填) - 降价提示默认开启、默认 24 小时
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
-- 147_consignment_match_and_rematch.sql
|
||||
-- 1) 超时「重新匹配」排除原卖家/原库存;
|
||||
-- 2) 手动/自动匹配统一留痕,供后台排查问题。
|
||||
-- 全部语句幂等,可安全重跑;不改动历史迁移。
|
||||
|
||||
ALTER TABLE work_orders
|
||||
ADD COLUMN IF NOT EXISTS rematch_excluded_worker_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS rematch_excluded_inventory_id BIGINT;
|
||||
|
||||
COMMENT ON COLUMN work_orders.rematch_excluded_worker_id IS
|
||||
'超时重新匹配时排除的原接单打手;匹配成功后清空';
|
||||
COMMENT ON COLUMN work_orders.rematch_excluded_inventory_id IS
|
||||
'超时重新匹配时排除的原寄卖库存;匹配成功后清空';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consignment_match_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
work_order_id BIGINT,
|
||||
work_order_no TEXT NOT NULL DEFAULT '',
|
||||
profile_id BIGINT,
|
||||
category_id BIGINT,
|
||||
trigger_type TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
reason_code TEXT NOT NULL DEFAULT '',
|
||||
reason_text TEXT NOT NULL DEFAULT '',
|
||||
candidate_count INTEGER NOT NULL DEFAULT 0,
|
||||
matched_count INTEGER NOT NULL DEFAULT 0,
|
||||
actor_type TEXT NOT NULL DEFAULT 'system',
|
||||
actor_id TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT consignment_match_logs_trigger_check
|
||||
CHECK (trigger_type IN ('auto', 'manual', 'rematch')),
|
||||
CONSTRAINT consignment_match_logs_result_check
|
||||
CHECK (result IN ('matched', 'failed', 'skipped'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_created
|
||||
ON consignment_match_logs (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_work_order
|
||||
ON consignment_match_logs (work_order_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_profile
|
||||
ON consignment_match_logs (profile_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_type_result
|
||||
ON consignment_match_logs (trigger_type, result, created_at DESC);
|
||||
|
||||
COMMENT ON TABLE consignment_match_logs IS
|
||||
'寄卖匹配日志:手动/自动/重新匹配的每次尝试(成功、失败、跳过)与原因,用于留痕和问题分析';
|
||||
COMMENT ON COLUMN consignment_match_logs.trigger_type IS
|
||||
'触发方式:auto 自动调度 / manual 后台手动 / rematch 超时重新匹配';
|
||||
COMMENT ON COLUMN consignment_match_logs.reason_code IS
|
||||
'机器可读原因码,例如 no_candidate / matching_disabled / spec_mismatch / exception';
|
||||
@@ -425,6 +425,9 @@ export type WorkOrderRow = {
|
||||
internal_match_deadline_at?: string | null
|
||||
hall_visible_at?: string | null
|
||||
internal_match_attempts?: number
|
||||
/** 超时「重新匹配」时排除的原卖家与原库存,避免同一卖家立刻再次接回同一单。 */
|
||||
rematch_excluded_worker_id?: number | null
|
||||
rematch_excluded_inventory_id?: number | null
|
||||
acceptance_json: string | Record<string, unknown>
|
||||
draft_acceptance_json: string | Record<string, unknown>
|
||||
admin_note: string
|
||||
|
||||
@@ -573,7 +573,7 @@ export async function disposeWorkOrderShareProblem(input: {
|
||||
|
||||
export async function settleOverdueWorkOrder(input: {
|
||||
workOrderId: number
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
|
||||
now: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
@@ -613,13 +613,15 @@ export async function settleOverdueWorkOrder(input: {
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
const releasePolicies = ['reopen', 'cancel_release']
|
||||
const releasePolicies = ['reopen', 'cancel_release', 'rematch_release']
|
||||
const isRematch = input.policy === 'rematch_release' || input.policy === 'rematch_deduct'
|
||||
const shouldReleaseDeposit = releasePolicies.includes(input.policy) && !forfeitGiftDeposit
|
||||
const shouldDeductDeposit =
|
||||
input.policy === 'cancel_deduct' ||
|
||||
input.policy === 'rematch_deduct' ||
|
||||
(releasePolicies.includes(input.policy) && forfeitGiftDeposit)
|
||||
const effectivePolicy =
|
||||
input.policy === 'cancel_release' && forfeitGiftDeposit ? 'cancel_deduct' : input.policy
|
||||
releasePolicies.includes(input.policy) && forfeitGiftDeposit ? 'cancel_deduct' : input.policy
|
||||
const workerId = Number(workOrder.assigned_worker_id || 0)
|
||||
let resolvedDepositAmount = 0
|
||||
|
||||
@@ -672,7 +674,11 @@ export async function settleOverdueWorkOrder(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const targetStatus = input.policy === 'reopen' ? 'open' : 'cancelled'
|
||||
const targetStatus = isRematch
|
||||
? 'internal_matching'
|
||||
: input.policy === 'reopen'
|
||||
? 'open'
|
||||
: 'cancelled'
|
||||
|
||||
await client.query(
|
||||
`
|
||||
@@ -685,6 +691,8 @@ export async function settleOverdueWorkOrder(input: {
|
||||
${CLEAR_DELIVERY_RESIDUE_SQL},
|
||||
published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END,
|
||||
hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END,
|
||||
internal_match_deadline_at = CASE WHEN $1 = 'internal_matching' THEN NULL ELSE internal_match_deadline_at END,
|
||||
hall_visible_at = CASE WHEN $1 = 'internal_matching' THEN NULL ELSE hall_visible_at END,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
|
||||
@@ -893,6 +893,8 @@ export interface OverdueWorkOrderShareItem {
|
||||
timeoutMinutes: number
|
||||
timeoutPolicy: string
|
||||
shareCreatedAt: string
|
||||
/** 寄卖份额对应的库存;重新匹配时用于排除原库存 */
|
||||
consignmentInventoryId: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -921,6 +923,7 @@ export async function listOverdueWorkOrderShares({
|
||||
timeout_minutes: number
|
||||
timeout_policy: string
|
||||
share_created_at: string
|
||||
consignment_inventory_id: string | null
|
||||
}>(
|
||||
`SELECT
|
||||
wos.id AS share_id,
|
||||
@@ -930,7 +933,8 @@ export async function listOverdueWorkOrderShares({
|
||||
wos.share_deposit,
|
||||
wos.timeout_minutes,
|
||||
wos.timeout_policy,
|
||||
wos.created_at AS share_created_at
|
||||
wos.created_at AS share_created_at,
|
||||
wos.consignment_inventory_id
|
||||
FROM work_order_shares wos
|
||||
INNER JOIN work_orders wo ON wo.id = wos.work_order_id
|
||||
WHERE wos.status = 'joined'
|
||||
@@ -951,6 +955,9 @@ export async function listOverdueWorkOrderShares({
|
||||
timeoutMinutes: Number(row.timeout_minutes),
|
||||
timeoutPolicy: String(row.timeout_policy || 'reopen'),
|
||||
shareCreatedAt: String(row.share_created_at),
|
||||
consignmentInventoryId: row.consignment_inventory_id
|
||||
? Number(row.consignment_inventory_id)
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -990,7 +990,7 @@ export async function cancelWorkOrderShare(input: {
|
||||
export async function settleOverdueWorkOrderShare(input: {
|
||||
workOrderId: number
|
||||
shareId: number
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
|
||||
now: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
@@ -1065,7 +1065,7 @@ export async function settleOverdueWorkOrderShare(input: {
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
if (frozenDeposit > 0) {
|
||||
if (input.policy === 'cancel_deduct') {
|
||||
if (input.policy === 'cancel_deduct' || input.policy === 'rematch_deduct') {
|
||||
deductedDepositAmount = frozenDeposit
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - frozenDeposit)
|
||||
await client.query(
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
listWorkOrderInventoryMatchOptions,
|
||||
} from '../../../services/worker-platform/consignment-fulfillment-service.js'
|
||||
import { listPendingConsignmentMatches } from '../../../services/worker-platform/consignment-pending-match-service.js'
|
||||
import { listConsignmentMatchLogs } from '../../../services/worker-platform/consignment-match-log-service.js'
|
||||
import {
|
||||
listConsignmentMatchCandidatesForWorkOrder,
|
||||
matchConsignmentWorkOrderManually,
|
||||
@@ -140,6 +141,16 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/consignment/match-logs',
|
||||
requireAdminPermission('consignment.view'),
|
||||
createJsonHandler((req) => listConsignmentMatchLogs(req.query), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取匹配日志失败',
|
||||
scope: '[admin/consignment/match-logs]',
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------- 库存管理与统计 ----------------
|
||||
router.get(
|
||||
'/worker-platform/consignment/inventories',
|
||||
|
||||
@@ -325,6 +325,14 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
|
||||
const profileConfig = row.profile_snapshot_json
|
||||
? normalizeConsignmentProfileConfig(row.profile_snapshot_json)
|
||||
: null
|
||||
const contentJson =
|
||||
row.content_json && typeof row.content_json === 'object'
|
||||
? (row.content_json as Record<string, unknown>)
|
||||
: safeParseJson(row.content_json)
|
||||
const intakeValues =
|
||||
contentJson?.intakeValues && typeof contentJson.intakeValues === 'object'
|
||||
? (contentJson.intakeValues as Record<string, unknown>)
|
||||
: {}
|
||||
return {
|
||||
id: Number(row.id),
|
||||
submissionNo: row.submission_no,
|
||||
@@ -334,6 +342,9 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
|
||||
profileId: row.profile_id ? Number(row.profile_id) : null,
|
||||
profileVersionId: row.profile_version_id ? Number(row.profile_version_id) : null,
|
||||
specs: profileConfig?.specs || [],
|
||||
/** 收货信息字段定义(按发布快照);历史提报没有快照时返回空,前端回退旧列展示。 */
|
||||
intakeFields: profileConfig?.intake.fields || [],
|
||||
intakeValues,
|
||||
specValues: parseConsignmentSpecValues(row.spec_values_json),
|
||||
specText: row.spec_text || '',
|
||||
priceCapFen: Number(row.price_cap_fen || 0),
|
||||
@@ -349,10 +360,7 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
|
||||
sellerPaymentInfo: row.seller_payment_info || '',
|
||||
sellerPhone: row.seller_phone || '',
|
||||
contactInfo: row.contact_info || '',
|
||||
contentJson:
|
||||
row.content_json && typeof row.content_json === 'object'
|
||||
? (row.content_json as Record<string, unknown>)
|
||||
: safeParseJson(row.content_json),
|
||||
contentJson,
|
||||
screenshotsJson: refreshScreenshotList(
|
||||
Array.isArray(row.screenshots_json)
|
||||
? row.screenshots_json
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
extractPeacekeeperSpecs,
|
||||
listConsignmentMatchOptions,
|
||||
} from './consignment-matching-service.js'
|
||||
import { isRematchExcludedInventory } from './consignment-match-engine-service.js'
|
||||
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
|
||||
|
||||
function inventory(overrides: Partial<ConsignmentInventoryRow> = {}): ConsignmentInventoryRow {
|
||||
@@ -41,6 +42,37 @@ test('手动指定现货:连不符合规格的也要列出来,但必须标
|
||||
assert.ok(options[2]!.conflicts.some((item) => item.includes('系列')))
|
||||
})
|
||||
|
||||
test('重新匹配排除:原库存与原卖家都不再参与,且匹配成功后清空', () => {
|
||||
const excluded = {
|
||||
rematch_excluded_worker_id: 7,
|
||||
rematch_excluded_inventory_id: 11,
|
||||
} as never
|
||||
assert.equal(isRematchExcludedInventory(excluded, { id: 11, worker_id: 99 }), true)
|
||||
assert.equal(isRematchExcludedInventory(excluded, { id: 12, worker_id: 7 }), true)
|
||||
assert.equal(isRematchExcludedInventory(excluded, { id: 12, worker_id: 8 }), false)
|
||||
assert.equal(isRematchExcludedInventory({} as never, { id: 12, worker_id: 7 }), false)
|
||||
})
|
||||
|
||||
test('超时重新匹配与匹配日志:链路必须接线到调度与后台接口', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const [timeoutSrc, engineSrc, routes, logSrc] = await Promise.all([
|
||||
readFile(new URL('./worker-order-timeout-service.ts', import.meta.url), 'utf8'),
|
||||
readFile(new URL('./consignment-match-engine-service.ts', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../../routes/admin/worker-platform/consignment.ts', import.meta.url), 'utf8'),
|
||||
readFile(new URL('./consignment-match-log-service.ts', import.meta.url), 'utf8'),
|
||||
])
|
||||
assert.ok(timeoutSrc.includes('startConsignmentRematch('), '超时服务必须触发重新匹配')
|
||||
assert.ok(timeoutSrc.includes('isRematchTimeoutPolicy(policy)'), '必须识别重新匹配策略')
|
||||
assert.ok(
|
||||
engineSrc.includes('rematch_excluded_worker_id = $3') &&
|
||||
engineSrc.includes('rematch_excluded_worker_id = NULL'),
|
||||
'重新匹配必须写入并清空排除字段',
|
||||
)
|
||||
assert.ok(engineSrc.includes('recordConsignmentMatchLog('), '自动/手动匹配必须写日志')
|
||||
assert.ok(routes.includes("'/worker-platform/consignment/match-logs'"), '必须提供匹配日志接口')
|
||||
assert.ok(logSrc.includes('INSERT INTO consignment_match_logs'), '日志表必须落库')
|
||||
})
|
||||
|
||||
test('字段级差异:订单没写规格时不报差异(那是"非精确",不是冲突)', () => {
|
||||
const specs = extractPeacekeeperSpecs('和平精英 暗潮 2钻', {})
|
||||
assert.deepEqual(describeConsignmentSpecDifferences(specs, inventory({})), [])
|
||||
|
||||
@@ -37,12 +37,26 @@ import {
|
||||
extractPeacekeeperSpecs,
|
||||
} from './consignment-matching-service.js'
|
||||
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
|
||||
import { recordConsignmentMatchLog } from './consignment-match-log-service.js'
|
||||
|
||||
type MatchCandidate = {
|
||||
workOrder: WorkOrderRow
|
||||
profile: ConsignmentProfileSnapshot | null
|
||||
}
|
||||
|
||||
/** 自动匹配的「无候选」跳过日志按工单做 10 分钟节流,避免 30 秒调度刷爆日志表。 */
|
||||
const matchSkipLogThrottle = new Map<string, number>()
|
||||
const MATCH_SKIP_LOG_WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
function shouldLogMatchSkip(key: string): boolean {
|
||||
const now = Date.now()
|
||||
const last = matchSkipLogThrottle.get(key) || 0
|
||||
if (now - last < MATCH_SKIP_LOG_WINDOW_MS) return false
|
||||
matchSkipLogThrottle.set(key, now)
|
||||
if (matchSkipLogThrottle.size > 5000) matchSkipLogThrottle.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
function parseMaterial(value: unknown): Record<string, unknown> {
|
||||
const parsed = safeParseJson(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
@@ -75,6 +89,22 @@ export function inventoryMatchesProfile(
|
||||
return !profile.config.specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
|
||||
}
|
||||
|
||||
/**
|
||||
* 超时「重新匹配」排除的原卖家/原库存:
|
||||
* 同一单重新进入匹配池后,不允许原卖家或原库存立刻再次匹配,避免死循环。
|
||||
*/
|
||||
export function isRematchExcludedInventory(
|
||||
workOrder: Pick<WorkOrderRow, 'rematch_excluded_worker_id' | 'rematch_excluded_inventory_id'>,
|
||||
inventory: Pick<ConsignmentInventoryRow, 'id' | 'worker_id'>,
|
||||
): boolean {
|
||||
const excludedInventoryId = Number(workOrder.rematch_excluded_inventory_id || 0)
|
||||
const excludedWorkerId = Number(workOrder.rematch_excluded_worker_id || 0)
|
||||
if (!excludedInventoryId && !excludedWorkerId) return false
|
||||
if (excludedInventoryId && Number(inventory.id) === excludedInventoryId) return true
|
||||
if (excludedWorkerId && Number(inventory.worker_id || 0) === excludedWorkerId) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function compareInventoryCandidates(
|
||||
a: { inventory: ConsignmentInventoryRow; evaluation: InventoryMatchEvaluation },
|
||||
b: { inventory: ConsignmentInventoryRow; evaluation: InventoryMatchEvaluation },
|
||||
@@ -236,6 +266,8 @@ export async function enterConsignmentInternalMatching(
|
||||
if (!workOrder || workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) return false
|
||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||
if (!profile) return false
|
||||
// 关闭匹配:新单直接进大厅,不进入内部匹配队列。
|
||||
if (profile.config.matching.mode === 'off') return false
|
||||
const delayMinutes = Math.max(0, profile.config.internalMatch.delayMinutes)
|
||||
if (delayMinutes <= 0 && !profile.config.internalMatch.fallbackToHall) return false
|
||||
const deadline = addMinutesIso(delayMinutes, now)
|
||||
@@ -283,6 +315,8 @@ export async function reenterConsignmentInternalMatching(
|
||||
}
|
||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||
if (!profile) return false
|
||||
// 关闭匹配:退回大厅后不再重新排队等内部库存。
|
||||
if (profile.config.matching.mode === 'off') return false
|
||||
const delayMinutes = Math.max(0, profile.config.internalMatch.delayMinutes)
|
||||
if (delayMinutes <= 0) return false
|
||||
const deadline = addMinutesIso(delayMinutes, now)
|
||||
@@ -318,6 +352,91 @@ export async function reenterConsignmentInternalMatching(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 超时「重新匹配」:把工单重新放回内部匹配池,并排除原卖家/原库存。
|
||||
* 分类关闭匹配或找不到配置时,退回大厅,避免工单卡在无人处理的状态。
|
||||
*/
|
||||
export async function startConsignmentRematch(
|
||||
workOrderId: number,
|
||||
input: { excludeWorkerId?: number | null; excludeInventoryId?: number | null } = {},
|
||||
now = nowIso(),
|
||||
): Promise<boolean> {
|
||||
const workOrder = await getWorkOrderById(workOrderId)
|
||||
if (!workOrder) return false
|
||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||
const fallbackToHall = async () => {
|
||||
await query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET status = 'open',
|
||||
assigned_worker_id = NULL,
|
||||
assigned_at = NULL,
|
||||
deadline_at = NULL,
|
||||
published_at = COALESCE(published_at, $1),
|
||||
internal_match_deadline_at = NULL,
|
||||
hall_visible_at = NULL,
|
||||
updated_at = $1
|
||||
WHERE id = $2 AND status IN ('internal_matching', 'open', 'unassigned', 'in_progress')
|
||||
`,
|
||||
[now, workOrderId],
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (!profile || profile.config.matching.mode === 'off') {
|
||||
return fallbackToHall()
|
||||
}
|
||||
const delayMinutes = Math.max(1, Number(profile.config.internalMatch.delayMinutes || 30))
|
||||
const deadline = addMinutesIso(delayMinutes, now)
|
||||
const result = await query<WorkOrderRow>(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET status = 'internal_matching',
|
||||
assigned_worker_id = NULL,
|
||||
assigned_at = NULL,
|
||||
deadline_at = NULL,
|
||||
consignment_profile_id = $1,
|
||||
internal_match_deadline_at = $2,
|
||||
hall_visible_at = $2,
|
||||
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
|
||||
rematch_excluded_worker_id = $3,
|
||||
rematch_excluded_inventory_id = $4,
|
||||
updated_at = $5
|
||||
WHERE id = $6 AND status IN ('internal_matching', 'open', 'unassigned', 'in_progress')
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
profile.profileId,
|
||||
deadline,
|
||||
input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
|
||||
input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
|
||||
now,
|
||||
workOrderId,
|
||||
],
|
||||
)
|
||||
if (!result.rows[0]) return false
|
||||
await query(
|
||||
`
|
||||
INSERT INTO work_order_events (
|
||||
work_order_id, actor_type, actor_id, event_type, from_status, to_status,
|
||||
payload_json, created_at
|
||||
) VALUES ($1, 'system', 'consignment_match', 'consignment_rematch_started', $2, 'internal_matching', $3::jsonb, $4)
|
||||
`,
|
||||
[
|
||||
workOrderId,
|
||||
workOrder.status,
|
||||
JSON.stringify({
|
||||
profileId: profile.profileId,
|
||||
delayMinutes,
|
||||
deadline,
|
||||
excludedWorkerId: input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
|
||||
excludedInventoryId: input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
|
||||
}),
|
||||
now,
|
||||
],
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
function pickCandidateInventories(
|
||||
workOrder: WorkOrderRow,
|
||||
profile: ConsignmentProfileSnapshot | null,
|
||||
@@ -329,6 +448,7 @@ function pickCandidateInventories(
|
||||
return inventories
|
||||
.filter((inventory) => inventoryMatchesProfile(inventory, profile))
|
||||
.filter((inventory) => Number(inventory.remaining_quantity ?? 1) > 0)
|
||||
.filter((inventory) => !isRematchExcludedInventory(workOrder, inventory))
|
||||
.map((inventory) => ({
|
||||
inventory,
|
||||
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
|
||||
@@ -447,6 +567,8 @@ async function applyWholeOrderMatch(
|
||||
hall_visible_at = NULL,
|
||||
internal_match_deadline_at = NULL,
|
||||
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
|
||||
rematch_excluded_worker_id = NULL,
|
||||
rematch_excluded_inventory_id = NULL,
|
||||
updated_at = $3
|
||||
WHERE id = $9 AND status = 'internal_matching'
|
||||
RETURNING *
|
||||
@@ -627,6 +749,8 @@ async function applySharingMatch(
|
||||
internal_match_deadline_at = CASE WHEN $1 = 'in_progress' THEN NULL ELSE internal_match_deadline_at END,
|
||||
hall_visible_at = CASE WHEN $1 = 'in_progress' THEN NULL ELSE hall_visible_at END,
|
||||
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
|
||||
rematch_excluded_worker_id = CASE WHEN $1 = 'in_progress' THEN NULL ELSE rematch_excluded_worker_id END,
|
||||
rematch_excluded_inventory_id = CASE WHEN $1 = 'in_progress' THEN NULL ELSE rematch_excluded_inventory_id END,
|
||||
updated_at = $3
|
||||
WHERE id = $4
|
||||
`,
|
||||
@@ -714,19 +838,41 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
|
||||
)
|
||||
let matched = 0
|
||||
for (const workOrder of orders.rows) {
|
||||
const startedAt = Date.now()
|
||||
const baseLog = {
|
||||
workOrderId: Number(workOrder.id),
|
||||
workOrderNo: workOrder.work_order_no || '',
|
||||
triggerType: 'auto' as const,
|
||||
actorType: 'system',
|
||||
actorId: 'consignment_match',
|
||||
}
|
||||
let profileRef: ConsignmentProfileSnapshot | null = null
|
||||
try {
|
||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||
profileRef = profile
|
||||
if (!profile) continue
|
||||
if (profile.config.matching.mode !== 'auto' || !profile.config.matching.autoMatchEnabled) {
|
||||
continue
|
||||
}
|
||||
const logBase = { ...baseLog, profileId: profile.profileId, categoryId: profile.categoryId }
|
||||
if (profile.config.matching.mode !== 'auto') continue
|
||||
const inventories = await listActiveConsignmentInventoriesForMatching()
|
||||
const matching = profile.config.matching
|
||||
const candidates = pickCandidateInventories(workOrder, profile, inventories, {
|
||||
allowPartial: matching.exactOnly === false,
|
||||
preferLowReward: matching.preferLowReward !== false,
|
||||
})
|
||||
if (candidates.length === 0) continue
|
||||
if (candidates.length === 0) {
|
||||
if (shouldLogMatchSkip(`no_candidate:${workOrder.id}`)) {
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: 'skipped',
|
||||
reasonCode: 'no_candidate',
|
||||
reasonText: '没有符合条件的在库库存',
|
||||
candidateCount: inventories.length,
|
||||
durationMs: Date.now() - startedAt,
|
||||
payload: { inventoryCount: inventories.length },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) {
|
||||
const context: ConsignmentMatchContext = {
|
||||
@@ -738,7 +884,31 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
|
||||
]),
|
||||
}
|
||||
const created = await applySharingMatch({ workOrder, profile }, candidates, context)
|
||||
if (created > 0) matched += 1
|
||||
if (created > 0) {
|
||||
matched += 1
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: 'matched',
|
||||
reasonCode: 'auto_sharing_matched',
|
||||
candidateCount: candidates.length,
|
||||
matchedCount: created,
|
||||
durationMs: Date.now() - startedAt,
|
||||
payload: {
|
||||
partial: context.partial,
|
||||
specConflicts: context.specConflicts,
|
||||
inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: 'failed',
|
||||
reasonCode: 'sharing_not_applied',
|
||||
reasonText: '拼单匹配未创建任何份额(库存可能已被其它任务占用)',
|
||||
candidateCount: candidates.length,
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 整单必须由一条库存一次性满足订单总数量,不能只占 1 件。
|
||||
const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
|
||||
@@ -747,16 +917,63 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
|
||||
const available = Number(candidate.inventory.remaining_quantity ?? 1)
|
||||
return available >= requiredQuantity && (!friendGift || available === requiredQuantity)
|
||||
})
|
||||
if (!chosen) continue
|
||||
if (!chosen) {
|
||||
if (shouldLogMatchSkip(`quantity_insufficient:${workOrder.id}`)) {
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: 'skipped',
|
||||
reasonCode: 'quantity_insufficient',
|
||||
reasonText: `没有库存能一次满足订单数量 ${requiredQuantity}`,
|
||||
candidateCount: candidates.length,
|
||||
durationMs: Date.now() - startedAt,
|
||||
payload: { requiredQuantity },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
const applied = await applyWholeOrderMatch({ workOrder, profile }, chosen, {
|
||||
matchType: 'auto',
|
||||
partial: !chosen.evaluation.exact,
|
||||
specConflicts: [...chosen.evaluation.conflicts, ...chosen.evaluation.unknowns],
|
||||
})
|
||||
if (applied) matched += 1
|
||||
if (applied) {
|
||||
matched += 1
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: 'matched',
|
||||
reasonCode: 'auto_matched',
|
||||
candidateCount: candidates.length,
|
||||
matchedCount: 1,
|
||||
durationMs: Date.now() - startedAt,
|
||||
payload: {
|
||||
partial: !chosen.evaluation.exact,
|
||||
specConflicts: [...chosen.evaluation.conflicts, ...chosen.evaluation.unknowns],
|
||||
inventoryId: Number(chosen.inventory.id),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: 'failed',
|
||||
reasonCode: 'apply_failed',
|
||||
reasonText: '匹配事务未生效(工单状态或库存数量已变化)',
|
||||
candidateCount: candidates.length,
|
||||
durationMs: Date.now() - startedAt,
|
||||
payload: { inventoryId: Number(chosen.inventory.id) },
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// 单笔失败不影响同批其它工单;下一次调度会重试。
|
||||
await recordConsignmentMatchLog({
|
||||
...baseLog,
|
||||
profileId: profileRef?.profileId ?? null,
|
||||
categoryId: profileRef?.categoryId ?? null,
|
||||
result: 'failed',
|
||||
reasonCode: 'exception',
|
||||
reasonText: error instanceof Error ? error.message : String(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
return { scanned: orders.rows.length, matched }
|
||||
@@ -860,6 +1077,11 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
||||
}
|
||||
}
|
||||
|
||||
function extractConsignmentErrorCode(error: unknown): string {
|
||||
const record = error as { errorCode?: unknown; code?: unknown } | null
|
||||
return String(record?.errorCode || record?.code || 'exception').slice(0, 80)
|
||||
}
|
||||
|
||||
/** 后台手动匹配:选中一条库存走整单;拼单工单可多选库存,每份一条份额。 */
|
||||
export async function matchConsignmentWorkOrderManually(input: {
|
||||
workOrderId: number
|
||||
@@ -868,6 +1090,53 @@ export async function matchConsignmentWorkOrderManually(input: {
|
||||
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
|
||||
allowNonExact?: boolean
|
||||
reason?: string
|
||||
}): Promise<{ matched: boolean; matchedCount: number }> {
|
||||
const startedAt = Date.now()
|
||||
const workOrder = await getWorkOrderById(input.workOrderId).catch(() => null)
|
||||
const logBase = {
|
||||
workOrderId: Number(input.workOrderId),
|
||||
workOrderNo: workOrder?.work_order_no || '',
|
||||
profileId: workOrder?.consignment_profile_id ? Number(workOrder.consignment_profile_id) : null,
|
||||
categoryId: workOrder?.category_id ? Number(workOrder.category_id) : null,
|
||||
triggerType: 'manual' as const,
|
||||
actorType: 'admin',
|
||||
actorId: String(input.actorName || '').trim() || 'admin',
|
||||
payload: {
|
||||
inventoryIds: input.inventoryIds,
|
||||
allowNonExact: Boolean(input.allowNonExact),
|
||||
reason: String(input.reason || '').trim(),
|
||||
},
|
||||
}
|
||||
try {
|
||||
const result = await runManualConsignmentMatch(input)
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: result.matched ? 'matched' : 'failed',
|
||||
reasonCode: result.matched ? 'manual_matched' : 'manual_not_applied',
|
||||
reasonText: result.matched ? '' : '手动匹配未生效(工单状态或库存数量已变化)',
|
||||
matchedCount: result.matchedCount,
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
await recordConsignmentMatchLog({
|
||||
...logBase,
|
||||
result: 'failed',
|
||||
reasonCode: extractConsignmentErrorCode(error),
|
||||
reasonText: error instanceof Error ? error.message : String(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function runManualConsignmentMatch(input: {
|
||||
workOrderId: number
|
||||
inventoryIds: number[]
|
||||
actorName?: string
|
||||
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
|
||||
allowNonExact?: boolean
|
||||
reason?: string
|
||||
}): Promise<{ matched: boolean; matchedCount: number }> {
|
||||
const workOrder = await getWorkOrderById(input.workOrderId)
|
||||
if (!workOrder) {
|
||||
@@ -888,7 +1157,9 @@ export async function matchConsignmentWorkOrderManually(input: {
|
||||
const profile = await resolveProfileForWorkOrder(workOrder)
|
||||
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
|
||||
(inventory) =>
|
||||
inventoryMatchesProfile(inventory, profile) && Number(inventory.remaining_quantity ?? 1) > 0,
|
||||
inventoryMatchesProfile(inventory, profile) &&
|
||||
Number(inventory.remaining_quantity ?? 1) > 0 &&
|
||||
!isRematchExcludedInventory(workOrder, inventory),
|
||||
)
|
||||
const selected = input.inventoryIds
|
||||
.map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id)))
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
export type ConsignmentMatchLogTrigger = 'auto' | 'manual' | 'rematch'
|
||||
export type ConsignmentMatchLogResult = 'matched' | 'failed' | 'skipped'
|
||||
|
||||
export type ConsignmentMatchLogRow = {
|
||||
id: number
|
||||
work_order_id: number | null
|
||||
work_order_no: string
|
||||
profile_id: number | null
|
||||
category_id: number | null
|
||||
trigger_type: string
|
||||
result: string
|
||||
reason_code: string
|
||||
reason_text: string
|
||||
candidate_count: number
|
||||
matched_count: number
|
||||
actor_type: string
|
||||
actor_id: string
|
||||
duration_ms: number
|
||||
payload_json: unknown
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type RecordConsignmentMatchLogInput = {
|
||||
workOrderId?: number | null
|
||||
workOrderNo?: string
|
||||
profileId?: number | null
|
||||
categoryId?: number | null
|
||||
triggerType: ConsignmentMatchLogTrigger
|
||||
result: ConsignmentMatchLogResult
|
||||
reasonCode?: string
|
||||
reasonText?: string
|
||||
candidateCount?: number
|
||||
matchedCount?: number
|
||||
actorType?: string
|
||||
actorId?: string
|
||||
durationMs?: number
|
||||
payload?: Record<string, unknown>
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
/** 写匹配日志:任何失败都不影响主流程。 */
|
||||
export async function recordConsignmentMatchLog(
|
||||
input: RecordConsignmentMatchLogInput,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`
|
||||
INSERT INTO consignment_match_logs (
|
||||
work_order_id, work_order_no, profile_id, category_id, trigger_type, result,
|
||||
reason_code, reason_text, candidate_count, matched_count,
|
||||
actor_type, actor_id, duration_ms, payload_json, created_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10,
|
||||
$11, $12, $13, $14::jsonb, $15
|
||||
)
|
||||
`,
|
||||
[
|
||||
input.workOrderId ?? null,
|
||||
String(input.workOrderNo || '').slice(0, 80),
|
||||
input.profileId ?? null,
|
||||
input.categoryId ?? null,
|
||||
input.triggerType,
|
||||
input.result,
|
||||
String(input.reasonCode || '').slice(0, 80),
|
||||
String(input.reasonText || '').slice(0, 500),
|
||||
Math.max(0, Math.round(Number(input.candidateCount || 0))),
|
||||
Math.max(0, Math.round(Number(input.matchedCount || 0))),
|
||||
String(input.actorType || 'system').slice(0, 30),
|
||||
String(input.actorId || '').slice(0, 120),
|
||||
Math.max(0, Math.round(Number(input.durationMs || 0))),
|
||||
JSON.stringify(input.payload || {}),
|
||||
input.createdAt || nowIso(),
|
||||
],
|
||||
)
|
||||
} catch {
|
||||
// 日志失败不影响匹配主流程。
|
||||
}
|
||||
}
|
||||
|
||||
function mapLog(row: ConsignmentMatchLogRow) {
|
||||
let payload: Record<string, unknown> = {}
|
||||
if (row.payload_json && typeof row.payload_json === 'object') {
|
||||
payload = row.payload_json as Record<string, unknown>
|
||||
} else if (typeof row.payload_json === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(row.payload_json)
|
||||
payload = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}
|
||||
} catch {
|
||||
payload = {}
|
||||
}
|
||||
}
|
||||
return {
|
||||
logId: Number(row.id),
|
||||
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
|
||||
workOrderNo: row.work_order_no || '',
|
||||
profileId: row.profile_id ? Number(row.profile_id) : null,
|
||||
categoryId: row.category_id ? Number(row.category_id) : null,
|
||||
triggerType: row.trigger_type as ConsignmentMatchLogTrigger,
|
||||
result: row.result as ConsignmentMatchLogResult,
|
||||
reasonCode: row.reason_code || '',
|
||||
reasonText: row.reason_text || '',
|
||||
candidateCount: Number(row.candidate_count || 0),
|
||||
matchedCount: Number(row.matched_count || 0),
|
||||
actorType: row.actor_type || '',
|
||||
actorId: row.actor_id || '',
|
||||
durationMs: Number(row.duration_ms || 0),
|
||||
payload,
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listConsignmentMatchLogs(
|
||||
input: {
|
||||
page?: unknown
|
||||
pageSize?: unknown
|
||||
profileId?: unknown
|
||||
triggerType?: unknown
|
||||
result?: unknown
|
||||
keyword?: unknown
|
||||
from?: unknown
|
||||
to?: unknown
|
||||
} = {},
|
||||
) {
|
||||
const page = Math.max(1, Number(input.page || 1))
|
||||
const pageSize = Math.min(100, Math.max(1, Number(input.pageSize || 20)))
|
||||
const values: unknown[] = []
|
||||
const where: string[] = []
|
||||
|
||||
const profileId = Number(input.profileId || 0)
|
||||
if (profileId > 0) {
|
||||
values.push(profileId)
|
||||
where.push(`profile_id = $${values.length}`)
|
||||
}
|
||||
const triggerType = String(input.triggerType || '').trim()
|
||||
if (triggerType === 'auto' || triggerType === 'manual' || triggerType === 'rematch') {
|
||||
values.push(triggerType)
|
||||
where.push(`trigger_type = $${values.length}`)
|
||||
}
|
||||
const result = String(input.result || '').trim()
|
||||
if (result === 'matched' || result === 'failed' || result === 'skipped') {
|
||||
values.push(result)
|
||||
where.push(`result = $${values.length}`)
|
||||
}
|
||||
const keyword = String(input.keyword || '').trim()
|
||||
if (keyword) {
|
||||
values.push(`%${keyword}%`)
|
||||
where.push(`(work_order_no ILIKE $${values.length} OR work_order_id::text = $${values.length})`)
|
||||
}
|
||||
const from = String(input.from || '').trim()
|
||||
if (from) {
|
||||
values.push(from)
|
||||
where.push(`created_at >= $${values.length}::timestamptz`)
|
||||
}
|
||||
const to = String(input.to || '').trim()
|
||||
if (to) {
|
||||
values.push(to)
|
||||
where.push(`created_at <= $${values.length}::timestamptz`)
|
||||
}
|
||||
|
||||
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
|
||||
const countResult = await query<{ total: number | string }>(
|
||||
`SELECT COUNT(*)::int AS total FROM consignment_match_logs ${whereSql}`,
|
||||
values,
|
||||
)
|
||||
const total = Number(countResult.rows[0]?.total || 0)
|
||||
|
||||
const listValues = [...values, pageSize, (page - 1) * pageSize]
|
||||
const rows = await query<ConsignmentMatchLogRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM consignment_match_logs
|
||||
${whereSql}
|
||||
ORDER BY id DESC
|
||||
LIMIT $${values.length + 1} OFFSET $${values.length + 2}
|
||||
`,
|
||||
listValues,
|
||||
)
|
||||
|
||||
return {
|
||||
items: rows.rows.map(mapLog),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
}
|
||||
@@ -91,10 +91,10 @@ const suffix = `${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`
|
||||
const pricing = {
|
||||
priceType: 'template_discount' as const,
|
||||
discount: 10,
|
||||
fixedPriceFen: 0,
|
||||
minusFen: 0,
|
||||
allowWorkerPrice: true,
|
||||
minPriceFen: 1000,
|
||||
missingPricePolicy: 'manual' as const,
|
||||
missingPricePolicy: 'block' as const,
|
||||
}
|
||||
|
||||
const specs = [
|
||||
@@ -204,7 +204,6 @@ const profileConfig = {
|
||||
inventoryLimit: { enabled: false, maxActiveQuantity: 0 },
|
||||
matching: {
|
||||
mode: 'auto',
|
||||
autoMatchEnabled: true,
|
||||
exactOnly: true,
|
||||
preferLowReward: true,
|
||||
},
|
||||
@@ -436,12 +435,18 @@ async function main() {
|
||||
assert(bad.errors.length > 0, '高于建议价必须报错')
|
||||
})
|
||||
|
||||
await check('价格:固定价 12000 高于模板价 10000 必须拒绝', () => {
|
||||
const result = resolveWorkerConsignmentPrice({
|
||||
pricing: { ...pricing, priceType: 'fixed', fixedPriceFen: 12000 },
|
||||
await check('价格:固定下调 2000 分后售价 8000,高于建议价必须拒绝', () => {
|
||||
const result = computeConsignmentPrice({
|
||||
pricing: { ...pricing, priceType: 'template_minus', minusFen: 2000 },
|
||||
templatePriceFen: 10000,
|
||||
})
|
||||
assert(result.errors.length > 0, '固定价高于模板价必须报错')
|
||||
assert(result.priceFen === 8000, `实际 ${result.priceFen}`)
|
||||
const above = resolveWorkerConsignmentPrice({
|
||||
pricing: { ...pricing, priceType: 'template_minus', minusFen: 2000 },
|
||||
templatePriceFen: 10000,
|
||||
submittedPriceFen: 9000,
|
||||
})
|
||||
assert(above.errors.length > 0, '高于建议价必须报错')
|
||||
})
|
||||
|
||||
await check('价格:allowWorkerPrice=false 时按系统建议价发布不应被拦截', () => {
|
||||
@@ -506,6 +511,7 @@ async function main() {
|
||||
brillianceCount: '1',
|
||||
},
|
||||
sellerContact: `wx_${suffix}`,
|
||||
sellerPhone: '13800138000',
|
||||
characterName: '测试角色',
|
||||
characterId: '123456',
|
||||
screenshotsJson: ['https://example.com/screenshot.png'],
|
||||
@@ -567,13 +573,14 @@ async function main() {
|
||||
profileId,
|
||||
quantity: 1,
|
||||
specValues: {
|
||||
system: '安卓',
|
||||
system: '苹果',
|
||||
serverZone: '微信区',
|
||||
series: '暗潮',
|
||||
diamondLevel: '1',
|
||||
brillianceCount: '1',
|
||||
},
|
||||
sellerContact: `wx_${suffix}`,
|
||||
sellerPhone: '13800138000',
|
||||
characterName: '测试角色2',
|
||||
characterId: '654321',
|
||||
screenshotsJson: ['https://example.com/screenshot2.png'],
|
||||
@@ -615,6 +622,7 @@ async function main() {
|
||||
brillianceCount: '1',
|
||||
},
|
||||
sellerContact: `wx_${suffix}`,
|
||||
sellerPhone: '13800138000',
|
||||
characterName: '穷角色',
|
||||
characterId: '999999',
|
||||
screenshotsJson: ['https://example.com/poor.png'],
|
||||
@@ -1081,6 +1089,7 @@ async function main() {
|
||||
brillianceCount: '1',
|
||||
},
|
||||
sellerContact: `wx_gift_${label}_${suffix}`,
|
||||
sellerPhone: '13800138000',
|
||||
characterName: `赠礼卖家${label}`,
|
||||
characterId: `GIFT${label}${suffix}`.slice(0, 20),
|
||||
screenshotsJson: ['https://example.com/gift-item.png'],
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
evaluateInventoryMatch,
|
||||
inventoryMatchesProfile,
|
||||
isRematchExcludedInventory,
|
||||
resolveProfileForWorkOrder,
|
||||
} from './consignment-match-engine-service.js'
|
||||
import type { ConsignmentProfileSnapshot } from './consignment-profile-service.js'
|
||||
@@ -302,6 +303,8 @@ export function buildPendingConsignmentMatchPairs(input: {
|
||||
const matchedInventoryIds = new Set<number>()
|
||||
for (const { order, profile, remainingQuantity, totalQuantity } of preparedOrders) {
|
||||
for (const inventory of inventories) {
|
||||
// 超时重新匹配的排除项:看板同样不能把它算成可匹配。
|
||||
if (isRematchExcludedInventory(order, inventory)) continue
|
||||
let matchType: 'exact' | 'partial' | 'conflict'
|
||||
let score: number
|
||||
let reason: string
|
||||
|
||||
@@ -13,9 +13,11 @@ test('寄卖配置:缺省值必须可安全用于发布(保证金/服务费
|
||||
const config = normalizeConsignmentProfileConfig({})
|
||||
assert.equal(config.deposit.enabled, false)
|
||||
assert.equal(config.serviceFee.enabled, false)
|
||||
assert.equal(config.matching.mode, 'manual')
|
||||
assert.equal(config.matching.mode, 'off')
|
||||
assert.equal(config.timeout.minutes, 30)
|
||||
assert.equal(config.internalMatch.delayMinutes, 30)
|
||||
assert.equal(config.priceDrop.enabled, true)
|
||||
assert.equal(config.priceDrop.hours, 24)
|
||||
assert.equal(config.fields.quantity, true)
|
||||
})
|
||||
|
||||
@@ -32,7 +34,7 @@ test('寄卖配置:非法枚举与越界数字回落到安全值', () => {
|
||||
assert.equal(config.deposit.unitAmountFen, 0)
|
||||
assert.equal(config.serviceFee.mode, 'percent')
|
||||
assert.equal(config.serviceFee.percent, 100)
|
||||
assert.equal(config.matching.mode, 'manual')
|
||||
assert.equal(config.matching.mode, 'off')
|
||||
assert.equal(config.timeout.policy, 'reopen')
|
||||
assert.equal(config.timeout.minutes, 0)
|
||||
assert.equal(config.priceDrop.hours, 1)
|
||||
|
||||
@@ -4,15 +4,24 @@ import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.js'
|
||||
import {
|
||||
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
|
||||
DEFAULT_CONSIGNMENT_PRICING,
|
||||
DEFAULT_CONSIGNMENT_SPECS,
|
||||
normalizeConsignmentIntakeFields,
|
||||
normalizeConsignmentPricing,
|
||||
normalizeConsignmentSpecs,
|
||||
type ConsignmentIntakeField,
|
||||
type ConsignmentPricingConfig,
|
||||
type ConsignmentSpecField,
|
||||
} from './consignment-spec-service.js'
|
||||
|
||||
export type { ConsignmentPricingConfig, ConsignmentSpecField } from './consignment-spec-service.js'
|
||||
export type {
|
||||
ConsignmentIntakeField,
|
||||
ConsignmentIntakeFieldType,
|
||||
ConsignmentIntakeValues,
|
||||
ConsignmentPricingConfig,
|
||||
ConsignmentSpecField,
|
||||
} from './consignment-spec-service.js'
|
||||
|
||||
export type ConsignmentDepositConfig = {
|
||||
enabled: boolean
|
||||
@@ -34,9 +43,8 @@ export type ConsignmentInventoryLimitConfig = {
|
||||
}
|
||||
|
||||
export type ConsignmentMatchingConfig = {
|
||||
/** manual = 只允许人工匹配;auto = 调度器自动匹配 */
|
||||
mode: 'manual' | 'auto'
|
||||
autoMatchEnabled: boolean
|
||||
/** off = 完全关闭匹配(新单直接进大厅);manual = 只允许人工匹配;auto = 调度器自动匹配 */
|
||||
mode: 'off' | 'manual' | 'auto'
|
||||
exactOnly: boolean
|
||||
preferLowReward: boolean
|
||||
}
|
||||
@@ -48,9 +56,9 @@ export type ConsignmentInternalMatchConfig = {
|
||||
}
|
||||
|
||||
export type ConsignmentTimeoutConfig = {
|
||||
/** 匹配成功进入代练中后的时限(分钟),默认 30 */
|
||||
/** 匹配成功进入代练中后的时限(分钟),默认 30(= 0.5 小时) */
|
||||
minutes: number
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
|
||||
}
|
||||
|
||||
export type ConsignmentPriceDropConfig = {
|
||||
@@ -87,6 +95,10 @@ export type ConsignmentProfileConfig = {
|
||||
internalMatch: ConsignmentInternalMatchConfig
|
||||
timeout: ConsignmentTimeoutConfig
|
||||
priceDrop: ConsignmentPriceDropConfig
|
||||
/** 联系方式与游戏凭据:按配置动态渲染;没有该字段时使用默认 7 项 */
|
||||
intake: {
|
||||
fields: ConsignmentIntakeField[]
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsignmentProfileRow = {
|
||||
@@ -148,8 +160,7 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
|
||||
maxActiveQuantity: 0,
|
||||
},
|
||||
matching: {
|
||||
mode: 'manual',
|
||||
autoMatchEnabled: false,
|
||||
mode: 'off',
|
||||
exactOnly: true,
|
||||
preferLowReward: true,
|
||||
},
|
||||
@@ -162,11 +173,14 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
|
||||
policy: 'reopen',
|
||||
},
|
||||
priceDrop: {
|
||||
enabled: false,
|
||||
hours: 72,
|
||||
enabled: true,
|
||||
hours: 24,
|
||||
mode: 'percent',
|
||||
value: 10,
|
||||
},
|
||||
intake: {
|
||||
fields: DEFAULT_CONSIGNMENT_INTAKE_FIELDS.map((field) => ({ ...field })),
|
||||
},
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
@@ -221,15 +235,29 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
|
||||
const timeout = record(source.timeout)
|
||||
const priceDrop = record(source.priceDrop)
|
||||
|
||||
const matchingMode = matching.mode === 'auto' ? 'auto' : 'manual'
|
||||
const matchingMode: ConsignmentMatchingConfig['mode'] =
|
||||
matching.mode === 'auto'
|
||||
? bool(matching.autoMatchEnabled, true)
|
||||
? 'auto'
|
||||
: 'manual'
|
||||
: matching.mode === 'manual'
|
||||
? 'manual'
|
||||
: 'off'
|
||||
const depositMode = deposit.mode === 'per_quantity' ? 'per_quantity' : 'fixed'
|
||||
const serviceFeeMode = serviceFee.mode === 'fixed' ? 'fixed' : 'percent'
|
||||
const priceDropMode = priceDrop.mode === 'fixed' ? 'fixed' : 'percent'
|
||||
const timeoutPolicy =
|
||||
timeout.policy === 'cancel_release' || timeout.policy === 'cancel_deduct'
|
||||
const timeoutPolicy: ConsignmentTimeoutConfig['policy'] =
|
||||
timeout.policy === 'cancel_release' ||
|
||||
timeout.policy === 'cancel_deduct' ||
|
||||
timeout.policy === 'rematch_release' ||
|
||||
timeout.policy === 'rematch_deduct'
|
||||
? timeout.policy
|
||||
: 'reopen'
|
||||
const specKeys = new Set(specs.map((spec) => spec.key))
|
||||
const intakeSource = record(source.intake)
|
||||
const intakeFields = normalizeConsignmentIntakeFields(
|
||||
intakeSource.fields ?? source.intakeFields ?? source.intake_fields,
|
||||
)
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
@@ -267,7 +295,6 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
|
||||
},
|
||||
matching: {
|
||||
mode: matchingMode,
|
||||
autoMatchEnabled: matchingMode === 'auto' ? bool(matching.autoMatchEnabled, true) : false,
|
||||
exactOnly: bool(matching.exactOnly, true),
|
||||
preferLowReward: bool(matching.preferLowReward, true),
|
||||
},
|
||||
@@ -280,11 +307,14 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
|
||||
policy: timeoutPolicy,
|
||||
},
|
||||
priceDrop: {
|
||||
enabled: bool(priceDrop.enabled, false),
|
||||
hours: integer(priceDrop.hours, 72, 1, 24 * 365),
|
||||
enabled: bool(priceDrop.enabled, true),
|
||||
hours: integer(priceDrop.hours, 24, 1, 24 * 365),
|
||||
mode: priceDropMode,
|
||||
value: integer(priceDrop.value, 10, 0, 100_000_000),
|
||||
},
|
||||
intake: {
|
||||
fields: intakeFields,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildConsignmentSpecText,
|
||||
computeConsignmentPrice,
|
||||
evaluateConsignmentSpecMatch,
|
||||
normalizeConsignmentIntakeValues,
|
||||
normalizeConsignmentSpecValues,
|
||||
normalizeConsignmentSpecs,
|
||||
parseConsignmentSpecValues,
|
||||
@@ -22,10 +23,10 @@ function buildPricing(overrides: Partial<ConsignmentPricingConfig> = {}): Consig
|
||||
return {
|
||||
priceType: 'template_discount',
|
||||
discount: 10,
|
||||
fixedPriceFen: 0,
|
||||
minusFen: 0,
|
||||
allowWorkerPrice: true,
|
||||
minPriceFen: 0,
|
||||
missingPricePolicy: 'manual',
|
||||
missingPricePolicy: 'block',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -121,7 +122,7 @@ test('动态规格:默认配置没有任何字段,价格默认十折', () =>
|
||||
assert.equal(config.pricing.priceType, 'template_discount')
|
||||
assert.equal(config.pricing.discount, 10)
|
||||
assert.equal(config.pricing.allowWorkerPrice, true)
|
||||
assert.equal(config.pricing.missingPricePolicy, 'manual')
|
||||
assert.equal(config.pricing.missingPricePolicy, 'block')
|
||||
assert.equal(config.fields.quantity, true)
|
||||
assert.equal(config.fields.system, false)
|
||||
})
|
||||
@@ -272,27 +273,29 @@ test('动态价格:模板折扣价与改价上限等于建议价', () => {
|
||||
assert.equal(result.source, 'template')
|
||||
})
|
||||
|
||||
test('动态价格:固定价格可用,且不能高于接单模板价', () => {
|
||||
test('动态价格:模板价固定下调,且无模板价时不可寄卖', () => {
|
||||
const normal = computeConsignmentPrice({
|
||||
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 10000 }),
|
||||
pricing: buildPricing({ priceType: 'template_minus', minusFen: 3000 }),
|
||||
templatePriceFen: 13000,
|
||||
})
|
||||
assert.equal(normal.priceFen, 10000)
|
||||
assert.equal(normal.capFen, 10000)
|
||||
assert.equal(normal.source, 'fixed')
|
||||
|
||||
const above = resolveWorkerConsignmentPrice({
|
||||
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 15000 }),
|
||||
templatePriceFen: 13000,
|
||||
})
|
||||
assert.ok(above.errors.some((error) => error.includes('固定价格不能高于接单模板价')))
|
||||
assert.equal(normal.source, 'template')
|
||||
|
||||
const noTemplate = resolveWorkerConsignmentPrice({
|
||||
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 8000 }),
|
||||
pricing: buildPricing({ priceType: 'template_minus', minusFen: 3000 }),
|
||||
templatePriceFen: 0,
|
||||
submittedPriceFen: 8000,
|
||||
})
|
||||
assert.deepEqual(noTemplate.errors, [])
|
||||
assert.equal(noTemplate.priceFen, 8000)
|
||||
assert.equal(noTemplate.priceFen, 0)
|
||||
assert.ok(noTemplate.errors.some((error) => error.includes('暂无模板价')))
|
||||
|
||||
const overDown = resolveWorkerConsignmentPrice({
|
||||
pricing: buildPricing({ priceType: 'template_minus', minusFen: 13000 }),
|
||||
templatePriceFen: 13000,
|
||||
submittedPriceFen: 1000,
|
||||
})
|
||||
assert.ok(overDown.errors.length > 0)
|
||||
})
|
||||
|
||||
test('动态价格:打手改价只能低于系统建议价,且受最低价约束', () => {
|
||||
@@ -321,21 +324,52 @@ test('动态价格:打手改价只能低于系统建议价,且受最低价
|
||||
assert.ok(belowMin.errors.some((error) => error.includes('不能少于')))
|
||||
})
|
||||
|
||||
test('动态价格:没有模板价时允许手填,block 策略则拦截', () => {
|
||||
const manual = resolveWorkerConsignmentPrice({
|
||||
pricing: buildPricing({ missingPricePolicy: 'manual' }),
|
||||
templatePriceFen: 0,
|
||||
submittedPriceFen: 8000,
|
||||
})
|
||||
assert.deepEqual(manual.errors, [])
|
||||
assert.equal(manual.priceFen, 8000)
|
||||
test('动态价格:没有模板价时一律阻止发布(不再允许手填)', () => {
|
||||
for (const pricing of [buildPricing(), buildPricing({ missingPricePolicy: 'manual' })]) {
|
||||
const blocked = resolveWorkerConsignmentPrice({
|
||||
pricing,
|
||||
templatePriceFen: 0,
|
||||
submittedPriceFen: 8000,
|
||||
})
|
||||
assert.equal(blocked.priceFen, 0)
|
||||
assert.ok(blocked.errors.some((error) => error.includes('暂无模板价')))
|
||||
}
|
||||
})
|
||||
|
||||
const blocked = resolveWorkerConsignmentPrice({
|
||||
pricing: buildPricing({ missingPricePolicy: 'block' }),
|
||||
templatePriceFen: 0,
|
||||
submittedPriceFen: 8000,
|
||||
test('收货信息:默认 7 个字段,手机号必填校验,显式空数组表示删除全部', () => {
|
||||
const config = normalizeConsignmentProfileConfig({})
|
||||
assert.equal(config.intake.fields.length, 7)
|
||||
assert.ok(
|
||||
config.intake.fields.some(
|
||||
(field) => field.key === 'sellerPhone' && field.type === 'phone' && field.required,
|
||||
),
|
||||
)
|
||||
|
||||
const missing = normalizeConsignmentIntakeValues(config.intake.fields, {})
|
||||
assert.ok(missing.errors.some((error) => error.includes('对接客服')))
|
||||
|
||||
const ok = normalizeConsignmentIntakeValues(config.intake.fields, {
|
||||
intakeValues: {
|
||||
contactSupporterId: 3,
|
||||
sellerContact: 'wx_1',
|
||||
sellerPhone: '13800138000',
|
||||
characterName: '角色',
|
||||
characterId: '123456',
|
||||
screenshots: ['https://example.com/1.png'],
|
||||
},
|
||||
})
|
||||
assert.ok(blocked.errors.length > 0)
|
||||
assert.deepEqual(ok.errors, [])
|
||||
assert.equal(ok.values.sellerPhone, '13800138000')
|
||||
assert.deepEqual(ok.values.screenshots, ['https://example.com/1.png'])
|
||||
|
||||
const badPhone = normalizeConsignmentIntakeValues(config.intake.fields, {
|
||||
...ok.values,
|
||||
sellerPhone: '123',
|
||||
})
|
||||
assert.ok(badPhone.errors.some((error) => error.includes('手机号')))
|
||||
|
||||
const empty = normalizeConsignmentProfileConfig({ intake: { fields: [] } })
|
||||
assert.equal(empty.intake.fields.length, 0)
|
||||
})
|
||||
|
||||
test('通用匹配:精确、部分、冲突、未知与通配', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - 没有任何「内置字段」:配置初始 specs 为空,字段全部由运营在寄卖配置里维护;
|
||||
* 苏尔南手枪的五个字段由迁移 131 作为种子配置写入;
|
||||
* - 字段类型支持:文本 / 长文本 / 下拉 / 单选 / 数字 / 日期;
|
||||
* - 价格只有两种:接单模板价打折、固定价格;打手只能按系统建议价往下调;
|
||||
* - 价格只有两种:接单模板价打折、模板价固定下调;打手只能按系统建议价往下调;
|
||||
* - 旧列兼容:字段 key 仍为 system/serverZone/series/diamondLevel/brillianceCount 时,
|
||||
* 继续同步旧列并沿用旧匹配器,保证存量库存与工单不回归。
|
||||
*/
|
||||
@@ -44,17 +44,123 @@ export type ConsignmentSpecField = {
|
||||
}
|
||||
|
||||
export type ConsignmentPricingConfig = {
|
||||
/** template_discount = 接单模板价的几折;fixed = 固定价格 */
|
||||
priceType: 'template_discount' | 'fixed'
|
||||
/** template_discount = 接单模板价的几折;template_minus = 模板价固定下调 */
|
||||
priceType: 'template_discount' | 'template_minus'
|
||||
/** 几折,范围 0.1 - 10,例如 9.5 表示 95% */
|
||||
discount: number
|
||||
fixedPriceFen: number
|
||||
/** 固定下调金额(分):售价 = 接单模板价 - minusFen */
|
||||
minusFen: number
|
||||
allowWorkerPrice: boolean
|
||||
minPriceFen: number
|
||||
/** 没有模板价时:允许打手手填 / 阻止发布 */
|
||||
/** 历史字段:现在统一按 block 处理(没有模板价一律阻止发布) */
|
||||
missingPricePolicy: 'block' | 'manual'
|
||||
}
|
||||
|
||||
/** 收货信息(联系方式与游戏凭据)动态字段类型。 */
|
||||
export type ConsignmentIntakeFieldType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'phone'
|
||||
| 'number'
|
||||
| 'image'
|
||||
| 'contact_supporter'
|
||||
|
||||
export type ConsignmentIntakeField = {
|
||||
key: string
|
||||
label: string
|
||||
type: ConsignmentIntakeFieldType
|
||||
required: boolean
|
||||
placeholder: string
|
||||
helpText: string
|
||||
/** 图片字段最多上传张数 */
|
||||
maxCount: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type ConsignmentIntakeValues = Record<string, string | number | string[]>
|
||||
|
||||
export const DEFAULT_CONSIGNMENT_INTAKE_FIELDS: ConsignmentIntakeField[] = [
|
||||
{
|
||||
key: 'contactSupporterId',
|
||||
label: '对接客服',
|
||||
type: 'contact_supporter',
|
||||
required: true,
|
||||
placeholder: '请选择对接客服',
|
||||
helpText: '用于后续提成归属与联系',
|
||||
maxCount: 0,
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
key: 'sellerContact',
|
||||
label: '微信号 / QQ号',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '方便派单与紧急联系',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
key: 'sellerPhone',
|
||||
label: '联系电话',
|
||||
type: 'phone',
|
||||
required: true,
|
||||
placeholder: '手机号',
|
||||
helpText: '默认使用当前接单账号绑定手机号',
|
||||
maxCount: 0,
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
key: 'characterName',
|
||||
label: '游戏内角色名',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '局内加好友丢枪用',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 40,
|
||||
},
|
||||
{
|
||||
key: 'characterId',
|
||||
label: '角色数字ID',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '如 1089238472',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 50,
|
||||
},
|
||||
{
|
||||
key: 'screenshots',
|
||||
label: '游戏主页截图',
|
||||
type: 'image',
|
||||
required: true,
|
||||
placeholder: '',
|
||||
helpText: '须包含角色名、ID 及仓库/手枪详情,最多 4 张',
|
||||
maxCount: 4,
|
||||
sortOrder: 60,
|
||||
},
|
||||
{
|
||||
key: 'sellerNote',
|
||||
label: '其他留言与补充',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
placeholder: '配件、子弹、涂装或在线时段等(选填)',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 70,
|
||||
},
|
||||
]
|
||||
|
||||
const INTAKE_FIELD_TYPES: ConsignmentIntakeFieldType[] = [
|
||||
'text',
|
||||
'textarea',
|
||||
'phone',
|
||||
'number',
|
||||
'image',
|
||||
'contact_supporter',
|
||||
]
|
||||
|
||||
export type ConsignmentSpecValues = Record<string, string | number>
|
||||
|
||||
export type ConsignmentSpecValidationResult = {
|
||||
@@ -133,10 +239,10 @@ export const DEFAULT_CONSIGNMENT_SPECS: ConsignmentSpecField[] = []
|
||||
export const DEFAULT_CONSIGNMENT_PRICING: ConsignmentPricingConfig = {
|
||||
priceType: 'template_discount',
|
||||
discount: 10,
|
||||
fixedPriceFen: 0,
|
||||
minusFen: 0,
|
||||
allowWorkerPrice: true,
|
||||
minPriceFen: 0,
|
||||
missingPricePolicy: 'manual',
|
||||
missingPricePolicy: 'block',
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
@@ -301,30 +407,136 @@ export function normalizeConsignmentSpecs(
|
||||
export function normalizeConsignmentPricing(value: unknown): ConsignmentPricingConfig {
|
||||
const source = record(value)
|
||||
const rawPriceType = text(source.priceType ?? source.price_type, 30)
|
||||
// 兼容上一版:source=template 视为 10 折;source=profile 尽量映射成固定价。
|
||||
const legacySource = text(source.source, 20)
|
||||
// 绝对固定价已下线:旧配置(fixed / source=profile)统一按 10 折兜底,
|
||||
// 历史快照里的旧价格不会被改写,只影响后续新保存/新发布的配置。
|
||||
const priceType: ConsignmentPricingConfig['priceType'] =
|
||||
rawPriceType === 'fixed' || legacySource === 'profile' ? 'fixed' : 'template_discount'
|
||||
const legacyFixedPriceFen = integer(
|
||||
source.fixedPriceFen ?? source.fixed_price_fen ?? source.basePriceFen ?? source.base_price_fen,
|
||||
0,
|
||||
0,
|
||||
100_000_000,
|
||||
)
|
||||
const missingPricePolicyRaw = text(source.missingPricePolicy ?? source.missing_price_policy, 30)
|
||||
rawPriceType === 'template_minus' ? 'template_minus' : 'template_discount'
|
||||
return {
|
||||
priceType,
|
||||
discount: decimal(source.discount, 10, 0.1, 10),
|
||||
fixedPriceFen: legacyFixedPriceFen,
|
||||
minusFen: integer(source.minusFen ?? source.minus_fen, 0, 0, 100_000_000),
|
||||
allowWorkerPrice: bool(
|
||||
source.allowWorkerPrice ?? source.allow_worker_price,
|
||||
DEFAULT_CONSIGNMENT_PRICING.allowWorkerPrice,
|
||||
),
|
||||
minPriceFen: integer(source.minPriceFen ?? source.min_price_fen, 0, 0, 100_000_000),
|
||||
missingPricePolicy: missingPricePolicyRaw === 'block' ? 'block' : 'manual',
|
||||
// 没有模板价一律阻止发布,历史 manual 配置也会被收敛为 block。
|
||||
missingPricePolicy: 'block',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIntakeFieldType(value: unknown): ConsignmentIntakeFieldType {
|
||||
const raw = text(value, 30).toLowerCase()
|
||||
return INTAKE_FIELD_TYPES.includes(raw as ConsignmentIntakeFieldType)
|
||||
? (raw as ConsignmentIntakeFieldType)
|
||||
: 'text'
|
||||
}
|
||||
|
||||
function normalizeIntakeField(value: unknown, index: number): ConsignmentIntakeField | null {
|
||||
const source = record(value)
|
||||
const key = text(source.key, 40)
|
||||
if (!key || !/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(key)) return null
|
||||
const type = normalizeIntakeFieldType(source.type)
|
||||
return {
|
||||
key,
|
||||
label: text(source.label, 80) || key,
|
||||
type,
|
||||
required: bool(source.required, true),
|
||||
placeholder: text(source.placeholder, 120),
|
||||
helpText: text(source.helpText ?? source.help_text, 300),
|
||||
maxCount: type === 'image' ? integer(source.maxCount ?? source.max_count, 4, 1, 12) : 0,
|
||||
sortOrder: integer(source.sortOrder ?? source.sort_order, (index + 1) * 10, 0, 100_000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化「联系方式与游戏凭据」动态字段。
|
||||
* - 配置里没有 intake 字段(历史配置)→ 使用 7 个默认字段;
|
||||
* - 显式传空数组 → 视为运营删光了所有字段。
|
||||
*/
|
||||
export function normalizeConsignmentIntakeFields(value: unknown): ConsignmentIntakeField[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return DEFAULT_CONSIGNMENT_INTAKE_FIELDS.map((field) => ({ ...field }))
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const result: ConsignmentIntakeField[] = []
|
||||
value.forEach((item, index) => {
|
||||
const field = normalizeIntakeField(item, index)
|
||||
if (!field || seen.has(field.key)) return
|
||||
seen.add(field.key)
|
||||
result.push(field)
|
||||
})
|
||||
return result.sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key))
|
||||
}
|
||||
|
||||
export type ConsignmentIntakeValidationResult = {
|
||||
values: ConsignmentIntakeValues
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/** 校验并归一化打手填写的收货信息;必填与手机号格式在这里统一收口。 */
|
||||
export function normalizeConsignmentIntakeValues(
|
||||
fields: ConsignmentIntakeField[],
|
||||
input: unknown,
|
||||
): ConsignmentIntakeValidationResult {
|
||||
const source = record(input)
|
||||
const nested = record(source.intakeValues ?? source.intake_values ?? source.intakeValuesJson)
|
||||
const values: ConsignmentIntakeValues = {}
|
||||
const errors: string[] = []
|
||||
|
||||
for (const field of fields) {
|
||||
const nestedValue = nested[field.key]
|
||||
const raw =
|
||||
nestedValue !== undefined && nestedValue !== null && String(nestedValue).trim() !== ''
|
||||
? nestedValue
|
||||
: source[field.key]
|
||||
|
||||
if (field.type === 'image') {
|
||||
const list = Array.isArray(raw) ? raw : []
|
||||
const urls = list
|
||||
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
.map((item) => item.trim())
|
||||
.slice(0, Math.max(1, field.maxCount || 4))
|
||||
if (urls.length === 0) {
|
||||
if (field.required) errors.push(`请上传「${field.label}」`)
|
||||
continue
|
||||
}
|
||||
values[field.key] = urls
|
||||
continue
|
||||
}
|
||||
|
||||
const textValue = raw === undefined || raw === null ? '' : String(raw).trim()
|
||||
if (!textValue) {
|
||||
if (field.required) errors.push(`请填写「${field.label}」`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (field.type === 'phone') {
|
||||
if (!/^1[3-9]\d{9}$/.test(textValue)) {
|
||||
errors.push(`「${field.label}」请填写正确的 11 位手机号`)
|
||||
continue
|
||||
}
|
||||
values[field.key] = textValue
|
||||
continue
|
||||
}
|
||||
|
||||
if (field.type === 'number' || field.type === 'contact_supporter') {
|
||||
const numeric = Number(textValue)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
errors.push(`「${field.label}」必须填写有效数字`)
|
||||
continue
|
||||
}
|
||||
values[field.key] = Math.round(numeric)
|
||||
continue
|
||||
}
|
||||
|
||||
values[field.key] =
|
||||
field.type === 'textarea' ? textValue.slice(0, 2000) : textValue.slice(0, 200)
|
||||
}
|
||||
|
||||
return { values, errors }
|
||||
}
|
||||
|
||||
function builtInAliases(key: string): string[] {
|
||||
if (key === 'system') return ['system', 'os']
|
||||
if (key === 'serverZone') return ['serverZone', 'server_zone', 'zone']
|
||||
@@ -549,7 +761,8 @@ export function buildConsignmentItemName(input: {
|
||||
/**
|
||||
* 计算系统建议价与改价上限。
|
||||
* - template_discount:接单模板价 × 折扣;
|
||||
* - fixed:固定价格;
|
||||
* - template_minus:接单模板价 − 固定下调金额;
|
||||
* - 没有模板价时一律返回 0(发布侧会直接阻止)。
|
||||
* - 改价上限 = 配置后的建议价(打手只能往下调)。
|
||||
*/
|
||||
export function computeConsignmentPrice(input: {
|
||||
@@ -562,20 +775,23 @@ export function computeConsignmentPrice(input: {
|
||||
let priceFen = 0
|
||||
let source: 'template' | 'fixed' | 'none' = 'none'
|
||||
|
||||
if (pricing.priceType === 'fixed') {
|
||||
priceFen = Math.max(0, Math.round(Number(pricing.fixedPriceFen || 0)))
|
||||
if (priceFen > 0) {
|
||||
source = 'fixed'
|
||||
breakdown.push({ label: '固定价格', amountFen: priceFen })
|
||||
}
|
||||
} else if (templatePriceFen > 0) {
|
||||
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
|
||||
priceFen = Math.round((templatePriceFen * discount) / 10)
|
||||
if (templatePriceFen > 0) {
|
||||
source = 'template'
|
||||
breakdown.push({ label: '接单模板价', amountFen: templatePriceFen })
|
||||
if (discount !== 10) {
|
||||
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - templatePriceFen })
|
||||
if (pricing.priceType === 'template_minus') {
|
||||
const minusFen = Math.max(0, Math.round(Number(pricing.minusFen || 0)))
|
||||
priceFen = Math.max(0, templatePriceFen - minusFen)
|
||||
if (minusFen > 0) {
|
||||
breakdown.push({ label: '固定下调', amountFen: -minusFen })
|
||||
}
|
||||
} else {
|
||||
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
|
||||
priceFen = Math.round((templatePriceFen * discount) / 10)
|
||||
if (discount !== 10) {
|
||||
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - templatePriceFen })
|
||||
}
|
||||
}
|
||||
if (priceFen <= 0) source = 'none'
|
||||
}
|
||||
|
||||
priceFen = Math.max(0, Math.round(priceFen))
|
||||
@@ -590,9 +806,9 @@ export function computeConsignmentPrice(input: {
|
||||
|
||||
/**
|
||||
* 打手提交价格时决定最终售价:
|
||||
* - 默认用系统建议价(模板折扣价 / 固定价);
|
||||
* - 默认用系统建议价(模板折扣价 / 模板价固定下调);
|
||||
* - 允许改价时只能下调,不能高于建议价;
|
||||
* - 没有建议价时按 missingPricePolicy 处理(manual=手填,仍受最低价约束)。
|
||||
* - 没有模板价时一律阻止发布(不再允许手填兜底)。
|
||||
*/
|
||||
export function resolveWorkerConsignmentPrice(input: {
|
||||
pricing: ConsignmentPricingConfig
|
||||
@@ -605,33 +821,19 @@ export function resolveWorkerConsignmentPrice(input: {
|
||||
const errors: string[] = []
|
||||
let priceFen = computed.priceFen
|
||||
|
||||
if (
|
||||
input.pricing.priceType === 'fixed' &&
|
||||
computed.templatePriceFen > 0 &&
|
||||
computed.priceFen > computed.templatePriceFen
|
||||
) {
|
||||
errors.push(`固定价格不能高于接单模板价 ¥${(computed.templatePriceFen / 100).toFixed(2)}`)
|
||||
}
|
||||
|
||||
if (priceFen > 0) {
|
||||
if (submittedPriceFen > 0) {
|
||||
// 前端发布时会回填系统建议价;禁用改价只应拦截“偏离建议价”的提交,
|
||||
// 不能把按建议价直接发布也一起拦掉。
|
||||
const usesSystemSuggestedPrice = submittedPriceFen === computed.capFen
|
||||
if (!input.pricing.allowWorkerPrice && !usesSystemSuggestedPrice) {
|
||||
errors.push('当前寄卖配置不允许修改售价')
|
||||
} else if (submittedPriceFen > computed.capFen) {
|
||||
errors.push(`寄售价格不能高于系统建议价 ¥${(computed.capFen / 100).toFixed(2)}`)
|
||||
} else {
|
||||
priceFen = submittedPriceFen
|
||||
}
|
||||
if (priceFen <= 0) {
|
||||
errors.push('该规格暂无模板价,暂不能寄卖,请联系客服补充模板价格')
|
||||
} else if (submittedPriceFen > 0) {
|
||||
// 前端发布时会回填系统建议价;禁用改价只应拦截“偏离建议价”的提交,
|
||||
// 不能把按建议价直接发布也一起拦掉。
|
||||
const usesSystemSuggestedPrice = submittedPriceFen === computed.capFen
|
||||
if (!input.pricing.allowWorkerPrice && !usesSystemSuggestedPrice) {
|
||||
errors.push('当前寄卖配置不允许修改售价')
|
||||
} else if (submittedPriceFen > computed.capFen) {
|
||||
errors.push(`寄售价格不能高于系统建议价 ¥${(computed.capFen / 100).toFixed(2)}`)
|
||||
} else {
|
||||
priceFen = submittedPriceFen
|
||||
}
|
||||
} else if (input.pricing.missingPricePolicy === 'block') {
|
||||
errors.push('该规格暂无价格,请联系客服补充价格后再提报')
|
||||
} else if (submittedPriceFen <= 0) {
|
||||
errors.push('该规格暂无模板价,请填写寄售价格')
|
||||
} else {
|
||||
priceFen = submittedPriceFen
|
||||
}
|
||||
|
||||
if (priceFen > 0 && priceFen < minPriceFen) {
|
||||
|
||||
@@ -36,10 +36,10 @@ function radioField(
|
||||
const PRICING: ConsignmentPricingConfig = {
|
||||
priceType: 'template_discount',
|
||||
discount: 10,
|
||||
fixedPriceFen: 0,
|
||||
minusFen: 0,
|
||||
allowWorkerPrice: true,
|
||||
minPriceFen: 0,
|
||||
missingPricePolicy: 'manual',
|
||||
missingPricePolicy: 'block',
|
||||
}
|
||||
|
||||
function baseSpecs(): ConsignmentSpecField[] {
|
||||
|
||||
@@ -33,11 +33,13 @@ import {
|
||||
} from './consignment-profile-service.js'
|
||||
import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
|
||||
import {
|
||||
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
|
||||
DEFAULT_CONSIGNMENT_PRICING,
|
||||
buildConsignmentItemName,
|
||||
buildConsignmentSpecKey,
|
||||
buildConsignmentSpecText,
|
||||
computeConsignmentPrice,
|
||||
normalizeConsignmentIntakeValues,
|
||||
normalizeConsignmentSpecValues,
|
||||
resolveLegacyConsignmentColumns,
|
||||
resolveWorkerConsignmentPrice,
|
||||
@@ -294,9 +296,6 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
})
|
||||
}
|
||||
|
||||
// 对接客服必填:发布时选定,后续按人统计提成 / 方便联系。
|
||||
const contactSupporter = await resolveEnabledConsignmentContact(payload.contactSupporterId)
|
||||
|
||||
// 分类级寄卖配置:新链路必须带 profileId;存量调用不带则沿用旧逻辑。
|
||||
const profileIdInput = Number(payload.profileId ?? payload.profile_id ?? 0)
|
||||
const profile =
|
||||
@@ -348,39 +347,50 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
const { system, serverZone, series, diamondLevel, brillianceCount } =
|
||||
resolveLegacyConsignmentColumns(specValues)
|
||||
|
||||
const contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim()
|
||||
if (!contactInfo) {
|
||||
throw createHttpError('请填写微信号或QQ号以便联系与核实', {
|
||||
// 联系方式与游戏凭据:完全按寄卖配置动态渲染/校验;历史调用回退默认 7 项。
|
||||
const intakeFields = profile?.config.intake.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
|
||||
const intakeInput = {
|
||||
...payload,
|
||||
sellerPhone: String(payload.sellerPhone || worker.phone || '').trim(),
|
||||
intakeValues: {
|
||||
...(payload.intakeValues &&
|
||||
typeof payload.intakeValues === 'object' &&
|
||||
!Array.isArray(payload.intakeValues)
|
||||
? (payload.intakeValues as Record<string, unknown>)
|
||||
: {}),
|
||||
...(Array.isArray(payload.screenshotsJson) ? { screenshots: payload.screenshotsJson } : {}),
|
||||
},
|
||||
}
|
||||
const intakeValidation = normalizeConsignmentIntakeValues(intakeFields, intakeInput)
|
||||
if (intakeValidation.errors.length > 0) {
|
||||
throw createHttpError(intakeValidation.errors[0]!, {
|
||||
statusCode: 400,
|
||||
errorCode: 'contact_required',
|
||||
errorCode: 'consignment_intake_invalid',
|
||||
})
|
||||
}
|
||||
const intakeValues = intakeValidation.values
|
||||
const contactSupporterIdInput = Number(
|
||||
intakeValues.contactSupporterId || payload.contactSupporterId || 0,
|
||||
)
|
||||
// 对接客服字段被运营删掉时不校验提成归属。
|
||||
const contactSupporter =
|
||||
contactSupporterIdInput > 0
|
||||
? await resolveEnabledConsignmentContact(contactSupporterIdInput)
|
||||
: null
|
||||
const contactInfo = String(
|
||||
intakeValues.sellerContact ?? payload.sellerContact ?? payload.contactInfo ?? '',
|
||||
).trim()
|
||||
const characterName = String(intakeValues.characterName ?? payload.characterName ?? '').trim()
|
||||
const characterId = String(intakeValues.characterId ?? payload.characterId ?? '').trim()
|
||||
const screenshots = Array.isArray(intakeValues.screenshots)
|
||||
? intakeValues.screenshots
|
||||
: Array.isArray(payload.screenshotsJson)
|
||||
? payload.screenshotsJson
|
||||
: []
|
||||
const sellerNote = String(intakeValues.sellerNote ?? payload.sellerNote ?? '').trim()
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
|
||||
// 寄售价:按配置算出系统建议价(模板折扣价 / 固定价),打手只能往下调。
|
||||
// 没有建议价时按 missingPricePolicy 处理(manual = 允许打手手填,仍受最低价约束)。
|
||||
// 寄售价:按配置算出系统建议价(模板折扣价 / 模板价固定下调),打手只能往下调。
|
||||
// 没有模板价时一律阻止发布。
|
||||
const priceSuggestion = await getWorkerConsignmentPriceSuggestion({
|
||||
profileId: profile?.profileId,
|
||||
specs,
|
||||
@@ -408,7 +418,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
const depositAmountFen = profile?.config.deposit.enabled
|
||||
? buildDepositAmountFen(profile.config, quantity)
|
||||
: getConsignmentPublishDepositFen()
|
||||
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
|
||||
const sellerPhone = String(
|
||||
intakeValues.sellerPhone ?? payload.sellerPhone ?? worker.phone ?? '',
|
||||
).trim()
|
||||
const defaultItemName = buildConsignmentItemName({
|
||||
template: profile?.config.nameTemplate || '',
|
||||
specs,
|
||||
@@ -441,7 +453,8 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
contentJson: {
|
||||
characterName,
|
||||
characterId,
|
||||
sellerNote: String(payload.sellerNote || '').trim(),
|
||||
sellerNote,
|
||||
intakeValues,
|
||||
channel: 'worker_portal',
|
||||
publishDepositFen: depositAmountFen,
|
||||
profileId: profile?.profileId ?? null,
|
||||
@@ -476,7 +489,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
workerName: worker.display_name || worker.username,
|
||||
workerPhone: sellerPhone || worker.phone || '',
|
||||
depositAmountFen,
|
||||
contactSupporterId: Number(contactSupporter.id),
|
||||
contactSupporterId: contactSupporter ? Number(contactSupporter.id) : null,
|
||||
now,
|
||||
})
|
||||
|
||||
@@ -521,7 +534,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
||||
contactSupporterId: submission.contact_supporter_id
|
||||
? Number(submission.contact_supporter_id)
|
||||
: null,
|
||||
contactSupporterName: contactSupporter.name,
|
||||
contactSupporterName: contactSupporter?.name || '',
|
||||
createdAt: submission.created_at,
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export const WORK_ORDER_TIMEOUT_POLICIES = ['reopen', 'cancel_release', 'cancel_deduct'] as const
|
||||
export const WORK_ORDER_TIMEOUT_POLICIES = [
|
||||
'reopen',
|
||||
'cancel_release',
|
||||
'cancel_deduct',
|
||||
'rematch_release',
|
||||
'rematch_deduct',
|
||||
] as const
|
||||
|
||||
export type WorkOrderTimeoutPolicy = (typeof WORK_ORDER_TIMEOUT_POLICIES)[number]
|
||||
|
||||
@@ -6,6 +12,10 @@ export function isWorkOrderTimeoutPolicy(value: unknown): value is WorkOrderTime
|
||||
return WORK_ORDER_TIMEOUT_POLICIES.includes(value as WorkOrderTimeoutPolicy)
|
||||
}
|
||||
|
||||
export function isRematchTimeoutPolicy(value: unknown): boolean {
|
||||
return value === 'rematch_release' || value === 'rematch_deduct'
|
||||
}
|
||||
|
||||
export function normalizeWorkOrderTimeoutPolicy(value: unknown): WorkOrderTimeoutPolicy {
|
||||
return isWorkOrderTimeoutPolicy(value) ? value : 'reopen'
|
||||
}
|
||||
|
||||
@@ -10,8 +10,31 @@ import {
|
||||
publishWorkerWalletRealtimeChange,
|
||||
publishWorkOrderRealtimeChange,
|
||||
} from '../realtime/realtime-event-service.js'
|
||||
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
|
||||
import { reenterConsignmentInternalMatching } from './consignment-match-engine-service.js'
|
||||
import {
|
||||
isRematchTimeoutPolicy,
|
||||
normalizeWorkOrderTimeoutPolicy,
|
||||
} from './work-order-timeout-policy.js'
|
||||
import {
|
||||
reenterConsignmentInternalMatching,
|
||||
startConsignmentRematch,
|
||||
} from './consignment-match-engine-service.js'
|
||||
|
||||
/** 从工单物料里取本次匹配占用的寄卖库存;重新匹配时用于排除原库存。 */
|
||||
function resolveConsignmentInventoryId(material: unknown): number | null {
|
||||
let source = material
|
||||
if (typeof material === 'string') {
|
||||
try {
|
||||
source = JSON.parse(material)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return null
|
||||
const consignment = (source as Record<string, unknown>).consignment
|
||||
if (!consignment || typeof consignment !== 'object' || Array.isArray(consignment)) return null
|
||||
const inventoryId = Number((consignment as Record<string, unknown>).inventoryId || 0)
|
||||
return inventoryId > 0 ? inventoryId : null
|
||||
}
|
||||
|
||||
export async function settleOverdueWorkOrders(options: { limit?: number; workerId?: number } = {}) {
|
||||
const limit = Math.max(1, Number(options.limit || 50))
|
||||
@@ -29,8 +52,18 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
|
||||
})
|
||||
if (result.order) {
|
||||
processedCount += 1
|
||||
// 退回大厅的寄卖单先尝试重新进入内部匹配;匹配不到再由内部匹配超时任务发回大厅。
|
||||
if (policy === 'reopen') {
|
||||
if (isRematchTimeoutPolicy(policy)) {
|
||||
// 重新匹配:回内部匹配池,并排除原卖家/原库存,避免同一卖家立刻接回同一单。
|
||||
await startConsignmentRematch(
|
||||
Number(workOrder.id),
|
||||
{
|
||||
excludeWorkerId: Number(workOrder.assigned_worker_id || 0) || null,
|
||||
excludeInventoryId: resolveConsignmentInventoryId(workOrder.material_json),
|
||||
},
|
||||
nowIso(),
|
||||
).catch(() => false)
|
||||
} else if (policy === 'reopen') {
|
||||
// 退回大厅的寄卖单先尝试重新进入内部匹配;匹配不到再由内部匹配超时任务发回大厅。
|
||||
await reenterConsignmentInternalMatching(Number(workOrder.id)).catch(() => false)
|
||||
}
|
||||
publishWorkOrderRealtimeChange({
|
||||
@@ -51,14 +84,26 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
|
||||
// 处置超时未提交材料的拼单份额(status=joined)
|
||||
const overdueShares = await listOverdueWorkOrderShares({ limit, workerId })
|
||||
for (const shareItem of overdueShares) {
|
||||
const policy = normalizeWorkOrderTimeoutPolicy(shareItem.timeoutPolicy)
|
||||
const result = await settleOverdueWorkOrderShare({
|
||||
workOrderId: shareItem.workOrderId,
|
||||
shareId: shareItem.shareId,
|
||||
policy: normalizeWorkOrderTimeoutPolicy(shareItem.timeoutPolicy),
|
||||
policy,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (result.share) {
|
||||
processedCount += 1
|
||||
if (isRematchTimeoutPolicy(policy)) {
|
||||
// 份额超时后空出的份数重新进入匹配池;排除该份额的卖家和库存。
|
||||
await startConsignmentRematch(
|
||||
Number(shareItem.workOrderId),
|
||||
{
|
||||
excludeWorkerId: Number(shareItem.workerId || 0) || null,
|
||||
excludeInventoryId: shareItem.consignmentInventoryId,
|
||||
},
|
||||
nowIso(),
|
||||
).catch(() => false)
|
||||
}
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(shareItem.workOrderId),
|
||||
workerIds: Number(shareItem.workerId) ? [Number(shareItem.workerId)] : [],
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Input, Modal, Select, Space, Tag, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import { fetchAdminConsignmentMatchLogs } from '@/services/admin/consignment'
|
||||
import type { ConsignmentMatchLogItem } from '@/types/consignment-match-log'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
auto: '自动匹配',
|
||||
manual: '手动匹配',
|
||||
rematch: '重新匹配',
|
||||
}
|
||||
|
||||
const RESULT_LABELS: Record<string, { label: string; color: string }> = {
|
||||
matched: { label: '已匹配', color: 'green' },
|
||||
failed: { label: '失败', color: 'red' },
|
||||
skipped: { label: '跳过', color: 'default' },
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default function ConsignmentMatchLogsModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [triggerType, setTriggerType] = useState<string | undefined>()
|
||||
const [result, setResult] = useState<string | undefined>()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [keywordInput, setKeywordInput] = useState('')
|
||||
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ['admin-consignment-match-logs', page, triggerType, result, keyword],
|
||||
queryFn: () =>
|
||||
fetchAdminConsignmentMatchLogs({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
triggerType,
|
||||
result,
|
||||
keyword: keyword || undefined,
|
||||
}),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const data = logsQuery.data?.data
|
||||
const items = data?.items || []
|
||||
const total = Number(data?.total || 0)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 160,
|
||||
render: (value: string) => (value ? formatDateTime(value) : '-'),
|
||||
},
|
||||
{
|
||||
title: '工单',
|
||||
dataIndex: 'workOrderNo',
|
||||
width: 150,
|
||||
render: (_: string, record: ConsignmentMatchLogItem) =>
|
||||
record.workOrderNo || (record.workOrderId ? `#${record.workOrderId}` : '-'),
|
||||
},
|
||||
{
|
||||
title: '触发',
|
||||
dataIndex: 'triggerType',
|
||||
width: 100,
|
||||
render: (value: string) => (
|
||||
<Tag color={value === 'manual' ? 'blue' : value === 'rematch' ? 'orange' : 'purple'}>
|
||||
{TRIGGER_LABELS[value] || value}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'result',
|
||||
width: 90,
|
||||
render: (value: string) => {
|
||||
const config = RESULT_LABELS[value] || { label: value, color: 'default' }
|
||||
return <Tag color={config.color}>{config.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '候选 / 成功',
|
||||
width: 100,
|
||||
render: (_: unknown, record: ConsignmentMatchLogItem) =>
|
||||
`${record.candidateCount} / ${record.matchedCount}`,
|
||||
},
|
||||
{
|
||||
title: '原因',
|
||||
dataIndex: 'reasonText',
|
||||
render: (value: string, record: ConsignmentMatchLogItem) => (
|
||||
<div style={{ display: 'grid', gap: 2 }}>
|
||||
<Text>{value || '-'}</Text>
|
||||
{record.reasonCode ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{record.reasonCode}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'actorId',
|
||||
width: 120,
|
||||
render: (value: string, record: ConsignmentMatchLogItem) =>
|
||||
record.actorType === 'system' ? '系统' : value || '-',
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'durationMs',
|
||||
width: 80,
|
||||
render: (value: number) => `${Number(value || 0)} ms`,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="寄卖匹配日志"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={1080}
|
||||
destroyOnClose
|
||||
>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="触发方式"
|
||||
style={{ width: 130 }}
|
||||
value={triggerType}
|
||||
onChange={(value) => {
|
||||
setTriggerType(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'auto', label: '自动匹配' },
|
||||
{ value: 'manual', label: '手动匹配' },
|
||||
{ value: 'rematch', label: '重新匹配' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="结果"
|
||||
style={{ width: 120 }}
|
||||
value={result}
|
||||
onChange={(value) => {
|
||||
setResult(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'matched', label: '已匹配' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'skipped', label: '跳过' },
|
||||
]}
|
||||
/>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索工单号"
|
||||
style={{ width: 220 }}
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onSearch={(value) => {
|
||||
setKeyword(value.trim())
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary">共 {total} 条</Text>
|
||||
<ReloadOutlined style={{ cursor: 'pointer' }} onClick={() => void logsQuery.refetch()} />
|
||||
</Space>
|
||||
<PercentTable<ConsignmentMatchLogItem>
|
||||
size="small"
|
||||
rowKey="logId"
|
||||
loading={logsQuery.isFetching}
|
||||
dataSource={items}
|
||||
columns={columns}
|
||||
scroll={{ x: 820 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
onChange: (nextPage) => setPage(nextPage),
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -39,12 +39,18 @@ import {
|
||||
} from '@/services/admin/consignment'
|
||||
import type {
|
||||
AdminConsignmentProfilesResponse,
|
||||
ConsignmentIntakeField,
|
||||
ConsignmentProfile,
|
||||
ConsignmentSpecField,
|
||||
ConsignmentSpecFieldType,
|
||||
ConsignmentSpecOption,
|
||||
} from '@/types/consignment'
|
||||
import { SPEC_FIELD_TYPE_OPTIONS, isOptionSpecField } from '@/utils/consignment-spec'
|
||||
import {
|
||||
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
|
||||
INTAKE_FIELD_TYPE_OPTIONS,
|
||||
SPEC_FIELD_TYPE_OPTIONS,
|
||||
isOptionSpecField,
|
||||
} from '@/utils/consignment-spec'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
@@ -58,9 +64,9 @@ type ConfigFormValues = {
|
||||
tabTitle: string
|
||||
fields: { quantity: boolean }
|
||||
pricing: {
|
||||
priceType: 'template_discount' | 'fixed'
|
||||
priceType: 'template_discount' | 'template_minus'
|
||||
discount: number
|
||||
fixedPriceYuan: number
|
||||
minusYuan: number
|
||||
allowWorkerPrice: boolean
|
||||
minPriceYuan: number
|
||||
missingPricePolicy: 'block' | 'manual'
|
||||
@@ -75,13 +81,15 @@ type ConfigFormValues = {
|
||||
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountYuan: number }
|
||||
inventoryLimit: { enabled: boolean; maxActiveQuantity: number }
|
||||
matching: {
|
||||
mode: 'manual' | 'auto'
|
||||
autoMatchEnabled: boolean
|
||||
mode: 'off' | 'manual' | 'auto'
|
||||
exactOnly: boolean
|
||||
preferLowReward: boolean
|
||||
}
|
||||
internalMatch: { delayMinutes: number; fallbackToHall: boolean }
|
||||
timeout: { minutes: number; policy: 'reopen' | 'cancel_release' | 'cancel_deduct' }
|
||||
timeout: {
|
||||
hours: number
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
|
||||
}
|
||||
priceDrop: { enabled: boolean; hours: number; mode: 'percent' | 'fixed'; value: number }
|
||||
}
|
||||
|
||||
@@ -106,6 +114,29 @@ function cloneSpecs(specs: ConsignmentSpecField[]): ConsignmentSpecField[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function cloneIntakeFields(fields: ConsignmentIntakeField[]): ConsignmentIntakeField[] {
|
||||
return fields.map((field) => ({ ...field }))
|
||||
}
|
||||
|
||||
function nextIntakeKey(fields: ConsignmentIntakeField[]): string {
|
||||
let index = 1
|
||||
while (fields.some((field) => field.key === `custom_${index}`)) index += 1
|
||||
return `custom_${index}`
|
||||
}
|
||||
|
||||
function newIntakeField(fields: ConsignmentIntakeField[]): ConsignmentIntakeField {
|
||||
return {
|
||||
key: nextIntakeKey(fields),
|
||||
label: '新字段',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: (fields.length + 1) * 10,
|
||||
}
|
||||
}
|
||||
|
||||
function nextCustomSpecKey(specs: ConsignmentSpecField[]): string {
|
||||
let index = 1
|
||||
while (specs.some((spec) => spec.key === `custom_${index}`)) index += 1
|
||||
@@ -137,12 +168,13 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues {
|
||||
tabTitle: profile.tabTitle,
|
||||
fields: { quantity: config.fields.quantity },
|
||||
pricing: {
|
||||
priceType: config.pricing.priceType || 'template_discount',
|
||||
priceType:
|
||||
config.pricing.priceType === 'template_minus' ? 'template_minus' : 'template_discount',
|
||||
discount: Number(config.pricing.discount ?? 10),
|
||||
fixedPriceYuan: yuan(config.pricing.fixedPriceFen),
|
||||
minusYuan: yuan(config.pricing.minusFen),
|
||||
allowWorkerPrice: config.pricing.allowWorkerPrice !== false,
|
||||
minPriceYuan: yuan(config.pricing.minPriceFen),
|
||||
missingPricePolicy: config.pricing.missingPricePolicy || 'manual',
|
||||
missingPricePolicy: 'block',
|
||||
},
|
||||
nameTemplate: config.nameTemplate || '',
|
||||
deposit: {
|
||||
@@ -158,9 +190,16 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues {
|
||||
amountYuan: yuan(config.serviceFee.amountFen),
|
||||
},
|
||||
inventoryLimit: { ...config.inventoryLimit },
|
||||
matching: { ...config.matching },
|
||||
matching: {
|
||||
mode: config.matching.mode || 'off',
|
||||
exactOnly: config.matching.exactOnly,
|
||||
preferLowReward: config.matching.preferLowReward,
|
||||
},
|
||||
internalMatch: { ...config.internalMatch },
|
||||
timeout: { ...config.timeout },
|
||||
timeout: {
|
||||
hours: Math.max(0, Number(config.timeout.minutes ?? 30) / 60),
|
||||
policy: config.timeout.policy,
|
||||
},
|
||||
priceDrop: { ...config.priceDrop },
|
||||
}
|
||||
}
|
||||
@@ -175,28 +214,32 @@ function emptyFormValues(): ConfigFormValues {
|
||||
pricing: {
|
||||
priceType: 'template_discount',
|
||||
discount: 10,
|
||||
fixedPriceYuan: 0,
|
||||
minusYuan: 0,
|
||||
allowWorkerPrice: true,
|
||||
minPriceYuan: 0,
|
||||
missingPricePolicy: 'manual',
|
||||
missingPricePolicy: 'block',
|
||||
},
|
||||
nameTemplate: '',
|
||||
deposit: { enabled: false, mode: 'fixed', fixedAmountYuan: 0, unitAmountYuan: 0 },
|
||||
serviceFee: { enabled: false, mode: 'percent', percent: 0, amountYuan: 0 },
|
||||
inventoryLimit: { enabled: false, maxActiveQuantity: 0 },
|
||||
matching: {
|
||||
mode: 'manual',
|
||||
autoMatchEnabled: false,
|
||||
mode: 'off',
|
||||
exactOnly: true,
|
||||
preferLowReward: true,
|
||||
},
|
||||
internalMatch: { delayMinutes: 30, fallbackToHall: true },
|
||||
timeout: { minutes: 30, policy: 'reopen' },
|
||||
priceDrop: { enabled: false, hours: 72, mode: 'percent', value: 10 },
|
||||
timeout: { hours: 0.5, policy: 'reopen' },
|
||||
priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 },
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(categoryId: number, values: ConfigFormValues, specs: ConsignmentSpecField[]) {
|
||||
function buildPayload(
|
||||
categoryId: number,
|
||||
values: ConfigFormValues,
|
||||
specs: ConsignmentSpecField[],
|
||||
intakeFields: ConsignmentIntakeField[],
|
||||
) {
|
||||
const hasSpec = (key: string) => specs.some((spec) => spec.key === key)
|
||||
return {
|
||||
categoryId,
|
||||
@@ -217,32 +260,57 @@ function buildPayload(categoryId: number, values: ConfigFormValues, specs: Consi
|
||||
quantity: values.fields.quantity,
|
||||
},
|
||||
specs,
|
||||
intake: { fields: intakeFields },
|
||||
nameTemplate: values.nameTemplate,
|
||||
pricing: {
|
||||
priceType: values.pricing.priceType,
|
||||
discount: Number(values.pricing.discount || 0),
|
||||
fixedPriceFen: fen(values.pricing.fixedPriceYuan),
|
||||
minusFen: fen(values.pricing.minusYuan),
|
||||
allowWorkerPrice: values.pricing.allowWorkerPrice,
|
||||
minPriceFen: fen(values.pricing.minPriceYuan),
|
||||
missingPricePolicy: values.pricing.missingPricePolicy,
|
||||
// 没有模板价一律阻止发布,不再提供手填兜底。
|
||||
missingPricePolicy: 'block',
|
||||
},
|
||||
deposit: {
|
||||
enabled: values.deposit.enabled,
|
||||
mode: values.deposit.mode,
|
||||
fixedAmountFen: fen(values.deposit.fixedAmountYuan),
|
||||
unitAmountFen: fen(values.deposit.unitAmountYuan),
|
||||
mode: values.deposit.enabled ? values.deposit.mode : 'fixed',
|
||||
fixedAmountFen: values.deposit.enabled ? fen(values.deposit.fixedAmountYuan) : 0,
|
||||
unitAmountFen: values.deposit.enabled ? fen(values.deposit.unitAmountYuan) : 0,
|
||||
},
|
||||
serviceFee: {
|
||||
enabled: values.serviceFee.enabled,
|
||||
mode: values.serviceFee.mode,
|
||||
percent: values.serviceFee.percent,
|
||||
amountFen: fen(values.serviceFee.amountYuan),
|
||||
mode: values.serviceFee.enabled ? values.serviceFee.mode : 'percent',
|
||||
percent: values.serviceFee.enabled ? values.serviceFee.percent : 0,
|
||||
amountFen: values.serviceFee.enabled ? fen(values.serviceFee.amountYuan) : 0,
|
||||
},
|
||||
inventoryLimit: {
|
||||
enabled: values.inventoryLimit.enabled,
|
||||
maxActiveQuantity: values.inventoryLimit.enabled
|
||||
? Number(values.inventoryLimit.maxActiveQuantity || 0)
|
||||
: 0,
|
||||
},
|
||||
matching: {
|
||||
mode: values.matching.mode,
|
||||
exactOnly: values.matching.exactOnly,
|
||||
preferLowReward: values.matching.preferLowReward,
|
||||
},
|
||||
internalMatch: {
|
||||
// 关闭匹配时不再等待内部库存,直接进大厅。
|
||||
delayMinutes:
|
||||
values.matching.mode === 'off' ? 0 : Number(values.internalMatch.delayMinutes || 0),
|
||||
fallbackToHall:
|
||||
values.matching.mode === 'off' ? false : values.internalMatch.fallbackToHall,
|
||||
},
|
||||
timeout: {
|
||||
minutes: Math.max(0, Math.round(Number(values.timeout.hours || 0) * 60)),
|
||||
policy: values.timeout.policy,
|
||||
},
|
||||
priceDrop: {
|
||||
enabled: values.priceDrop.enabled,
|
||||
hours: Number(values.priceDrop.hours || 24),
|
||||
mode: values.priceDrop.enabled ? values.priceDrop.mode : 'percent',
|
||||
value: values.priceDrop.enabled ? Number(values.priceDrop.value || 0) : 0,
|
||||
},
|
||||
inventoryLimit: values.inventoryLimit,
|
||||
matching: values.matching,
|
||||
internalMatch: values.internalMatch,
|
||||
timeout: values.timeout,
|
||||
priceDrop: values.priceDrop,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -329,6 +397,7 @@ export default function ConsignmentConfigPanel() {
|
||||
const [expandedCategoryIds, setExpandedCategoryIds] = useState<Set<number>>(() => new Set())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [specDrafts, setSpecDrafts] = useState<ConsignmentSpecField[]>([])
|
||||
const [intakeDrafts, setIntakeDrafts] = useState<ConsignmentIntakeField[]>([])
|
||||
const [form] = Form.useForm<ConfigFormValues>()
|
||||
|
||||
const profilesQuery = useQuery({
|
||||
@@ -406,6 +475,11 @@ export default function ConsignmentConfigPanel() {
|
||||
if (selectedProfile) {
|
||||
form.setFieldsValue(toFormValues(selectedProfile))
|
||||
setSpecDrafts(cloneSpecs(selectedProfile.config.specs))
|
||||
setIntakeDrafts(
|
||||
cloneIntakeFields(
|
||||
selectedProfile.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
...emptyFormValues(),
|
||||
@@ -413,6 +487,7 @@ export default function ConsignmentConfigPanel() {
|
||||
tabTitle: selectedCategory?.name || '',
|
||||
})
|
||||
setSpecDrafts([])
|
||||
setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS))
|
||||
}
|
||||
}, [selectedCategoryId, selectedProfile, selectedCategory?.name, form])
|
||||
|
||||
@@ -424,6 +499,11 @@ export default function ConsignmentConfigPanel() {
|
||||
if (selectedProfile) {
|
||||
form.setFieldsValue(toFormValues(selectedProfile))
|
||||
setSpecDrafts(cloneSpecs(selectedProfile.config.specs))
|
||||
setIntakeDrafts(
|
||||
cloneIntakeFields(
|
||||
selectedProfile.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
form.setFieldsValue({
|
||||
@@ -432,6 +512,7 @@ export default function ConsignmentConfigPanel() {
|
||||
tabTitle: selectedCategory?.name || '',
|
||||
})
|
||||
setSpecDrafts([])
|
||||
setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -463,10 +544,24 @@ export default function ConsignmentConfigPanel() {
|
||||
return
|
||||
}
|
||||
}
|
||||
const intakeKeys = new Set<string>()
|
||||
for (const field of intakeDrafts) {
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(field.key)) {
|
||||
message.error(`收货字段 Key 不合法:${field.key || '(空)'}`)
|
||||
return
|
||||
}
|
||||
if (intakeKeys.has(field.key)) {
|
||||
message.error(`收货字段 Key 重复:${field.key}`)
|
||||
return
|
||||
}
|
||||
intakeKeys.add(field.key)
|
||||
}
|
||||
const values = await form.validateFields()
|
||||
setSaving(true)
|
||||
try {
|
||||
await saveAdminConsignmentProfile(buildPayload(selectedCategoryId, values, specs))
|
||||
await saveAdminConsignmentProfile(
|
||||
buildPayload(selectedCategoryId, values, specs, intakeDrafts),
|
||||
)
|
||||
message.success('寄卖配置已保存')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
@@ -560,7 +655,45 @@ export default function ConsignmentConfigPanel() {
|
||||
setSpecDrafts((current) => [...current, newCustomSpec(current)])
|
||||
}
|
||||
|
||||
function updateIntakeField(index: number, patch: Partial<ConsignmentIntakeField>) {
|
||||
setIntakeDrafts((current) =>
|
||||
current.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)),
|
||||
)
|
||||
}
|
||||
|
||||
function removeIntakeField(index: number) {
|
||||
setIntakeDrafts((current) =>
|
||||
current
|
||||
.filter((_, fieldIndex) => fieldIndex !== index)
|
||||
.map((field, fieldIndex) => ({ ...field, sortOrder: (fieldIndex + 1) * 10 })),
|
||||
)
|
||||
}
|
||||
|
||||
function moveIntakeField(index: number, direction: -1 | 1) {
|
||||
setIntakeDrafts((current) => {
|
||||
const next = [...current]
|
||||
const target = index + direction
|
||||
if (target < 0 || target >= next.length) return current
|
||||
const [item] = next.splice(index, 1)
|
||||
if (!item) return current
|
||||
next.splice(target, 0, item)
|
||||
return next.map((field, fieldIndex) => ({ ...field, sortOrder: (fieldIndex + 1) * 10 }))
|
||||
})
|
||||
}
|
||||
|
||||
function addIntakeField() {
|
||||
setIntakeDrafts((current) => [...current, newIntakeField(current)])
|
||||
}
|
||||
|
||||
const priceType = Form.useWatch(['pricing', 'priceType'], form)
|
||||
const depositEnabled = Form.useWatch(['deposit', 'enabled'], form)
|
||||
const depositMode = Form.useWatch(['deposit', 'mode'], form)
|
||||
const serviceFeeEnabled = Form.useWatch(['serviceFee', 'enabled'], form)
|
||||
const serviceFeeMode = Form.useWatch(['serviceFee', 'mode'], form)
|
||||
const inventoryLimitEnabled = Form.useWatch(['inventoryLimit', 'enabled'], form)
|
||||
const matchingMode = Form.useWatch(['matching', 'mode'], form)
|
||||
const priceDropEnabled = Form.useWatch(['priceDrop', 'enabled'], form)
|
||||
const priceDropMode = Form.useWatch(['priceDrop', 'mode'], form)
|
||||
const headerStatus: ProfileStatusInfo = selectedProfile
|
||||
? resolveProfileStatus(selectedProfile)
|
||||
: runtimeInheritsParent
|
||||
@@ -984,9 +1117,15 @@ export default function ConsignmentConfigPanel() {
|
||||
<ConsignmentSectionTitle
|
||||
index="03"
|
||||
title="价格配置"
|
||||
description="模板折扣价或固定价格;固定价不得高于接单模板价"
|
||||
description="按接单模板价打折,或按模板价固定下调;没有模板价一律阻止发布"
|
||||
/>
|
||||
</div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="寄售价始终基于接单模板价计算;该规格没有模板价时,打手端会直接阻止发布。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<div style={gridStyle}>
|
||||
<Form.Item
|
||||
label="价格类型"
|
||||
@@ -998,15 +1137,16 @@ export default function ConsignmentConfigPanel() {
|
||||
buttonStyle="solid"
|
||||
options={[
|
||||
{ value: 'template_discount', label: '接单模板价打折' },
|
||||
{ value: 'fixed', label: '固定价格' },
|
||||
{ value: 'template_minus', label: '模板价固定下调' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{priceType === 'fixed' ? (
|
||||
{priceType === 'template_minus' ? (
|
||||
<Form.Item
|
||||
label="固定价格(元)"
|
||||
name={['pricing', 'fixedPriceYuan']}
|
||||
extra="发布时会按接单模板价校验,超过模板价会被后端拒绝"
|
||||
label="固定下调(元)"
|
||||
name={['pricing', 'minusYuan']}
|
||||
rules={[{ type: 'number', min: 0 }]}
|
||||
extra="售价 = 接单模板价 − 固定下调金额;下调后不足 1 元会被拒绝"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
@@ -1055,19 +1195,6 @@ export default function ConsignmentConfigPanel() {
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="没有模板价时"
|
||||
name={['pricing', 'missingPricePolicy']}
|
||||
extra="固定价格不受此项影响"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'manual', label: '允许打手手填' },
|
||||
{ value: 'block', label: '阻止发布' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1088,42 +1215,48 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="保证金模式"
|
||||
name={['deposit', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
{ value: 'per_quantity', label: '按数量' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="固定金额(元)"
|
||||
name={['deposit', 'fixedAmountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="每件金额(元)"
|
||||
name={['deposit', 'unitAmountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
{depositEnabled ? (
|
||||
<Form.Item
|
||||
label="保证金模式"
|
||||
name={['deposit', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
{ value: 'per_quantity', label: '按数量' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{depositEnabled && depositMode === 'fixed' ? (
|
||||
<Form.Item
|
||||
label="固定金额(元)"
|
||||
name={['deposit', 'fixedAmountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{depositEnabled && depositMode === 'per_quantity' ? (
|
||||
<Form.Item
|
||||
label="每件金额(元)"
|
||||
name={['deposit', 'unitAmountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1144,37 +1277,43 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="服务费模式"
|
||||
name={['serviceFee', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'percent', label: '按比例 %' },
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="比例(%)"
|
||||
name={['serviceFee', 'percent']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="固定金额(元)"
|
||||
name={['serviceFee', 'amountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
{serviceFeeEnabled ? (
|
||||
<Form.Item
|
||||
label="服务费模式"
|
||||
name={['serviceFee', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'percent', label: '按比例 %' },
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{serviceFeeEnabled && serviceFeeMode === 'percent' ? (
|
||||
<Form.Item
|
||||
label="比例(%)"
|
||||
name={['serviceFee', 'percent']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{serviceFeeEnabled && serviceFeeMode === 'fixed' ? (
|
||||
<Form.Item
|
||||
label="固定金额(元)"
|
||||
name={['serviceFee', 'amountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1195,18 +1334,20 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="最多在售数量"
|
||||
name={['inventoryLimit', 'maxActiveQuantity']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="件"
|
||||
/>
|
||||
</Form.Item>
|
||||
{inventoryLimitEnabled ? (
|
||||
<Form.Item
|
||||
label="最多在售数量"
|
||||
name={['inventoryLimit', 'maxActiveQuantity']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="件"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1215,7 +1356,7 @@ export default function ConsignmentConfigPanel() {
|
||||
<ConsignmentSectionTitle
|
||||
index="07"
|
||||
title="匹配设置"
|
||||
description="自动/手动匹配、精确匹配与内部匹配等待策略"
|
||||
description="匹配模式决定新单是否进入内部库存匹配队列"
|
||||
/>
|
||||
</div>
|
||||
<div style={gridStyle}>
|
||||
@@ -1226,77 +1367,85 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'off', label: '关闭匹配' },
|
||||
{ value: 'manual', label: '手动匹配' },
|
||||
{ value: 'auto', label: '自动匹配' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="自动匹配开关"
|
||||
name={['matching', 'autoMatchEnabled']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="仅精确匹配"
|
||||
name={['matching', 'exactOnly']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="仅精确" unCheckedChildren="允许高配" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="低价订单优先"
|
||||
name={['matching', 'preferLowReward']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="优先" unCheckedChildren="不优先" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="内部匹配等待(分钟)"
|
||||
name={['internalMatch', 'delayMinutes']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="分钟"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="超时发回大厅"
|
||||
name={['internalMatch', 'fallbackToHall']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
{matchingMode !== 'off' ? (
|
||||
<>
|
||||
<Form.Item
|
||||
label="仅精确匹配"
|
||||
name={['matching', 'exactOnly']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="仅精确" unCheckedChildren="允许高配" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="低价订单优先"
|
||||
name={['matching', 'preferLowReward']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="优先" unCheckedChildren="不优先" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="内部匹配等待(分钟)"
|
||||
name={['internalMatch', 'delayMinutes']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="分钟"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="超时发回大厅"
|
||||
name={['internalMatch', 'fallbackToHall']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{matchingMode === 'off' ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message="关闭匹配后:新单直接进入接单大厅,不等待内部库存,也不会自动匹配。"
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="order-service-section">
|
||||
<div className="order-service-section-head">
|
||||
<ConsignmentSectionTitle
|
||||
index="08"
|
||||
title="超时与降价提示"
|
||||
description="代练超时动作与寄卖长时间未售出的降价提醒"
|
||||
title="超时设置"
|
||||
description="匹配成功进入代练中后的时限与超时处置动作"
|
||||
/>
|
||||
</div>
|
||||
<div style={gridStyle}>
|
||||
<Form.Item
|
||||
label="代练超时(分钟)"
|
||||
name={['timeout', 'minutes']}
|
||||
label="代练超时(小时)"
|
||||
name={['timeout', 'hours']}
|
||||
rules={[{ type: 'number', min: 0 }]}
|
||||
extra="默认 0.5 小时;到点后按下方超时动作处置"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
step={0.5}
|
||||
precision={1}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="分钟"
|
||||
addonAfter="小时"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -1306,12 +1455,32 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'reopen', label: '退回大厅' },
|
||||
{ value: 'cancel_release', label: '取消并释放' },
|
||||
{ value: 'cancel_deduct', label: '取消并扣押金' },
|
||||
{ value: 'reopen', label: '退回大厅(退还押金)' },
|
||||
{ value: 'cancel_release', label: '取消并释放押金' },
|
||||
{ value: 'cancel_deduct', label: '取消并扣除押金' },
|
||||
{ value: 'rematch_release', label: '重新匹配并退还押金' },
|
||||
{ value: 'rematch_deduct', label: '重新匹配并扣除押金' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message="重新匹配:订单回到内部匹配池,并排除原卖家与原库存,避免同一卖家立刻接回同一单。"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="order-service-section">
|
||||
<div className="order-service-section-head">
|
||||
<ConsignmentSectionTitle
|
||||
index="09"
|
||||
title="降价提示"
|
||||
description="寄卖长时间未售出时,在后台给出建议售价(仅提示,不自动改价)"
|
||||
/>
|
||||
</div>
|
||||
<div style={gridStyle}>
|
||||
<Form.Item
|
||||
label="开启降价提示"
|
||||
name={['priceDrop', 'enabled']}
|
||||
@@ -1320,40 +1489,186 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="未售出时长(小时)"
|
||||
name={['priceDrop', 'hours']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="小时"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="降幅模式"
|
||||
name={['priceDrop', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'percent', label: '按比例 %' },
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="降幅数值"
|
||||
name={['priceDrop', 'value']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
{priceDropEnabled ? (
|
||||
<>
|
||||
<Form.Item
|
||||
label="未售出时长(小时)"
|
||||
name={['priceDrop', 'hours']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="小时"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="降幅模式"
|
||||
name={['priceDrop', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'percent', label: '按比例 %' },
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={priceDropMode === 'fixed' ? '下调金额(元)' : '下调比例(%)'}
|
||||
name={['priceDrop', 'value']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={priceDropMode === 'fixed' ? undefined : 100}
|
||||
precision={priceDropMode === 'fixed' ? 2 : 0}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore={priceDropMode === 'fixed' ? '¥' : undefined}
|
||||
addonAfter={priceDropMode === 'fixed' ? undefined : '%'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="order-service-section">
|
||||
<div className="order-service-section-head">
|
||||
<ConsignmentSectionTitle
|
||||
index="10"
|
||||
title="联系方式与游戏凭据"
|
||||
description="打手发布页按这里配置的字段动态渲染;字段为空时该模块不显示"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addIntakeField}
|
||||
>
|
||||
添加字段
|
||||
</Button>
|
||||
</div>
|
||||
<Text type="secondary">
|
||||
默认字段与旧发布页一致;手机号字段会校验 11
|
||||
位手机号。对接客服字段被删除后不再校验提成归属。
|
||||
</Text>
|
||||
{intakeDrafts.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="还没有收货字段,点击右上角「添加字段」"
|
||||
style={{ margin: '16px 0 4px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="order-service-field-list" style={{ marginTop: 12 }}>
|
||||
{intakeDrafts.map((field, index) => (
|
||||
<div className="order-service-field-row" key={`${field.key}-${index}`}>
|
||||
<div className="order-service-field-main">
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">字段 Key</Text>
|
||||
<Input
|
||||
value={field.key}
|
||||
placeholder="如 sellerPhone"
|
||||
onChange={(event) =>
|
||||
updateIntakeField(index, { key: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">显示名称</Text>
|
||||
<Input
|
||||
value={field.label}
|
||||
placeholder="如 联系电话"
|
||||
onChange={(event) =>
|
||||
updateIntakeField(index, { label: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">类型</Text>
|
||||
<Select
|
||||
value={field.type}
|
||||
options={INTAKE_FIELD_TYPE_OPTIONS}
|
||||
onChange={(value) => updateIntakeField(index, { type: value })}
|
||||
/>
|
||||
</div>
|
||||
<div style={fieldCellStyle} className="order-service-field-required">
|
||||
<Text type="secondary">必填</Text>
|
||||
<Switch
|
||||
checked={field.required}
|
||||
checkedChildren="必填"
|
||||
unCheckedChildren="选填"
|
||||
onChange={(checked) =>
|
||||
updateIntakeField(index, { required: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="order-service-field-actions">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => moveIntakeField(index, -1)}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ArrowDownOutlined />}
|
||||
disabled={index === intakeDrafts.length - 1}
|
||||
onClick={() => moveIntakeField(index, 1)}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeIntakeField(index)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="order-service-field-extra">
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">占位提示</Text>
|
||||
<Input
|
||||
value={field.placeholder}
|
||||
placeholder="输入框提示文案"
|
||||
onChange={(event) =>
|
||||
updateIntakeField(index, { placeholder: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">填写说明</Text>
|
||||
<Input
|
||||
value={field.helpText}
|
||||
placeholder="给打手看的说明"
|
||||
onChange={(event) =>
|
||||
updateIntakeField(index, { helpText: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{field.type === 'image' ? (
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">最多上传张数</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={12}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
value={field.maxCount}
|
||||
onChange={(value) =>
|
||||
updateIntakeField(index, { maxCount: Number(value || 1) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="order-service-save-bar">
|
||||
<Text type="secondary">保存只更新草稿,发布后才会影响新工单。</Text>
|
||||
<Space>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
EyeOutlined,
|
||||
FileSearchOutlined,
|
||||
ReloadOutlined,
|
||||
RollbackOutlined,
|
||||
StopOutlined,
|
||||
@@ -39,6 +40,7 @@ import { useState } from 'react'
|
||||
|
||||
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
|
||||
import ConsignmentPendingMatchModal from '@/components/admin/ConsignmentPendingMatchModal'
|
||||
import ConsignmentMatchLogsModal from '@/components/admin/ConsignmentMatchLogsModal'
|
||||
import {
|
||||
archiveAdminConsignmentInventory,
|
||||
fetchAdminConsignmentContacts,
|
||||
@@ -163,6 +165,7 @@ export default function ConsignmentInventoryPanel() {
|
||||
const [returnItem, setReturnItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [manualMatchItem, setManualMatchItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [pendingMatchOpen, setPendingMatchOpen] = useState(false)
|
||||
const [matchLogsOpen, setMatchLogsOpen] = useState(false)
|
||||
const [returnReason, setReturnReason] = useState<string>('')
|
||||
|
||||
const [editForm] = Form.useForm()
|
||||
@@ -891,6 +894,10 @@ export default function ConsignmentInventoryPanel() {
|
||||
一键查询待匹配订单
|
||||
</Button>
|
||||
|
||||
<Button icon={<FileSearchOutlined />} onClick={() => setMatchLogsOpen(true)}>
|
||||
匹配日志
|
||||
</Button>
|
||||
|
||||
<Button icon={<ReloadOutlined />} onClick={refresh}>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -1409,6 +1416,8 @@ export default function ConsignmentInventoryPanel() {
|
||||
open={pendingMatchOpen}
|
||||
onClose={() => setPendingMatchOpen(false)}
|
||||
/>
|
||||
|
||||
<ConsignmentMatchLogsModal open={matchLogsOpen} onClose={() => setMatchLogsOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -897,15 +897,53 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
¥{(viewingSubmission.askingPriceFen / 100).toFixed(2)}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卖家微信号">
|
||||
<Text copyable>{viewingSubmission.contactInfo}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卖家电话">
|
||||
<Text copyable>{viewingSubmission.sellerPhone || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卖家游戏ID" span={2}>
|
||||
<Text copyable>{viewingSubmission.sellerGameId || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
{viewingSubmission.intakeFields && viewingSubmission.intakeFields.length > 0 ? (
|
||||
<>
|
||||
{viewingSubmission.intakeFields.map((field) => {
|
||||
const raw = viewingSubmission.intakeValues?.[field.key]
|
||||
if (field.type === 'image') {
|
||||
const images = Array.isArray(raw)
|
||||
? raw.filter((item): item is string => typeof item === 'string')
|
||||
: []
|
||||
return (
|
||||
<Descriptions.Item label={field.label} span={2} key={field.key}>
|
||||
<ConsignmentEvidenceGallery
|
||||
images={images}
|
||||
emptyText="未上传"
|
||||
max={8}
|
||||
size={80}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)
|
||||
}
|
||||
const display =
|
||||
raw === undefined || raw === null || String(raw).trim() === ''
|
||||
? '-'
|
||||
: String(raw)
|
||||
return (
|
||||
<Descriptions.Item
|
||||
label={field.label}
|
||||
span={field.type === 'textarea' ? 2 : 1}
|
||||
key={field.key}
|
||||
>
|
||||
<Text copyable={field.type !== 'textarea'}>{display}</Text>
|
||||
</Descriptions.Item>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Descriptions.Item label="卖家微信号">
|
||||
<Text copyable>{viewingSubmission.contactInfo}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卖家电话">
|
||||
<Text copyable>{viewingSubmission.sellerPhone || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卖家游戏ID" span={2}>
|
||||
<Text copyable>{viewingSubmission.sellerGameId || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
<Descriptions.Item label="关联接单打手">
|
||||
{viewingSubmission.workerId ? (
|
||||
<Tag color="blue">
|
||||
|
||||
@@ -55,12 +55,13 @@ import {
|
||||
} from '@/services/worker'
|
||||
import type { WorkerConsignmentRecord } from '@/services/worker'
|
||||
import type {
|
||||
ConsignmentIntakeField,
|
||||
ConsignmentSpecField,
|
||||
ConsignmentSpecValues,
|
||||
WorkerConsignmentProfile,
|
||||
} from '@/types/consignment'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { buildSpecItemName } from '@/utils/consignment-spec'
|
||||
import { DEFAULT_CONSIGNMENT_INTAKE_FIELDS, buildSpecItemName } from '@/utils/consignment-spec'
|
||||
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
|
||||
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
|
||||
import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist'
|
||||
@@ -151,8 +152,9 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
const [screenshots, setScreenshots] = useState<UploadedFile[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
/** 收货信息图片字段:key -> 已上传文件 */
|
||||
const [intakeImages, setIntakeImages] = useState<Record<string, UploadedFile[]>>({})
|
||||
const [evidenceItem, setEvidenceItem] = useState<WorkerConsignmentRecord | null>(null)
|
||||
const [evidenceFiles, setEvidenceFiles] = useState<UploadedFile[]>([])
|
||||
const [evidenceNote, setEvidenceNote] = useState('')
|
||||
@@ -203,9 +205,16 @@ export default function WorkerConsignmentPage() {
|
||||
consignmentProfiles[0] ||
|
||||
null
|
||||
const selectedSpecs: ConsignmentSpecField[] = selectedProfile?.config.specs ?? []
|
||||
const intakeFields: ConsignmentIntakeField[] =
|
||||
selectedProfile?.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
|
||||
useEffect(() => {
|
||||
if (selectedProfileId === null && consignmentProfiles.length > 0) {
|
||||
setSelectedProfileId(consignmentProfiles[0]!.profileId)
|
||||
// 默认苏尔南手枪;没有该分类时退回第一个已发布配置。
|
||||
const preferred =
|
||||
consignmentProfiles.find((profile) =>
|
||||
/苏尔南|苏里南/.test(`${profile.categoryName}${profile.tabTitle}${profile.name}`),
|
||||
) || consignmentProfiles[0]
|
||||
if (preferred) setSelectedProfileId(preferred.profileId)
|
||||
}
|
||||
}, [consignmentProfiles, selectedProfileId])
|
||||
|
||||
@@ -339,6 +348,18 @@ export default function WorkerConsignmentPage() {
|
||||
return values
|
||||
}
|
||||
|
||||
function handleProfileChange(profileId: number) {
|
||||
setSelectedProfileId(profileId)
|
||||
form.resetFields()
|
||||
setPriceState({ status: 'idle' })
|
||||
lastPriceQueryKeyRef.current = ''
|
||||
setIntakeImages({})
|
||||
if (worker?.phone) form.setFieldsValue({ sellerPhone: worker.phone })
|
||||
if (firstContactSupporterId != null) {
|
||||
form.setFieldsValue({ contactSupporterId: firstContactSupporterId })
|
||||
}
|
||||
}
|
||||
|
||||
function handleFormValuesChange(changed: Record<string, unknown>) {
|
||||
// 钻级改变时:璀璨数不能超过钻石等级(如 5钻调成 2钻,原 5璀自动收敛到 2璀)
|
||||
if (changed && changed.diamondLevel !== undefined) {
|
||||
@@ -417,30 +438,67 @@ export default function WorkerConsignmentPage() {
|
||||
return
|
||||
}
|
||||
|
||||
if (contactSupporters.length === 0) {
|
||||
message.error('暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布')
|
||||
return
|
||||
// 联系方式与游戏凭据完全按寄卖配置动态收集;图片字段用独立 state 保存。
|
||||
const activeIntakeFields =
|
||||
selectedProfile?.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
|
||||
const intakeValues: Record<string, unknown> = {}
|
||||
for (const field of activeIntakeFields) {
|
||||
if (field.type === 'image') {
|
||||
const urls = (intakeImages[field.key] || []).map((file) => file.url).filter(Boolean)
|
||||
if (urls.length > 0) {
|
||||
intakeValues[field.key] = urls
|
||||
} else if (field.required) {
|
||||
message.error(`请上传「${field.label}」`)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
const raw = values[field.key]
|
||||
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
||||
if (field.required) {
|
||||
message.error(`请填写「${field.label}」`)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
intakeValues[field.key] =
|
||||
field.type === 'number' || field.type === 'contact_supporter'
|
||||
? Number(raw)
|
||||
: String(raw).trim()
|
||||
}
|
||||
if (values.contactSupporterId == null) {
|
||||
message.error('请选择对接客服')
|
||||
return
|
||||
const phoneField = activeIntakeFields.find((field) => field.type === 'phone')
|
||||
if (phoneField) {
|
||||
const phoneValue = String(intakeValues[phoneField.key] || '').trim()
|
||||
if (phoneValue && !/^1[3-9]\d{9}$/.test(phoneValue)) {
|
||||
message.error(`「${phoneField.label}」请填写正确的 11 位手机号`)
|
||||
return
|
||||
}
|
||||
}
|
||||
const sellerContact = String(intakeValues.sellerContact ?? '').trim()
|
||||
const sellerPhone = String(intakeValues.sellerPhone ?? worker?.phone ?? '').trim()
|
||||
const characterName = String(intakeValues.characterName ?? '').trim()
|
||||
const characterId = String(intakeValues.characterId ?? '').trim()
|
||||
const sellerNote = String(intakeValues.sellerNote ?? '').trim()
|
||||
const publishScreenshots = Array.isArray(intakeValues.screenshots)
|
||||
? (intakeValues.screenshots as string[])
|
||||
: []
|
||||
|
||||
const urls = screenshots.map((f) => f.url).filter(Boolean)
|
||||
if (urls.length === 0) {
|
||||
message.error('请上传游戏主页截图以供核验')
|
||||
return
|
||||
}
|
||||
|
||||
// 寄售价:按后台配置带出系统建议价;允许改价时可下调,但不能高于建议价。
|
||||
// 寄售价:必须命中接单模板价;允许改价时可下调,但不能高于建议价。
|
||||
const minPriceFen = Math.max(0, selectedProfile?.config.pricing.minPriceFen || 0)
|
||||
const suggestedPriceFen = priceState.status === 'matched' ? priceState.fen : 0
|
||||
const capPriceFen = priceState.status === 'matched' ? priceState.capFen : 0
|
||||
let askingPriceFen = suggestedPriceFen
|
||||
if (priceState.status === 'loading') {
|
||||
message.warning('正在计算系统建议价,请稍候')
|
||||
return
|
||||
}
|
||||
if (priceState.status !== 'matched' || priceState.fen <= 0) {
|
||||
message.error('该规格暂无模板价,暂不能寄卖,请联系客服补充模板价格')
|
||||
return
|
||||
}
|
||||
const capPriceFen = priceState.capFen > 0 ? priceState.capFen : priceState.fen
|
||||
let askingPriceFen = priceState.fen
|
||||
if (manualPriceAllowed) {
|
||||
const submittedPriceFen = Math.max(0, Math.round(Number(values.askingPrice || 0) * 100))
|
||||
if (submittedPriceFen > 0) {
|
||||
if (capPriceFen > 0 && submittedPriceFen > capPriceFen) {
|
||||
if (submittedPriceFen > capPriceFen) {
|
||||
message.error(`寄售价格不能高于系统建议价 ¥${(capPriceFen / 100).toFixed(2)}`)
|
||||
return
|
||||
}
|
||||
@@ -449,24 +507,12 @@ export default function WorkerConsignmentPage() {
|
||||
return
|
||||
}
|
||||
askingPriceFen = submittedPriceFen
|
||||
} else if (suggestedPriceFen <= 0) {
|
||||
message.error('该规格暂无价格,请填写寄售价格')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (priceState.status === 'loading') {
|
||||
message.warning('正在计算系统建议价,请稍候')
|
||||
return
|
||||
}
|
||||
if (priceState.status !== 'matched') {
|
||||
message.error('该规格暂无价格,请联系客服补充配置后再提报')
|
||||
return
|
||||
}
|
||||
if (askingPriceFen < 100) {
|
||||
message.error('寄售价格不能少于1元')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (askingPriceFen < 100) {
|
||||
message.error('寄售价格不能少于1元')
|
||||
return
|
||||
}
|
||||
|
||||
const specValues = collectSpecValues()
|
||||
const itemName =
|
||||
@@ -491,14 +537,17 @@ export default function WorkerConsignmentPage() {
|
||||
diamondLevel: Number(specValues.diamondLevel || 1),
|
||||
brillianceCount: Number(specValues.brillianceCount || 0),
|
||||
askingPriceFen,
|
||||
sellerContact: values.sellerContact,
|
||||
sellerPhone: values.sellerPhone || worker?.phone || '',
|
||||
characterName: values.characterName,
|
||||
characterId: values.characterId,
|
||||
screenshotsJson: urls,
|
||||
sellerNote: values.sellerNote,
|
||||
sellerContact,
|
||||
sellerPhone,
|
||||
characterName,
|
||||
characterId,
|
||||
screenshotsJson: publishScreenshots,
|
||||
sellerNote,
|
||||
intakeValues,
|
||||
depositAmountFen: publishDepositFen,
|
||||
contactSupporterId: Number(values.contactSupporterId),
|
||||
contactSupporterId: intakeValues.contactSupporterId
|
||||
? Number(intakeValues.contactSupporterId)
|
||||
: null,
|
||||
})
|
||||
|
||||
message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`)
|
||||
@@ -508,7 +557,7 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
setPriceState({ status: 'idle' })
|
||||
lastPriceQueryKeyRef.current = ''
|
||||
setScreenshots([])
|
||||
setIntakeImages({})
|
||||
await Promise.all([myConsignmentsQuery.refetch(), profileQuery.refetch()])
|
||||
setRecordFilter('reviewing')
|
||||
setActiveTab('records')
|
||||
@@ -654,6 +703,31 @@ export default function WorkerConsignmentPage() {
|
||||
onFinish={handlePublishSubmit}
|
||||
onValuesChange={handleFormValuesChange}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>寄卖分类</Typography.Text>
|
||||
<Select
|
||||
value={selectedProfile?.profileId}
|
||||
style={{ minWidth: 220 }}
|
||||
placeholder={consignmentProfiles.length === 0 ? '暂无可发布的寄卖分类' : '请选择寄卖分类'}
|
||||
options={consignmentProfiles.map((profile) => ({
|
||||
value: profile.profileId,
|
||||
label: profile.tabTitle || profile.categoryName || profile.name,
|
||||
}))}
|
||||
onChange={(value) => handleProfileChange(Number(value))}
|
||||
/>
|
||||
{selectedProfile?.tabDescription ? (
|
||||
<Typography.Text type="secondary">{selectedProfile.tabDescription}</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type={canPublish ? 'info' : 'warning'}
|
||||
showIcon
|
||||
@@ -846,84 +920,94 @@ export default function WorkerConsignmentPage() {
|
||||
联系方式与游戏凭据
|
||||
</Divider>
|
||||
|
||||
<Row gutter={[12, 0]}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
name="contactSupporterId"
|
||||
label="对接客服"
|
||||
rules={[{ required: true, message: '请选择对接客服' }]}
|
||||
extra={
|
||||
contactSupporters.length === 0
|
||||
? '暂无可选客服,请联系管理员配置'
|
||||
: '用于后续提成归属与联系'
|
||||
{intakeFields.length === 0 ? (
|
||||
<Alert type="info" showIcon message="当前寄卖配置未启用收货信息字段。" />
|
||||
) : (
|
||||
<Row gutter={[12, 0]}>
|
||||
{intakeFields.map((field) => {
|
||||
if (field.type === 'image') {
|
||||
return (
|
||||
<Col span={24} key={field.key}>
|
||||
<Form.Item
|
||||
label={field.label}
|
||||
required={field.required}
|
||||
extra={field.helpText || undefined}
|
||||
>
|
||||
<ImageUpload
|
||||
value={intakeImages[field.key] || []}
|
||||
onChange={(files) =>
|
||||
setIntakeImages((current) => ({ ...current, [field.key]: files }))
|
||||
}
|
||||
scene="consignment_screenshot"
|
||||
scope="worker"
|
||||
maxCount={field.maxCount || 4}
|
||||
showPasteHint={!isMobile}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择对接客服"
|
||||
options={contactSupporters.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
}))}
|
||||
notFoundContent="暂无启用中的对接客服"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
name="sellerContact"
|
||||
label="微信号 / QQ号"
|
||||
rules={[{ required: true, message: '请填写微信号或QQ' }]}
|
||||
>
|
||||
<Input placeholder="方便派单与紧急联系" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="sellerPhone" label="联系电话" tooltip="默认使用当前接单账号绑定手机号">
|
||||
<Input placeholder="手机号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
name="characterName"
|
||||
label="游戏内角色名"
|
||||
rules={[{ required: true, message: '请填写角色名' }]}
|
||||
>
|
||||
<Input placeholder="局内加好友丢枪用" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
name="characterId"
|
||||
label="角色数字ID"
|
||||
rules={[{ required: true, message: '请填写数字ID' }]}
|
||||
>
|
||||
<Input placeholder="如 1089238472" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
label="游戏主页截图"
|
||||
required
|
||||
extra="须包含角色名、ID 及仓库/手枪详情,最多 4 张"
|
||||
>
|
||||
<ImageUpload
|
||||
value={screenshots}
|
||||
onChange={setScreenshots}
|
||||
scene="consignment_screenshot"
|
||||
scope="worker"
|
||||
maxCount={4}
|
||||
showPasteHint={!isMobile}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="sellerNote" label="其他留言与补充">
|
||||
<Input.TextArea rows={2} placeholder="配件、子弹、涂装或在线时段等(选填)" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
const rules = [
|
||||
...(field.required ? [{ required: true, message: `请填写${field.label}` }] : []),
|
||||
...(field.type === 'phone'
|
||||
? [{ pattern: /^1[3-9]\d{9}$/, message: '请填写正确的 11 位手机号' }]
|
||||
: []),
|
||||
]
|
||||
return (
|
||||
<Col xs={24} sm={12} key={field.key}>
|
||||
<Form.Item
|
||||
name={field.key}
|
||||
label={field.label}
|
||||
rules={rules}
|
||||
extra={field.helpText || undefined}
|
||||
>
|
||||
{field.type === 'contact_supporter' ? (
|
||||
<Select
|
||||
placeholder={field.placeholder || '请选择对接客服'}
|
||||
options={contactSupporters.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
}))}
|
||||
notFoundContent="暂无启用中的对接客服"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
) : field.type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder={field.placeholder || undefined}
|
||||
maxLength={2000}
|
||||
/>
|
||||
) : field.type === 'number' ? (
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
placeholder={field.placeholder || undefined}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={field.placeholder || undefined}
|
||||
maxLength={field.type === 'phone' ? 11 : 200}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
{intakeFields.some((field) => field.type === 'contact_supporter') &&
|
||||
!consignmentContactsQuery.isLoading &&
|
||||
contactSupporters.length === 0 ? (
|
||||
<Col span={24}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布"
|
||||
/>
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<div className="worker-consignment-actions">
|
||||
<Button
|
||||
@@ -945,28 +1029,17 @@ export default function WorkerConsignmentPage() {
|
||||
</Form>
|
||||
)
|
||||
|
||||
const publishTabs =
|
||||
consignmentProfiles.length > 0
|
||||
? consignmentProfiles.map((profile) => ({
|
||||
key: `publish-${profile.profileId}`,
|
||||
label: (
|
||||
<span>
|
||||
<DollarCircleOutlined /> {profile.tabTitle || '发布寄卖'}
|
||||
</span>
|
||||
),
|
||||
children: publishTabContent,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
key: 'publish',
|
||||
label: (
|
||||
<span>
|
||||
<DollarCircleOutlined /> 发布寄卖
|
||||
</span>
|
||||
),
|
||||
children: publishTabContent,
|
||||
},
|
||||
]
|
||||
const publishTabs = [
|
||||
{
|
||||
key: 'publish',
|
||||
label: (
|
||||
<span>
|
||||
<DollarCircleOutlined /> 发布寄卖
|
||||
</span>
|
||||
),
|
||||
children: publishTabContent,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack worker-consignment-page">
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
WorkOrderInventoryMatchCheck,
|
||||
WorkOrderInventoryMatchOptions,
|
||||
} from '@/types/consignment'
|
||||
import type { ConsignmentMatchLogsResponse } from '@/types/consignment-match-log'
|
||||
|
||||
// --- 分类级寄卖配置 ---
|
||||
|
||||
@@ -63,6 +64,23 @@ export function matchAdminConsignmentWorkOrder(payload: {
|
||||
)
|
||||
}
|
||||
|
||||
/** 匹配日志:自动/手动/重新匹配的每次尝试(含失败/跳过原因)。 */
|
||||
export function fetchAdminConsignmentMatchLogs(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
profileId?: number
|
||||
triggerType?: string
|
||||
result?: string
|
||||
keyword?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}) {
|
||||
return apiGet<ConsignmentMatchLogsResponse>(
|
||||
'/api/v1/admin/worker-platform/consignment/match-logs',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
// --- 对接客服名单 ---
|
||||
|
||||
export function fetchAdminConsignmentContacts(params?: { keyword?: string; enabled?: string }) {
|
||||
|
||||
@@ -387,15 +387,17 @@ export type WorkerConsignmentPublishPayload = {
|
||||
diamondLevel?: number
|
||||
brillianceCount?: number
|
||||
askingPriceFen: number
|
||||
sellerContact: string
|
||||
sellerContact?: string
|
||||
sellerPhone?: string
|
||||
characterName: string
|
||||
characterId: string
|
||||
screenshotsJson: string[]
|
||||
characterName?: string
|
||||
characterId?: string
|
||||
screenshotsJson?: string[]
|
||||
sellerNote?: string
|
||||
/** 动态收货信息(联系方式与游戏凭据)字段值 */
|
||||
intakeValues?: Record<string, unknown>
|
||||
depositAmountFen?: number
|
||||
/** 对接客服名单 id(必填,发布时选定) */
|
||||
contactSupporterId: number
|
||||
/** 对接客服名单 id;配置里删除该字段时可为空 */
|
||||
contactSupporterId?: number | null
|
||||
}
|
||||
|
||||
export type WorkerConsignmentItem = {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 寄卖匹配日志(后台「匹配日志」抽屉)
|
||||
* 记录自动/手动/重新匹配的每次尝试,用于留痕和问题分析。
|
||||
*/
|
||||
export type ConsignmentMatchLogTrigger = 'auto' | 'manual' | 'rematch'
|
||||
export type ConsignmentMatchLogResult = 'matched' | 'failed' | 'skipped'
|
||||
|
||||
export type ConsignmentMatchLogItem = {
|
||||
logId: number
|
||||
workOrderId: number | null
|
||||
workOrderNo: string
|
||||
profileId: number | null
|
||||
categoryId: number | null
|
||||
triggerType: ConsignmentMatchLogTrigger
|
||||
result: ConsignmentMatchLogResult
|
||||
reasonCode: string
|
||||
reasonText: string
|
||||
candidateCount: number
|
||||
matchedCount: number
|
||||
actorType: string
|
||||
actorId: string
|
||||
durationMs: number
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type ConsignmentMatchLogsResponse = {
|
||||
items: ConsignmentMatchLogItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
@@ -53,6 +53,28 @@ export type ConsignmentSpecField = {
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
/** 联系方式与游戏凭据:按寄卖配置动态渲染的收货信息字段 */
|
||||
export type ConsignmentIntakeFieldType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'phone'
|
||||
| 'number'
|
||||
| 'image'
|
||||
| 'contact_supporter'
|
||||
|
||||
export type ConsignmentIntakeField = {
|
||||
key: string
|
||||
label: string
|
||||
type: ConsignmentIntakeFieldType
|
||||
required: boolean
|
||||
placeholder: string
|
||||
helpText: string
|
||||
maxCount: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type ConsignmentIntakeValues = Record<string, string | number | string[]>
|
||||
|
||||
/**
|
||||
* 客服列表 / 详情接口额外返回的寄卖凭证字段(提报列表与库存列表都有)。
|
||||
*
|
||||
@@ -216,6 +238,9 @@ export type ConsignmentSubmission = ConsignmentEvidenceFields &
|
||||
specValues?: ConsignmentSpecValues
|
||||
specText?: string
|
||||
specs?: ConsignmentSpecField[]
|
||||
/** 收货信息动态字段与提交值(按发布快照) */
|
||||
intakeFields?: ConsignmentIntakeField[]
|
||||
intakeValues?: Record<string, unknown>
|
||||
priceCapFen?: number
|
||||
quantity?: number
|
||||
requestedSalePriceFen?: number
|
||||
@@ -363,11 +388,12 @@ export type ConsignmentProfileConfig = {
|
||||
/** 物品名模板,支持 {system}/{series}/{category} 等占位符 */
|
||||
nameTemplate: string
|
||||
pricing: {
|
||||
/** 接单模板价打折 / 固定价格 */
|
||||
priceType: 'template_discount' | 'fixed'
|
||||
/** 接单模板价打折 / 模板价固定下调 */
|
||||
priceType: 'template_discount' | 'template_minus'
|
||||
/** 几折,0.1 - 10,例如 9.5 */
|
||||
discount: number
|
||||
fixedPriceFen: number
|
||||
/** 固定下调金额(分):售价 = 模板价 - minusFen */
|
||||
minusFen: number
|
||||
allowWorkerPrice: boolean
|
||||
minPriceFen: number
|
||||
missingPricePolicy: 'block' | 'manual'
|
||||
@@ -381,19 +407,26 @@ export type ConsignmentProfileConfig = {
|
||||
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountFen: number }
|
||||
inventoryLimit: { enabled: boolean; maxActiveQuantity: number }
|
||||
matching: {
|
||||
mode: 'manual' | 'auto'
|
||||
autoMatchEnabled: boolean
|
||||
/** off = 完全关闭匹配;manual = 人工匹配;auto = 自动调度 */
|
||||
mode: 'off' | 'manual' | 'auto'
|
||||
exactOnly: boolean
|
||||
preferLowReward: boolean
|
||||
}
|
||||
internalMatch: { delayMinutes: number; fallbackToHall: boolean }
|
||||
timeout: { minutes: number; policy: 'reopen' | 'cancel_release' | 'cancel_deduct' }
|
||||
timeout: {
|
||||
minutes: number
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
|
||||
}
|
||||
priceDrop: {
|
||||
enabled: boolean
|
||||
hours: number
|
||||
mode: 'percent' | 'fixed'
|
||||
value: number
|
||||
}
|
||||
/** 联系方式与游戏凭据动态字段 */
|
||||
intake: {
|
||||
fields: ConsignmentIntakeField[]
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsignmentPricingConfig = ConsignmentProfileConfig['pricing']
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
ConsignmentIntakeField,
|
||||
ConsignmentIntakeFieldType,
|
||||
ConsignmentPricingConfig,
|
||||
ConsignmentSpecField,
|
||||
ConsignmentSpecFieldType,
|
||||
@@ -38,6 +40,96 @@ export function isOptionSpecField(field: Pick<ConsignmentSpecField, 'type'>): bo
|
||||
return field.type === 'select' || field.type === 'radio'
|
||||
}
|
||||
|
||||
export const INTAKE_FIELD_TYPE_OPTIONS: Array<{
|
||||
label: string
|
||||
value: ConsignmentIntakeFieldType
|
||||
}> = [
|
||||
{ label: '文本', value: 'text' },
|
||||
{ label: '多行文本', value: 'textarea' },
|
||||
{ label: '手机号', value: 'phone' },
|
||||
{ label: '数字', value: 'number' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '对接客服', value: 'contact_supporter' },
|
||||
]
|
||||
|
||||
export function intakeFieldTypeLabel(type: ConsignmentIntakeFieldType): string {
|
||||
return INTAKE_FIELD_TYPE_OPTIONS.find((item) => item.value === type)?.label || '文本'
|
||||
}
|
||||
|
||||
/** 与后端 DEFAULT_CONSIGNMENT_INTAKE_FIELDS 保持一致。 */
|
||||
export const DEFAULT_CONSIGNMENT_INTAKE_FIELDS: ConsignmentIntakeField[] = [
|
||||
{
|
||||
key: 'contactSupporterId',
|
||||
label: '对接客服',
|
||||
type: 'contact_supporter',
|
||||
required: true,
|
||||
placeholder: '请选择对接客服',
|
||||
helpText: '用于后续提成归属与联系',
|
||||
maxCount: 0,
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
key: 'sellerContact',
|
||||
label: '微信号 / QQ号',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '方便派单与紧急联系',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
key: 'sellerPhone',
|
||||
label: '联系电话',
|
||||
type: 'phone',
|
||||
required: true,
|
||||
placeholder: '手机号',
|
||||
helpText: '默认使用当前接单账号绑定手机号',
|
||||
maxCount: 0,
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
key: 'characterName',
|
||||
label: '游戏内角色名',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '局内加好友丢枪用',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 40,
|
||||
},
|
||||
{
|
||||
key: 'characterId',
|
||||
label: '角色数字ID',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '如 1089238472',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 50,
|
||||
},
|
||||
{
|
||||
key: 'screenshots',
|
||||
label: '游戏主页截图',
|
||||
type: 'image',
|
||||
required: true,
|
||||
placeholder: '',
|
||||
helpText: '须包含角色名、ID 及仓库/手枪详情,最多 4 张',
|
||||
maxCount: 4,
|
||||
sortOrder: 60,
|
||||
},
|
||||
{
|
||||
key: 'sellerNote',
|
||||
label: '其他留言与补充',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
placeholder: '配件、子弹、涂装或在线时段等(选填)',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 70,
|
||||
},
|
||||
]
|
||||
|
||||
export function formatSpecValue(
|
||||
field: ConsignmentSpecField,
|
||||
value: unknown,
|
||||
@@ -122,15 +214,18 @@ export function computeSpecPrice(
|
||||
const breakdown: Array<{ label: string; amountFen: number }> = []
|
||||
let priceFen = 0
|
||||
|
||||
if (pricing.priceType === 'fixed') {
|
||||
priceFen = Math.max(0, Math.round(Number(pricing.fixedPriceFen || 0)))
|
||||
if (priceFen > 0) breakdown.push({ label: '固定价格', amountFen: priceFen })
|
||||
} else if (template > 0) {
|
||||
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
|
||||
priceFen = Math.round((template * discount) / 10)
|
||||
if (template > 0) {
|
||||
breakdown.push({ label: '接单模板价', amountFen: template })
|
||||
if (discount !== 10) {
|
||||
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - template })
|
||||
if (pricing.priceType === 'template_minus') {
|
||||
const minusFen = Math.max(0, Math.round(Number(pricing.minusFen || 0)))
|
||||
priceFen = Math.max(0, template - minusFen)
|
||||
if (minusFen > 0) breakdown.push({ label: '固定下调', amountFen: -minusFen })
|
||||
} else {
|
||||
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
|
||||
priceFen = Math.round((template * discount) / 10)
|
||||
if (discount !== 10) {
|
||||
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - template })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,10 @@ test('寄卖配置面板:必须覆盖保证金/服务费/数量限制/匹配/
|
||||
'开启服务费',
|
||||
'开启单人寄售上限',
|
||||
'匹配模式',
|
||||
'自动匹配开关',
|
||||
'关闭匹配',
|
||||
'内部匹配等待(分钟)',
|
||||
'代练超时(分钟)',
|
||||
'代练超时(小时)',
|
||||
'重新匹配并退还押金',
|
||||
'开启降价提示',
|
||||
]) {
|
||||
assert.ok(panel.includes(label), `寄卖配置面板缺少「${label}」`)
|
||||
@@ -75,9 +76,11 @@ test('寄卖配置面板:订单服务风格分类树、独立分区与价格
|
||||
'服务费',
|
||||
'库存数量限制',
|
||||
'匹配设置',
|
||||
'超时与降价提示',
|
||||
'超时设置',
|
||||
'降价提示',
|
||||
'联系方式与游戏凭据',
|
||||
'接单模板价打折',
|
||||
'固定价格',
|
||||
'模板价固定下调',
|
||||
'模板折扣',
|
||||
'物品名模板',
|
||||
]) {
|
||||
|
||||
Reference in New Issue
Block a user