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

+ 首次发布苏尔南寄售前,请先联系客服充值至少{' '} + + ¥{publishDepositYuan.toFixed(0)} + {' '} + 履约押金到接单钱包。 +

+

+ 发布成功后系统将暂扣该押金;审核驳回或履约完成后自动释放回可用余额。 +

+
+ ), + okText: '我知道了', + centered: true, + }) + }, [profileQuery.isLoading, modal, publishDepositYuan]) + + function handleFormValuesChange() { const sys = form.getFieldValue('system') const zone = form.getFieldValue('serverZone') const ser = form.getFieldValue('series') const dia = form.getFieldValue('diamondLevel') const bri = form.getFieldValue('brillianceCount') - const next: Record = {} - if (sys && zone && ser && dia != null && bri != null) { const briNum = Number(bri) const briStr = briNum > 0 ? `${briNum}璀璨` : '' - next.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) + form.setFieldsValue({ + itemName: `${sys}${zone}${ser}${dia}钻${briStr} 苏尔南`, + }) } } async function handlePublishSubmit(values: Record) { + if (!canPublish) { + message.warning(`可用余额不足 ¥${publishDepositYuan.toFixed(0)},请先联系客服充值履约押金`) + return + } + const urls = screenshots.map((f) => f.url).filter(Boolean) if (urls.length === 0) { message.error('请上传游戏主页截图以供核验') @@ -140,18 +164,16 @@ export default function WorkerConsignmentPage() { return } - const depositAmountFen = Math.round( - Number(values.depositAmount || values.askingPrice || 100) * 100, - ) - if (depositAmountFen < 100) { - message.error('履约押金不能少于1元') - return - } + const briNum = Number(values.brillianceCount || 0) + const briStr = briNum > 0 ? `${briNum}璀璨` : '' + const itemName = + String(values.itemName || '').trim() || + `${values.system}${values.serverZone}${values.series}${values.diamondLevel}钻${briStr} 苏尔南` setSubmitting(true) try { await publishWorkerConsignment({ - itemName: values.itemName, + itemName, system: values.system, serverZone: values.serverZone, series: values.series, @@ -164,14 +186,13 @@ export default function WorkerConsignmentPage() { characterId: values.characterId, screenshotsJson: urls, sellerNote: values.sellerNote, - depositAmountFen, + depositAmountFen: publishDepositFen, }) - message.success('寄卖提报成功,已进入审核') + message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`) form.resetFields() - setDepositTouched(false) setScreenshots([]) - await myConsignmentsQuery.refetch() + await Promise.all([myConsignmentsQuery.refetch(), profileQuery.refetch()]) setRecordFilter('pending') setActiveTab('records') } catch (err: any) { @@ -189,7 +210,10 @@ export default function WorkerConsignmentPage() { const done = inventories.filter((i) => i.status === 'consumed' || i.status === 'archived') if (recordFilter === 'pending') { - return { submissions: [...pendingSubs, ...rejectedSubs], inventories: [] as WorkerInventoryItem[] } + return { + submissions: [...pendingSubs, ...rejectedSubs], + inventories: [] as WorkerInventoryItem[], + } } if (recordFilter === 'in_stock') { return { submissions: [] as WorkerConsignmentItem[], inventories: inStock } @@ -203,8 +227,7 @@ export default function WorkerConsignmentPage() { } }, [submissions, inventories, recordFilter]) - const recordTotal = - filteredRecords.submissions.length + filteredRecords.inventories.length + const recordTotal = filteredRecords.submissions.length + filteredRecords.inventories.length return (
@@ -347,92 +370,112 @@ export default function WorkerConsignmentPage() { onValuesChange={handleFormValuesChange} > } className="worker-consignment-tip" - message="走接单平台押金履约:审核入库后匹配买家工单,系统按登记押金冻结钱包;局内交枪验货通过后解冻并结算货款。" + message={ + canPublish + ? `履约押金 ¥${publishDepositYuan.toFixed(0)}:发布成功后暂扣,驳回或履约完成后释放。当前可用余额 ¥${(availableAmount / 100).toFixed(2)}` + : `可用余额不足 ¥${publishDepositYuan.toFixed(0)}。请先联系客服充值履约押金后,才能发布寄售。当前可用 ¥${(availableAmount / 100).toFixed(2)}` + } /> - + 规格与价格 - - +
+
- - - - + ({ + label: `${o.value}钻`, + value: o.value, + }))} + /> - - - - - - +
+ +
- - - - + +
+
- + 联系方式与游戏凭据 @@ -532,12 +575,15 @@ export default function WorkerConsignmentPage() { htmlType="submit" size="large" loading={submitting} + disabled={!canPublish} icon={} > - 提交寄卖提报 + {canPublish + ? `提交寄卖(暂扣押金 ¥${publishDepositYuan.toFixed(0)})` + : '余额不足,请先充值押金'} - 提交后进入客服审核,通过后入库匹配 + 提交后进入客服审核;押金在驳回或履约完成后释放 diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 3748f424..fa4953ed 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -495,6 +495,7 @@ export function fetchAdminWorkerFinanceConfig() { export function saveAdminWorkerFinanceConfig(payload: { depositUnfreezeDays?: number rewardUnfreezeEnabled?: boolean + consignmentPublishDepositFen?: number recharge?: { enabled?: boolean channelName?: string diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index f2fe7476..6f42e6af 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -412,7 +412,9 @@ export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayloa } export function fetchWorkerConsignments() { - return apiGet<{ submissions: WorkerConsignmentItem[]; inventories: WorkerInventoryItem[] }>( - '/api/v1/worker/consignment/my-items', - ) + return apiGet<{ + publishDepositFen: number + submissions: WorkerConsignmentItem[] + inventories: WorkerInventoryItem[] + }>('/api/v1/worker/consignment/my-items') } diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index 7330c914..b253aff1 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -4223,8 +4223,12 @@ body { border-radius: 8px; } +.worker-consignment-tip.ant-alert { + padding: 6px 12px; +} + .worker-consignment-divider.ant-divider { - margin: 8px 0 16px; + margin: 4px 0 10px; border-color: #e2e8f0; color: #64748b; font-size: 13px; @@ -4232,7 +4236,102 @@ body { } .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 { @@ -4245,6 +4344,27 @@ body { 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 { display: flex; flex-wrap: wrap; @@ -4327,4 +4447,3 @@ body { width: 100%; } } - diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index 9e172bc2..a48c74d5 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -118,6 +118,7 @@ export type ConsignmentMatchResult = { matchType: 'exact' | 'partial' matchScore: number matchReasons: string[] + reason?: string } export type WorkOrderInventoryMatchCheck = { diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index aa08e8b4..24029cbd 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -259,6 +259,8 @@ export type WorkerAfterSalesCaseEvent = { export type WorkerFinanceConfig = { depositUnfreezeDays: number rewardUnfreezeEnabled: boolean + /** 苏尔南寄卖发布履约押金(分),默认 2000 */ + consignmentPublishDepositFen?: number recharge: { enabled: boolean channelName: string