From ee79195f47c9798986aaa152b9adcdb33fa40464 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 18 Aug 2026 18:02:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8B=BC=E5=8D=95=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E4=BB=BD=E9=A2=9D=E9=AA=8C=E6=94=B6=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../038_repair_filled_sharing_status.sql | 29 ++ .../src/repositories/worker-platform/types.ts | 23 + .../worker-platform/work-order-repo.ts | 407 ++++++++++++++++-- .../src/routes/admin/worker-platform.ts | 25 ++ .../realtime/realtime-event-service.ts | 22 +- .../services/worker-platform/admin-service.ts | 150 ++++--- .../src/services/worker-platform/mappers.ts | 131 +++++- .../worker-platform-service.test.ts | 107 +++++ .../worker-platform/worker-service.ts | 76 +++- apps/frontend/src/layouts/AdminLayout.tsx | 2 + apps/frontend/src/layouts/WorkerLayout.tsx | 7 +- .../pages/admin/panels/WorkOrdersPanel.tsx | 119 +++-- .../src/pages/worker/WorkerHallPage.tsx | 96 +++-- .../src/services/admin/worker-platform.ts | 6 + apps/frontend/src/types/worker-platform.ts | 19 + .../frontend/src/utils/worker-product-name.ts | 51 +++ .../tests/worker-product-name.test.ts | 69 +++ 17 files changed, 1143 insertions(+), 196 deletions(-) create mode 100644 apps/backend/src/db/migrations/038_repair_filled_sharing_status.sql create mode 100644 apps/frontend/src/utils/worker-product-name.ts create mode 100644 apps/frontend/tests/worker-product-name.test.ts diff --git a/apps/backend/src/db/migrations/038_repair_filled_sharing_status.sql b/apps/backend/src/db/migrations/038_repair_filled_sharing_status.sql new file mode 100644 index 00000000..a9d1a5a2 --- /dev/null +++ b/apps/backend/src/db/migrations/038_repair_filled_sharing_status.sql @@ -0,0 +1,29 @@ +-- 修复历史拼单:份数已拼满且所有参与者均已提交时,母工单应进入待验收。 +UPDATE work_orders wo +SET + status = 'pending_acceptance', + submitted_at = COALESCE( + wo.submitted_at, + ( + SELECT MAX(wos.submitted_at) + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.status != 'cancelled' + ), + NOW() + ), + updated_at = NOW() +WHERE wo.status = 'open' + AND wo.sharing_enabled = true + AND COALESCE(( + SELECT SUM(wos.quantity) + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.status != 'cancelled' + ), 0) >= wo.sharing_total_quantity + AND NOT EXISTS ( + SELECT 1 + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.status = 'joined' + ); diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 17284155..5df4d2a5 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -250,6 +250,9 @@ export type WorkOrderRow = { timeout_policy: string created_at: string updated_at: string + /** 拼单聚合字段,由工单查询统一计算。 */ + active_share_quantity?: number + pending_share_count?: number category_name?: string worker_username?: string worker_display_name?: string @@ -285,11 +288,15 @@ export type ListInput = { categoryId?: number workerSharingId?: number visibleAfterIso?: string + /** 抢单大厅隐藏已被拼完的工单。 */ + excludeFilledSharing?: boolean /** VIP 免审核后仍在补图期限内的订单;与状态筛选取并集 */ vipEvidencePending?: boolean /** 置顶订单优先展示(抢单大厅使用) */ pinnedFirst?: boolean sort?: 'id_desc' | 'worker_claimed_at_desc' + /** 后台使用拼单聚合业务状态;大厅和打手列表使用原始生命周期。 */ + useOperationalStatus?: boolean } export type WorkOrderStatistics = { @@ -300,6 +307,22 @@ export type WorkOrderStatistics = { statusCounts: Record } +export type WorkOrderCapabilities = { + canEdit: boolean + canConfigureSharing: boolean + canAssign: boolean + canPin: boolean + canUnpublish: boolean + canUpdateMaterial: boolean + canAccept: boolean + canMarkProblem: boolean + canDelete: boolean + canPublish: boolean + canCancel: boolean + canResolveProblem: boolean + canDeduct: boolean +} + export type ProductRuleListInput = { page?: number pageSize?: number diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index 620e6351..48a23b29 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -24,6 +24,16 @@ import { maybeUpgradeWorkerLevelWithClient } from './worker-repo.js' const WORK_ORDER_SELECT = ` SELECT wo.*, + COALESCE(( + SELECT SUM(wos.quantity) + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id AND wos.status != 'cancelled' + ), 0)::int AS active_share_quantity, + COALESCE(( + SELECT COUNT(*) + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id AND wos.status = 'joined' + ), 0)::int AS pending_share_count, wc.name AS category_name, wu.username AS worker_username, wu.display_name AS worker_display_name @@ -65,6 +75,22 @@ type WorkOrderStatisticsRow = { refund_amount: number | string } +/** 后台按母工单拼单参与情况计算业务状态,open 只代表大厅生命周期。 */ +function adminOperationalStatusExpression(alias = 'wo') { + return `CASE + WHEN ${alias}.status = 'open' + AND ${alias}.sharing_enabled IS TRUE + AND COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) > 0 + THEN CASE + WHEN COALESCE((SELECT SUM(wos.quantity) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status != 'cancelled'), 0) >= ${alias}.sharing_total_quantity + AND COALESCE((SELECT COUNT(*) FROM work_order_shares wos WHERE wos.work_order_id = ${alias}.id AND wos.status = 'joined'), 0) = 0 + THEN 'pending_acceptance' + ELSE 'in_progress' + END + ELSE ${alias}.status + END` +} + /** 工单统一搜索字段,兼容当前资料结构和历史资料字段。 */ function workOrderSearchExpression(alias = 'wo') { return `LOWER( @@ -82,6 +108,27 @@ function workOrderSearchExpression(alias = 'wo') { )` } +/** 打手订单筛选使用个人份额状态,而不是拼单母工单的 open 状态。 */ +function workerOrderStatusExpression(workerIdPlaceholder: string) { + return `CASE + WHEN wo.status IN ('accepted', 'cancelled', 'problem') THEN wo.status + ELSE COALESCE(( + SELECT CASE wos.status + WHEN 'joined' THEN 'in_progress' + WHEN 'submitted' THEN 'pending_acceptance' + WHEN 'accepted' THEN 'accepted' + WHEN 'cancelled' THEN 'cancelled' + ELSE wo.status + END + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.worker_id = ${workerIdPlaceholder} + ORDER BY wos.id DESC + LIMIT 1 + ), wo.status) + END` +} + export async function listWorkOrderShares( workOrderId: number | string, ): Promise { @@ -143,10 +190,21 @@ export async function getWorkOrderShare( return result.rows[0] || null } +/** 普通拼单使用指定份数,立即抢单则在事务中承接全部剩余份数。 */ +export function resolveWorkOrderShareJoinQuantity(input: { + quantity: number + remaining: number + takeRemaining?: boolean +}): number { + return input.takeRemaining ? Math.max(0, input.remaining) : input.quantity +} + export async function joinWorkOrderShare(input: { workOrderId: number workerId: number quantity: number + /** 立即抢单时在事务锁内承接全部剩余份数。 */ + takeRemaining?: boolean maxActiveOrders: number now: string }): Promise<{ @@ -194,57 +252,87 @@ export async function joinWorkOrderShare(input: { ) const joinedQuantity = Number(joinedResult.rows[0]?.total || 0) const remaining = Math.max(0, totalQuantity - joinedQuantity) - if (input.quantity <= 0 || input.quantity > remaining) { + const requestedQuantity = resolveWorkOrderShareJoinQuantity({ + quantity: input.quantity, + remaining, + takeRemaining: input.takeRemaining === true, + }) + if (requestedQuantity <= 0 || requestedQuantity > remaining) { return { share: null, failureReason: 'sharing_quantity_full' } } - const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) - if (activeOrderCount >= input.maxActiveOrders) { - return { share: null, failureReason: 'worker_active_order_limit' } - } - - const existingShare = await client.query<{ id: number }>( - 'SELECT id FROM work_order_shares WHERE work_order_id = $1 AND worker_id = $2', + const existingShareResult = await client.query( + `SELECT * + FROM work_order_shares + WHERE work_order_id = $1 AND worker_id = $2 + FOR UPDATE`, [input.workOrderId, input.workerId], ) - if (existingShare.rows[0]) { + const existingShare = existingShareResult.rows[0] || null + if (existingShare && (!input.takeRemaining || existingShare.status !== 'joined')) { return { share: null, failureReason: 'sharing_quantity_full' } } + if (!existingShare) { + const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) + if (activeOrderCount >= input.maxActiveOrders) { + return { share: null, failureReason: 'worker_active_order_limit' } + } + } const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0) - const shareReward = input.quantity * unitReward - const shareDeposit = Math.round( - (Number(workOrder.required_deposit_amount || 0) * input.quantity) / totalQuantity, + const nextQuantity = Number(existingShare?.quantity || 0) + requestedQuantity + const shareReward = nextQuantity * unitReward + const nextShareDeposit = Math.round( + (Number(workOrder.required_deposit_amount || 0) * nextQuantity) / totalQuantity, + ) + const depositToFreeze = Math.max( + 0, + nextShareDeposit - Number(existingShare?.share_deposit || 0), ) const wallet = await getWorkerWalletWithClient(client, input.workerId) const available = Number(wallet?.available_amount || 0) - if (available < shareDeposit) { + if (available < depositToFreeze) { return { share: null, failureReason: 'worker_deposit_insufficient' } } - const shareResult = await client.query<{ id: number }>( - ` + const shareResult = existingShare + ? await client.query<{ id: number }>( + ` + UPDATE work_order_shares + SET + quantity = $1, + unit_reward = $2, + share_reward = $3, + share_deposit = $4, + updated_at = $5 + WHERE id = $6 + RETURNING id + `, + [nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id], + ) + : await client.query<{ id: number }>( + ` INSERT INTO work_order_shares ( work_order_id, worker_id, quantity, unit_reward, share_reward, share_deposit, status, acceptance_json, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $7) RETURNING id `, - [ - input.workOrderId, - input.workerId, - input.quantity, - unitReward, - shareReward, - shareDeposit, - input.now, - ], - ) + [ + input.workOrderId, + input.workerId, + requestedQuantity, + unitReward, + shareReward, + nextShareDeposit, + input.now, + ], + ) - if (shareDeposit > 0) { - const nextAvailable = available - shareDeposit - const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + shareDeposit + if (depositToFreeze > 0) { + const nextAvailable = available - depositToFreeze + const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + depositToFreeze await client.query( ` UPDATE worker_wallets @@ -262,12 +350,12 @@ export async function joinWorkOrderShare(input: { `, [ input.workerId, - shareDeposit, + depositToFreeze, nextAvailable, nextFrozen, input.workOrderId, - `拼单冻结押金 ${input.quantity} 份`, - JSON.stringify({ workOrderId: input.workOrderId, quantity: input.quantity }), + `拼单冻结押金 ${requestedQuantity} 份`, + JSON.stringify({ workOrderId: input.workOrderId, quantity: requestedQuantity }), input.now, ], ) @@ -280,7 +368,11 @@ export async function joinWorkOrderShare(input: { eventType: 'sharing_joined', fromStatus: 'open', toStatus: 'open', - payloadJson: JSON.stringify({ workerId: input.workerId, quantity: input.quantity }), + payloadJson: JSON.stringify({ + workerId: input.workerId, + quantity: requestedQuantity, + takeRemaining: input.takeRemaining === true, + }), now: input.now, }) @@ -302,6 +394,21 @@ export async function submitWorkOrderShareAcceptance(input: { failureReason: 'share_not_found' | 'share_status_invalid' | null }> { return withTransaction(async (client) => { + const orderResult = await client.query<{ + status: string + sharing_total_quantity: number + }>( + `SELECT status, sharing_total_quantity + FROM work_orders + WHERE id = $1 + FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder || !['open', 'pending_acceptance'].includes(workOrder.status)) { + return { share: null, failureReason: 'share_not_found' } + } + const lockResult = await client.query<{ id: number }>( ` SELECT id @@ -334,6 +441,36 @@ export async function submitWorkOrderShareAcceptance(input: { `, [input.acceptanceJson, input.now, Number(share.id)], ) + + const progressResult = await client.query<{ + joined_quantity: number + pending_submission_count: number + }>( + `SELECT + COALESCE(SUM(quantity), 0)::int AS joined_quantity, + COUNT(*) FILTER (WHERE status = 'joined')::int AS pending_submission_count + FROM work_order_shares + WHERE work_order_id = $1 + AND status != 'cancelled'`, + [input.workOrderId], + ) + const progress = progressResult.rows[0] + const allQuantityAllocated = + Number(progress?.joined_quantity || 0) >= + toPositiveInteger(workOrder.sharing_total_quantity, 1) + if (allQuantityAllocated && Number(progress?.pending_submission_count || 0) === 0) { + await client.query( + `UPDATE work_orders + SET + status = 'pending_acceptance', + submitted_at = COALESCE(submitted_at, $1), + updated_at = $1 + WHERE id = $2 + AND status = 'open'`, + [input.now, input.workOrderId], + ) + } + await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'worker', @@ -911,7 +1048,9 @@ export async function listWorkOrders({ categoryId = 0, workerSharingId = 0, visibleAfterIso = '', + excludeFilledSharing = false, vipEvidencePending = false, + useOperationalStatus = false, pinnedFirst = false, sort = 'id_desc', }: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> { @@ -924,7 +1063,9 @@ export async function listWorkOrders({ categoryId, workerSharingId, visibleAfterIso, + excludeFilledSharing, vipEvidencePending, + useOperationalStatus, }) const totalResult = await query<{ total: number }>( ` @@ -977,9 +1118,12 @@ export async function getWorkOrderStatistics(input: ListInput = {}): Promise>'evidenceStatus' = 'pending' AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() ` - const statusExpression = input.vipEvidencePending - ? `CASE WHEN ${vipEvidencePendingWhere} THEN 'vip_evidence_pending' ELSE wo.status END` + const operationalStatus = input.useOperationalStatus + ? adminOperationalStatusExpression() : 'wo.status' + const statusExpression = input.vipEvidencePending + ? `CASE WHEN ${vipEvidencePendingWhere} THEN 'vip_evidence_pending' ELSE ${operationalStatus} END` + : operationalStatus const paymentExpression = ` CASE WHEN COALESCE(wo.material_json->'source'->>'orderAmountFen', '') ~ '^-?[0-9]+$' @@ -1041,7 +1185,17 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise ` WITH worker_orders AS ( SELECT - wo.status, + CASE + WHEN wos.id IS NOT NULL AND wo.status NOT IN ('accepted', 'cancelled', 'problem') + THEN CASE wos.status + WHEN 'joined' THEN 'in_progress' + WHEN 'submitted' THEN 'pending_acceptance' + WHEN 'accepted' THEN 'accepted' + WHEN 'cancelled' THEN 'cancelled' + ELSE wo.status + END + ELSE wo.status + END AS status, wo.acceptance_json, wos.id IS NOT NULL AS is_worker_share, CASE @@ -2124,6 +2278,162 @@ export async function acceptWorkOrderAndSettle(input: { }) } +/** 单独验收一个拼单份额,只结算该参与者,不影响其他份额。 */ +export async function acceptWorkOrderShareAndSettle(input: { + workOrderId: number + shareId: number + unfreezeDays: number + now: string + actorType?: string + actorName?: string +}): Promise<{ + order: WorkOrderRow | null + share: WorkOrderShareRow | null + failureReason: 'work_order_not_accepting' | 'share_not_submitted' | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 AND status IN ('open', 'pending_acceptance') FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder) { + return { order: null, share: null, failureReason: 'work_order_not_accepting' } + } + + const shareResult = await client.query( + `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, + [input.shareId, input.workOrderId], + ) + const share = shareResult.rows[0] || null + if (!share || share.status !== 'submitted') { + return { order: workOrder, share, failureReason: 'share_not_submitted' } + } + + const workerId = Number(share.worker_id || 0) + if (workerId > 0) { + await ensureWorkerWalletWithClient(client, workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, workerId) + const releaseAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + const shareReward = Number(share.share_reward || 0) + const shouldDelayUnfreeze = input.unfreezeDays > 0 + const nextAvailable = + Number(wallet?.available_amount || 0) + + shareReward + + (shouldDelayUnfreeze ? 0 : releaseAmount) + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, + pending_unfreeze_amount = pending_unfreeze_amount + $3, + total_settled_amount = total_settled_amount + $4, updated_at = $5 + WHERE worker_id = $6`, + [ + nextAvailable, + nextFrozen, + shouldDelayUnfreeze ? releaseAmount : 0, + shareReward, + input.now, + workerId, + ], + ) + + if (releaseAmount > 0 && shouldDelayUnfreeze) { + await enqueueDepositUnfreezeWithClient(client, { + workerId, + workOrderId: input.workOrderId, + amount: releaseAmount, + unfreezeDays: input.unfreezeDays, + now: input.now, + }) + } + if (releaseAmount > 0) { + 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, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`, + [ + workerId, + shouldDelayUnfreeze ? 'deposit_pending_unfreeze' : 'deposit_release', + releaseAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + shouldDelayUnfreeze ? '拼单份额验收押金待解冻' : '拼单份额验收释放押金', + JSON.stringify({ workOrderId: input.workOrderId, shareId: input.shareId }), + input.now, + ], + ) + } + if (shareReward > 0) { + 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, 'sharing_reward', $2, $3, $4, $5, $6, $7::jsonb, $8)`, + [ + workerId, + shareReward, + nextAvailable, + nextFrozen, + input.workOrderId, + `拼单份额验收结算报酬 ${share.quantity} 份`, + JSON.stringify({ workOrderId: input.workOrderId, shareId: input.shareId }), + input.now, + ], + ) + } + await maybeUpgradeWorkerLevelWithClient(client, workerId, input.now) + } + + await client.query( + `UPDATE work_order_shares SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`, + [input.now, input.shareId], + ) + const progressResult = await client.query<{ joined_quantity: number; unfinished_count: number }>( + `SELECT COALESCE(SUM(quantity), 0)::int AS joined_quantity, + COUNT(*) FILTER (WHERE status != 'accepted' AND status != 'cancelled')::int AS unfinished_count + FROM work_order_shares WHERE work_order_id = $1 AND status != 'cancelled'`, + [input.workOrderId], + ) + const progress = progressResult.rows[0] + const filled = + Number(progress?.joined_quantity || 0) >= Number(workOrder.sharing_total_quantity || 1) + const allAccepted = Number(progress?.unfinished_count || 0) === 0 + const nextOrderStatus = filled && allAccepted ? 'accepted' : workOrder.status + if (nextOrderStatus === 'accepted') { + await client.query( + `UPDATE work_orders SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`, + [input.now, input.workOrderId], + ) + } + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: input.actorType || 'admin', + actorId: input.actorName || '', + eventType: 'sharing_share_accepted', + fromStatus: workOrder.status, + toStatus: nextOrderStatus, + payloadJson: JSON.stringify({ shareId: input.shareId, workerId }), + now: input.now, + }) + const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId) + const updatedShareResult = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [input.shareId], + ) + return { + order: updatedOrder, + share: updatedShareResult.rows[0] || null, + failureReason: null, + } + }) +} + export async function resolveProblemWorkOrder(input: { workOrderId: number action: ProblemWorkOrderResolutionAction @@ -2869,7 +3179,9 @@ function buildWorkOrderWhere({ categoryId = 0, workerSharingId = 0, visibleAfterIso = '', + excludeFilledSharing = false, vipEvidencePending = false, + useOperationalStatus = false, }: ListInput) { const filters: string[] = [] const params: unknown[] = [] @@ -2883,15 +3195,23 @@ function buildWorkOrderWhere({ AND wo.acceptance_json->>'evidenceStatus' = 'pending' AND NULLIF(wo.acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() ` - if (normalizedStatuses.length > 0) { + if (normalizedStatuses.length > 0) { params.push(normalizedStatuses) - filters.push(`(wo.status = ANY($${params.length}::text[]) OR (${vipEvidencePendingWhere}))`) + filters.push(`(${useOperationalStatus ? adminOperationalStatusExpression() : 'wo.status'} = ANY($${params.length}::text[]) OR (${vipEvidencePendingWhere}))`) } else { filters.push(`(${vipEvidencePendingWhere})`) } } else if (normalizedStatuses.length > 0) { params.push(normalizedStatuses) - filters.push(`wo.status = ANY($${params.length}::text[])`) + const statusesPlaceholder = `$${params.length}` + if (workerSharingId) { + params.push(workerSharingId) + filters.push( + `${workerOrderStatusExpression(`$${params.length}`)} = ANY(${statusesPlaceholder}::text[])`, + ) + } else { + filters.push(`${useOperationalStatus ? adminOperationalStatusExpression() : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`) + } } if (workOrderId) { params.push(workOrderId) @@ -2924,6 +3244,17 @@ function buildWorkOrderWhere({ params.push(visibleAfterIso) filters.push(`(wo.published_at IS NULL OR wo.published_at <= $${params.length})`) } + if (excludeFilledSharing) { + filters.push(`( + wo.sharing_enabled IS NOT TRUE + OR COALESCE(( + SELECT SUM(wos.quantity) + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.status != 'cancelled' + ), 0) < wo.sharing_total_quantity + )`) + } return { whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', params, diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 41b54ec1..09a41b93 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { acceptAdminWorkOrder, + acceptAdminWorkOrderShare, acceptAdminWorkOrders, assignAdminWorkOrderToWorker, cancelAdminWorkOrder, @@ -829,6 +830,30 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/shares/:shareId/accept', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => + acceptAdminWorkOrderShare( + String(req.params.workOrderId || ''), + String(req.params.shareId || ''), + req.adminSession?.username || '', + ), + { + successMessage: '拼单份额已验收通过', + errorMessage: '验收拼单份额失败', + scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/accept]', + audit: (req, data) => ({ + action: 'work_order_share_accepted', + targetType: 'work_order_share', + targetId: String(req.params.shareId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/deduct-pending-deposit', requireAdminRoles(['admin', 'operator']), diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index 55e95374..47b0c41a 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -20,6 +20,7 @@ import { getAdminDashboardSummary } from '../admin/admin-dashboard-service.js' import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js' import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js' import { + isWorkOrderSharingFilled, mapFinanceRequest, mapWorkOrderAdmin, mapWorkOrderForWorker, @@ -256,7 +257,19 @@ async function publishWorkOrderSnapshots(input: { const adminSnapshot = { ...mapWorkOrderAdmin(workOrder), pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(workOrderId) || 0, - sharingProgress: { joinedQuantity, pendingSubmissionCount }, + sharingProgress: { + joinedQuantity, + pendingSubmissionCount, + remainingQuantity: Math.max( + 0, + Number(workOrder.sharing_total_quantity || 1) - joinedQuantity, + ), + filled: isWorkOrderSharingFilled( + workOrder.sharing_enabled === true, + Number(workOrder.sharing_total_quantity || 1), + joinedQuantity, + ), + }, } publishRealtimeEvent({ @@ -269,11 +282,16 @@ async function publishWorkOrderSnapshots(input: { }) if (input.hallChanged) { + const sharingFilled = isWorkOrderSharingFilled( + workOrder.sharing_enabled === true, + Number(workOrder.sharing_total_quantity || 1), + joinedQuantity, + ) publishRealtimeEvent({ type: 'work_order.changed', entityId: workOrderId, scopes: ['worker_hall'], - operation: workOrder.status === 'open' ? 'upsert' : 'remove', + operation: workOrder.status === 'open' && !sharingFilled ? 'upsert' : 'remove', data: mapWorkerHallWorkOrder(workOrder, shares), broadcastWorkers: true, }) diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 75b45d92..549b9a41 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -2,6 +2,7 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { listKuaishouIndustryVoucherWorkOrderSyncSources } from '../../repositories/kuaishou-industry-voucher-repo.js' import { acceptWorkOrderAndSettle, + acceptWorkOrderShareAndSettle, addWorkerWalletCredit, assignWorkOrderToWorker, cancelAssignedWorkOrder, @@ -43,7 +44,6 @@ import { listWorkerUsers, listWorkerWithdrawalAccounts, listWorkOrderShares, - listWorkOrderSharesByOrderIds, sumPendingUnfreezeByOrderIds, resolveProblemWorkOrder, reviewWorkerFinanceRequest, @@ -59,7 +59,6 @@ import { type WorkOrderRow, type WorkProductMatchLogRow, type WorkProductRuleMappingRow, - type WorkOrderShareRow, type WorkerWithdrawalAccountRow, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' @@ -135,6 +134,7 @@ import { resolveRequirementFields, resolveSkuNameQuantity, resolveWorkOrderRulePricing, + resolveWorkOrderSharingEnabled, resolveWorkerPermissions, } from './mappers.js' import { @@ -943,7 +943,8 @@ export async function assignAdminWorkOrderToWorker( actorName = '', ) { const workOrder = await getRequiredWorkOrder(workOrderId) - if (!['unassigned', 'open'].includes(workOrder.status) || workOrder.assigned_worker_id) { + const capabilities = mapWorkOrderAdmin(workOrder).capabilities + if (!capabilities.canAssign) { throw createHttpError('只有未分配或待抢单且未指派打手的订单才能指派', { statusCode: 409, errorCode: 'work_order_not_assignable', @@ -1284,6 +1285,7 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { keyword: String(query.keyword || '').trim(), workerSharingId: workerId, vipEvidencePending, + useOperationalStatus: true, }) const statistics = await getWorkOrderStatistics({ statuses, @@ -1291,32 +1293,16 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { keyword: String(query.keyword || '').trim(), workerSharingId: workerId, vipEvidencePending, + useOperationalStatus: true, }) - const shares = await listWorkOrderSharesByOrderIds(items.map((item) => Number(item.id))) - const sharesByOrderId = new Map() - for (const share of shares) { - const orderId = Number(share.work_order_id) - const bucket = sharesByOrderId.get(orderId) || [] - bucket.push(share) - sharesByOrderId.set(orderId, bucket) - } const pendingUnfreezeByOrderId = await sumPendingUnfreezeByOrderIds( items.map((item) => Number(item.id)), ) return { items: items.map((item) => { - const orderShares = sharesByOrderId.get(Number(item.id)) || [] - const joinedQuantity = orderShares - .filter((share) => share.status !== 'cancelled') - .reduce((sum, share) => sum + Number(share.quantity || 0), 0) - const pendingSubmissionCount = orderShares.filter((share) => share.status === 'joined').length return { ...mapWorkOrderAdmin(item), pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(Number(item.id)) || 0, - sharingProgress: { - joinedQuantity, - pendingSubmissionCount, - }, } }), pagination: { page, pageSize, total }, @@ -1400,13 +1386,7 @@ export async function updateAdminWorkOrderSharing( actorName = '', ) { const workOrder = await getRequiredWorkOrder(workOrderId) - if ( - ![ - WORK_ORDER_STATUS.PENDING_MATERIAL, - WORK_ORDER_STATUS.UNASSIGNED, - WORK_ORDER_STATUS.OPEN, - ].includes(workOrder.status as never) - ) { + if (!mapWorkOrderAdmin(workOrder).capabilities.canConfigureSharing) { throw createHttpError('只有待完善、未分配或待抢单的工单可以配置拼单', { statusCode: 409, errorCode: 'work_order_sharing_config_status_invalid', @@ -1484,21 +1464,16 @@ export async function submitAdminWorkOrderMaterial( actorName = '', ) { const workOrder = await getRequiredWorkOrder(workOrderId) - if ( - ![ - WORK_ORDER_STATUS.PENDING_MATERIAL, - WORK_ORDER_STATUS.UNASSIGNED, - WORK_ORDER_STATUS.IN_PROGRESS, - ].includes(workOrder.status as never) - ) { + const materialCapabilities = mapWorkOrderAdmin(workOrder).capabilities + if (!materialCapabilities.canUpdateMaterial) { throw createHttpError('当前状态不能更新订单资料', { statusCode: 409, errorCode: 'work_order_material_status_invalid', }) } if ( - workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS && workOrder.sharing_enabled === true && + Number(workOrder.active_share_quantity || 0) > 0 && payload.rewardAmount !== undefined ) { throw createHttpError('拼单订单的份额奖励已锁定,不能在更新资料时修改价格', { @@ -1515,7 +1490,9 @@ export async function submitAdminWorkOrderMaterial( const now = nowIso() const screenshots = normalizeUploadedFiles(payload.screenshots || payload.files) const rewardAmount = - workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS && payload.rewardAmount !== undefined + workOrder.sharing_enabled === true && + Number(workOrder.active_share_quantity || 0) > 0 && + payload.rewardAmount !== undefined ? normalizeAmountFen(payload.rewardAmount, workOrder.reward_amount) : workOrder.reward_amount const material = { @@ -1528,9 +1505,10 @@ export async function submitAdminWorkOrderMaterial( source: 'admin', }, } + const hasActiveShares = Number(workOrder.active_share_quantity || 0) > 0 const nextStatus = - workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS - ? WORK_ORDER_STATUS.IN_PROGRESS + hasActiveShares || workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS + ? workOrder.status : complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL @@ -1545,7 +1523,7 @@ export async function submitAdminWorkOrderMaterial( actorType: 'admin', actorId: actorName, eventType: - workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS + hasActiveShares || workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS ? 'material_updated' : 'material_completed', fromStatus: workOrder.status, @@ -1654,13 +1632,7 @@ export async function updateAdminWorkOrder( options: { canEditRequirementFields?: boolean; actorName?: string } = {}, ) { const workOrder = await getRequiredWorkOrder(workOrderId) - if ( - ![ - WORK_ORDER_STATUS.PENDING_MATERIAL, - WORK_ORDER_STATUS.UNASSIGNED, - WORK_ORDER_STATUS.OPEN, - ].includes(workOrder.status as never) - ) { + if (!mapWorkOrderAdmin(workOrder).capabilities.canEdit) { throw createHttpError('只有待完善、未分配或待抢单的工单可以编辑', { statusCode: 409, errorCode: 'work_order_edit_status_invalid', @@ -1738,13 +1710,7 @@ export async function updateAdminWorkOrder( export async function deleteAdminWorkOrder(workOrderId: number | string) { const workOrder = await getRequiredWorkOrder(workOrderId) - if ( - ![ - WORK_ORDER_STATUS.PENDING_MATERIAL, - WORK_ORDER_STATUS.UNASSIGNED, - WORK_ORDER_STATUS.CANCELLED, - ].includes(workOrder.status as never) - ) { + if (!mapWorkOrderAdmin(workOrder).capabilities.canDelete) { throw createHttpError('只有待完善、未分配或已取消的工单可以删除', { statusCode: 409, errorCode: 'work_order_delete_status_invalid', @@ -1847,7 +1813,7 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN export async function unpublishAdminWorkOrder(workOrderId: number | string, actorName = '') { const workOrder = await getRequiredWorkOrder(workOrderId) - if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) { + if (!mapWorkOrderAdmin(workOrder).capabilities.canUnpublish) { throw createHttpError('只有待抢单且未被接走的订单可以下架', { statusCode: 409, errorCode: 'work_order_unpublish_status_invalid', @@ -1879,7 +1845,8 @@ export async function pinAdminWorkOrder( ) { const workOrder = await getRequiredWorkOrder(workOrderId) const pinned = payload.pinned === true - if (pinned && workOrder.status !== WORK_ORDER_STATUS.OPEN) { + const capabilities = mapWorkOrderAdmin(workOrder).capabilities + if (pinned && (!capabilities.canPin || !mapWorkOrderAdmin(workOrder).hallAvailable)) { throw createHttpError('只有待抢单订单可以置顶', { statusCode: 409, errorCode: 'work_order_pin_status_invalid', @@ -1909,11 +1876,7 @@ export async function markAdminWorkOrderProblem( actorName = '', ) { const workOrder = await getRequiredWorkOrder(workOrderId) - if ( - ![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes( - workOrder.status as never, - ) - ) { + if (!mapWorkOrderAdmin(workOrder).capabilities.canMarkProblem) { throw createHttpError('当前状态不能标记问题单', { statusCode: 409, errorCode: 'work_order_problem_status_invalid', @@ -1989,13 +1952,21 @@ export async function resolveAdminProblemWorkOrder( } export async function acceptAdminWorkOrder(workOrderId: number | string, actorName = '') { - const workOrder = await getRequiredWorkOrder(workOrderId) - if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) { + let workOrder = await getRequiredWorkOrder(workOrderId) + if (!mapWorkOrderAdmin(workOrder).capabilities.canAccept) { throw createHttpError('只有待验收订单可以通过验收', { statusCode: 409, errorCode: 'work_order_accept_status_invalid', }) } + // 历史拼单订单可能仍保留 open,验收前先补齐母工单生命周期状态。 + if (workOrder.status === WORK_ORDER_STATUS.OPEN) { + const promoted = await updateWorkOrder(workOrder.id, { + status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + updated_at: nowIso(), + }) + if (promoted) workOrder = promoted + } const now = nowIso() const { order: updated, failureReason } = await acceptWorkOrderAndSettle({ workOrderId: workOrder.id, @@ -2028,6 +1999,46 @@ export async function acceptAdminWorkOrder(workOrderId: number | string, actorNa return { order: mapWorkOrderAdmin(updated || workOrder) } } +export async function acceptAdminWorkOrderShare( + workOrderId: number | string, + shareId: number | string, + actorName = '', +) { + const result = await acceptWorkOrderShareAndSettle({ + workOrderId: Number(workOrderId), + shareId: Number(shareId), + unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays, + now: nowIso(), + actorType: 'admin', + actorName, + }) + if (result.failureReason === 'work_order_not_accepting') { + throw createHttpError('当前母工单不能验收拼单份额', { + statusCode: 409, + errorCode: 'work_order_sharing_accept_status_invalid', + }) + } + if (result.failureReason === 'share_not_submitted' || !result.share || !result.order) { + throw createHttpError('该拼单参与者尚未提交验收资料或已被验收', { + statusCode: 409, + errorCode: 'work_order_sharing_share_accept_status_invalid', + }) + } + const workerId = Number(result.share.worker_id || 0) + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrderId), + workerIds: workerId > 0 ? [workerId] : [], + }) + if (workerId > 0) publishWorkerWalletRealtimeChange(workerId) + if (result.order.status === WORK_ORDER_STATUS.ACCEPTED) { + await resolveAdminNotificationEntity('work_order', Number(workOrderId), 'accepted') + } + return { + order: mapWorkOrderAdmin(result.order), + share: mapWorkOrderShare(result.share), + } +} + export async function acceptAdminWorkOrders(payload: JsonObject = {}, actorName = '') { const workOrderIds = [ ...new Set( @@ -2187,8 +2198,13 @@ export async function getAdminWorkerPlatformSummary() { const [pendingWorkers, pendingMaterial, openOrders, inProgressOrders] = await Promise.all([ listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }), listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.PENDING_MATERIAL] }), - listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.OPEN] }), - listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.IN_PROGRESS] }), + listWorkOrders({ + page: 1, + pageSize: 1, + statuses: [WORK_ORDER_STATUS.OPEN], + excludeFilledSharing: true, + }), + listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.IN_PROGRESS], useOperationalStatus: true }), ]) return { pendingWorkerCount: pendingWorkers.total, @@ -2270,6 +2286,10 @@ export async function syncWorkerOrdersForSourceOrder( sharingTotalQuantity: Number(rule.sharing_total_quantity || 1), sharingUnitReward: Number(rule.sharing_unit_reward || 0), }) + const sharingEnabled = resolveWorkOrderSharingEnabled( + rule.sharing_enabled === true, + sharingTotalQuantity, + ) const now = nowIso() const materialComplete = fields.every((field) => !field.required) const workOrder = await createWorkOrder({ @@ -2284,7 +2304,7 @@ export async function syncWorkerOrdersForSourceOrder( rewardAmount, requiredDepositAmount: 0, depositThresholdAmount: 0, - sharingEnabled: rule.sharing_enabled === true, + sharingEnabled, sharingTotalQuantity, sharingUnitReward, timeoutMinutes: Number(rule.timeout_minutes || 0), diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index b0c1e35a..ae5d90ca 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -1,12 +1,14 @@ import crypto from 'node:crypto' import { runtimeConfig } from '../../config/runtime.js' +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { addWorkerWalletCredit, getWorkerUserById, type WorkCategoryRow, type WorkerFinanceRequestRow, type WorkOrderRow, + type WorkOrderCapabilities, type WorkProductRuleMappingRow, type WorkProductRuleRow, type WorkerLevelRow, @@ -323,6 +325,62 @@ export function mapWorkOrderMaterial(value: unknown) { } export function mapWorkOrderAdmin(workOrder: WorkOrderRow) { + const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) + const pendingShareCount = Math.max(0, Number(workOrder.pending_share_count || 0)) + const sharingTotalQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1)) + const hasActiveShares = activeShareQuantity > 0 + const sharingFilled = + workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity + const status = + workOrder.status === WORK_ORDER_STATUS.OPEN && + workOrder.sharing_enabled === true && + hasActiveShares + ? sharingFilled && pendingShareCount === 0 + ? WORK_ORDER_STATUS.PENDING_ACCEPTANCE + : WORK_ORDER_STATUS.IN_PROGRESS + : workOrder.status + const hallAvailable = + workOrder.status === WORK_ORDER_STATUS.OPEN && + (!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity) + const editableBeforeClaim = + !workOrder.assigned_worker_id && + (workOrder.status === WORK_ORDER_STATUS.PENDING_MATERIAL || + workOrder.status === WORK_ORDER_STATUS.UNASSIGNED || + (workOrder.status === WORK_ORDER_STATUS.OPEN && !hasActiveShares)) + const capabilities: WorkOrderCapabilities = { + canEdit: editableBeforeClaim, + canConfigureSharing: editableBeforeClaim, + canAssign: + !workOrder.assigned_worker_id && + (workOrder.status === WORK_ORDER_STATUS.UNASSIGNED || + (workOrder.status === WORK_ORDER_STATUS.OPEN && !hasActiveShares)), + canPin: hallAvailable || Boolean(workOrder.pinned_at), + canUnpublish: + workOrder.status === WORK_ORDER_STATUS.OPEN && + !hasActiveShares && + !workOrder.assigned_worker_id, + canUpdateMaterial: + workOrder.status === WORK_ORDER_STATUS.PENDING_MATERIAL || + workOrder.status === WORK_ORDER_STATUS.UNASSIGNED || + workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS || + (workOrder.status === WORK_ORDER_STATUS.OPEN && hasActiveShares), + canAccept: status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + canMarkProblem: + status === WORK_ORDER_STATUS.IN_PROGRESS || status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + canDelete: + !workOrder.assigned_worker_id && + !hasActiveShares && + [ + WORK_ORDER_STATUS.PENDING_MATERIAL, + WORK_ORDER_STATUS.UNASSIGNED, + WORK_ORDER_STATUS.CANCELLED, + ].includes(workOrder.status as never), + canPublish: + workOrder.status === WORK_ORDER_STATUS.UNASSIGNED && Number(workOrder.reward_amount || 0) > 0, + canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id), + canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM, + canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED, + } return { workOrderId: Number(workOrder.id), orderId: workOrder.order_id ? Number(workOrder.order_id) : null, @@ -332,7 +390,16 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow) { productName: workOrder.product_name, categoryId: workOrder.category_id ? Number(workOrder.category_id) : null, categoryName: workOrder.category_name || '', - status: workOrder.status, + status, + rawStatus: workOrder.status, + hallAvailable, + sharingProgress: { + joinedQuantity: activeShareQuantity, + pendingSubmissionCount: pendingShareCount, + remainingQuantity: Math.max(0, sharingTotalQuantity - activeShareQuantity), + filled: sharingFilled, + }, + capabilities, rewardAmount: Number(workOrder.reward_amount || 0), requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), sharing: { @@ -384,17 +451,53 @@ export function mapWorkOrderForWorker( worker: _worker, orderId: _orderId, orderItemId: _orderItemId, + rawStatus: _rawStatus, + hallAvailable: _hallAvailable, + capabilities: _capabilities, ...visibleOrder } = mapped + const personalStatus = resolveWorkerShareStatus(workOrder.status, myShare?.status) return { ...visibleOrder, - freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions), + productName: myShare + ? resolveWorkerShareProductName(workOrder.product_name, Number(myShare.quantity || 0)) + : visibleOrder.productName, + status: personalStatus, + rewardAmount: myShare ? Number(myShare.share_reward || 0) : visibleOrder.rewardAmount, + freezeDepositAmount: myShare + ? Number(myShare.share_deposit || 0) + : resolveFreezeDepositAmount(workOrder, permissions), + assignedAt: myShare?.created_at || visibleOrder.assignedAt, + submittedAt: myShare?.submitted_at || visibleOrder.submittedAt, + acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt, workerNote, myShare: myShare ? mapWorkOrderShare(myShare) : null, ...(viewedAt === undefined ? {} : { isUnread: !viewedAt }), } } +/** 拼单参与者看到个人份额规格,而不是母工单的原始总规格。 */ +export function resolveWorkerShareProductName(productName: unknown, quantity: number): string { + const text = String(productName || '').trim() + const normalizedQuantity = Math.max(1, Number(quantity) || 1) + return text.replace( + /(\d+|[零一二三四五六七八九十百千两]+)(\s*)(个|份|张|枚)/, + (_matched, _oldQuantity: string, spacing: string, unit: string) => + `${normalizedQuantity}${spacing}${unit}`, + ) +} + +/** 把份额状态映射成打手端订单状态,避免母工单 open 被误显示为已超时。 */ +export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: unknown): string { + if (!shareStatus) return workOrderStatus + if (['accepted', 'cancelled', 'problem'].includes(workOrderStatus)) return workOrderStatus + if (shareStatus === 'joined') return WORK_ORDER_STATUS.IN_PROGRESS + if (shareStatus === 'submitted') return WORK_ORDER_STATUS.PENDING_ACCEPTANCE + if (shareStatus === 'accepted') return WORK_ORDER_STATUS.ACCEPTED + if (shareStatus === 'cancelled') return WORK_ORDER_STATUS.CANCELLED + return workOrderStatus +} + export function mapWorkOrderShare(share: WorkOrderShareRow) { return { shareId: Number(share.id), @@ -405,8 +508,8 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) { shareReward: Number(share.share_reward || 0), shareDeposit: Number(share.share_deposit || 0), status: share.status, - acceptance: safeParseJson(share.acceptance_json), - draftAcceptance: safeParseJson(share.draft_acceptance_json), + acceptance: mapAcceptanceForResponse(share.acceptance_json), + draftAcceptance: mapAcceptanceForResponse(share.draft_acceptance_json), submittedAt: share.submitted_at, acceptedAt: share.accepted_at, createdAt: share.created_at, @@ -1017,6 +1120,26 @@ export function resolveWorkOrderRulePricing(input: { return { sharingTotalQuantity, sharingUnitReward, rewardAmount } } +/** 接单模板仅在实际总份数大于 1 时生成拼单工单。 */ +export function resolveWorkOrderSharingEnabled( + configuredEnabled: boolean, + totalQuantity: number, +): boolean { + return configuredEnabled && Math.max(1, Number(totalQuantity) || 1) > 1 +} + +/** 判断拼单工单是否已经没有剩余可承接份数。 */ +export function isWorkOrderSharingFilled( + sharingEnabled: boolean, + totalQuantity: number, + joinedQuantity: number, +): boolean { + return ( + sharingEnabled && + Math.max(0, Number(joinedQuantity) || 0) >= Math.max(1, Number(totalQuantity) || 1) + ) +} + const CHINESE_DIGITS: Record = { 零: 0, 一: 1, 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 c30ed5f4..9b4f073f 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 @@ -3,24 +3,29 @@ import test from 'node:test' import type { WorkOrderRow, + WorkOrderShareRow, WorkProductRuleMappingRow, WorkProductRuleRow, WorkerUserRow, } from '../../repositories/worker-platform/index.js' +import { resolveWorkOrderShareJoinQuantity } from '../../repositories/worker-platform/index.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import { assertWithdrawChannelAllowedForAmount, assertWorkerLoginAllowed, + isWorkOrderSharingFilled, isWorkerPasswordStrong, normalizeWithdrawChannel, normalizeRequirementFields, mapWorkOrderForWorker, + mapWorkOrderAdmin, maskWorkerLeaderboardDisplayName, resolveCollectSubmitTargetWorkOrder, resolveMatchingProductRuleDecision, resolveWorkerPermissions, resolveSkuNameQuantity, resolveWorkOrderRulePricing, + resolveWorkOrderSharingEnabled, validateWorkerPassword, } from './index.js' @@ -582,6 +587,32 @@ test('按件拼单手动覆盖数量和单价时,保留手工配置', () => { }) }) +test('模板启用拼单但实际只有一份时生成普通工单', () => { + assert.equal(resolveWorkOrderSharingEnabled(true, 1), false) +}) + +test('模板启用拼单且实际大于一份时生成拼单工单', () => { + assert.equal(resolveWorkOrderSharingEnabled(true, 2), true) + assert.equal(resolveWorkOrderSharingEnabled(false, 3), false) +}) + +test('拼单工单立即抢单承接全部剩余份数', () => { + assert.equal( + resolveWorkOrderShareJoinQuantity({ quantity: 0, remaining: 12, takeRemaining: true }), + 12, + ) + assert.equal( + resolveWorkOrderShareJoinQuantity({ quantity: 0, remaining: 0, takeRemaining: true }), + 0, + ) +}) + +test('拼单份数全部承接后不再显示在抢单大厅', () => { + assert.equal(isWorkOrderSharingFilled(true, 15, 15), true) + assert.equal(isWorkOrderSharingFilled(true, 15, 14), false) + assert.equal(isWorkOrderSharingFilled(false, 15, 15), false) +}) + function buildWorkOrderRow(overrides: Partial = {}): WorkOrderRow { return { id: 1, @@ -611,6 +642,26 @@ function buildWorkOrderRow(overrides: Partial = {}): WorkOrderRow } } +function buildWorkOrderShareRow(overrides: Partial = {}): WorkOrderShareRow { + return { + id: 1, + work_order_id: 1, + worker_id: 1, + quantity: 2, + unit_reward: 700, + share_reward: 1400, + share_deposit: 200, + status: 'joined', + acceptance_json: {}, + draft_acceptance_json: {}, + submitted_at: null, + accepted_at: null, + created_at: '2026-08-18T09:09:08.000Z', + updated_at: '2026-08-18T09:09:08.000Z', + ...overrides, + } +} + test('mapWorkOrderForWorker reflects the personal first-view state', () => { const workOrder = buildWorkOrderRow({ assigned_worker_id: 1 }) const permissions = resolveWorkerPermissions(buildWorkerUserRow()) @@ -622,6 +673,62 @@ test('mapWorkOrderForWorker reflects the personal first-view state', () => { assert.equal(mapWorkOrderForWorker(workOrder, permissions).isUnread, undefined) }) +test('mapWorkOrderForWorker 使用个人拼单份数、金额、状态和接单时间', () => { + const workOrder = buildWorkOrderRow({ + product_name: '指挥官秘钥15个', + status: 'open', + reward_amount: 10_500, + sharing_enabled: true, + sharing_total_quantity: 15, + sharing_unit_reward: 700, + }) + const share = buildWorkOrderShareRow() + const mapped = mapWorkOrderForWorker( + workOrder, + resolveWorkerPermissions(buildWorkerUserRow()), + share, + ) + + assert.equal(mapped.productName, '指挥官秘钥2个') + assert.equal(mapped.rewardAmount, 1400) + assert.equal(mapped.freezeDepositAmount, 200) + assert.equal(mapped.status, 'in_progress') + assert.equal(mapped.assignedAt, '2026-08-18T09:09:08.000Z') +}) + +test('后台拼单聚合状态同时收敛筛选语义和操作能力', () => { + const partial = mapWorkOrderAdmin( + buildWorkOrderRow({ + status: 'open', + sharing_enabled: true, + sharing_total_quantity: 15, + active_share_quantity: 3, + pending_share_count: 1, + }), + ) + assert.equal(partial.status, 'in_progress') + assert.equal(partial.rawStatus, 'open') + assert.equal(partial.hallAvailable, true) + assert.equal(partial.capabilities.canEdit, false) + assert.equal(partial.capabilities.canConfigureSharing, false) + assert.equal(partial.capabilities.canAssign, false) + assert.equal(partial.capabilities.canUpdateMaterial, true) + + const filled = mapWorkOrderAdmin( + buildWorkOrderRow({ + status: 'open', + sharing_enabled: true, + sharing_total_quantity: 15, + active_share_quantity: 15, + pending_share_count: 0, + }), + ) + assert.equal(filled.status, 'pending_acceptance') + assert.equal(filled.hallAvailable, false) + assert.equal(filled.capabilities.canAccept, true) + assert.equal(filled.capabilities.canUnpublish, false) +}) + test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => { const workOrder = buildWorkOrderRow() const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '') diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 9f72c338..46870d14 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -49,6 +49,7 @@ import { listWorkOrders, listWorkOrderEventsByOrderId, listWorkOrderShares, + listWorkOrderSharesByOrderIds, listWorkerLevels, listDueDepositUnfreezes, listOverdueWorkOrders, @@ -1131,19 +1132,46 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work keyword: String(query.keyword || '').trim(), categoryId: categoryId || 0, visibleAfterIso, + excludeFilledSharing: true, pinnedFirst: true, }) const categories = (await listWorkCategories()).map(mapWorkCategory) - const myShares = await listWorkerSharesByWorker(worker.id) + const orderIds = items.map((item) => Number(item.id)) + const shares = await listWorkOrderSharesByOrderIds(orderIds) const myShareByOrderId = new Map( - myShares - .filter((share) => Number(share.work_order_id)) + shares + .filter( + (share) => share.status !== 'cancelled' && Number(share.worker_id) === Number(worker.id), + ) .map((share) => [Number(share.work_order_id), share]), ) + const sharingProgressByOrderId = new Map< + number, + { joinedQuantity: number; pendingSubmissionCount: number } + >() + for (const share of shares) { + if (share.status === 'cancelled') continue + const orderId = Number(share.work_order_id) + const progress = sharingProgressByOrderId.get(orderId) || { + joinedQuantity: 0, + pendingSubmissionCount: 0, + } + progress.joinedQuantity += Number(share.quantity || 0) + if (share.status === 'joined') progress.pendingSubmissionCount += 1 + sharingProgressByOrderId.set(orderId, progress) + } return { - items: items.map((item) => - mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))), - ), + items: items.map((item) => { + const myShare = myShareByOrderId.get(Number(item.id)) + return { + ...mapWorkOrderForWorker(item, permissions), + myShare: myShare ? mapWorkOrderShare(myShare) : null, + sharingProgress: sharingProgressByOrderId.get(Number(item.id)) || { + joinedQuantity: 0, + pendingSubmissionCount: 0, + }, + } + }), pagination: { page, pageSize, total }, categories, visibleDelaySeconds, @@ -1180,14 +1208,6 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: requireActiveWorkerSession(session) const worker = await getRequiredWorker(session.workerId) const permissions = resolveWorkerPermissions(worker) - const activeCount = await countWorkerActiveOrders(worker.id) - if (activeCount >= permissions.maxActiveOrders) { - throw createHttpError('已达到当前等级最大同时接单量', { - statusCode: 409, - errorCode: 'worker_active_order_limit', - }) - } - const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) { throw createHttpError('该订单已不可抢', { @@ -1196,6 +1216,34 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: }) } + if (workOrder.sharing_enabled) { + const result = await joinWorkOrderShare({ + workOrderId: Number(workOrder.id), + workerId: Number(worker.id), + quantity: 0, + takeRemaining: true, + maxActiveOrders: permissions.maxActiveOrders, + now: nowIso(), + }) + if (!result.share) { + throw resolveWorkOrderShareJoinError(result.failureReason) + } + await publishWorkOrderParticipantChange(Number(workOrder.id), { + workerIds: [Number(worker.id)], + hallChanged: true, + walletChangedWorkerIds: [Number(worker.id)], + }) + return { order: mapWorkOrderForWorker(workOrder, permissions, result.share) } + } + + const activeCount = await countWorkerActiveOrders(worker.id) + if (activeCount >= permissions.maxActiveOrders) { + throw createHttpError('已达到当前等级最大同时接单量', { + statusCode: 409, + errorCode: 'worker_active_order_limit', + }) + } + const freezeAmount = resolveFreezeDepositAmount(workOrder, permissions) if (Number(worker.available_amount || 0) < freezeAmount) { throw createHttpError('余额不足,无法冻结所需押金', { diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 8f5a0092..c20bacdb 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -424,6 +424,8 @@ function patchAdminWorkOrderCaches( patchWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert), ) } + // 状态聚合会影响分页统计,实时行快照不能单独修正全量统计,因此同步刷新查询。 + void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }) } function patchAdminTaskCaches(queryClient: ReturnType, tasks: unknown[]) { diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 37143dee..4396fa89 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -333,7 +333,12 @@ function upsertById(items: unknown[], nextItem: unknown, idKey: string, insert = (item) => Number((item as Record)?.[idKey] || 0) === id, ) if (index < 0) return insert ? [nextItem, ...items] : items - return items.map((item, itemIndex) => (itemIndex === index ? nextItem : item)) + return items.map((item, itemIndex) => { + if (itemIndex !== index) return item + if (!isRecord(item) || !isRecord(nextItem)) return nextItem + // 大厅广播是公共快照,合并后保留当前打手私有的 myShare 等字段。 + return { ...item, ...nextItem } + }) } function isRecord(value: unknown): value is Record { diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 5e08d7a5..875724eb 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -45,6 +45,7 @@ import ImageUpload from '@/components/files/ImageUpload' import ImagePreviewList from '@/components/files/ImagePreviewList' import { acceptAdminWorkOrder, + acceptAdminWorkOrderShare, acceptAdminWorkOrders, assignAdminWorkOrderToWorker, cancelAdminWorkOrder, @@ -741,16 +742,18 @@ export default function WorkOrdersPanel() { { title: '状态', width: 150, - render: (_, row) => ( - - {formatStatus(row.status)} - {row.status === 'open' && row.pinnedAt ? ( - }> - 置顶 - - ) : null} - - ), + render: (_, row) => { + return ( + + {formatStatus(row.status)} + {row.rawStatus === 'open' && row.pinnedAt ? ( + }> + 置顶 + + ) : null} + + ) + }, }, { title: '打手', @@ -768,6 +771,16 @@ export default function WorkOrdersPanel() { render: (_, row) => { const isVipAutoAcceptance = row.acceptance?.reviewMode === 'vip_auto' const evidencePending = row.acceptance?.evidenceStatus === 'pending' + if (row.sharing?.enabled) { + return ( + + ) + } return ( ( - {['pending_material', 'unassigned', 'open'].includes(row.status) ? ( + {row.capabilities?.canEdit ? ( ) : null} - {['pending_material', 'unassigned', 'cancelled'].includes(row.status) ? ( + {row.capabilities?.canDelete ? ( ) : null} - {['pending_material', 'unassigned', 'open'].includes(row.status) ? ( + {row.sharing?.enabled || row.capabilities?.canConfigureSharing ? ( ) : null} - {['unassigned', 'open'].includes(row.status) ? ( + {row.capabilities?.canAssign ? ( ) : null} - {row.status === 'in_progress' && row.worker ? ( + {row.capabilities?.canCancel ? ( ) : null} - {['pending_material', 'in_progress'].includes(row.status) ? ( + {row.capabilities?.canUpdateMaterial ? ( ) : null} - {row.status === 'unassigned' ? ( + {row.capabilities?.canPublish ? ( ) : null} - {row.status === 'open' ? ( + {row.capabilities?.canPin ? ( ) : null} - {row.status === 'open' ? ( + {row.capabilities?.canUnpublish ? ( ) : null} - {row.status === 'pending_acceptance' ? ( + {row.capabilities?.canAccept ? ( ) : null} - {row.status === 'accepted' && Number(row.pendingUnfreezeAmount || 0) > 0 ? ( + {row.capabilities?.canDeduct && Number(row.pendingUnfreezeAmount || 0) > 0 ? ( ) : null} - {['in_progress', 'pending_acceptance'].includes(row.status) ? ( + {row.capabilities?.canMarkProblem ? ( ) : null} - {row.status === 'problem' ? ( + {row.capabilities?.canResolveProblem ? ( @@ -1572,13 +1585,14 @@ export default function WorkOrdersPanel() { { setSharingOrder(null) sharingForm.resetFields() }} onOk={() => sharingForm.submit()} + footer={sharingOrder?.capabilities?.canConfigureSharing ? undefined : null} destroyOnHidden width={720} > @@ -1593,6 +1607,7 @@ export default function WorkOrdersPanel() { unitReward: 0, }} onFinish={submitSharingConfig} + disabled={!sharingOrder.capabilities?.canConfigureSharing} > share.submittedAt ? formatAdminDateTime(share.submittedAt) : '-', }, + { + title: '验收图', + width: 150, + render: (_, share) => ( + + ), + }, + { + title: '备注', + render: (_, share) => String(share.acceptance?.note || '').trim() || '-', + }, + { + title: '操作', + width: 110, + render: (_, share) => + share.status === 'submitted' ? ( + + ) : share.status === 'accepted' ? ( + 已验收 + ) : ( + 待提交 + ), + }, ]} /> )} @@ -1796,8 +1859,7 @@ function ProductNameEditableCell({ const savingRef = useRef(false) const clickTimerRef = useRef(null) - const editable = - ['pending_material', 'unassigned', 'open'].includes(order.status) && !order.worker + const editable = order.capabilities?.canEdit === true function handleClick() { if (clickTimerRef.current) window.clearTimeout(clickTimerRef.current) @@ -1880,8 +1942,7 @@ function AmountEditableCell({ const [draft, setDraft] = useState(null) const savingRef = useRef(false) - const editable = - ['pending_material', 'unassigned', 'open'].includes(order.status) && !order.worker + const editable = order.capabilities?.canEdit === true const currentFen = Number(order[field] || 0) const currentYuan = currentFen / 100 const paymentFen = field === 'rewardAmount' ? getUserPaymentFen(order) : null diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index 55fb8f46..b68e7a67 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -36,6 +36,12 @@ import { } from '@/services/worker' import type { WorkOrder, WorkerUser } from '@/types/worker-platform' import { useIsMobile } from '@/utils/use-is-mobile' +import { + resolveHallDepositAmount, + resolveHallProductName, + resolveHallRemainingQuantity, + resolveHallRewardAmount, +} from '@/utils/worker-product-name' type HallSummary = { pendingAcceptanceCount: number @@ -203,10 +209,7 @@ export default function WorkerHallPage() { } function resolveSharingRemaining(order: WorkOrder): number { - const sharing = order.sharing - if (!sharing?.enabled) return 0 - const progress = order.sharingProgress - return Math.max(0, sharing.totalQuantity - (progress?.joinedQuantity || 0)) + return resolveHallRemainingQuantity(order) } function resolveSharingDeposit(order: WorkOrder, quantity: number): number { @@ -387,6 +390,8 @@ export default function WorkerHallPage() { ) : ( orders.map((order) => { const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) + const productName = resolveHallProductName(order) || '未命名商品' + const sharingRemaining = resolveSharingRemaining(order) return (
@@ -409,7 +414,8 @@ export default function WorkerHallPage() { ) : null} {order.sharing?.enabled ? ( - 拼单 剩{resolveSharingRemaining(order)}份 + 拼单 {order.sharingProgress?.joinedQuantity || 0}/ + {order.sharing.totalQuantity} · 剩{resolveSharingRemaining(order)}份 ) : null} {Number(order.timeoutMinutes || 0) > 0 ? ( @@ -429,14 +435,11 @@ export default function WorkerHallPage() { {/* 商品标题与保证金说明 */}
- - {order.productName || '未命名商品'} + + {productName}
- 所需保证金: {formatMoney(order.freezeDepositAmount)} + 所需保证金: {formatMoney(resolveHallDepositAmount(order))} {order.sharing?.enabled ? ( · 拼单单价 {formatMoney(order.sharing.unitReward)}/份 ) : null} @@ -448,12 +451,14 @@ export default function WorkerHallPage() {
接单报酬 - {formatMoney(order.rewardAmount)} + {formatMoney(resolveHallRewardAmount(order))}
- {order.sharing?.enabled && !disabledReason ? ( + {order.sharing?.enabled && + (sharingRemaining > 0 || order.myShare) && + !disabledReason ? ( + {sharingRemaining > 0 || !order.sharing?.enabled ? ( + + ) : null}
@@ -613,6 +620,8 @@ export default function WorkerHallPage() {
{orders.map((order) => { const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) + const productName = resolveHallProductName(order) || '未命名商品' + const sharingRemaining = resolveSharingRemaining(order) return (
@@ -626,11 +635,8 @@ export default function WorkerHallPage() { 置顶 ) : null} - - {order.productName || '未命名商品'} + + {productName} {order.categoryName || '默认分类'} @@ -661,10 +667,10 @@ export default function WorkerHallPage() {
- {formatMoney(order.rewardAmount)} + {formatMoney(resolveHallRewardAmount(order))}
- 所需保证金:{formatMoney(order.freezeDepositAmount)} + 所需保证金:{formatMoney(resolveHallDepositAmount(order))}
@@ -676,7 +682,9 @@ export default function WorkerHallPage() {
- {order.sharing?.enabled && !disabledReason ? ( + {order.sharing?.enabled && + (sharingRemaining > 0 || order.myShare) && + !disabledReason ? ( + {sharingRemaining > 0 || !order.sharing?.enabled ? ( + + ) : null}
@@ -713,7 +723,7 @@ export default function WorkerHallPage() { )} (`/api/v1/admin/worker-platform/orders/${workOrderId}/accept`) } +export function acceptAdminWorkOrderShare(workOrderId: number, shareId: number) { + return apiPost<{ order: WorkOrder; share: WorkOrderShare }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/accept`, + ) +} + export function acceptAdminWorkOrders(workOrderIds: number[]) { return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', { workOrderIds, diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 193e3ce6..dc5ed343 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -353,6 +353,23 @@ export type WorkOrder = { categoryId?: number | null categoryName: string status: WorkOrderStatus + rawStatus?: WorkOrderStatus + hallAvailable?: boolean + capabilities?: { + canEdit: boolean + canConfigureSharing: boolean + canAssign: boolean + canPin: boolean + canUnpublish: boolean + canUpdateMaterial: boolean + canAccept: boolean + canMarkProblem: boolean + canDelete: boolean + canPublish: boolean + canCancel: boolean + canResolveProblem: boolean + canDeduct: boolean + } rewardAmount: number requiredDepositAmount?: number depositThresholdAmount?: number @@ -362,6 +379,8 @@ export type WorkOrder = { sharingProgress?: { joinedQuantity: number pendingSubmissionCount: number + remainingQuantity?: number + filled?: boolean } pendingUnfreezeAmount?: number timeoutMinutes?: number diff --git a/apps/frontend/src/utils/worker-product-name.ts b/apps/frontend/src/utils/worker-product-name.ts new file mode 100644 index 00000000..51f9ccc6 --- /dev/null +++ b/apps/frontend/src/utils/worker-product-name.ts @@ -0,0 +1,51 @@ +type HallProduct = { + productName?: string + rewardAmount?: number + freezeDepositAmount?: number + sharing?: { + enabled: boolean + totalQuantity: number + unitReward?: number + } + sharingProgress?: { + joinedQuantity: number + } +} + +const PRODUCT_QUANTITY_PATTERN = /(\d+|[零一二三四五六七八九十百千两]+)(\s*)(个|份|张|枚)/ + +/** 计算大厅当前仍可承接的份数。 */ +export function resolveHallRemainingQuantity(order: HallProduct): number { + if (!order.sharing?.enabled) return 0 + const totalQuantity = Math.max(1, Number(order.sharing.totalQuantity) || 1) + const joinedQuantity = Math.max(0, Number(order.sharingProgress?.joinedQuantity || 0)) + return Math.max(0, totalQuantity - joinedQuantity) +} + +/** 大厅拼单标题展示剩余可承接数量,避免把原始总规格误认为剩余库存。 */ +export function resolveHallProductName(order: HallProduct): string { + const productName = String(order.productName || '').trim() + if (!productName || !order.sharing?.enabled) return productName + + const remainingQuantity = resolveHallRemainingQuantity(order) + return productName.replace( + PRODUCT_QUANTITY_PATTERN, + (_matched, _quantity: string, spacing: string, unit: string) => + `${remainingQuantity}${spacing}${unit}`, + ) +} + +/** 拼单卡片金额展示一次性抢走全部剩余份数可获得的报酬。 */ +export function resolveHallRewardAmount(order: HallProduct): number { + if (!order.sharing?.enabled) return Number(order.rewardAmount || 0) + return resolveHallRemainingQuantity(order) * Number(order.sharing.unitReward || 0) +} + +/** 拼单卡片保证金按剩余份数占总份数的比例展示。 */ +export function resolveHallDepositAmount(order: HallProduct): number { + if (!order.sharing?.enabled) return Number(order.freezeDepositAmount || 0) + const totalQuantity = Math.max(1, Number(order.sharing.totalQuantity) || 1) + return Math.round( + (Number(order.freezeDepositAmount || 0) * resolveHallRemainingQuantity(order)) / totalQuantity, + ) +} diff --git a/apps/frontend/tests/worker-product-name.test.ts b/apps/frontend/tests/worker-product-name.test.ts new file mode 100644 index 00000000..1e1937c7 --- /dev/null +++ b/apps/frontend/tests/worker-product-name.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + resolveHallDepositAmount, + resolveHallProductName, + resolveHallRemainingQuantity, + resolveHallRewardAmount, +} from '../src/utils/worker-product-name.ts' + +test('拼单大厅标题按剩余份数更新阿拉伯数字规格', () => { + assert.equal( + resolveHallProductName({ + productName: '指挥官密钥15个', + sharing: { enabled: true, totalQuantity: 15 }, + sharingProgress: { joinedQuantity: 3 }, + }), + '指挥官密钥12个', + ) +}) + +test('拼单大厅标题兼容中文数字规格', () => { + assert.equal( + resolveHallProductName({ + productName: '指挥官密钥十五份', + sharing: { enabled: true, totalQuantity: 15 }, + sharingProgress: { joinedQuantity: 4 }, + }), + '指挥官密钥11份', + ) +}) + +test('普通单保留原始标题', () => { + assert.equal( + resolveHallProductName({ + productName: '指挥官密钥1个', + sharing: { enabled: false, totalQuantity: 1 }, + sharingProgress: { joinedQuantity: 0 }, + }), + '指挥官密钥1个', + ) +}) + +test('立即抢单的份数、报酬和保证金都按剩余数量计算', () => { + const order = { + productName: '指挥官密钥15个', + rewardAmount: 10_500, + freezeDepositAmount: 1_500, + sharing: { enabled: true, totalQuantity: 15, unitReward: 700 }, + sharingProgress: { joinedQuantity: 3 }, + } + + assert.equal(resolveHallRemainingQuantity(order), 12) + assert.equal(resolveHallRewardAmount(order), 8_400) + assert.equal(resolveHallDepositAmount(order), 1_200) +}) + +test('拼单份数用完后剩余报酬和保证金归零', () => { + const order = { + rewardAmount: 10_500, + freezeDepositAmount: 1_500, + sharing: { enabled: true, totalQuantity: 15, unitReward: 700 }, + sharingProgress: { joinedQuantity: 15 }, + } + + assert.equal(resolveHallRemainingQuantity(order), 0) + assert.equal(resolveHallRewardAmount(order), 0) + assert.equal(resolveHallDepositAmount(order), 0) +})