feat(consignment): 内部现货占库进入赠送冷静期并闭环发布押金
打手发布冻结履约押金,驳回/作废退还,验收释放;内部匹配履约立刻占用库存并跳过打手资料进入冷静期,撤销回库时补冻押金。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
|||||||
|
import type { PoolClient } from 'pg'
|
||||||
|
|
||||||
|
import { withTransaction } from '../../db/client.js'
|
||||||
|
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, toJsonString } from './shared.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询寄卖提报押金尚未释放的金额(分)
|
||||||
|
*/
|
||||||
|
export async function getOutstandingConsignmentDepositFen(
|
||||||
|
client: PoolClient,
|
||||||
|
workerId: number,
|
||||||
|
submissionId: number | string,
|
||||||
|
): Promise<number> {
|
||||||
|
const res = await client.query<{ outstanding: number }>(
|
||||||
|
`
|
||||||
|
SELECT (
|
||||||
|
COALESCE(SUM(CASE WHEN ledger_type = 'deposit_freeze' THEN ABS(amount) ELSE 0 END), 0)
|
||||||
|
- COALESCE(SUM(CASE WHEN ledger_type = 'deposit_release' THEN ABS(amount) ELSE 0 END), 0)
|
||||||
|
- COALESCE(SUM(CASE WHEN ledger_type = 'deposit_deduction' THEN ABS(amount) ELSE 0 END), 0)
|
||||||
|
)::int AS outstanding
|
||||||
|
FROM worker_wallet_ledgers
|
||||||
|
WHERE worker_id = $1
|
||||||
|
AND COALESCE(payload_json->>'kind', '') = 'consignment_publish'
|
||||||
|
AND COALESCE(payload_json->>'consignmentSubmissionId', '') = $2
|
||||||
|
`,
|
||||||
|
[workerId, String(submissionId)],
|
||||||
|
)
|
||||||
|
return Math.max(0, Number(res.rows[0]?.outstanding || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConsignmentDepositErrorCode(error: unknown): string | null {
|
||||||
|
if (!(error instanceof Error)) return null
|
||||||
|
const code = String((error as Error & { code?: string }).code || error.message || '').trim()
|
||||||
|
if (
|
||||||
|
code === 'consignment_deposit_insufficient' ||
|
||||||
|
code === 'consignment_deposit_balance_inconsistent'
|
||||||
|
) {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 寄卖发布成功:从可用余额冻结履约押金
|
||||||
|
*/
|
||||||
|
export async function freezeConsignmentPublishDepositWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
input: {
|
||||||
|
workerId: number
|
||||||
|
amountFen: number
|
||||||
|
submissionId: number | string
|
||||||
|
submissionNo: string
|
||||||
|
now: string
|
||||||
|
note?: string
|
||||||
|
},
|
||||||
|
): Promise<{ availableAfter: number; frozenAfter: number }> {
|
||||||
|
const amount = Math.max(0, Math.round(Number(input.amountFen || 0)))
|
||||||
|
if (amount <= 0) {
|
||||||
|
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||||
|
return {
|
||||||
|
availableAfter: Number(wallet?.available_amount || 0),
|
||||||
|
frozenAfter: Number(wallet?.frozen_deposit_amount || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
||||||
|
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||||
|
const available = Number(wallet?.available_amount || 0)
|
||||||
|
if (available < amount) {
|
||||||
|
const err = new Error('consignment_deposit_insufficient')
|
||||||
|
;(err as Error & { code?: string }).code = 'consignment_deposit_insufficient'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAvailable = available - amount
|
||||||
|
const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + amount
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE worker_wallets
|
||||||
|
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
|
||||||
|
WHERE worker_id = $4
|
||||||
|
`,
|
||||||
|
[nextAvailable, nextFrozen, input.now, input.workerId],
|
||||||
|
)
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO worker_wallet_ledgers (
|
||||||
|
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||||
|
related_work_order_id, note, payload_json, created_at
|
||||||
|
) VALUES ($1, 'deposit_freeze', $2, $3, $4, NULL, $5, $6::jsonb, $7)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.workerId,
|
||||||
|
-amount,
|
||||||
|
nextAvailable,
|
||||||
|
nextFrozen,
|
||||||
|
input.note || `寄卖发布冻结押金 ${input.submissionNo}`,
|
||||||
|
toJsonString({
|
||||||
|
kind: 'consignment_publish',
|
||||||
|
consignmentSubmissionId: String(input.submissionId),
|
||||||
|
submissionNo: input.submissionNo,
|
||||||
|
}),
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { availableAfter: nextAvailable, frozenAfter: nextFrozen }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 寄卖驳回或履约完成后:释放发布时冻结的押金
|
||||||
|
*/
|
||||||
|
export async function releaseConsignmentPublishDepositWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
input: {
|
||||||
|
workerId: number
|
||||||
|
submissionId: number | string
|
||||||
|
note: string
|
||||||
|
now: string
|
||||||
|
},
|
||||||
|
): Promise<number> {
|
||||||
|
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
||||||
|
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||||
|
// 锁住钱包后再读取流水,串行化同一打手的并发释放,避免重复退款或挪用其他押金。
|
||||||
|
const outstanding = await getOutstandingConsignmentDepositFen(
|
||||||
|
client,
|
||||||
|
input.workerId,
|
||||||
|
input.submissionId,
|
||||||
|
)
|
||||||
|
if (outstanding <= 0) return 0
|
||||||
|
|
||||||
|
const frozen = Number(wallet?.frozen_deposit_amount || 0)
|
||||||
|
if (frozen < outstanding) {
|
||||||
|
const error = new Error('consignment_deposit_balance_inconsistent')
|
||||||
|
;(error as Error & { code?: string }).code = 'consignment_deposit_balance_inconsistent'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
const releaseAmount = outstanding
|
||||||
|
|
||||||
|
const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount
|
||||||
|
const nextFrozen = Math.max(0, frozen - releaseAmount)
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE worker_wallets
|
||||||
|
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
|
||||||
|
WHERE worker_id = $4
|
||||||
|
`,
|
||||||
|
[nextAvailable, nextFrozen, input.now, input.workerId],
|
||||||
|
)
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO worker_wallet_ledgers (
|
||||||
|
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||||
|
related_work_order_id, note, payload_json, created_at
|
||||||
|
) VALUES ($1, 'deposit_release', $2, $3, $4, NULL, $5, $6::jsonb, $7)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.workerId,
|
||||||
|
releaseAmount,
|
||||||
|
nextAvailable,
|
||||||
|
nextFrozen,
|
||||||
|
input.note,
|
||||||
|
toJsonString({
|
||||||
|
kind: 'consignment_publish',
|
||||||
|
consignmentSubmissionId: String(input.submissionId),
|
||||||
|
}),
|
||||||
|
input.now,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return releaseAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConsignmentSubmissionDepositTarget(
|
||||||
|
client: PoolClient,
|
||||||
|
submissionId: number | string,
|
||||||
|
): Promise<{
|
||||||
|
workerId: number
|
||||||
|
depositAmountFen: number
|
||||||
|
submissionNo: string
|
||||||
|
} | null> {
|
||||||
|
const submission = await client.query<{
|
||||||
|
worker_id: number | string | null
|
||||||
|
deposit_amount_fen: number | string | null
|
||||||
|
submission_no: string
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
SELECT worker_id, deposit_amount_fen, submission_no
|
||||||
|
FROM consignment_submissions
|
||||||
|
WHERE id = $1
|
||||||
|
FOR SHARE
|
||||||
|
`,
|
||||||
|
[submissionId],
|
||||||
|
)
|
||||||
|
const row = submission.rows[0]
|
||||||
|
const workerId = Number(row?.worker_id || 0)
|
||||||
|
if (!workerId) return null
|
||||||
|
return {
|
||||||
|
workerId,
|
||||||
|
depositAmountFen: Math.max(0, Math.round(Number(row?.deposit_amount_fen || 0))),
|
||||||
|
submissionNo: String(row?.submission_no || ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseConsignmentPublishDepositBySubmissionWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
input: {
|
||||||
|
submissionId: number | string
|
||||||
|
note: string
|
||||||
|
now: string
|
||||||
|
},
|
||||||
|
): Promise<number> {
|
||||||
|
const target = await getConsignmentSubmissionDepositTarget(client, input.submissionId)
|
||||||
|
if (!target) return 0
|
||||||
|
return releaseConsignmentPublishDepositWithClient(client, {
|
||||||
|
workerId: target.workerId,
|
||||||
|
submissionId: input.submissionId,
|
||||||
|
note: input.note,
|
||||||
|
now: input.now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回库或重新挂卖时:若发布押金已释放则按提报金额重新冻结,已冻结则保持。 */
|
||||||
|
export async function ensureConsignmentPublishDepositFrozenBySubmissionWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
input: {
|
||||||
|
submissionId: number | string
|
||||||
|
note: string
|
||||||
|
now: string
|
||||||
|
},
|
||||||
|
): Promise<number> {
|
||||||
|
const target = await getConsignmentSubmissionDepositTarget(client, input.submissionId)
|
||||||
|
if (!target) return 0
|
||||||
|
|
||||||
|
await ensureWorkerWalletWithClient(client, target.workerId, input.now)
|
||||||
|
await getWorkerWalletWithClient(client, target.workerId)
|
||||||
|
const outstanding = await getOutstandingConsignmentDepositFen(
|
||||||
|
client,
|
||||||
|
target.workerId,
|
||||||
|
input.submissionId,
|
||||||
|
)
|
||||||
|
if (outstanding > 0) return outstanding
|
||||||
|
if (target.depositAmountFen <= 0) return 0
|
||||||
|
|
||||||
|
await freezeConsignmentPublishDepositWithClient(client, {
|
||||||
|
workerId: target.workerId,
|
||||||
|
amountFen: target.depositAmountFen,
|
||||||
|
submissionId: input.submissionId,
|
||||||
|
submissionNo: target.submissionNo,
|
||||||
|
note: input.note,
|
||||||
|
now: input.now,
|
||||||
|
})
|
||||||
|
return target.depositAmountFen
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseConsignmentPublishDeposit(input: {
|
||||||
|
workerId: number
|
||||||
|
submissionId: number | string
|
||||||
|
note: string
|
||||||
|
now: string
|
||||||
|
}): Promise<number> {
|
||||||
|
return withTransaction((client) => releaseConsignmentPublishDepositWithClient(client, input))
|
||||||
|
}
|
||||||
@@ -542,33 +542,66 @@ export async function updateConsignmentInventory(
|
|||||||
return res.rows[0] || null
|
return res.rows[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function archiveConsignmentInventoryWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
id: number | string,
|
||||||
|
now: string,
|
||||||
|
): Promise<ConsignmentInventoryRow | null> {
|
||||||
|
const res = await client.query<ConsignmentInventoryRow>(
|
||||||
|
`
|
||||||
|
UPDATE consignment_inventories
|
||||||
|
SET
|
||||||
|
status = 'archived',
|
||||||
|
allow_matching = FALSE,
|
||||||
|
updated_at = $1
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[now, id],
|
||||||
|
)
|
||||||
|
return res.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function returnConsignmentInventoryToStockWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
id: number | string,
|
||||||
|
reason: string,
|
||||||
|
now: string,
|
||||||
|
): Promise<ConsignmentInventoryRow | null> {
|
||||||
|
const res = await client.query<ConsignmentInventoryRow>(
|
||||||
|
`
|
||||||
|
UPDATE consignment_inventories
|
||||||
|
SET
|
||||||
|
status = 'in_stock',
|
||||||
|
allocation_status = '待安排',
|
||||||
|
bound_order_id = NULL,
|
||||||
|
bound_work_order_id = NULL,
|
||||||
|
bound_platform_order_id = '',
|
||||||
|
shop_name = '',
|
||||||
|
consumed_at = NULL,
|
||||||
|
allow_matching = TRUE,
|
||||||
|
remark = CASE
|
||||||
|
WHEN $1 <> '' THEN remark || ' [退库原因: ' || $1 || ']'
|
||||||
|
ELSE remark
|
||||||
|
END,
|
||||||
|
updated_at = $2
|
||||||
|
WHERE id = $3 AND status = 'consumed'
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[reason, now, id],
|
||||||
|
)
|
||||||
|
return res.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
/** 退回复库(将已出库物品恢复为在库待匹配) */
|
/** 退回复库(将已出库物品恢复为在库待匹配) */
|
||||||
export async function returnConsignmentInventoryToStock(
|
export async function returnConsignmentInventoryToStock(
|
||||||
id: number | string,
|
id: number | string,
|
||||||
reason: string,
|
reason: string,
|
||||||
now: string,
|
now: string,
|
||||||
): Promise<ConsignmentInventoryRow | null> {
|
): Promise<ConsignmentInventoryRow | null> {
|
||||||
const sql = `
|
return withTransaction((client) =>
|
||||||
UPDATE consignment_inventories
|
returnConsignmentInventoryToStockWithClient(client, id, reason, now),
|
||||||
SET
|
)
|
||||||
status = 'in_stock',
|
|
||||||
allocation_status = '待安排',
|
|
||||||
bound_order_id = NULL,
|
|
||||||
bound_work_order_id = NULL,
|
|
||||||
bound_platform_order_id = '',
|
|
||||||
shop_name = '',
|
|
||||||
consumed_at = NULL,
|
|
||||||
allow_matching = TRUE,
|
|
||||||
remark = CASE
|
|
||||||
WHEN $1 <> '' THEN remark || ' [退库原因: ' || $1 || ']'
|
|
||||||
ELSE remark
|
|
||||||
END,
|
|
||||||
updated_at = $2
|
|
||||||
WHERE id = $3 AND status = 'consumed'
|
|
||||||
RETURNING *
|
|
||||||
`
|
|
||||||
const res = await query<ConsignmentInventoryRow>(sql, [reason, now, id])
|
|
||||||
return res.rows[0] || null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询当前所有可供匹配的在库物品(status = 'in_stock' 且 allow_matching = TRUE) */
|
/** 查询当前所有可供匹配的在库物品(status = 'in_stock' 且 allow_matching = TRUE) */
|
||||||
@@ -819,6 +852,36 @@ export async function createConsignmentSubmission(input: {
|
|||||||
depositAmountFen?: number
|
depositAmountFen?: number
|
||||||
now: string
|
now: string
|
||||||
}): Promise<ConsignmentSubmissionRow> {
|
}): Promise<ConsignmentSubmissionRow> {
|
||||||
|
return withTransaction((client) => createConsignmentSubmissionWithClient(client, input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConsignmentSubmissionWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
input: {
|
||||||
|
submissionNo: string
|
||||||
|
linkId?: number | null
|
||||||
|
gameName?: string
|
||||||
|
categoryId?: number | null
|
||||||
|
itemName: string
|
||||||
|
system?: string
|
||||||
|
serverZone?: string
|
||||||
|
series?: string
|
||||||
|
diamondLevel?: number
|
||||||
|
brillianceCount?: number
|
||||||
|
sellerGameId?: string
|
||||||
|
sellerPaymentInfo?: string
|
||||||
|
sellerPhone?: string
|
||||||
|
contactInfo?: string
|
||||||
|
contentJson?: Record<string, unknown>
|
||||||
|
screenshotsJson?: unknown[]
|
||||||
|
askingPriceFen?: number
|
||||||
|
workerId?: number | null
|
||||||
|
workerName?: string
|
||||||
|
workerPhone?: string
|
||||||
|
depositAmountFen?: number
|
||||||
|
now: string
|
||||||
|
},
|
||||||
|
): Promise<ConsignmentSubmissionRow> {
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO consignment_submissions (
|
INSERT INTO consignment_submissions (
|
||||||
submission_no, link_id, game_name, category_id, item_name,
|
submission_no, link_id, game_name, category_id, item_name,
|
||||||
@@ -837,7 +900,7 @@ export async function createConsignmentSubmission(input: {
|
|||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`
|
`
|
||||||
const res = await query<ConsignmentSubmissionRow>(sql, [
|
const res = await client.query<ConsignmentSubmissionRow>(sql, [
|
||||||
input.submissionNo,
|
input.submissionNo,
|
||||||
input.linkId || null,
|
input.linkId || null,
|
||||||
input.gameName || '和平精英',
|
input.gameName || '和平精英',
|
||||||
@@ -870,6 +933,18 @@ export async function rejectConsignmentSubmission(input: {
|
|||||||
reviewerName: string
|
reviewerName: string
|
||||||
now: string
|
now: string
|
||||||
}): Promise<ConsignmentSubmissionRow | null> {
|
}): Promise<ConsignmentSubmissionRow | null> {
|
||||||
|
return withTransaction((client) => rejectConsignmentSubmissionWithClient(client, input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectConsignmentSubmissionWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
input: {
|
||||||
|
submissionId: number | string
|
||||||
|
reviewNote: string
|
||||||
|
reviewerName: string
|
||||||
|
now: string
|
||||||
|
},
|
||||||
|
): Promise<ConsignmentSubmissionRow | null> {
|
||||||
const sql = `
|
const sql = `
|
||||||
UPDATE consignment_submissions
|
UPDATE consignment_submissions
|
||||||
SET
|
SET
|
||||||
@@ -881,7 +956,7 @@ export async function rejectConsignmentSubmission(input: {
|
|||||||
WHERE id = $4 AND status = 'pending_review'
|
WHERE id = $4 AND status = 'pending_review'
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`
|
`
|
||||||
const res = await query<ConsignmentSubmissionRow>(sql, [
|
const res = await client.query<ConsignmentSubmissionRow>(sql, [
|
||||||
input.reviewNote,
|
input.reviewNote,
|
||||||
input.reviewerName,
|
input.reviewerName,
|
||||||
input.now,
|
input.now,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import type { PoolClient } from 'pg'
|
||||||
import { withTransaction } from '../../db/client.js'
|
import { withTransaction } from '../../db/client.js'
|
||||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||||
import type { WorkOrderRow } from './types.js'
|
import type { WorkOrderRow } from './types.js'
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_GIFT_COOLDOWN_HOURS,
|
||||||
normalizeWorkOrderAcceptanceMode,
|
normalizeWorkOrderAcceptanceMode,
|
||||||
normalizeWorkOrderGiftCooldownHours,
|
normalizeWorkOrderGiftCooldownHours,
|
||||||
normalizeWorkOrderGiftCooldownMinutes,
|
normalizeWorkOrderGiftCooldownMinutes,
|
||||||
@@ -10,6 +12,20 @@ import {
|
|||||||
WORK_ORDER_GIFT_PHASE,
|
WORK_ORDER_GIFT_PHASE,
|
||||||
} from '../../domain/work-order-acceptance-mode.js'
|
} from '../../domain/work-order-acceptance-mode.js'
|
||||||
|
|
||||||
|
export const CONSIGNMENT_FULFILLMENT_TYPE = {
|
||||||
|
INTERNAL_STOCK: 'internal_stock',
|
||||||
|
WORKER_DEPOSIT: 'worker_deposit',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const GIFT_COUNTDOWN_BLOCKED_STATUSES = new Set(['accepted', 'cancelled', 'pending_acceptance'])
|
||||||
|
const GIFT_COUNTDOWN_ALLOWED_STATUSES = new Set([
|
||||||
|
'unassigned',
|
||||||
|
'pending_material',
|
||||||
|
'in_progress',
|
||||||
|
'problem',
|
||||||
|
'open',
|
||||||
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工单脱离打手(回大厅、取消指派、恢复未分配等)时重置好友赠送进度,
|
* 工单脱离打手(回大厅、取消指派、恢复未分配等)时重置好友赠送进度,
|
||||||
* 下一个打手重新走“提交资料 → 确认好友 → 冷却”流程。
|
* 下一个打手重新走“提交资料 → 确认好友 → 冷却”流程。
|
||||||
@@ -22,6 +38,136 @@ export const RESET_GIFT_FLOW_SQL = `
|
|||||||
gift_available_at = NULL
|
gift_available_at = NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export function parseWorkOrderMaterial(workOrder: {
|
||||||
|
material_json?: unknown
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
if (typeof workOrder.material_json === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(workOrder.material_json) as unknown
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
if (workOrder.material_json && typeof workOrder.material_json === 'object') {
|
||||||
|
return workOrder.material_json as Record<string, unknown>
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkOrderConsignment(workOrder: {
|
||||||
|
material_json?: unknown
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
const material = parseWorkOrderMaterial(workOrder)
|
||||||
|
return material.consignment &&
|
||||||
|
typeof material.consignment === 'object' &&
|
||||||
|
!Array.isArray(material.consignment)
|
||||||
|
? (material.consignment as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasInternalConsignmentFulfillment(workOrder: {
|
||||||
|
assigned_worker_id?: number | null
|
||||||
|
material_json?: unknown
|
||||||
|
}): boolean {
|
||||||
|
if (Number(workOrder.assigned_worker_id || 0) > 0) return false
|
||||||
|
const consignment = parseWorkOrderConsignment(workOrder)
|
||||||
|
if (!consignment.inventoryId) return false
|
||||||
|
if (String(consignment.fulfillmentType || '') === CONSIGNMENT_FULFILLMENT_TYPE.WORKER_DEPOSIT) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (Number(consignment.workerId || 0) > 0) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开始好友赠送冷却:内部寄售跳过打手资料,直接进入等 N 天。 */
|
||||||
|
export async function startWorkOrderGiftCountdownWithClient(
|
||||||
|
client: PoolClient,
|
||||||
|
workOrder: WorkOrderRow,
|
||||||
|
input: {
|
||||||
|
now: string
|
||||||
|
actorName?: string | undefined
|
||||||
|
eventType?: string | undefined
|
||||||
|
materialJson?: unknown
|
||||||
|
extraPayload?: Record<string, unknown>
|
||||||
|
forceFriendGift?: boolean
|
||||||
|
},
|
||||||
|
): Promise<WorkOrderRow | null> {
|
||||||
|
if (GIFT_COUNTDOWN_BLOCKED_STATUSES.has(workOrder.status)) return null
|
||||||
|
|
||||||
|
let cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours)
|
||||||
|
let cooldownMinutes = normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes)
|
||||||
|
if (input.forceFriendGift && cooldownHours === 0 && cooldownMinutes === 0) {
|
||||||
|
cooldownHours = DEFAULT_GIFT_COOLDOWN_HOURS
|
||||||
|
}
|
||||||
|
const nextStatus = ['problem'].includes(workOrder.status) ? workOrder.status : 'in_progress'
|
||||||
|
const clearAcceptedAt = ['accepted', 'completed'].includes(workOrder.status)
|
||||||
|
const materialJson =
|
||||||
|
input.materialJson === undefined
|
||||||
|
? null
|
||||||
|
: typeof input.materialJson === 'string'
|
||||||
|
? input.materialJson
|
||||||
|
: JSON.stringify(input.materialJson)
|
||||||
|
const materialForInternalCheck =
|
||||||
|
input.materialJson === undefined ? workOrder.material_json : input.materialJson
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE work_orders
|
||||||
|
SET
|
||||||
|
status = $1,
|
||||||
|
acceptance_mode = CASE WHEN $7 THEN 'friend_gift' ELSE acceptance_mode END,
|
||||||
|
gift_cooldown_hours = CASE WHEN $7 THEN $3 ELSE gift_cooldown_hours END,
|
||||||
|
gift_phase = 'friend_countdown',
|
||||||
|
friend_added_at = $2::timestamptz,
|
||||||
|
gift_available_at = $2::timestamptz + ($3::int * INTERVAL '1 hour') + ($4::int * INTERVAL '1 minute'),
|
||||||
|
deadline_at = CASE
|
||||||
|
WHEN timeout_minutes > 0
|
||||||
|
THEN $2::timestamptz + ($3::int * INTERVAL '1 hour') + ($4::int * INTERVAL '1 minute') + (timeout_minutes * INTERVAL '1 minute')
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
accepted_at = CASE WHEN $5 THEN NULL ELSE accepted_at END,
|
||||||
|
material_json = COALESCE($8::jsonb, material_json),
|
||||||
|
updated_at = $2::timestamptz
|
||||||
|
WHERE id = $6
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
nextStatus,
|
||||||
|
input.now,
|
||||||
|
cooldownHours,
|
||||||
|
cooldownMinutes,
|
||||||
|
clearAcceptedAt,
|
||||||
|
workOrder.id,
|
||||||
|
Boolean(input.forceFriendGift),
|
||||||
|
materialJson,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await createWorkOrderEventWithClient(client, {
|
||||||
|
workOrderId: Number(workOrder.id),
|
||||||
|
actorType: 'admin',
|
||||||
|
actorId: input.actorName || '',
|
||||||
|
eventType: input.eventType || 'friend_confirmed',
|
||||||
|
fromStatus: workOrder.status,
|
||||||
|
toStatus: nextStatus,
|
||||||
|
payloadJson: JSON.stringify({
|
||||||
|
cooldownHours,
|
||||||
|
cooldownMinutes,
|
||||||
|
friendAddedAt: input.now,
|
||||||
|
internalConsignment: hasInternalConsignmentFulfillment({
|
||||||
|
...workOrder,
|
||||||
|
material_json: materialForInternalCheck,
|
||||||
|
}),
|
||||||
|
skippedWorkerMaterial: Boolean(input.forceFriendGift),
|
||||||
|
giftPhase: WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN,
|
||||||
|
...(input.extraPayload || {}),
|
||||||
|
}),
|
||||||
|
now: input.now,
|
||||||
|
})
|
||||||
|
return getWorkOrderByIdWithClient(client, workOrder.id)
|
||||||
|
}
|
||||||
|
|
||||||
/** 打手提交本人资料(昵称/ID/区服/系统 + 主页截图),进入待确认好友阶段。 */
|
/** 打手提交本人资料(昵称/ID/区服/系统 + 主页截图),进入待确认好友阶段。 */
|
||||||
export async function submitWorkOrderGiftMaterial(input: {
|
export async function submitWorkOrderGiftMaterial(input: {
|
||||||
workOrderId: number
|
workOrderId: number
|
||||||
@@ -102,6 +248,7 @@ export async function confirmWorkOrderFriendAdded(input: {
|
|||||||
| 'work_order_not_found'
|
| 'work_order_not_found'
|
||||||
| 'work_order_mode_invalid'
|
| 'work_order_mode_invalid'
|
||||||
| 'work_order_gift_phase_invalid'
|
| 'work_order_gift_phase_invalid'
|
||||||
|
| 'work_order_status_invalid'
|
||||||
| null
|
| null
|
||||||
}> {
|
}> {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
@@ -117,46 +264,26 @@ export async function confirmWorkOrderFriendAdded(input: {
|
|||||||
) {
|
) {
|
||||||
return { order: null, failureReason: 'work_order_mode_invalid' }
|
return { order: null, failureReason: 'work_order_mode_invalid' }
|
||||||
}
|
}
|
||||||
if (workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED) {
|
if (!GIFT_COUNTDOWN_ALLOWED_STATUSES.has(workOrder.status)) {
|
||||||
|
return { order: null, failureReason: 'work_order_status_invalid' }
|
||||||
|
}
|
||||||
|
const internalConsignment = hasInternalConsignmentFulfillment(workOrder)
|
||||||
|
const canStartFromWorkerMaterial =
|
||||||
|
workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED
|
||||||
|
const canStartFromInternalStock =
|
||||||
|
internalConsignment &&
|
||||||
|
(workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED ||
|
||||||
|
workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED)
|
||||||
|
if (!canStartFromWorkerMaterial && !canStartFromInternalStock) {
|
||||||
return { order: null, failureReason: 'work_order_gift_phase_invalid' }
|
return { order: null, failureReason: 'work_order_gift_phase_invalid' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours)
|
|
||||||
const cooldownMinutes = normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes)
|
|
||||||
// 参数必须显式转型:未知类型参数与 interval 做运算时 PostgreSQL 无法唯一解析操作符。
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE work_orders
|
|
||||||
SET
|
|
||||||
gift_phase = 'friend_countdown',
|
|
||||||
friend_added_at = $1::timestamptz,
|
|
||||||
gift_available_at = $1::timestamptz + ($2::int * INTERVAL '1 hour') + ($3::int * INTERVAL '1 minute'),
|
|
||||||
deadline_at = CASE
|
|
||||||
WHEN timeout_minutes > 0
|
|
||||||
THEN $1::timestamptz + ($2::int * INTERVAL '1 hour') + ($3::int * INTERVAL '1 minute') + (timeout_minutes * INTERVAL '1 minute')
|
|
||||||
ELSE NULL
|
|
||||||
END,
|
|
||||||
updated_at = $1::timestamptz
|
|
||||||
WHERE id = $4
|
|
||||||
`,
|
|
||||||
[input.now, cooldownHours, cooldownMinutes, input.workOrderId],
|
|
||||||
)
|
|
||||||
await createWorkOrderEventWithClient(client, {
|
|
||||||
workOrderId: input.workOrderId,
|
|
||||||
actorType: 'admin',
|
|
||||||
actorId: input.actorName || '',
|
|
||||||
eventType: 'friend_confirmed',
|
|
||||||
fromStatus: workOrder.status,
|
|
||||||
toStatus: workOrder.status,
|
|
||||||
payloadJson: JSON.stringify({
|
|
||||||
cooldownHours,
|
|
||||||
cooldownMinutes,
|
|
||||||
friendAddedAt: input.now,
|
|
||||||
}),
|
|
||||||
now: input.now,
|
|
||||||
})
|
|
||||||
return {
|
return {
|
||||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
order: await startWorkOrderGiftCountdownWithClient(client, workOrder, {
|
||||||
|
now: input.now,
|
||||||
|
actorName: input.actorName,
|
||||||
|
eventType: internalConsignment ? 'consignment_gift_countdown_started' : 'friend_confirmed',
|
||||||
|
}),
|
||||||
failureReason: null,
|
failureReason: null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from './work-order-deposit-repo.js'
|
} from './work-order-deposit-repo.js'
|
||||||
import { maybeUpgradeWorkerLevelWithClient } from './worker-ranking-repo.js'
|
import { maybeUpgradeWorkerLevelWithClient } from './worker-ranking-repo.js'
|
||||||
import type { WorkOrderRow, WorkOrderShareRow } from './types.js'
|
import type { WorkOrderRow, WorkOrderShareRow } from './types.js'
|
||||||
|
import { releaseConsignmentPublishDepositBySubmissionWithClient } from './consignment-deposit-repo.js'
|
||||||
|
|
||||||
export async function acceptWorkOrderAndSettle(input: {
|
export async function acceptWorkOrderAndSettle(input: {
|
||||||
workOrderId: number
|
workOrderId: number
|
||||||
@@ -76,6 +77,31 @@ export async function acceptWorkOrderAndSettle(input: {
|
|||||||
[input.now, input.workOrderId],
|
[input.now, input.workOrderId],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const material =
|
||||||
|
typeof workOrder.material_json === 'string'
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(workOrder.material_json) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: workOrder.material_json && typeof workOrder.material_json === 'object'
|
||||||
|
? (workOrder.material_json as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const consignment =
|
||||||
|
material.consignment && typeof material.consignment === 'object'
|
||||||
|
? (material.consignment as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
const sourceSubmissionId = consignment?.sourceSubmissionId
|
||||||
|
if (sourceSubmissionId) {
|
||||||
|
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||||
|
submissionId: String(sourceSubmissionId),
|
||||||
|
note: `寄卖工单验收完成释放发布押金 #${input.workOrderId}`,
|
||||||
|
now: input.now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (shares.length > 0) {
|
if (shares.length > 0) {
|
||||||
// 单独验收过的份额已经完成资金结算,统一验收时只处理尚未结算的份额。
|
// 单独验收过的份额已经完成资金结算,统一验收时只处理尚未结算的份额。
|
||||||
const unsettledShares = shares.filter((share) => share.status !== 'accepted')
|
const unsettledShares = shares.filter((share) => share.status !== 'accepted')
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import {
|
|||||||
} from './mappers.js'
|
} from './mappers.js'
|
||||||
import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js'
|
import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js'
|
||||||
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
||||||
|
import {
|
||||||
|
resolveWorkOrderGiftPhase,
|
||||||
|
WORK_ORDER_GIFT_PHASE,
|
||||||
|
} from '../../domain/work-order-acceptance-mode.js'
|
||||||
|
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
|
||||||
|
|
||||||
export async function acceptAdminWorkOrder(workOrderId: number | string, actorName = '') {
|
export async function acceptAdminWorkOrder(workOrderId: number | string, actorName = '') {
|
||||||
let workOrder = await getRequiredWorkOrder(workOrderId)
|
let workOrder = await getRequiredWorkOrder(workOrderId)
|
||||||
@@ -33,15 +38,28 @@ export async function acceptAdminWorkOrder(workOrderId: number | string, actorNa
|
|||||||
errorCode: 'work_order_accept_status_invalid',
|
errorCode: 'work_order_accept_status_invalid',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const now = nowIso()
|
||||||
// 历史拼单订单可能仍保留 open,验收前先补齐母工单生命周期状态。
|
// 历史拼单订单可能仍保留 open,验收前先补齐母工单生命周期状态。
|
||||||
if (workOrder.status === WORK_ORDER_STATUS.OPEN) {
|
if (workOrder.status === WORK_ORDER_STATUS.OPEN) {
|
||||||
const promoted = await updateWorkOrder(workOrder.id, {
|
const promoted = await updateWorkOrder(workOrder.id, {
|
||||||
status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||||
updated_at: nowIso(),
|
updated_at: now,
|
||||||
|
})
|
||||||
|
if (promoted) workOrder = promoted
|
||||||
|
}
|
||||||
|
// 内部寄售好友赠送:冷却结束后无需打手交图,直接进入待验收再结算。
|
||||||
|
if (
|
||||||
|
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
||||||
|
hasInternalConsignmentFulfillment(workOrder) &&
|
||||||
|
resolveWorkOrderGiftPhase(workOrder) === WORK_ORDER_GIFT_PHASE.GIFT_READY
|
||||||
|
) {
|
||||||
|
const promoted = await updateWorkOrder(workOrder.id, {
|
||||||
|
status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||||
|
submitted_at: now,
|
||||||
|
updated_at: now,
|
||||||
})
|
})
|
||||||
if (promoted) workOrder = promoted
|
if (promoted) workOrder = promoted
|
||||||
}
|
}
|
||||||
const now = nowIso()
|
|
||||||
const { order: updated, failureReason } = await acceptWorkOrderAndSettle({
|
const { order: updated, failureReason } = await acceptWorkOrderAndSettle({
|
||||||
workOrderId: workOrder.id,
|
workOrderId: workOrder.id,
|
||||||
...resolveAcceptanceFinanceOptions(),
|
...resolveAcceptanceFinanceOptions(),
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ export async function confirmAdminWorkOrderFriendAdded(
|
|||||||
errorCode: 'work_order_friend_confirm_phase_invalid',
|
errorCode: 'work_order_friend_confirm_phase_invalid',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (failureReason === 'work_order_status_invalid') {
|
||||||
|
throw createHttpError('当前订单状态不能开始赠送冷却', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_friend_confirm_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
if (!order) {
|
if (!order) {
|
||||||
throw createHttpError('确认加好友失败,订单可能已变化', {
|
throw createHttpError('确认加好友失败,订单可能已变化', {
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { withTransaction } from '../../db/client.js'
|
||||||
import {
|
import {
|
||||||
type ConsignmentCollectLinkRow,
|
type ConsignmentCollectLinkRow,
|
||||||
type ConsignmentInventoryRow,
|
type ConsignmentInventoryRow,
|
||||||
@@ -11,8 +12,9 @@ import {
|
|||||||
listConsignmentCollectLinks,
|
listConsignmentCollectLinks,
|
||||||
listConsignmentInventories,
|
listConsignmentInventories,
|
||||||
listConsignmentSubmissions,
|
listConsignmentSubmissions,
|
||||||
rejectConsignmentSubmission,
|
rejectConsignmentSubmissionWithClient,
|
||||||
returnConsignmentInventoryToStock,
|
archiveConsignmentInventoryWithClient,
|
||||||
|
returnConsignmentInventoryToStockWithClient,
|
||||||
updateConsignmentCollectLink,
|
updateConsignmentCollectLink,
|
||||||
updateConsignmentInventory,
|
updateConsignmentInventory,
|
||||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||||
@@ -25,6 +27,31 @@ import { createHttpError } from '../../utils/http.js'
|
|||||||
import { generateConsignmentInventoryNo, randomToken } from '../../utils/random.js'
|
import { generateConsignmentInventoryNo, randomToken } from '../../utils/random.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||||
|
import {
|
||||||
|
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
|
||||||
|
getConsignmentDepositErrorCode,
|
||||||
|
releaseConsignmentPublishDepositBySubmissionWithClient,
|
||||||
|
releaseConsignmentPublishDepositWithClient,
|
||||||
|
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
|
||||||
|
|
||||||
|
function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never {
|
||||||
|
const code = getConsignmentDepositErrorCode(error)
|
||||||
|
if (code === 'consignment_deposit_insufficient') {
|
||||||
|
throw createHttpError(
|
||||||
|
restock
|
||||||
|
? '库存回库需要重新冻结履约押金,打手可用余额不足,请先充值后再操作'
|
||||||
|
: '可用余额不足,无法冻结寄卖履约押金',
|
||||||
|
{ statusCode: 409, errorCode: 'consignment_deposit_insufficient' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (code === 'consignment_deposit_balance_inconsistent') {
|
||||||
|
throw createHttpError('寄卖押金账户异常,冻结额与流水不一致,请核对后再操作', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'consignment_deposit_balance_inconsistent',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
|
export function mapConsignmentInventory(row: ConsignmentInventoryRow) {
|
||||||
return {
|
return {
|
||||||
@@ -321,15 +348,29 @@ export async function toggleAdminConsignmentMatching(id: number | string, allowM
|
|||||||
/** 物品下架作废 */
|
/** 物品下架作废 */
|
||||||
export async function archiveAdminConsignmentInventory(id: number | string) {
|
export async function archiveAdminConsignmentInventory(id: number | string) {
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const updated = await updateConsignmentInventory(id, {
|
try {
|
||||||
status: 'archived',
|
const updated = await withTransaction(async (client) => {
|
||||||
allowMatching: false,
|
const archived = await archiveConsignmentInventoryWithClient(client, id, now)
|
||||||
now,
|
if (!archived) return null
|
||||||
})
|
if (archived.source_submission_id) {
|
||||||
if (!updated) {
|
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||||
throw createHttpError('寄卖物品不存在', { statusCode: 404, errorCode: 'consignment_not_found' })
|
submissionId: archived.source_submission_id,
|
||||||
|
note: `寄卖库存作废退还押金 ${archived.inventory_no}`,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return archived
|
||||||
|
})
|
||||||
|
if (!updated) {
|
||||||
|
throw createHttpError('寄卖物品不存在', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'consignment_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return mapConsignmentInventory(updated)
|
||||||
|
} catch (error) {
|
||||||
|
throwConsignmentDepositHttpError(error, false)
|
||||||
}
|
}
|
||||||
return mapConsignmentInventory(updated)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已出库物品退回复库 */
|
/** 已出库物品退回复库 */
|
||||||
@@ -340,14 +381,29 @@ export async function returnAdminConsignmentInventory(
|
|||||||
) {
|
) {
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const reason = String(payload.reason || '').trim()
|
const reason = String(payload.reason || '').trim()
|
||||||
const updated = await returnConsignmentInventoryToStock(id, reason, now)
|
try {
|
||||||
if (!updated) {
|
const updated = await withTransaction(async (client) => {
|
||||||
throw createHttpError('只有已出库物品可以退回复库', {
|
const returned = await returnConsignmentInventoryToStockWithClient(client, id, reason, now)
|
||||||
statusCode: 409,
|
if (!returned) return null
|
||||||
errorCode: 'consignment_not_returnable',
|
if (returned.source_submission_id) {
|
||||||
|
await ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, {
|
||||||
|
submissionId: returned.source_submission_id,
|
||||||
|
note: `寄卖退回复库重新冻结押金 ${returned.inventory_no}`,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return returned
|
||||||
})
|
})
|
||||||
|
if (!updated) {
|
||||||
|
throw createHttpError('只有已出库物品可以退回复库', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'consignment_not_returnable',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return mapConsignmentInventory(updated)
|
||||||
|
} catch (error) {
|
||||||
|
throwConsignmentDepositHttpError(error, true)
|
||||||
}
|
}
|
||||||
return mapConsignmentInventory(updated)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 收集链接管理 ----------------
|
// ---------------- 收集链接管理 ----------------
|
||||||
@@ -536,11 +592,22 @@ export async function rejectAdminConsignmentSubmission(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
const updated = await rejectConsignmentSubmission({
|
const updated = await withTransaction(async (client) => {
|
||||||
submissionId: id,
|
const rejected = await rejectConsignmentSubmissionWithClient(client, {
|
||||||
reviewNote: note,
|
submissionId: id,
|
||||||
reviewerName: actorName,
|
reviewNote: note,
|
||||||
now,
|
reviewerName: actorName,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
if (rejected?.worker_id) {
|
||||||
|
await releaseConsignmentPublishDepositWithClient(client, {
|
||||||
|
workerId: Number(rejected.worker_id),
|
||||||
|
submissionId: rejected.id,
|
||||||
|
note: `寄卖提报驳回退还押金 ${rejected.submission_no}`,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rejected
|
||||||
})
|
})
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw createHttpError('提报单不存在或已被处理', {
|
throw createHttpError('提报单不存在或已被处理', {
|
||||||
|
|||||||
@@ -8,8 +8,17 @@ import {
|
|||||||
updateConsignmentInventory,
|
updateConsignmentInventory,
|
||||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||||
import {
|
import {
|
||||||
|
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
|
||||||
|
getConsignmentDepositErrorCode,
|
||||||
|
releaseConsignmentPublishDepositBySubmissionWithClient,
|
||||||
|
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
|
||||||
|
import {
|
||||||
|
CONSIGNMENT_FULFILLMENT_TYPE,
|
||||||
|
RESET_GIFT_FLOW_SQL,
|
||||||
createWorkOrderEvent,
|
createWorkOrderEvent,
|
||||||
|
createWorkOrderEventWithClient,
|
||||||
getWorkOrderById,
|
getWorkOrderById,
|
||||||
|
startWorkOrderGiftCountdownWithClient,
|
||||||
updateWorkOrder,
|
updateWorkOrder,
|
||||||
type WorkOrderRow,
|
type WorkOrderRow,
|
||||||
} from '../../repositories/worker-platform/index.js'
|
} from '../../repositories/worker-platform/index.js'
|
||||||
@@ -28,6 +37,26 @@ import {
|
|||||||
findMatchingPeacekeeperInventories,
|
findMatchingPeacekeeperInventories,
|
||||||
} from './consignment-matching-service.js'
|
} from './consignment-matching-service.js'
|
||||||
import { mapConsignmentInventory } from './consignment-admin-service.js'
|
import { mapConsignmentInventory } from './consignment-admin-service.js'
|
||||||
|
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
|
||||||
|
|
||||||
|
function throwConsignmentDepositHttpError(error: unknown, restock: boolean): never {
|
||||||
|
const code = getConsignmentDepositErrorCode(error)
|
||||||
|
if (code === 'consignment_deposit_insufficient') {
|
||||||
|
throw createHttpError(
|
||||||
|
restock
|
||||||
|
? '库存回库需要重新冻结履约押金,打手可用余额不足,请先充值后再撤销'
|
||||||
|
: '可用余额不足,无法冻结寄卖履约押金',
|
||||||
|
{ statusCode: 409, errorCode: 'consignment_deposit_insufficient' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (code === 'consignment_deposit_balance_inconsistent') {
|
||||||
|
throw createHttpError('寄卖押金账户异常,冻结额与流水不一致,请核对后再操作', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'consignment_deposit_balance_inconsistent',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
export type ConsignmentMatchResultView = Omit<ConsignmentMatchResult, 'inventory'> & {
|
export type ConsignmentMatchResultView = Omit<ConsignmentMatchResult, 'inventory'> & {
|
||||||
inventory: ReturnType<typeof mapConsignmentInventory>
|
inventory: ReturnType<typeof mapConsignmentInventory>
|
||||||
@@ -47,18 +76,14 @@ export async function checkWorkOrderInventoryMatches(workOrderId: number | strin
|
|||||||
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
|
throw createHttpError('工单不存在', { statusCode: 404, errorCode: 'work_order_not_found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const material = safeParseJson(workOrder.material_json)
|
const fields = resolveCollectSubmittedFieldValues(workOrder)
|
||||||
const collect = safeParseJson(material.collect)
|
|
||||||
const fields = safeParseJson(collect.fields)
|
|
||||||
|
|
||||||
const system = String(fields.system || '').trim()
|
|
||||||
const serverZone = String(fields.serverZone || '').trim()
|
|
||||||
|
|
||||||
const inventories = await listActiveConsignmentInventoriesForMatching()
|
const inventories = await listActiveConsignmentInventoriesForMatching()
|
||||||
|
|
||||||
const matches = findMatchingPeacekeeperInventories(workOrder.product_name, inventories, {
|
const matches = findMatchingPeacekeeperInventories(workOrder.product_name, inventories, {
|
||||||
system,
|
system: fields.system,
|
||||||
serverZone,
|
serverZone: fields.serverZone,
|
||||||
|
series: fields.series,
|
||||||
})
|
})
|
||||||
|
|
||||||
const exactCount = matches.filter((m) => m.matchType === 'exact').length
|
const exactCount = matches.filter((m) => m.matchType === 'exact').length
|
||||||
@@ -70,6 +95,8 @@ export async function checkWorkOrderInventoryMatches(workOrderId: number | strin
|
|||||||
partialCount,
|
partialCount,
|
||||||
matches: matches.map((m) => ({
|
matches: matches.map((m) => ({
|
||||||
...m,
|
...m,
|
||||||
|
matchScore: m.score,
|
||||||
|
matchReasons: m.reason ? [m.reason] : [],
|
||||||
inventory: mapConsignmentInventory(m.inventory),
|
inventory: mapConsignmentInventory(m.inventory),
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
@@ -125,8 +152,20 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
|||||||
|
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
|
|
||||||
// 开启原子事务扣减库存并流转工单
|
// 开启原子事务:占库 + 工单进入赠送冷静期。押金留到验收再释放。
|
||||||
const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => {
|
const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => {
|
||||||
|
const lockedOrderRes = await client.query<WorkOrderRow>(
|
||||||
|
'SELECT * FROM work_orders WHERE id = $1 AND status = $2 FOR UPDATE',
|
||||||
|
[workOrder.id, workOrder.status],
|
||||||
|
)
|
||||||
|
const lockedOrder = lockedOrderRes.rows[0]
|
||||||
|
if (!lockedOrder) {
|
||||||
|
throw createHttpError('工单状态已发生变化,履约失败', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_conflict',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const invSql = `
|
const invSql = `
|
||||||
UPDATE consignment_inventories
|
UPDATE consignment_inventories
|
||||||
SET
|
SET
|
||||||
@@ -142,9 +181,9 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
|||||||
RETURNING *
|
RETURNING *
|
||||||
`
|
`
|
||||||
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
|
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
|
||||||
workOrder.order_id || null,
|
lockedOrder.order_id || null,
|
||||||
workOrder.id,
|
lockedOrder.id,
|
||||||
workOrder.platform_order_id || '',
|
lockedOrder.platform_order_id || '',
|
||||||
shopName,
|
shopName,
|
||||||
now,
|
now,
|
||||||
inventory.id,
|
inventory.id,
|
||||||
@@ -173,53 +212,34 @@ export async function fulfillWorkOrderWithConsignment(input: {
|
|||||||
inboundBatch: inv.inbound_batch,
|
inboundBatch: inv.inbound_batch,
|
||||||
credentials: safeParseJson(inv.credentials_json),
|
credentials: safeParseJson(inv.credentials_json),
|
||||||
costPriceFen: inv.cost_price_fen,
|
costPriceFen: inv.cost_price_fen,
|
||||||
|
sourceSubmissionId: inv.source_submission_id ? Number(inv.source_submission_id) : null,
|
||||||
|
fulfillmentType: CONSIGNMENT_FULFILLMENT_TYPE.INTERNAL_STOCK,
|
||||||
fulfilledAt: now,
|
fulfilledAt: now,
|
||||||
fulfilledBy: input.actorName,
|
fulfilledBy: input.actorName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const woSql = `
|
const wo = await startWorkOrderGiftCountdownWithClient(client, lockedOrder, {
|
||||||
UPDATE work_orders
|
|
||||||
SET
|
|
||||||
status = 'completed',
|
|
||||||
material_json = $1::jsonb,
|
|
||||||
accepted_at = $2,
|
|
||||||
updated_at = $2
|
|
||||||
WHERE id = $3 AND status = $4
|
|
||||||
RETURNING *
|
|
||||||
`
|
|
||||||
const woRes = await client.query<WorkOrderRow>(woSql, [
|
|
||||||
JSON.stringify(updatedMaterial),
|
|
||||||
now,
|
now,
|
||||||
workOrder.id,
|
actorName: input.actorName,
|
||||||
workOrder.status,
|
|
||||||
])
|
|
||||||
|
|
||||||
const wo = woRes.rows[0]
|
|
||||||
if (!wo) {
|
|
||||||
throw createHttpError('工单状态已发生变化,履约失败', {
|
|
||||||
statusCode: 409,
|
|
||||||
errorCode: 'work_order_conflict',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await createWorkOrderEvent({
|
|
||||||
workOrderId: workOrder.id,
|
|
||||||
actorType: 'operator',
|
|
||||||
actorId: input.actorName || 'admin',
|
|
||||||
eventType: 'consignment_fulfilled',
|
eventType: 'consignment_fulfilled',
|
||||||
fromStatus: workOrder.status,
|
materialJson: updatedMaterial,
|
||||||
toStatus: 'completed',
|
forceFriendGift: true,
|
||||||
payloadJson: JSON.stringify({
|
extraPayload: {
|
||||||
inventoryId: inv.id,
|
inventoryId: inv.id,
|
||||||
inventoryNo: inv.inventory_no,
|
inventoryNo: inv.inventory_no,
|
||||||
itemName: inv.item_name,
|
itemName: inv.item_name,
|
||||||
costPriceFen: inv.cost_price_fen,
|
costPriceFen: inv.cost_price_fen,
|
||||||
buyerName: inv.buyer_name,
|
buyerName: inv.buyer_name,
|
||||||
shopName,
|
shopName,
|
||||||
}),
|
},
|
||||||
now,
|
|
||||||
})
|
})
|
||||||
|
if (!wo) {
|
||||||
|
throw createHttpError('工单状态已发生变化,履约失败', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_conflict',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return { updatedOrder: wo, updatedInventory: inv }
|
return { updatedOrder: wo, updatedInventory: inv }
|
||||||
})
|
})
|
||||||
@@ -315,6 +335,9 @@ export async function assignWorkOrderWithConsignmentWorker(input: {
|
|||||||
costPriceFen: inventory.cost_price_fen,
|
costPriceFen: inventory.cost_price_fen,
|
||||||
workerId: Number(targetWorkerId),
|
workerId: Number(targetWorkerId),
|
||||||
workerName: inventory.worker_name || '',
|
workerName: inventory.worker_name || '',
|
||||||
|
sourceSubmissionId: inventory.source_submission_id
|
||||||
|
? Number(inventory.source_submission_id)
|
||||||
|
: null,
|
||||||
fulfillmentType: 'worker_deposit',
|
fulfillmentType: 'worker_deposit',
|
||||||
assignedAt: now,
|
assignedAt: now,
|
||||||
assignedBy: input.actorName,
|
assignedBy: input.actorName,
|
||||||
@@ -388,14 +411,15 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
|||||||
|
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
|
|
||||||
const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => {
|
try {
|
||||||
let inv: ConsignmentInventoryRow | null = null
|
const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => {
|
||||||
|
let inv: ConsignmentInventoryRow | null = null
|
||||||
|
|
||||||
// 1. 处理关联库存解绑与状态流转
|
// 1. 处理关联库存解绑与状态流转
|
||||||
if (inventoryId) {
|
if (inventoryId) {
|
||||||
if (input.action === 'archive_inventory') {
|
if (input.action === 'archive_inventory') {
|
||||||
// 物品异常作废
|
// 物品异常作废
|
||||||
const invSql = `
|
const invSql = `
|
||||||
UPDATE consignment_inventories
|
UPDATE consignment_inventories
|
||||||
SET
|
SET
|
||||||
status = 'archived',
|
status = 'archived',
|
||||||
@@ -409,15 +433,15 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
|||||||
WHERE id = $3
|
WHERE id = $3
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`
|
`
|
||||||
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
|
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
|
||||||
input.reason || '异常作废',
|
input.reason || '异常作废',
|
||||||
now,
|
now,
|
||||||
inventoryId,
|
inventoryId,
|
||||||
])
|
])
|
||||||
inv = invRes.rows[0] || null
|
inv = invRes.rows[0] || null
|
||||||
} else {
|
} else {
|
||||||
// 恢复在库 (in_stock)
|
// 恢复在库 (in_stock)
|
||||||
const invSql = `
|
const invSql = `
|
||||||
UPDATE consignment_inventories
|
UPDATE consignment_inventories
|
||||||
SET
|
SET
|
||||||
status = 'in_stock',
|
status = 'in_stock',
|
||||||
@@ -433,72 +457,93 @@ export async function revokeWorkOrderConsignmentFulfillment(input: {
|
|||||||
WHERE id = $3
|
WHERE id = $3
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`
|
`
|
||||||
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
|
const invRes = await client.query<ConsignmentInventoryRow>(invSql, [
|
||||||
input.reason || '撤单回库',
|
input.reason || '撤单回库',
|
||||||
now,
|
now,
|
||||||
inventoryId,
|
inventoryId,
|
||||||
])
|
])
|
||||||
inv = invRes.rows[0] || null
|
inv = invRes.rows[0] || null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 处理工单状态流转
|
const submissionId = consignmentInfo.sourceSubmissionId || inv?.source_submission_id
|
||||||
let nextOrderStatus: WorkOrderStatus = WORK_ORDER_STATUS.UNASSIGNED
|
if (submissionId) {
|
||||||
if (input.action === 'cancel_order') {
|
if (input.action === 'archive_inventory') {
|
||||||
nextOrderStatus = WORK_ORDER_STATUS.CANCELLED
|
await releaseConsignmentPublishDepositBySubmissionWithClient(client, {
|
||||||
}
|
submissionId,
|
||||||
|
note: `寄卖履约作废退还押金 ${inv?.inventory_no || submissionId}`,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, {
|
||||||
|
submissionId,
|
||||||
|
note: `寄卖撤销回库重新冻结押金 ${inv?.inventory_no || submissionId}`,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { consignment: _removed, ...cleanMaterial } = material
|
// 2. 处理工单状态流转
|
||||||
const archiveHistory = [
|
let nextOrderStatus: WorkOrderStatus = WORK_ORDER_STATUS.UNASSIGNED
|
||||||
...(Array.isArray(material.consignmentHistory) ? material.consignmentHistory : []),
|
if (input.action === 'cancel_order') {
|
||||||
{
|
nextOrderStatus = WORK_ORDER_STATUS.CANCELLED
|
||||||
...consignmentInfo,
|
}
|
||||||
revokedAt: now,
|
|
||||||
revokedBy: input.actorName,
|
|
||||||
revokeReason: input.reason,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const woSql = `
|
const { consignment: _removed, ...cleanMaterial } = material
|
||||||
|
const archiveHistory = [
|
||||||
|
...(Array.isArray(material.consignmentHistory) ? material.consignmentHistory : []),
|
||||||
|
{
|
||||||
|
...consignmentInfo,
|
||||||
|
revokedAt: now,
|
||||||
|
revokedBy: input.actorName,
|
||||||
|
revokeReason: input.reason,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const woSql = `
|
||||||
UPDATE work_orders
|
UPDATE work_orders
|
||||||
SET
|
SET
|
||||||
status = $1,
|
status = $1,
|
||||||
material_json = $2::jsonb,
|
material_json = $2::jsonb,
|
||||||
|
${RESET_GIFT_FLOW_SQL},
|
||||||
updated_at = $3
|
updated_at = $3
|
||||||
WHERE id = $4
|
WHERE id = $4
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`
|
`
|
||||||
const woRes = await client.query<WorkOrderRow>(woSql, [
|
const woRes = await client.query<WorkOrderRow>(woSql, [
|
||||||
nextOrderStatus,
|
nextOrderStatus,
|
||||||
JSON.stringify({ ...cleanMaterial, consignmentHistory: archiveHistory }),
|
JSON.stringify({ ...cleanMaterial, consignmentHistory: archiveHistory }),
|
||||||
now,
|
now,
|
||||||
workOrder.id,
|
workOrder.id,
|
||||||
])
|
])
|
||||||
const wo = woRes.rows[0]!
|
const wo = woRes.rows[0]!
|
||||||
|
|
||||||
// 3. 记录事件
|
// 3. 记录事件
|
||||||
await createWorkOrderEvent({
|
await createWorkOrderEventWithClient(client, {
|
||||||
workOrderId: workOrder.id,
|
workOrderId: workOrder.id,
|
||||||
actorType: 'operator',
|
actorType: 'operator',
|
||||||
actorId: input.actorName || 'admin',
|
actorId: input.actorName || 'admin',
|
||||||
eventType: 'consignment_revoked',
|
eventType: 'consignment_revoked',
|
||||||
fromStatus: workOrder.status,
|
fromStatus: workOrder.status,
|
||||||
toStatus: nextOrderStatus,
|
toStatus: nextOrderStatus,
|
||||||
payloadJson: JSON.stringify({
|
payloadJson: JSON.stringify({
|
||||||
action: input.action,
|
action: input.action,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
inventoryId,
|
inventoryId,
|
||||||
}),
|
}),
|
||||||
now,
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { updatedOrder: wo, updatedInventory: inv }
|
||||||
})
|
})
|
||||||
|
|
||||||
return { updatedOrder: wo, updatedInventory: inv }
|
publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true })
|
||||||
})
|
|
||||||
|
|
||||||
publishWorkOrderRealtimeChange({ workOrderId: Number(updatedOrder.id), hallChanged: true })
|
return {
|
||||||
|
order: mapWorkOrderAdmin(updatedOrder),
|
||||||
return {
|
inventory: updatedInventory ? mapConsignmentInventory(updatedInventory) : null,
|
||||||
order: mapWorkOrderAdmin(updatedOrder),
|
}
|
||||||
inventory: updatedInventory ? mapConsignmentInventory(updatedInventory) : null,
|
} catch (error) {
|
||||||
|
throwConsignmentDepositHttpError(error, input.action !== 'archive_inventory')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,28 @@ test('extractPeacekeeperSpecs extracts specs accurately', () => {
|
|||||||
assert.equal(spec3.brillianceCount, 7)
|
assert.equal(spec3.brillianceCount, 7)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('extractPeacekeeperSpecs parses compact 苹果微信暗潮1钻 and aliases', () => {
|
||||||
|
const spec = extractPeacekeeperSpecs('苹果微信暗潮1钻')
|
||||||
|
assert.equal(spec.system, '苹果')
|
||||||
|
assert.equal(spec.serverZone, '微信区')
|
||||||
|
assert.equal(spec.series, '暗潮')
|
||||||
|
assert.equal(spec.diamondLevel, 1)
|
||||||
|
assert.equal(spec.brillianceCount, 0)
|
||||||
|
|
||||||
|
const aliased = extractPeacekeeperSpecs('暗潮1钻', { system: 'iOS', serverZone: '微信' })
|
||||||
|
assert.equal(aliased.system, '苹果')
|
||||||
|
assert.equal(aliased.serverZone, '微信区')
|
||||||
|
assert.equal(aliased.series, '暗潮')
|
||||||
|
assert.equal(aliased.diamondLevel, 1)
|
||||||
|
|
||||||
|
const productWins = extractPeacekeeperSpecs('苹果微信暗潮1钻', {
|
||||||
|
system: '安卓',
|
||||||
|
serverZone: 'QQ区',
|
||||||
|
})
|
||||||
|
assert.equal(productWins.system, '苹果')
|
||||||
|
assert.equal(productWins.serverZone, '微信区')
|
||||||
|
})
|
||||||
|
|
||||||
test('matchPeacekeeperOrderWithInventory matches exact spec', () => {
|
test('matchPeacekeeperOrderWithInventory matches exact spec', () => {
|
||||||
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
|
const order = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
|
||||||
const inv = buildInventory({
|
const inv = buildInventory({
|
||||||
@@ -142,37 +164,79 @@ test('matchPeacekeeperOrderWithInventory rejects lower tier for higher tier orde
|
|||||||
assert.equal(matched, null)
|
assert.equal(matched, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('findMatchingPeacekeeperInventories ranks exact before partial and lower cost first', () => {
|
test('findMatchingPeacekeeperInventories ranks exact first, then lower price, then earlier inbound', () => {
|
||||||
const invExactHigh = buildInventory({
|
const invExactHigh = buildInventory({
|
||||||
id: 1,
|
id: 1,
|
||||||
series: '暗潮',
|
series: '暗潮',
|
||||||
diamond_level: 1,
|
diamond_level: 1,
|
||||||
brilliance_count: 0,
|
brilliance_count: 0,
|
||||||
cost_price_fen: 12000,
|
cost_price_fen: 12000,
|
||||||
|
inbound_at: '2026-09-01T10:00:00.000Z',
|
||||||
})
|
})
|
||||||
const invExactLow = buildInventory({
|
const invExactLowLate = buildInventory({
|
||||||
id: 2,
|
id: 2,
|
||||||
series: '暗潮',
|
series: '暗潮',
|
||||||
diamond_level: 1,
|
diamond_level: 1,
|
||||||
brilliance_count: 0,
|
brilliance_count: 0,
|
||||||
cost_price_fen: 9500,
|
cost_price_fen: 9500,
|
||||||
|
inbound_at: '2026-09-03T10:00:00.000Z',
|
||||||
})
|
})
|
||||||
const invPartial = buildInventory({
|
const invExactLowEarly = buildInventory({
|
||||||
|
id: 4,
|
||||||
|
series: '暗潮',
|
||||||
|
diamond_level: 1,
|
||||||
|
brilliance_count: 0,
|
||||||
|
cost_price_fen: 9500,
|
||||||
|
inbound_at: '2026-09-02T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
const invPartialCheapest = buildInventory({
|
||||||
id: 3,
|
id: 3,
|
||||||
series: '暗潮',
|
series: '暗潮',
|
||||||
diamond_level: 1,
|
diamond_level: 1,
|
||||||
brilliance_count: 1,
|
brilliance_count: 1,
|
||||||
cost_price_fen: 8000,
|
cost_price_fen: 8000,
|
||||||
|
inbound_at: '2026-09-01T08:00:00.000Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
const matches = findMatchingPeacekeeperInventories('安卓微信区 暗潮 1钻', [
|
const matches = findMatchingPeacekeeperInventories('安卓微信区 暗潮 1钻', [
|
||||||
invExactHigh,
|
invExactHigh,
|
||||||
invPartial,
|
invPartialCheapest,
|
||||||
invExactLow,
|
invExactLowLate,
|
||||||
|
invExactLowEarly,
|
||||||
])
|
])
|
||||||
|
|
||||||
assert.equal(matches.length, 3)
|
assert.equal(matches.length, 4)
|
||||||
assert.equal(matches[0]!.inventory.id, 2) // exact low cost
|
assert.equal(matches[0]!.inventory.id, 4) // 完全匹配 + 低价 + 更早
|
||||||
assert.equal(matches[1]!.inventory.id, 1) // exact high cost
|
assert.equal(matches[1]!.inventory.id, 2) // 完全匹配 + 同价更晚
|
||||||
assert.equal(matches[2]!.inventory.id, 3) // partial
|
assert.equal(matches[2]!.inventory.id, 1) // 完全匹配但更贵
|
||||||
|
assert.equal(matches[3]!.inventory.id, 3) // 高配充低配,即使更便宜也排后
|
||||||
|
})
|
||||||
|
|
||||||
|
test('findMatchingPeacekeeperInventories matches 苹果微信暗潮1钻 to 苹果/微信区 inventory', () => {
|
||||||
|
const inv = buildInventory({
|
||||||
|
item_name: '苹果微信暗潮1钻苏尔南',
|
||||||
|
system: '苹果',
|
||||||
|
server_zone: '微信区',
|
||||||
|
series: '暗潮',
|
||||||
|
diamond_level: 1,
|
||||||
|
brilliance_count: 0,
|
||||||
|
})
|
||||||
|
const aliasedInv = buildInventory({
|
||||||
|
id: 2,
|
||||||
|
item_name: '苹果微信暗潮1钻苏尔南',
|
||||||
|
system: '苹果 ',
|
||||||
|
server_zone: '微信',
|
||||||
|
series: '暗潮',
|
||||||
|
diamond_level: 1,
|
||||||
|
brilliance_count: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const matches = findMatchingPeacekeeperInventories('苹果微信暗潮1钻', [inv, aliasedInv], {
|
||||||
|
system: 'iOS',
|
||||||
|
serverZone: '微信',
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(matches.length, 2)
|
||||||
|
assert.equal(matches[0]!.matchType, 'exact')
|
||||||
|
assert.equal(matches[1]!.matchType, 'exact')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,75 +19,114 @@ export type ParsedOrderSpecs = {
|
|||||||
rawText: string
|
rawText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function extractPeacekeeperSystem(value: string): string {
|
||||||
* 提取订单或文本中的《和平精英》规格(系统、区服、暗潮/怒嚎/血宴、钻级、璀璨)
|
const text = String(value || '')
|
||||||
*/
|
|
||||||
export function extractPeacekeeperSpecs(
|
|
||||||
rawText: string,
|
|
||||||
extraMeta?: { system?: string; serverZone?: string; series?: string; diamondLevel?: number },
|
|
||||||
): ParsedOrderSpecs {
|
|
||||||
const text = String(rawText || '')
|
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
if (!text) return ''
|
||||||
|
if (text.includes('通用') || text.includes('双端') || text.includes('全端')) return '通用'
|
||||||
|
if (text.includes('苹果') || text.includes('ios') || text.includes('iphone')) return '苹果'
|
||||||
|
if (text.includes('安卓') || text.includes('android')) return '安卓'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 系统识别
|
function extractPeacekeeperServerZone(value: string): string {
|
||||||
let system = (extraMeta?.system || '').trim()
|
const text = String(value || '')
|
||||||
if (!system) {
|
.trim()
|
||||||
if (text.includes('苹果') || text.includes('ios') || text.includes('iphone')) {
|
.toLowerCase()
|
||||||
system = '苹果'
|
if (!text) return ''
|
||||||
} else if (text.includes('安卓') || text.includes('android')) {
|
if (text.includes('通用') || text.includes('全区') || text.includes('不限')) return '通用'
|
||||||
system = '安卓'
|
if (text.includes('微信') || text === 'wx' || text.includes('wx区')) return '微信区'
|
||||||
}
|
if (text.includes('qq') || text.includes('手q') || text.includes('企鹅')) return 'QQ区'
|
||||||
}
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 大区识别
|
function extractPeacekeeperSeries(value: string): string {
|
||||||
let serverZone = (extraMeta?.serverZone || '').trim()
|
const text = String(value || '').trim()
|
||||||
if (!serverZone) {
|
if (!text) return ''
|
||||||
if (text.includes('微信') || text.includes('wx')) {
|
if (text.includes('暗潮')) return '暗潮'
|
||||||
serverZone = '微信区'
|
if (text.includes('怒嚎') || text.includes('怒号')) return '怒嚎'
|
||||||
} else if (text.includes('qq') || text.includes('手q')) {
|
if (text.includes('血宴')) return '血宴'
|
||||||
serverZone = 'QQ区'
|
return ''
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 词条系列识别 (暗潮 / 怒嚎 / 血宴)
|
export function normalizePeacekeeperSystem(value: string): string {
|
||||||
let series = (extraMeta?.series || '').trim()
|
return extractPeacekeeperSystem(value) || String(value || '').trim()
|
||||||
if (!series) {
|
}
|
||||||
if (text.includes('暗潮')) {
|
|
||||||
series = '暗潮'
|
|
||||||
} else if (text.includes('怒嚎') || text.includes('怒号')) {
|
|
||||||
series = '怒嚎'
|
|
||||||
} else if (text.includes('血宴')) {
|
|
||||||
series = '血宴'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 钻级识别 (1~7钻)
|
export function normalizePeacekeeperServerZone(value: string): string {
|
||||||
let diamondLevel = extraMeta?.diamondLevel || 1
|
return extractPeacekeeperServerZone(value) || String(value || '').trim()
|
||||||
const diamondMatch = text.match(/([1-7])\s*钻/)
|
}
|
||||||
if (diamondMatch?.[1]) {
|
|
||||||
diamondLevel = Number(diamondMatch[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 璀璨数量识别 (0~7璀璨)
|
export function normalizePeacekeeperSeries(value: string): string {
|
||||||
let brillianceCount = 0
|
return extractPeacekeeperSeries(value) || String(value || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSpecsFromText(rawText: string): Omit<ParsedOrderSpecs, 'rawText'> {
|
||||||
|
const text = String(rawText || '').trim()
|
||||||
|
|
||||||
|
const diamondMatch = text.toLowerCase().match(/([1-7])\s*钻/)
|
||||||
|
const brillianceZero = /无璀璨|不带璀璨|0\s*璀璨/.test(text)
|
||||||
const brillianceMatch = text.match(/([1-7])\s*璀璨/)
|
const brillianceMatch = text.match(/([1-7])\s*璀璨/)
|
||||||
if (brillianceMatch?.[1]) {
|
|
||||||
|
let brillianceCount = 0
|
||||||
|
if (brillianceZero) {
|
||||||
|
brillianceCount = 0
|
||||||
|
} else if (brillianceMatch?.[1]) {
|
||||||
brillianceCount = Number(brillianceMatch[1])
|
brillianceCount = Number(brillianceMatch[1])
|
||||||
} else if (text.includes('璀璨')) {
|
} else if (text.includes('璀璨')) {
|
||||||
brillianceCount = 1
|
brillianceCount = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
system,
|
system: extractPeacekeeperSystem(text),
|
||||||
serverZone,
|
serverZone: extractPeacekeeperServerZone(text),
|
||||||
series,
|
series: extractPeacekeeperSeries(text),
|
||||||
diamondLevel,
|
diamondLevel: diamondMatch?.[1] ? Number(diamondMatch[1]) : 0,
|
||||||
brillianceCount,
|
brillianceCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取订单或文本中的《和平精英》规格(系统、区服、暗潮/怒嚎/血宴、钻级、璀璨)
|
||||||
|
* 商品名优先,收集资料只补空白项;系统/区服别名会归一化(微信→微信区、ios→苹果)。
|
||||||
|
*/
|
||||||
|
export function extractPeacekeeperSpecs(
|
||||||
|
rawText: string,
|
||||||
|
extraMeta?: {
|
||||||
|
system?: string | undefined
|
||||||
|
serverZone?: string | undefined
|
||||||
|
series?: string | undefined
|
||||||
|
diamondLevel?: number | undefined
|
||||||
|
},
|
||||||
|
): ParsedOrderSpecs {
|
||||||
|
const fromText = parseSpecsFromText(rawText)
|
||||||
|
const fromMeta = parseSpecsFromText(
|
||||||
|
[
|
||||||
|
extraMeta?.system,
|
||||||
|
extraMeta?.serverZone,
|
||||||
|
extraMeta?.series,
|
||||||
|
extraMeta?.diamondLevel ? `${extraMeta.diamondLevel}钻` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
system: fromText.system || fromMeta.system,
|
||||||
|
serverZone: fromText.serverZone || fromMeta.serverZone,
|
||||||
|
series: fromText.series || fromMeta.series,
|
||||||
|
diamondLevel: fromText.diamondLevel || fromMeta.diamondLevel || extraMeta?.diamondLevel || 1,
|
||||||
|
brillianceCount: fromText.brillianceCount,
|
||||||
rawText,
|
rawText,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameSpec(orderValue: string, inventoryValue: string): boolean {
|
||||||
|
if (!orderValue || !inventoryValue || inventoryValue === '通用') return true
|
||||||
|
return orderValue === inventoryValue
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单品匹配度比对
|
* 单品匹配度比对
|
||||||
*/
|
*/
|
||||||
@@ -100,25 +139,16 @@ export function matchPeacekeeperOrderWithInventory(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 硬约束:系统必须一致(若指定)
|
const invSystem = normalizePeacekeeperSystem(inventory.system)
|
||||||
if (order.system && inventory.system && inventory.system !== '通用') {
|
const invZone = normalizePeacekeeperServerZone(inventory.server_zone)
|
||||||
if (order.system !== inventory.system) {
|
const invSeries = normalizePeacekeeperSeries(inventory.series)
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 硬约束:区服必须一致(若指定)
|
if (!sameSpec(order.system, invSystem)) return null
|
||||||
if (order.serverZone && inventory.server_zone && inventory.server_zone !== '通用') {
|
if (!sameSpec(order.serverZone, invZone)) return null
|
||||||
if (order.serverZone !== inventory.server_zone) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 硬约束:系列必须完全一致(暗潮 只能配 暗潮,不能拿 怒嚎 充数)
|
// 硬约束:系列必须完全一致(暗潮 只能配 暗潮,不能拿 怒嚎 充数)
|
||||||
if (order.series && inventory.series) {
|
if (order.series && invSeries && order.series !== invSeries) {
|
||||||
if (order.series !== inventory.series) {
|
return null
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const invDiamond = Number(inventory.diamond_level || 1)
|
const invDiamond = Number(inventory.diamond_level || 1)
|
||||||
@@ -135,7 +165,7 @@ export function matchPeacekeeperOrderWithInventory(
|
|||||||
inventory,
|
inventory,
|
||||||
matchType: 'exact',
|
matchType: 'exact',
|
||||||
score: 100,
|
score: 100,
|
||||||
matchedSeries: inventory.series || order.series,
|
matchedSeries: invSeries || order.series,
|
||||||
matchedDiamond: invDiamond,
|
matchedDiamond: invDiamond,
|
||||||
matchedBrilliance: invBrilliance,
|
matchedBrilliance: invBrilliance,
|
||||||
reason: '规格完全一致',
|
reason: '规格完全一致',
|
||||||
@@ -155,7 +185,7 @@ export function matchPeacekeeperOrderWithInventory(
|
|||||||
inventory,
|
inventory,
|
||||||
matchType: 'partial',
|
matchType: 'partial',
|
||||||
score: invDiamond > order.diamondLevel ? 70 : 80,
|
score: invDiamond > order.diamondLevel ? 70 : 80,
|
||||||
matchedSeries: inventory.series || order.series,
|
matchedSeries: invSeries || order.series,
|
||||||
matchedDiamond: invDiamond,
|
matchedDiamond: invDiamond,
|
||||||
matchedBrilliance: invBrilliance,
|
matchedBrilliance: invBrilliance,
|
||||||
reason: `属于高配充低配:${reasons.join(',')}`,
|
reason: `属于高配充低配:${reasons.join(',')}`,
|
||||||
@@ -168,7 +198,12 @@ export function matchPeacekeeperOrderWithInventory(
|
|||||||
export function findMatchingPeacekeeperInventories(
|
export function findMatchingPeacekeeperInventories(
|
||||||
rawRequirementText: string,
|
rawRequirementText: string,
|
||||||
inventories: ConsignmentInventoryRow[],
|
inventories: ConsignmentInventoryRow[],
|
||||||
extraMeta?: { system?: string; serverZone?: string; series?: string; diamondLevel?: number },
|
extraMeta?: {
|
||||||
|
system?: string | undefined
|
||||||
|
serverZone?: string | undefined
|
||||||
|
series?: string | undefined
|
||||||
|
diamondLevel?: number | undefined
|
||||||
|
},
|
||||||
): ConsignmentMatchResult[] {
|
): ConsignmentMatchResult[] {
|
||||||
const orderSpecs = extractPeacekeeperSpecs(rawRequirementText, extraMeta)
|
const orderSpecs = extractPeacekeeperSpecs(rawRequirementText, extraMeta)
|
||||||
const results: ConsignmentMatchResult[] = []
|
const results: ConsignmentMatchResult[] = []
|
||||||
@@ -180,15 +215,23 @@ export function findMatchingPeacekeeperInventories(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序:
|
// 排序(多条都符合需求时):
|
||||||
// 1. 全部匹配优先 (score DESC)
|
// 1. 完全匹配优先于高配充低配
|
||||||
// 2. 收货成本从低到高 (cost_price_fen ASC)
|
// 2. 同档内收购价越低越好
|
||||||
// 3. 先入库优先 (id ASC)
|
// 3. 同价则入库时间越早越好
|
||||||
return results.sort((a, b) => {
|
return results.sort((a, b) => {
|
||||||
if (b.score !== a.score) return b.score - a.score
|
if (b.score !== a.score) return b.score - a.score
|
||||||
if (a.inventory.cost_price_fen !== b.inventory.cost_price_fen) {
|
|
||||||
return a.inventory.cost_price_fen - b.inventory.cost_price_fen
|
const priceDiff =
|
||||||
}
|
Number(a.inventory.cost_price_fen || 0) - Number(b.inventory.cost_price_fen || 0)
|
||||||
|
if (priceDiff !== 0) return priceDiff
|
||||||
|
|
||||||
|
const aTime =
|
||||||
|
Date.parse(a.inventory.inbound_at || a.inventory.created_at || '') || Number(a.inventory.id)
|
||||||
|
const bTime =
|
||||||
|
Date.parse(b.inventory.inbound_at || b.inventory.created_at || '') || Number(b.inventory.id)
|
||||||
|
if (aTime !== bTime) return aTime - bTime
|
||||||
|
|
||||||
return Number(a.inventory.id) - Number(b.inventory.id)
|
return Number(a.inventory.id) - Number(b.inventory.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { JsonObject } from '../../types/json.js'
|
|||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { generateConsignmentSubmissionNo } from '../../utils/random.js'
|
import { generateConsignmentSubmissionNo } from '../../utils/random.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
import { getConsignmentPublishDepositFen } from './worker-finance-config-service.js'
|
||||||
|
|
||||||
export async function getPublicConsignmentLink(token: string) {
|
export async function getPublicConsignmentLink(token: string) {
|
||||||
const cleanToken = String(token || '').trim()
|
const cleanToken = String(token || '').trim()
|
||||||
@@ -79,9 +80,7 @@ export async function submitPublicConsignment(token: string, payload: JsonObject
|
|||||||
? (payload.content as Record<string, unknown>)
|
? (payload.content as Record<string, unknown>)
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
const characterName = String(
|
const characterName = String(contentPayload.characterName || payload.characterName || '').trim()
|
||||||
contentPayload.characterName || payload.characterName || '',
|
|
||||||
).trim()
|
|
||||||
const characterId = String(contentPayload.characterId || payload.characterId || '').trim()
|
const characterId = String(contentPayload.characterId || payload.characterId || '').trim()
|
||||||
if (!characterName) {
|
if (!characterName) {
|
||||||
throw createHttpError('请填写游戏内角色名', {
|
throw createHttpError('请填写游戏内角色名', {
|
||||||
@@ -145,9 +144,7 @@ export async function submitPublicConsignment(token: string, payload: JsonObject
|
|||||||
Math.min(7, Number(payload.brillianceCount ?? link.brilliance_count ?? 0)),
|
Math.min(7, Number(payload.brillianceCount ?? link.brilliance_count ?? 0)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const sellerGameId = String(
|
const sellerGameId = String(payload.sellerGameId || `${characterName} (${characterId})`).trim()
|
||||||
payload.sellerGameId || `${characterName} (${characterId})`,
|
|
||||||
).trim()
|
|
||||||
|
|
||||||
const submission = await createConsignmentSubmission({
|
const submission = await createConsignmentSubmission({
|
||||||
submissionNo,
|
submissionNo,
|
||||||
@@ -177,8 +174,8 @@ export async function submitPublicConsignment(token: string, payload: JsonObject
|
|||||||
workerName,
|
workerName,
|
||||||
workerPhone,
|
workerPhone,
|
||||||
depositAmountFen: Math.max(
|
depositAmountFen: Math.max(
|
||||||
100,
|
0,
|
||||||
Math.round(Number(payload.depositAmountFen || askingPriceFen || 10000)),
|
Math.round(Number(payload.depositAmountFen ?? getConsignmentPublishDepositFen())),
|
||||||
),
|
),
|
||||||
now,
|
now,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { withTransaction } from '../../db/client.js'
|
||||||
|
import { freezeConsignmentPublishDepositWithClient } from '../../repositories/worker-platform/consignment-deposit-repo.js'
|
||||||
import {
|
import {
|
||||||
createConsignmentSubmission,
|
createConsignmentSubmissionWithClient,
|
||||||
listConsignmentInventories,
|
listConsignmentInventories,
|
||||||
listConsignmentSubmissions,
|
listConsignmentSubmissions,
|
||||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||||
@@ -8,6 +10,11 @@ import type { JsonObject } from '../../types/json.js'
|
|||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { generateConsignmentSubmissionNo } from '../../utils/random.js'
|
import { generateConsignmentSubmissionNo } from '../../utils/random.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
import { getConsignmentPublishDepositFen as getConfiguredConsignmentPublishDepositFen } from './worker-finance-config-service.js'
|
||||||
|
|
||||||
|
export function getConsignmentPublishDepositFen() {
|
||||||
|
return getConfiguredConsignmentPublishDepositFen()
|
||||||
|
}
|
||||||
|
|
||||||
export async function publishWorkerConsignment(workerId: number, payload: JsonObject) {
|
export async function publishWorkerConsignment(workerId: number, payload: JsonObject) {
|
||||||
const worker = await getWorkerUserById(workerId)
|
const worker = await getWorkerUserById(workerId)
|
||||||
@@ -15,11 +22,38 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
|||||||
throw createHttpError('打手账户不存在', { statusCode: 404, errorCode: 'worker_not_found' })
|
throw createHttpError('打手账户不存在', { statusCode: 404, errorCode: 'worker_not_found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const system = String(payload.system || '苹果').trim()
|
const system = String(payload.system || '').trim()
|
||||||
const serverZone = String(payload.serverZone || '微信区').trim()
|
const serverZone = String(payload.serverZone || '').trim()
|
||||||
const series = String(payload.series || '暗潮').trim()
|
const series = String(payload.series || '').trim()
|
||||||
const diamondLevel = Math.max(1, Math.min(7, Number(payload.diamondLevel || 1)))
|
if (!system || !serverZone || !series) {
|
||||||
const brillianceCount = Math.max(0, Math.min(7, Number(payload.brillianceCount || 0)))
|
throw createHttpError('请完整填写操作系统、区服与枪系', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'specs_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
payload.diamondLevel === undefined ||
|
||||||
|
payload.diamondLevel === null ||
|
||||||
|
payload.diamondLevel === ''
|
||||||
|
) {
|
||||||
|
throw createHttpError('请选择钻石等级', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'diamond_level_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const diamondLevel = Math.max(1, Math.min(7, Number(payload.diamondLevel)))
|
||||||
|
if (
|
||||||
|
payload.brillianceCount === undefined ||
|
||||||
|
payload.brillianceCount === null ||
|
||||||
|
payload.brillianceCount === ''
|
||||||
|
) {
|
||||||
|
throw createHttpError('请选择璀璨数', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'brilliance_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const brillianceCount = Math.max(0, Math.min(7, Number(payload.brillianceCount)))
|
||||||
|
|
||||||
const contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim()
|
const contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim()
|
||||||
if (!contactInfo) {
|
if (!contactInfo) {
|
||||||
@@ -60,65 +94,88 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const depositAmountFen = Math.max(
|
const depositAmountFen = getConsignmentPublishDepositFen()
|
||||||
100,
|
|
||||||
Math.round(Number(payload.depositAmountFen || askingPriceFen || 10000)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
|
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
|
||||||
const briStr = brillianceCount > 0 ? `${brillianceCount}璀璨` : ''
|
const briStr = brillianceCount > 0 ? `${brillianceCount}璀璨` : ''
|
||||||
const defaultItemName = `${system}${serverZone}${series}${diamondLevel}钻${briStr} 苏尔南`
|
const defaultItemName = `${system}${serverZone}${series}${diamondLevel}钻${briStr} 苏尔南`
|
||||||
const itemName = String(payload.itemName || defaultItemName).trim()
|
const itemName = String(payload.itemName || defaultItemName).trim()
|
||||||
|
|
||||||
const submissionNo = generateConsignmentSubmissionNo()
|
const submissionNo = generateConsignmentSubmissionNo()
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
|
|
||||||
const submission = await createConsignmentSubmission({
|
try {
|
||||||
submissionNo,
|
const submission = await withTransaction(async (client) => {
|
||||||
linkId: null,
|
const created = await createConsignmentSubmissionWithClient(client, {
|
||||||
gameName: '和平精英',
|
submissionNo,
|
||||||
categoryId: null,
|
linkId: null,
|
||||||
itemName,
|
gameName: '和平精英',
|
||||||
system,
|
categoryId: null,
|
||||||
serverZone,
|
itemName,
|
||||||
series,
|
system,
|
||||||
diamondLevel,
|
serverZone,
|
||||||
brillianceCount,
|
series,
|
||||||
sellerGameId: characterId ? `${characterName} (${characterId})` : characterName,
|
diamondLevel,
|
||||||
sellerPaymentInfo: '走打手接单平台钱包',
|
brillianceCount,
|
||||||
sellerPhone,
|
sellerGameId: characterId ? `${characterName} (${characterId})` : characterName,
|
||||||
contactInfo,
|
sellerPaymentInfo: '走打手接单平台钱包',
|
||||||
contentJson: {
|
sellerPhone,
|
||||||
characterName,
|
contactInfo,
|
||||||
characterId,
|
contentJson: {
|
||||||
sellerNote: String(payload.sellerNote || '').trim(),
|
characterName,
|
||||||
channel: 'worker_portal',
|
characterId,
|
||||||
},
|
sellerNote: String(payload.sellerNote || '').trim(),
|
||||||
screenshotsJson: screenshots,
|
channel: 'worker_portal',
|
||||||
askingPriceFen,
|
publishDepositFen: depositAmountFen,
|
||||||
workerId: Number(worker.id),
|
},
|
||||||
workerName: worker.display_name || worker.username,
|
screenshotsJson: screenshots,
|
||||||
workerPhone: sellerPhone || worker.phone || '',
|
askingPriceFen,
|
||||||
depositAmountFen,
|
workerId: Number(worker.id),
|
||||||
now,
|
workerName: worker.display_name || worker.username,
|
||||||
})
|
workerPhone: sellerPhone || worker.phone || '',
|
||||||
|
depositAmountFen,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
await freezeConsignmentPublishDepositWithClient(client, {
|
||||||
id: submission.id,
|
workerId: Number(worker.id),
|
||||||
submissionNo: submission.submission_no,
|
amountFen: depositAmountFen,
|
||||||
itemName: submission.item_name,
|
submissionId: created.id,
|
||||||
system: submission.system,
|
submissionNo: created.submission_no,
|
||||||
serverZone: submission.server_zone,
|
now,
|
||||||
series: submission.series,
|
})
|
||||||
diamondLevel: submission.diamond_level,
|
|
||||||
brillianceCount: submission.brilliance_count,
|
return created
|
||||||
askingPriceFen: submission.asking_price_fen,
|
})
|
||||||
status: submission.status,
|
|
||||||
workerId: submission.worker_id,
|
return {
|
||||||
workerName: submission.worker_name,
|
id: submission.id,
|
||||||
workerPhone: submission.worker_phone,
|
submissionNo: submission.submission_no,
|
||||||
depositAmountFen: submission.deposit_amount_fen,
|
itemName: submission.item_name,
|
||||||
createdAt: submission.created_at,
|
system: submission.system,
|
||||||
|
serverZone: submission.server_zone,
|
||||||
|
series: submission.series,
|
||||||
|
diamondLevel: submission.diamond_level,
|
||||||
|
brillianceCount: submission.brilliance_count,
|
||||||
|
askingPriceFen: submission.asking_price_fen,
|
||||||
|
status: submission.status,
|
||||||
|
workerId: submission.worker_id,
|
||||||
|
workerName: submission.worker_name,
|
||||||
|
workerPhone: submission.worker_phone,
|
||||||
|
depositAmountFen: submission.deposit_amount_fen,
|
||||||
|
createdAt: submission.created_at,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
((error as Error & { code?: string }).code === 'consignment_deposit_insufficient' ||
|
||||||
|
error.message === 'consignment_deposit_insufficient')
|
||||||
|
) {
|
||||||
|
const yuan = (depositAmountFen / 100).toFixed(0)
|
||||||
|
throw createHttpError(`可用余额不足,请先联系客服充值至少 ¥${yuan} 履约押金后再发布寄售`, {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'consignment_deposit_insufficient',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,8 +184,10 @@ export async function listWorkerConsignments(workerId: number) {
|
|||||||
listConsignmentSubmissions({ workerId, pageSize: 100 }),
|
listConsignmentSubmissions({ workerId, pageSize: 100 }),
|
||||||
listConsignmentInventories({ workerId, pageSize: 100 }),
|
listConsignmentInventories({ workerId, pageSize: 100 }),
|
||||||
])
|
])
|
||||||
|
const publishDepositFen = getConsignmentPublishDepositFen()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
publishDepositFen,
|
||||||
submissions: submissionsRes.items.map((sub) => ({
|
submissions: submissionsRes.items.map((sub) => ({
|
||||||
id: sub.id,
|
id: sub.id,
|
||||||
submissionNo: sub.submission_no,
|
submissionNo: sub.submission_no,
|
||||||
@@ -144,6 +203,7 @@ export async function listWorkerConsignments(workerId: number) {
|
|||||||
resultInventoryId: sub.result_inventory_id,
|
resultInventoryId: sub.result_inventory_id,
|
||||||
screenshotsJson: sub.screenshots_json,
|
screenshotsJson: sub.screenshots_json,
|
||||||
contentJson: sub.content_json,
|
contentJson: sub.content_json,
|
||||||
|
depositAmountFen: sub.deposit_amount_fen,
|
||||||
createdAt: sub.created_at,
|
createdAt: sub.created_at,
|
||||||
})),
|
})),
|
||||||
inventories: inventoriesRes.items.map((inv) => ({
|
inventories: inventoriesRes.items.map((inv) => ({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type WorkOrderRow,
|
type WorkOrderRow,
|
||||||
type WorkOrderShareRow,
|
type WorkOrderShareRow,
|
||||||
} from '../../repositories/worker-platform/index.js'
|
} from '../../repositories/worker-platform/index.js'
|
||||||
|
import { hasInternalConsignmentFulfillment } from '../../repositories/worker-platform/work-order-gift-repo.js'
|
||||||
import type { JsonObject } from '../../types/json.js'
|
import type { JsonObject } from '../../types/json.js'
|
||||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||||
import {
|
import {
|
||||||
@@ -141,6 +142,8 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
|
|||||||
const acceptanceMode = normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode)
|
const acceptanceMode = normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode)
|
||||||
const isFriendGift = acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
const isFriendGift = acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||||
const giftPhase = String(workOrder.gift_phase || '').trim()
|
const giftPhase = String(workOrder.gift_phase || '').trim()
|
||||||
|
const resolvedGiftPhase = resolveWorkOrderGiftPhase(workOrder)
|
||||||
|
const internalConsignment = hasInternalConsignmentFulfillment(workOrder)
|
||||||
const hallAvailable =
|
const hallAvailable =
|
||||||
workOrder.status === WORK_ORDER_STATUS.OPEN &&
|
workOrder.status === WORK_ORDER_STATUS.OPEN &&
|
||||||
(!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity)
|
(!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity)
|
||||||
@@ -166,7 +169,12 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
|
|||||||
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED ||
|
workOrder.status === WORK_ORDER_STATUS.UNASSIGNED ||
|
||||||
workOrder.status === WORK_ORDER_STATUS.OPEN ||
|
workOrder.status === WORK_ORDER_STATUS.OPEN ||
|
||||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS,
|
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS,
|
||||||
canAccept: status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
canAccept:
|
||||||
|
status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE ||
|
||||||
|
(isFriendGift &&
|
||||||
|
internalConsignment &&
|
||||||
|
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
||||||
|
resolvedGiftPhase === WORK_ORDER_GIFT_PHASE.GIFT_READY),
|
||||||
canMarkProblem:
|
canMarkProblem:
|
||||||
status === WORK_ORDER_STATUS.IN_PROGRESS || status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
status === WORK_ORDER_STATUS.IN_PROGRESS || status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||||
canDelete:
|
canDelete:
|
||||||
@@ -189,8 +197,15 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
|
|||||||
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
|
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
|
||||||
canConfirmFriend:
|
canConfirmFriend:
|
||||||
isFriendGift &&
|
isFriendGift &&
|
||||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
((workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
||||||
giftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED,
|
giftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED) ||
|
||||||
|
(internalConsignment &&
|
||||||
|
giftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED &&
|
||||||
|
[
|
||||||
|
WORK_ORDER_STATUS.UNASSIGNED,
|
||||||
|
WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||||
|
WORK_ORDER_STATUS.IN_PROGRESS,
|
||||||
|
].includes(workOrder.status as never))),
|
||||||
canCompleteGiftCooldown:
|
canCompleteGiftCooldown:
|
||||||
isFriendGift &&
|
isFriendGift &&
|
||||||
[WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(
|
[WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(
|
||||||
@@ -200,6 +215,7 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
|
|||||||
!isWorkOrderGiftAvailable(workOrder),
|
!isWorkOrderGiftAvailable(workOrder),
|
||||||
canResetGiftFlow:
|
canResetGiftFlow:
|
||||||
isFriendGift &&
|
isFriendGift &&
|
||||||
|
!internalConsignment &&
|
||||||
[WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(
|
[WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(
|
||||||
workOrder.status as never,
|
workOrder.status as never,
|
||||||
) &&
|
) &&
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
export type WorkerFinanceConfig = {
|
export type WorkerFinanceConfig = {
|
||||||
depositUnfreezeDays: number
|
depositUnfreezeDays: number
|
||||||
rewardUnfreezeEnabled: boolean
|
rewardUnfreezeEnabled: boolean
|
||||||
|
/** 苏尔南寄卖发布所需履约押金(分),默认 2000 = ¥20 */
|
||||||
|
consignmentPublishDepositFen: number
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
channelName: string
|
channelName: string
|
||||||
@@ -36,6 +38,11 @@ export function getWorkerFinanceConfig(): WorkerFinanceConfig {
|
|||||||
return refreshWorkerFinanceConfig(config)
|
return refreshWorkerFinanceConfig(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getConsignmentPublishDepositFen(): number {
|
||||||
|
const value = Number(getWorkerFinanceConfig().consignmentPublishDepositFen)
|
||||||
|
return Number.isFinite(value) ? Math.max(0, Math.round(value)) : 2000
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveWorkerFinanceConfig(rawValue: unknown): Promise<WorkerFinanceConfig> {
|
export async function saveWorkerFinanceConfig(rawValue: unknown): Promise<WorkerFinanceConfig> {
|
||||||
const saved = await saveAppConfigEntry({
|
const saved = await saveAppConfigEntry({
|
||||||
configKey: APP_CONFIG_KEYS.workerFinance,
|
configKey: APP_CONFIG_KEYS.workerFinance,
|
||||||
@@ -58,6 +65,12 @@ export function normalizeWorkerFinanceConfig(rawValue: unknown): WorkerFinanceCo
|
|||||||
return {
|
return {
|
||||||
depositUnfreezeDays: normalizeRangeInteger(source.depositUnfreezeDays, 3, 0, 30),
|
depositUnfreezeDays: normalizeRangeInteger(source.depositUnfreezeDays, 3, 0, 30),
|
||||||
rewardUnfreezeEnabled: source.rewardUnfreezeEnabled !== false,
|
rewardUnfreezeEnabled: source.rewardUnfreezeEnabled !== false,
|
||||||
|
consignmentPublishDepositFen: normalizeRangeInteger(
|
||||||
|
source.consignmentPublishDepositFen ?? source.consignment_publish_deposit_fen,
|
||||||
|
2000,
|
||||||
|
0,
|
||||||
|
1_000_000,
|
||||||
|
),
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: typeof recharge.enabled === 'boolean' ? recharge.enabled : true,
|
enabled: typeof recharge.enabled === 'boolean' ? recharge.enabled : true,
|
||||||
channelName: String(recharge.channelName || recharge.channel_name || '').trim(),
|
channelName: String(recharge.channelName || recharge.channel_name || '').trim(),
|
||||||
@@ -83,6 +96,7 @@ export function createDefaultWorkerFinanceConfig(): WorkerFinanceConfig {
|
|||||||
return {
|
return {
|
||||||
depositUnfreezeDays: 3,
|
depositUnfreezeDays: 3,
|
||||||
rewardUnfreezeEnabled: true,
|
rewardUnfreezeEnabled: true,
|
||||||
|
consignmentPublishDepositFen: 2000,
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
channelName: '',
|
channelName: '',
|
||||||
@@ -105,6 +119,7 @@ function refreshWorkerFinanceConfig(config: WorkerFinanceConfig): WorkerFinanceC
|
|||||||
return {
|
return {
|
||||||
depositUnfreezeDays: config.depositUnfreezeDays,
|
depositUnfreezeDays: config.depositUnfreezeDays,
|
||||||
rewardUnfreezeEnabled: config.rewardUnfreezeEnabled,
|
rewardUnfreezeEnabled: config.rewardUnfreezeEnabled,
|
||||||
|
consignmentPublishDepositFen: config.consignmentPublishDepositFen,
|
||||||
recharge: {
|
recharge: {
|
||||||
...config.recharge,
|
...config.recharge,
|
||||||
qrCodeImage: config.recharge.qrCodeImage
|
qrCodeImage: config.recharge.qrCodeImage
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
import type { PoolClient } from 'pg'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
WorkOrderRow,
|
WorkOrderRow,
|
||||||
@@ -51,6 +52,96 @@ import {
|
|||||||
validateWorkerPassword,
|
validateWorkerPassword,
|
||||||
} from './index.js'
|
} from './index.js'
|
||||||
import { resolveAfterSalesRecoveryAllocation } from '../../repositories/worker-platform/after-sales-recovery.js'
|
import { resolveAfterSalesRecoveryAllocation } from '../../repositories/worker-platform/after-sales-recovery.js'
|
||||||
|
import { normalizeWorkerFinanceConfig } from './worker-finance-config-service.js'
|
||||||
|
import {
|
||||||
|
ensureConsignmentPublishDepositFrozenBySubmissionWithClient,
|
||||||
|
releaseConsignmentPublishDepositWithClient,
|
||||||
|
} from '../../repositories/worker-platform/consignment-deposit-repo.js'
|
||||||
|
|
||||||
|
test('寄卖发布押金配置保留显式零值并回退非法值', () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeWorkerFinanceConfig({ consignmentPublishDepositFen: 0 }).consignmentPublishDepositFen,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
normalizeWorkerFinanceConfig({ consignmentPublishDepositFen: 'invalid' })
|
||||||
|
.consignmentPublishDepositFen,
|
||||||
|
2000,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('寄卖押金释放先锁钱包再读流水,冻结额不足时拒绝部分退款', async () => {
|
||||||
|
const statements: string[] = []
|
||||||
|
const client = {
|
||||||
|
query: async (sql: string) => {
|
||||||
|
statements.push(sql)
|
||||||
|
if (sql.includes('SELECT * FROM worker_wallets')) {
|
||||||
|
return { rows: [{ available_amount: 0, frozen_deposit_amount: 10 }] }
|
||||||
|
}
|
||||||
|
if (sql.includes('COALESCE(SUM')) {
|
||||||
|
return { rows: [{ outstanding: 20 }] }
|
||||||
|
}
|
||||||
|
return { rows: [] }
|
||||||
|
},
|
||||||
|
} as unknown as PoolClient
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
releaseConsignmentPublishDepositWithClient(client, {
|
||||||
|
workerId: 1,
|
||||||
|
submissionId: 2,
|
||||||
|
note: 'test',
|
||||||
|
now: '2026-09-15T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof Error &&
|
||||||
|
(error as Error & { code?: string }).code === 'consignment_deposit_balance_inconsistent',
|
||||||
|
)
|
||||||
|
|
||||||
|
const walletLockIndex = statements.findIndex(
|
||||||
|
(sql) => sql.includes('SELECT * FROM worker_wallets') && sql.includes('FOR UPDATE'),
|
||||||
|
)
|
||||||
|
const outstandingIndex = statements.findIndex((sql) => sql.includes('COALESCE(SUM'))
|
||||||
|
assert.ok(walletLockIndex >= 0)
|
||||||
|
assert.ok(outstandingIndex > walletLockIndex)
|
||||||
|
assert.equal(
|
||||||
|
statements.some(
|
||||||
|
(sql) => sql.includes('INSERT INTO worker_wallet_ledgers') && sql.includes('deposit_release'),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('寄卖回库时若押金仍冻结则不再重复扣款', async () => {
|
||||||
|
const statements: string[] = []
|
||||||
|
const client = {
|
||||||
|
query: async (sql: string) => {
|
||||||
|
statements.push(sql)
|
||||||
|
if (sql.includes('FROM consignment_submissions')) {
|
||||||
|
return { rows: [{ worker_id: 1, deposit_amount_fen: 2000, submission_no: 'CS1' }] }
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT * FROM worker_wallets')) {
|
||||||
|
return { rows: [{ available_amount: 0, frozen_deposit_amount: 2000 }] }
|
||||||
|
}
|
||||||
|
if (sql.includes('COALESCE(SUM')) {
|
||||||
|
return { rows: [{ outstanding: 2000 }] }
|
||||||
|
}
|
||||||
|
return { rows: [] }
|
||||||
|
},
|
||||||
|
} as unknown as PoolClient
|
||||||
|
|
||||||
|
const frozen = await ensureConsignmentPublishDepositFrozenBySubmissionWithClient(client, {
|
||||||
|
submissionId: 2,
|
||||||
|
note: 'restock',
|
||||||
|
now: '2026-09-15T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
assert.equal(frozen, 2000)
|
||||||
|
assert.equal(
|
||||||
|
statements.some(
|
||||||
|
(sql) => sql.includes('INSERT INTO worker_wallet_ledgers') && sql.includes('deposit_freeze'),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('售后追缴优先扣减待解冻押金', () => {
|
test('售后追缴优先扣减待解冻押金', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -1238,6 +1329,77 @@ test('好友赠送冷却中才允许客服手动结束冷却', () => {
|
|||||||
assert.equal(completed.capabilities.canCompleteGiftCooldown, false)
|
assert.equal(completed.capabilities.canCompleteGiftCooldown, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('内部寄售跳过打手资料:未完成冷却不可验收,冷却结束可验收', () => {
|
||||||
|
const stuckAccepted = mapWorkOrderAdmin(
|
||||||
|
buildWorkOrderRow({
|
||||||
|
status: 'accepted',
|
||||||
|
assigned_worker_id: null,
|
||||||
|
acceptance_mode: 'friend_gift',
|
||||||
|
gift_phase: 'material_required',
|
||||||
|
material_json: { consignment: { inventoryId: 88, fulfillmentType: 'internal_stock' } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.equal(stuckAccepted.capabilities.canConfirmFriend, false)
|
||||||
|
assert.equal(stuckAccepted.capabilities.canAccept, false)
|
||||||
|
|
||||||
|
const pendingStart = mapWorkOrderAdmin(
|
||||||
|
buildWorkOrderRow({
|
||||||
|
status: 'unassigned',
|
||||||
|
assigned_worker_id: null,
|
||||||
|
acceptance_mode: 'friend_gift',
|
||||||
|
gift_phase: 'material_required',
|
||||||
|
material_json: { consignment: { inventoryId: 88, fulfillmentType: 'internal_stock' } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.equal(pendingStart.capabilities.canConfirmFriend, true)
|
||||||
|
assert.equal(pendingStart.capabilities.canResetGiftFlow, false)
|
||||||
|
|
||||||
|
const cooling = mapWorkOrderAdmin(
|
||||||
|
buildWorkOrderRow({
|
||||||
|
status: 'in_progress',
|
||||||
|
assigned_worker_id: null,
|
||||||
|
acceptance_mode: 'friend_gift',
|
||||||
|
gift_phase: 'friend_countdown',
|
||||||
|
gift_available_at: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
material_json: { consignment: { inventoryId: 88, fulfillmentType: 'internal_stock' } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.equal(cooling.gift?.phase, 'friend_countdown')
|
||||||
|
assert.equal(cooling.capabilities.canCompleteGiftCooldown, true)
|
||||||
|
assert.equal(cooling.capabilities.canAccept, false)
|
||||||
|
assert.equal(cooling.capabilities.canResetGiftFlow, false)
|
||||||
|
|
||||||
|
const ready = mapWorkOrderAdmin(
|
||||||
|
buildWorkOrderRow({
|
||||||
|
status: 'in_progress',
|
||||||
|
assigned_worker_id: null,
|
||||||
|
acceptance_mode: 'friend_gift',
|
||||||
|
gift_phase: 'friend_countdown',
|
||||||
|
gift_available_at: new Date(Date.now() - 60_000).toISOString(),
|
||||||
|
material_json: { consignment: { inventoryId: 88, fulfillmentType: 'internal_stock' } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.equal(ready.gift?.phase, 'gift_ready')
|
||||||
|
assert.equal(ready.capabilities.canAccept, true)
|
||||||
|
assert.equal(ready.capabilities.canCompleteGiftCooldown, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('取消指派后的打手寄售不会被当成内部现货', () => {
|
||||||
|
const unassignedWorkerStock = mapWorkOrderAdmin(
|
||||||
|
buildWorkOrderRow({
|
||||||
|
status: 'unassigned',
|
||||||
|
assigned_worker_id: null,
|
||||||
|
acceptance_mode: 'friend_gift',
|
||||||
|
gift_phase: 'material_required',
|
||||||
|
material_json: {
|
||||||
|
consignment: { inventoryId: 88, fulfillmentType: 'worker_deposit', workerId: 12 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.equal(unassignedWorkerStock.capabilities.canConfirmFriend, false)
|
||||||
|
assert.equal(unassignedWorkerStock.capabilities.canAccept, false)
|
||||||
|
})
|
||||||
|
|
||||||
test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
|
test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => {
|
||||||
const waitingForClaim = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'open' }))
|
const waitingForClaim = mapWorkOrderAdmin(buildWorkOrderRow({ status: 'open' }))
|
||||||
assert.equal(waitingForClaim.status, 'open')
|
assert.equal(waitingForClaim.status, 'open')
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { CheckCircleOutlined, CloseCircleOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons'
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { asRecord, formatMoney } from './shared'
|
|||||||
type FinanceConfigFormValues = {
|
type FinanceConfigFormValues = {
|
||||||
depositUnfreezeDays?: number
|
depositUnfreezeDays?: number
|
||||||
rewardUnfreezeEnabled?: boolean
|
rewardUnfreezeEnabled?: boolean
|
||||||
|
consignmentPublishDepositYuan?: number
|
||||||
recharge?: {
|
recharge?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
channelName?: string
|
channelName?: string
|
||||||
@@ -169,6 +170,9 @@ export default function FinancePanel() {
|
|||||||
await saveAdminWorkerFinanceConfig({
|
await saveAdminWorkerFinanceConfig({
|
||||||
depositUnfreezeDays: Number(values.depositUnfreezeDays ?? 3),
|
depositUnfreezeDays: Number(values.depositUnfreezeDays ?? 3),
|
||||||
rewardUnfreezeEnabled: values.rewardUnfreezeEnabled !== false,
|
rewardUnfreezeEnabled: values.rewardUnfreezeEnabled !== false,
|
||||||
|
consignmentPublishDepositFen: Math.round(
|
||||||
|
Number(values.consignmentPublishDepositYuan ?? 20) * 100,
|
||||||
|
),
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: values.recharge?.enabled !== false,
|
enabled: values.recharge?.enabled !== false,
|
||||||
channelName: String(values.recharge?.channelName || '').trim(),
|
channelName: String(values.recharge?.channelName || '').trim(),
|
||||||
@@ -416,6 +420,13 @@ export default function FinancePanel() {
|
|||||||
>
|
>
|
||||||
<InputNumber min={0} max={30} step={1} addonAfter="天" />
|
<InputNumber min={0} max={30} step={1} addonAfter="天" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="苏尔南寄卖发布押金"
|
||||||
|
name="consignmentPublishDepositYuan"
|
||||||
|
tooltip="打手发布寄卖时从可用余额冻结的履约押金;驳回或履约完成后释放"
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={10000} step={1} addonBefore="¥" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="报酬待解冻"
|
label="报酬待解冻"
|
||||||
name="rewardUnfreezeEnabled"
|
name="rewardUnfreezeEnabled"
|
||||||
@@ -676,6 +687,7 @@ function mapFinanceConfigToFormValues(config?: WorkerFinanceConfig): FinanceConf
|
|||||||
return {
|
return {
|
||||||
depositUnfreezeDays: Number(config?.depositUnfreezeDays ?? 3),
|
depositUnfreezeDays: Number(config?.depositUnfreezeDays ?? 3),
|
||||||
rewardUnfreezeEnabled: config?.rewardUnfreezeEnabled !== false,
|
rewardUnfreezeEnabled: config?.rewardUnfreezeEnabled !== false,
|
||||||
|
consignmentPublishDepositYuan: Number(config?.consignmentPublishDepositFen ?? 2000) / 100,
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: config?.recharge.enabled !== false,
|
enabled: config?.recharge.enabled !== false,
|
||||||
channelName: config?.recharge.channelName || '',
|
channelName: config?.recharge.channelName || '',
|
||||||
|
|||||||
@@ -161,6 +161,12 @@ type CancelSharingFormValues = {
|
|||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isInternalConsignmentOrder(row: WorkOrder) {
|
||||||
|
const consignment = asRecord((row.material as Record<string, unknown> | undefined)?.consignment)
|
||||||
|
if (consignment.fulfillmentType === 'worker_deposit' || consignment.workerId) return false
|
||||||
|
return Boolean(consignment.inventoryId) && !row.worker
|
||||||
|
}
|
||||||
|
|
||||||
export default function WorkOrdersPanel() {
|
export default function WorkOrdersPanel() {
|
||||||
const { message, modal } = App.useApp()
|
const { message, modal } = App.useApp()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -715,7 +721,7 @@ export default function WorkOrdersPanel() {
|
|||||||
workOrderId: consignmentInterceptModal.order.workOrderId,
|
workOrderId: consignmentInterceptModal.order.workOrderId,
|
||||||
inventoryId,
|
inventoryId,
|
||||||
})
|
})
|
||||||
message.success('已使用内部寄售库存直接履约!工单已自动完成,无需打手接单佣金')
|
message.success('已占用内部库存,跳过打手资料,进入赠送冷静期')
|
||||||
setConsignmentInterceptModal({ open: false, order: null, matchData: null, submitting: false })
|
setConsignmentInterceptModal({ open: false, order: null, matchData: null, submitting: false })
|
||||||
await refreshAll()
|
await refreshAll()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -1034,34 +1040,50 @@ export default function WorkOrdersPanel() {
|
|||||||
const cooldownHours = Number(row.gift?.cooldownHours || 72)
|
const cooldownHours = Number(row.gift?.cooldownHours || 72)
|
||||||
const cooldownMinutes = Number(row.gift?.cooldownMinutes || 0)
|
const cooldownMinutes = Number(row.gift?.cooldownMinutes || 0)
|
||||||
const cooldownLabel = cooldownMinutes > 0 ? `${cooldownMinutes} 分钟` : `${cooldownHours} 小时`
|
const cooldownLabel = cooldownMinutes > 0 ? `${cooldownMinutes} 分钟` : `${cooldownHours} 小时`
|
||||||
|
const internal = isInternalConsignmentOrder(row)
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: '确认已添加打手为游戏好友?',
|
title: internal ? '跳过打手资料,开始赠送冷却?' : '确认已添加打手为游戏好友?',
|
||||||
content: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Paragraph>
|
{internal ? (
|
||||||
请先核对打手提交的昵称 / ID / 区服与主页截图,确认已在游戏内互为好友后再点击确认。
|
<>
|
||||||
</Typography.Paragraph>
|
<Typography.Paragraph>
|
||||||
<Typography.Paragraph>
|
内部寄售履约没有打手交资料。确认后直接开始 {cooldownLabel}
|
||||||
确认后开始 {cooldownLabel}
|
赠送冷却,冷却结束后再用库存账号给买家赠送并验收。
|
||||||
赠送冷却,冷却结束前打手不能提交赠送凭证;任务时限将顺延到冷却结束后重新起算。
|
</Typography.Paragraph>
|
||||||
</Typography.Paragraph>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography.Paragraph>
|
||||||
|
请先核对打手提交的昵称 / ID / 区服与主页截图,确认已在游戏内互为好友后再点击确认。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Typography.Paragraph>
|
||||||
|
确认后开始 {cooldownLabel}
|
||||||
|
赠送冷却,冷却结束前打手不能提交赠送凭证;任务时限将顺延到冷却结束后重新起算。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
okText: '已加好友,开始冷却',
|
okText: internal ? '开始冷却' : '已加好友,开始冷却',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: () =>
|
onOk: () =>
|
||||||
runAction(
|
runAction(
|
||||||
() => confirmAdminWorkOrderFriend(row.workOrderId),
|
() => confirmAdminWorkOrderFriend(row.workOrderId),
|
||||||
`已确认加好友,${cooldownLabel} 冷却开始`,
|
internal
|
||||||
|
? `已跳过打手资料,${cooldownLabel} 冷却开始`
|
||||||
|
: `已确认加好友,${cooldownLabel} 冷却开始`,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeGiftCooldown(row: WorkOrder) {
|
function completeGiftCooldown(row: WorkOrder) {
|
||||||
|
const internal = isInternalConsignmentOrder(row)
|
||||||
modal.confirm({
|
modal.confirm({
|
||||||
title: '手动结束赠送冷却?',
|
title: '手动结束赠送冷却?',
|
||||||
content:
|
content: internal
|
||||||
'确认后将立即允许打手提交赠送凭证与验收资料;订单任务时限将从当前时刻重新计算。此操作会记录在订单流转记录中。',
|
? '确认后将立即进入可赠送,可用库存账号给买家赠送,然后直接验收。此操作会记录在订单流转记录中。'
|
||||||
|
: '确认后将立即允许打手提交赠送凭证与验收资料;订单任务时限将从当前时刻重新计算。此操作会记录在订单流转记录中。',
|
||||||
okText: '确认结束冷却',
|
okText: '确认结束冷却',
|
||||||
okButtonProps: { type: 'primary' },
|
okButtonProps: { type: 'primary' },
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
@@ -1237,7 +1259,8 @@ export default function WorkOrdersPanel() {
|
|||||||
{row.acceptanceMode === 'friend_gift' ? <Tag color="purple">好友赠送</Tag> : null}
|
{row.acceptanceMode === 'friend_gift' ? <Tag color="purple">好友赠送</Tag> : null}
|
||||||
{row.acceptanceMode === 'friend_gift' &&
|
{row.acceptanceMode === 'friend_gift' &&
|
||||||
row.gift &&
|
row.gift &&
|
||||||
!['pending_material', 'unassigned', 'open'].includes(row.rawStatus || row.status) ? (
|
(!['pending_material', 'unassigned', 'open'].includes(row.rawStatus || row.status) ||
|
||||||
|
isInternalConsignmentOrder(row)) ? (
|
||||||
<Tag color={resolveGiftPhaseColor(row.gift.phase)}>
|
<Tag color={resolveGiftPhaseColor(row.gift.phase)}>
|
||||||
{formatGiftPhase(row.gift.phase)}
|
{formatGiftPhase(row.gift.phase)}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -1459,7 +1482,9 @@ export default function WorkOrdersPanel() {
|
|||||||
icon={<UserAddOutlined />}
|
icon={<UserAddOutlined />}
|
||||||
onClick={() => confirmFriendAdded(row)}
|
onClick={() => confirmFriendAdded(row)}
|
||||||
>
|
>
|
||||||
确认加好友
|
{isInternalConsignmentOrder(row) && row.gift?.phase === 'material_required'
|
||||||
|
? '开始赠送冷却'
|
||||||
|
: '确认加好友'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{row.capabilities?.canCompleteGiftCooldown && canManageProblem ? (
|
{row.capabilities?.canCompleteGiftCooldown && canManageProblem ? (
|
||||||
@@ -1873,7 +1898,10 @@ export default function WorkOrdersPanel() {
|
|||||||
size="small"
|
size="small"
|
||||||
onClick={() => confirmFriendAdded(detailOrder)}
|
onClick={() => confirmFriendAdded(detailOrder)}
|
||||||
>
|
>
|
||||||
确认加好友
|
{isInternalConsignmentOrder(detailOrder) &&
|
||||||
|
detailOrder.gift?.phase === 'material_required'
|
||||||
|
? '开始赠送冷却'
|
||||||
|
: '确认加好友'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{detailOrder.capabilities?.canCompleteGiftCooldown && canManageProblem ? (
|
{detailOrder.capabilities?.canCompleteGiftCooldown && canManageProblem ? (
|
||||||
@@ -2821,7 +2849,8 @@ export default function WorkOrdersPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#389e0d', marginTop: 4 }}>
|
<div style={{ color: '#389e0d', marginTop: 4 }}>
|
||||||
若使用内部寄售库存履约,<strong>无需向外部打手支付佣金(打手支出 ¥0.00)</strong>
|
若使用内部寄售库存履约,<strong>无需向外部打手支付佣金(打手支出 ¥0.00)</strong>
|
||||||
,系统将自动核销工单并归档!
|
。系统会立刻占用库存,并跳过「待打手提交资料」,直接进入默认 3
|
||||||
|
天赠送冷静期;冷静期结束后再用库存账号赠送并验收。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -2914,7 +2943,7 @@ export default function WorkOrdersPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 4, fontSize: 11, color: '#8c8c8c' }}>
|
<div style={{ marginTop: 4, fontSize: 11, color: '#8c8c8c' }}>
|
||||||
匹配分析: {m.matchReasons.join(' · ')}
|
匹配分析: {(m.matchReasons || []).join(' · ') || m.reason || '规格匹配'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2935,7 +2964,7 @@ export default function WorkOrdersPanel() {
|
|||||||
loading={consignmentInterceptModal.submitting}
|
loading={consignmentInterceptModal.submitting}
|
||||||
onClick={() => handleFulfillWithConsignment(inv.id)}
|
onClick={() => handleFulfillWithConsignment(inv.id)}
|
||||||
>
|
>
|
||||||
内部现货直接核销
|
内部现货履约
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
|
Radio,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
|
||||||
Space,
|
Space,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
@@ -59,14 +59,15 @@ const BRILLIANCE_OPTIONS = [
|
|||||||
|
|
||||||
type RecordFilter = 'all' | 'pending' | 'in_stock' | 'done'
|
type RecordFilter = 'all' | 'pending' | 'in_stock' | 'done'
|
||||||
|
|
||||||
|
const DEPOSIT_TIP_KEY = 'worker-consignment-deposit-tip-seen'
|
||||||
|
|
||||||
export default function WorkerConsignmentPage() {
|
export default function WorkerConsignmentPage() {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const { message } = App.useApp()
|
const { message, modal } = App.useApp()
|
||||||
const [activeTab, setActiveTab] = useState<'publish' | 'records'>('publish')
|
const [activeTab, setActiveTab] = useState<'publish' | 'records'>('publish')
|
||||||
const [recordFilter, setRecordFilter] = useState<RecordFilter>('all')
|
const [recordFilter, setRecordFilter] = useState<RecordFilter>('all')
|
||||||
const [screenshots, setScreenshots] = useState<UploadedFile[]>([])
|
const [screenshots, setScreenshots] = useState<UploadedFile[]>([])
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [depositTouched, setDepositTouched] = useState(false)
|
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({
|
||||||
@@ -75,6 +76,7 @@ export default function WorkerConsignmentPage() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
const worker = profileQuery.data?.data.worker
|
const worker = profileQuery.data?.data.worker
|
||||||
|
const financeConfig = profileQuery.data?.data.financeConfig
|
||||||
|
|
||||||
const myConsignmentsQuery = useQuery({
|
const myConsignmentsQuery = useQuery({
|
||||||
queryKey: ['worker-my-consignments'],
|
queryKey: ['worker-my-consignments'],
|
||||||
@@ -83,6 +85,14 @@ export default function WorkerConsignmentPage() {
|
|||||||
|
|
||||||
const submissions = myConsignmentsQuery.data?.data.submissions || []
|
const submissions = myConsignmentsQuery.data?.data.submissions || []
|
||||||
const inventories = myConsignmentsQuery.data?.data.inventories || []
|
const inventories = myConsignmentsQuery.data?.data.inventories || []
|
||||||
|
const publishDepositFen = Number(
|
||||||
|
myConsignmentsQuery.data?.data.publishDepositFen ??
|
||||||
|
financeConfig?.consignmentPublishDepositFen ??
|
||||||
|
2000,
|
||||||
|
)
|
||||||
|
const publishDepositYuan = publishDepositFen / 100
|
||||||
|
const availableAmount = Number(worker?.wallet?.availableAmount || 0)
|
||||||
|
const canPublish = availableAmount >= publishDepositFen
|
||||||
|
|
||||||
const pendingCount = submissions.filter((s) => s.status === 'pending_review').length
|
const pendingCount = submissions.filter((s) => s.status === 'pending_review').length
|
||||||
const inStockCount = inventories.filter((i) => i.status === 'in_stock').length
|
const inStockCount = inventories.filter((i) => i.status === 'in_stock').length
|
||||||
@@ -95,39 +105,53 @@ export default function WorkerConsignmentPage() {
|
|||||||
}
|
}
|
||||||
}, [worker, form])
|
}, [worker, form])
|
||||||
|
|
||||||
function handleFormValuesChange(changedValues: Record<string, unknown>) {
|
useEffect(() => {
|
||||||
|
if (profileQuery.isLoading) return
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (window.localStorage.getItem(DEPOSIT_TIP_KEY)) return
|
||||||
|
window.localStorage.setItem(DEPOSIT_TIP_KEY, '1')
|
||||||
|
modal.info({
|
||||||
|
title: '发布寄售需先充值履约押金',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
首次发布苏尔南寄售前,请先联系客服充值至少{' '}
|
||||||
|
<Typography.Text strong type="danger">
|
||||||
|
¥{publishDepositYuan.toFixed(0)}
|
||||||
|
</Typography.Text>{' '}
|
||||||
|
履约押金到接单钱包。
|
||||||
|
</p>
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
发布成功后系统将暂扣该押金;审核驳回或履约完成后自动释放回可用余额。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
okText: '我知道了',
|
||||||
|
centered: true,
|
||||||
|
})
|
||||||
|
}, [profileQuery.isLoading, modal, publishDepositYuan])
|
||||||
|
|
||||||
|
function handleFormValuesChange() {
|
||||||
const sys = form.getFieldValue('system')
|
const sys = form.getFieldValue('system')
|
||||||
const zone = form.getFieldValue('serverZone')
|
const zone = form.getFieldValue('serverZone')
|
||||||
const ser = form.getFieldValue('series')
|
const ser = form.getFieldValue('series')
|
||||||
const dia = form.getFieldValue('diamondLevel')
|
const dia = form.getFieldValue('diamondLevel')
|
||||||
const bri = form.getFieldValue('brillianceCount')
|
const bri = form.getFieldValue('brillianceCount')
|
||||||
const next: Record<string, unknown> = {}
|
|
||||||
|
|
||||||
if (sys && zone && ser && dia != null && bri != null) {
|
if (sys && zone && ser && dia != null && bri != null) {
|
||||||
const briNum = Number(bri)
|
const briNum = Number(bri)
|
||||||
const briStr = briNum > 0 ? `${briNum}璀璨` : ''
|
const briStr = briNum > 0 ? `${briNum}璀璨` : ''
|
||||||
next.itemName = `${sys}${zone}${ser}${dia}钻${briStr} 苏尔南`
|
form.setFieldsValue({
|
||||||
}
|
itemName: `${sys}${zone}${ser}${dia}钻${briStr} 苏尔南`,
|
||||||
|
})
|
||||||
if (Object.prototype.hasOwnProperty.call(changedValues, 'depositAmount')) {
|
|
||||||
setDepositTouched(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
Object.prototype.hasOwnProperty.call(changedValues, 'askingPrice') &&
|
|
||||||
!depositTouched &&
|
|
||||||
!Object.prototype.hasOwnProperty.call(changedValues, 'depositAmount')
|
|
||||||
) {
|
|
||||||
const price = Number(changedValues.askingPrice || 0)
|
|
||||||
if (price > 0) next.depositAmount = price
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(next).length > 0) {
|
|
||||||
form.setFieldsValue(next)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublishSubmit(values: Record<string, any>) {
|
async function handlePublishSubmit(values: Record<string, any>) {
|
||||||
|
if (!canPublish) {
|
||||||
|
message.warning(`可用余额不足 ¥${publishDepositYuan.toFixed(0)},请先联系客服充值履约押金`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const urls = screenshots.map((f) => f.url).filter(Boolean)
|
const urls = screenshots.map((f) => f.url).filter(Boolean)
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
message.error('请上传游戏主页截图以供核验')
|
message.error('请上传游戏主页截图以供核验')
|
||||||
@@ -140,18 +164,16 @@ export default function WorkerConsignmentPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const depositAmountFen = Math.round(
|
const briNum = Number(values.brillianceCount || 0)
|
||||||
Number(values.depositAmount || values.askingPrice || 100) * 100,
|
const briStr = briNum > 0 ? `${briNum}璀璨` : ''
|
||||||
)
|
const itemName =
|
||||||
if (depositAmountFen < 100) {
|
String(values.itemName || '').trim() ||
|
||||||
message.error('履约押金不能少于1元')
|
`${values.system}${values.serverZone}${values.series}${values.diamondLevel}钻${briStr} 苏尔南`
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await publishWorkerConsignment({
|
await publishWorkerConsignment({
|
||||||
itemName: values.itemName,
|
itemName,
|
||||||
system: values.system,
|
system: values.system,
|
||||||
serverZone: values.serverZone,
|
serverZone: values.serverZone,
|
||||||
series: values.series,
|
series: values.series,
|
||||||
@@ -164,14 +186,13 @@ export default function WorkerConsignmentPage() {
|
|||||||
characterId: values.characterId,
|
characterId: values.characterId,
|
||||||
screenshotsJson: urls,
|
screenshotsJson: urls,
|
||||||
sellerNote: values.sellerNote,
|
sellerNote: values.sellerNote,
|
||||||
depositAmountFen,
|
depositAmountFen: publishDepositFen,
|
||||||
})
|
})
|
||||||
|
|
||||||
message.success('寄卖提报成功,已进入审核')
|
message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
setDepositTouched(false)
|
|
||||||
setScreenshots([])
|
setScreenshots([])
|
||||||
await myConsignmentsQuery.refetch()
|
await Promise.all([myConsignmentsQuery.refetch(), profileQuery.refetch()])
|
||||||
setRecordFilter('pending')
|
setRecordFilter('pending')
|
||||||
setActiveTab('records')
|
setActiveTab('records')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -189,7 +210,10 @@ export default function WorkerConsignmentPage() {
|
|||||||
const done = inventories.filter((i) => i.status === 'consumed' || i.status === 'archived')
|
const done = inventories.filter((i) => i.status === 'consumed' || i.status === 'archived')
|
||||||
|
|
||||||
if (recordFilter === 'pending') {
|
if (recordFilter === 'pending') {
|
||||||
return { submissions: [...pendingSubs, ...rejectedSubs], inventories: [] as WorkerInventoryItem[] }
|
return {
|
||||||
|
submissions: [...pendingSubs, ...rejectedSubs],
|
||||||
|
inventories: [] as WorkerInventoryItem[],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (recordFilter === 'in_stock') {
|
if (recordFilter === 'in_stock') {
|
||||||
return { submissions: [] as WorkerConsignmentItem[], inventories: inStock }
|
return { submissions: [] as WorkerConsignmentItem[], inventories: inStock }
|
||||||
@@ -203,8 +227,7 @@ export default function WorkerConsignmentPage() {
|
|||||||
}
|
}
|
||||||
}, [submissions, inventories, recordFilter])
|
}, [submissions, inventories, recordFilter])
|
||||||
|
|
||||||
const recordTotal =
|
const recordTotal = filteredRecords.submissions.length + filteredRecords.inventories.length
|
||||||
filteredRecords.submissions.length + filteredRecords.inventories.length
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack worker-consignment-page">
|
<section className="page-stack worker-consignment-page">
|
||||||
@@ -347,92 +370,112 @@ export default function WorkerConsignmentPage() {
|
|||||||
onValuesChange={handleFormValuesChange}
|
onValuesChange={handleFormValuesChange}
|
||||||
>
|
>
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type={canPublish ? 'info' : 'warning'}
|
||||||
showIcon
|
showIcon
|
||||||
icon={<SafetyCertificateOutlined />}
|
icon={<SafetyCertificateOutlined />}
|
||||||
className="worker-consignment-tip"
|
className="worker-consignment-tip"
|
||||||
message="走接单平台押金履约:审核入库后匹配买家工单,系统按登记押金冻结钱包;局内交枪验货通过后解冻并结算货款。"
|
message={
|
||||||
|
canPublish
|
||||||
|
? `履约押金 ¥${publishDepositYuan.toFixed(0)}:发布成功后暂扣,驳回或履约完成后释放。当前可用余额 ¥${(availableAmount / 100).toFixed(2)}`
|
||||||
|
: `可用余额不足 ¥${publishDepositYuan.toFixed(0)}。请先联系客服充值履约押金后,才能发布寄售。当前可用 ¥${(availableAmount / 100).toFixed(2)}`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider orientation="left" plain className="worker-consignment-divider">
|
<Divider plain className="worker-consignment-divider">
|
||||||
规格与价格
|
规格与价格
|
||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
<Row gutter={[12, 0]}>
|
<div className="worker-consignment-specs">
|
||||||
<Col xs={12} sm={8} md={6}>
|
<div className="worker-consignment-spec-grid">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="system"
|
name="system"
|
||||||
label="操作系统"
|
label="操作系统"
|
||||||
rules={[{ required: true, message: '请选择系统' }]}
|
rules={[{ required: true, message: '请选择系统' }]}
|
||||||
|
className="worker-consignment-spec-card"
|
||||||
>
|
>
|
||||||
<Select
|
<Radio.Group
|
||||||
placeholder="请选择"
|
className="worker-consignment-chips"
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
options={[
|
options={[
|
||||||
{ label: '苹果 (iOS)', value: '苹果' },
|
{ label: '苹果', value: '苹果' },
|
||||||
{ label: '安卓 (Android)', value: '安卓' },
|
{ label: '安卓', value: '安卓' },
|
||||||
{ label: '通用/双端', value: '通用' },
|
{ label: '通用', value: '通用' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={8} md={6}>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="serverZone"
|
name="serverZone"
|
||||||
label="游戏区服"
|
label="游戏区服"
|
||||||
rules={[{ required: true, message: '请选择区服' }]}
|
rules={[{ required: true, message: '请选择区服' }]}
|
||||||
|
className="worker-consignment-spec-card"
|
||||||
>
|
>
|
||||||
<Select
|
<Radio.Group
|
||||||
placeholder="请选择"
|
className="worker-consignment-chips"
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
options={[
|
options={[
|
||||||
{ label: '微信区', value: '微信区' },
|
{ label: '微信区', value: '微信区' },
|
||||||
{ label: 'QQ区', value: 'QQ区' },
|
{ label: 'QQ区', value: 'QQ区' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={8} md={6}>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="series"
|
name="series"
|
||||||
label="系列/枪系"
|
label="系列 / 枪系"
|
||||||
rules={[{ required: true, message: '请选择枪系' }]}
|
rules={[{ required: true, message: '请选择枪系' }]}
|
||||||
|
className="worker-consignment-spec-card"
|
||||||
>
|
>
|
||||||
<Select placeholder="请选择" options={SERIES_OPTIONS} />
|
<Radio.Group
|
||||||
|
className="worker-consignment-chips"
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
options={SERIES_OPTIONS}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={8} md={6}>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="diamondLevel"
|
name="diamondLevel"
|
||||||
label="钻石等级"
|
label="钻石等级"
|
||||||
rules={[{ required: true, message: '请选择钻级' }]}
|
rules={[{ required: true, message: '请选择钻级' }]}
|
||||||
|
className="worker-consignment-spec-card is-count"
|
||||||
>
|
>
|
||||||
<Select placeholder="请选择" options={DIAMOND_OPTIONS} />
|
<Radio.Group
|
||||||
|
className="worker-consignment-chips is-count"
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
options={DIAMOND_OPTIONS.map((o) => ({
|
||||||
|
label: `${o.value}钻`,
|
||||||
|
value: o.value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={8} md={6}>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="brillianceCount"
|
name="brillianceCount"
|
||||||
label="璀璨数"
|
label="璀璨数"
|
||||||
rules={[{ required: true, message: '请选择璀璨数' }]}
|
rules={[{ required: true, message: '请选择璀璨数' }]}
|
||||||
|
className="worker-consignment-spec-card is-count"
|
||||||
>
|
>
|
||||||
<Select placeholder="请选择" options={BRILLIANCE_OPTIONS} />
|
<Radio.Group
|
||||||
|
className="worker-consignment-chips is-count"
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
options={BRILLIANCE_OPTIONS.map((o) => ({
|
||||||
|
label: o.value === 0 ? '无' : `${o.value}璀`,
|
||||||
|
value: o.value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} sm={16} md={12}>
|
|
||||||
<Form.Item
|
<div className="worker-consignment-price-grid">
|
||||||
name="itemName"
|
|
||||||
label="物品名称"
|
|
||||||
rules={[{ required: true, message: '请输入名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="选择规格后自动生成,也可手动修改" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={8} md={6}>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="askingPrice"
|
name="askingPrice"
|
||||||
label="寄售价格 (元)"
|
label="寄售价格"
|
||||||
rules={[{ required: true, message: '请填写寄售价格' }]}
|
rules={[{ required: true, message: '请填写寄售价格' }]}
|
||||||
|
className="worker-consignment-spec-card is-price"
|
||||||
>
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
|
size="large"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -440,26 +483,26 @@ export default function WorkerConsignmentPage() {
|
|||||||
addonBefore="¥"
|
addonBefore="¥"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={8} md={6}>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="depositAmount"
|
label="履约押金"
|
||||||
label="履约押金 (元)"
|
tooltip="平台统一配置。发布成功后暂扣,驳回或履约完成后释放。"
|
||||||
rules={[{ required: true, message: '请填写履约押金' }]}
|
className="worker-consignment-spec-card is-price"
|
||||||
tooltip="派单交枪时冻结,验货后解冻。填写寄售价后可自动带出,也可手动改。"
|
|
||||||
>
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
size="large"
|
||||||
step={10}
|
disabled
|
||||||
|
value={publishDepositYuan}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
placeholder="请输入"
|
|
||||||
addonBefore="¥"
|
addonBefore="¥"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
<Form.Item name="itemName" hidden>
|
||||||
</Row>
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Divider orientation="left" plain className="worker-consignment-divider">
|
<Divider plain className="worker-consignment-divider">
|
||||||
联系方式与游戏凭据
|
联系方式与游戏凭据
|
||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
@@ -532,12 +575,15 @@ export default function WorkerConsignmentPage() {
|
|||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
size="large"
|
size="large"
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
|
disabled={!canPublish}
|
||||||
icon={<CloudUploadOutlined />}
|
icon={<CloudUploadOutlined />}
|
||||||
>
|
>
|
||||||
提交寄卖提报
|
{canPublish
|
||||||
|
? `提交寄卖(暂扣押金 ¥${publishDepositYuan.toFixed(0)})`
|
||||||
|
: '余额不足,请先充值押金'}
|
||||||
</Button>
|
</Button>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
提交后进入客服审核,通过后入库匹配
|
提交后进入客服审核;押金在驳回或履约完成后释放
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -495,6 +495,7 @@ export function fetchAdminWorkerFinanceConfig() {
|
|||||||
export function saveAdminWorkerFinanceConfig(payload: {
|
export function saveAdminWorkerFinanceConfig(payload: {
|
||||||
depositUnfreezeDays?: number
|
depositUnfreezeDays?: number
|
||||||
rewardUnfreezeEnabled?: boolean
|
rewardUnfreezeEnabled?: boolean
|
||||||
|
consignmentPublishDepositFen?: number
|
||||||
recharge?: {
|
recharge?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
channelName?: string
|
channelName?: string
|
||||||
|
|||||||
@@ -412,7 +412,9 @@ export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayloa
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fetchWorkerConsignments() {
|
export function fetchWorkerConsignments() {
|
||||||
return apiGet<{ submissions: WorkerConsignmentItem[]; inventories: WorkerInventoryItem[] }>(
|
return apiGet<{
|
||||||
'/api/v1/worker/consignment/my-items',
|
publishDepositFen: number
|
||||||
)
|
submissions: WorkerConsignmentItem[]
|
||||||
|
inventories: WorkerInventoryItem[]
|
||||||
|
}>('/api/v1/worker/consignment/my-items')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4223,8 +4223,12 @@ body {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.worker-consignment-tip.ant-alert {
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.worker-consignment-divider.ant-divider {
|
.worker-consignment-divider.ant-divider {
|
||||||
margin: 8px 0 16px;
|
margin: 4px 0 10px;
|
||||||
border-color: #e2e8f0;
|
border-color: #e2e8f0;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -4232,7 +4236,102 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.worker-consignment-form .ant-form-item {
|
.worker-consignment-form .ant-form-item {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-specs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-spec-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(168px, 1.05fr) minmax(148px, 0.9fr) minmax(176px, 1.05fr) minmax(280px, 1.7fr)
|
||||||
|
minmax(280px, 1.7fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-price-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 240px;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-spec-card {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-spec-card .ant-form-item-label {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-spec-card .ant-form-item-label > label {
|
||||||
|
height: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-spec-card .ant-form-item-control-input,
|
||||||
|
.worker-consignment-spec-card .ant-form-item-control-input-content {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-chips {
|
||||||
|
display: flex !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-chips .ant-radio-button-wrapper {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 8px;
|
||||||
|
line-height: 38px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-chips .ant-radio-button-wrapper:first-child {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-chips .ant-radio-button-wrapper:last-child {
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-chips .ant-radio-button-wrapper-checked {
|
||||||
|
background: #ff6a00;
|
||||||
|
border-color: #ff6a00;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-chips.is-count .ant-radio-button-wrapper {
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-spec-card.is-name .ant-input,
|
||||||
|
.worker-consignment-spec-card.is-price .ant-input-number,
|
||||||
|
.worker-consignment-spec-card.is-price .ant-input-number-input {
|
||||||
|
height: 40px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-spec-card.is-price .ant-input-number-group-addon {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-consignment-actions {
|
.worker-consignment-actions {
|
||||||
@@ -4245,6 +4344,27 @@ body {
|
|||||||
border-top: 1px solid #f1f5f9;
|
border-top: 1px solid #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.worker-consignment-spec-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-spec-card.is-count {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.worker-consignment-spec-grid,
|
||||||
|
.worker-consignment-price-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-consignment-spec-card.is-count {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.worker-consignment-filter-row {
|
.worker-consignment-filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -4327,4 +4447,3 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export type ConsignmentMatchResult = {
|
|||||||
matchType: 'exact' | 'partial'
|
matchType: 'exact' | 'partial'
|
||||||
matchScore: number
|
matchScore: number
|
||||||
matchReasons: string[]
|
matchReasons: string[]
|
||||||
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkOrderInventoryMatchCheck = {
|
export type WorkOrderInventoryMatchCheck = {
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ export type WorkerAfterSalesCaseEvent = {
|
|||||||
export type WorkerFinanceConfig = {
|
export type WorkerFinanceConfig = {
|
||||||
depositUnfreezeDays: number
|
depositUnfreezeDays: number
|
||||||
rewardUnfreezeEnabled: boolean
|
rewardUnfreezeEnabled: boolean
|
||||||
|
/** 苏尔南寄卖发布履约押金(分),默认 2000 */
|
||||||
|
consignmentPublishDepositFen?: number
|
||||||
recharge: {
|
recharge: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
channelName: string
|
channelName: string
|
||||||
|
|||||||
Reference in New Issue
Block a user