完善拼单状态与份额验收流程

This commit is contained in:
yml2213
2026-08-18 18:02:43 +08:00
parent 052b99b05f
commit ee79195f47
17 changed files with 1143 additions and 196 deletions
@@ -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'
);
@@ -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<string, number>
}
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
@@ -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<WorkOrderShareRow[]> {
@@ -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<WorkOrderShareRow>(
`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<Wor
AND wo.acceptance_json->>'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<WorkOrderRow>(
`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<WorkOrderShareRow>(
`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<WorkOrderShareRow>(
`${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,
@@ -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<string, unknown>) : {},
}),
},
),
)
router.post(
'/worker-platform/orders/:workOrderId/deduct-pending-deposit',
requireAdminRoles(['admin', 'operator']),
@@ -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,
})
@@ -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<number, WorkOrderShareRow[]>()
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),
@@ -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<string, number> = {
: 0,
: 1,
@@ -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> = {}): WorkOrderRow {
return {
id: 1,
@@ -611,6 +642,26 @@ function buildWorkOrderRow(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow
}
}
function buildWorkOrderShareRow(overrides: Partial<WorkOrderShareRow> = {}): 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], '')
@@ -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('余额不足,无法冻结所需押金', {
@@ -424,6 +424,8 @@ function patchAdminWorkOrderCaches(
patchWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert),
)
}
// 状态聚合会影响分页统计,实时行快照不能单独修正全量统计,因此同步刷新查询。
void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] })
}
function patchAdminTaskCaches(queryClient: ReturnType<typeof useQueryClient>, tasks: unknown[]) {
+6 -1
View File
@@ -333,7 +333,12 @@ function upsertById(items: unknown[], nextItem: unknown, idKey: string, insert =
(item) => Number((item as Record<string, unknown>)?.[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<string, unknown> {
@@ -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) => (
<Space wrap size={4}>
<Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>
{row.status === 'open' && row.pinnedAt ? (
<Tag color="gold" icon={<PushpinOutlined />}>
</Tag>
) : null}
</Space>
),
render: (_, row) => {
return (
<Space wrap size={4}>
<Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>
{row.rawStatus === 'open' && row.pinnedAt ? (
<Tag color="gold" icon={<PushpinOutlined />}>
</Tag>
) : null}
</Space>
)
},
},
{
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 (
<Button type="link" size="small" onClick={() => openSharingConfigModal(row)}>
{row.sharingProgress?.pendingSubmissionCount
? `${row.sharingProgress.pendingSubmissionCount}人待提交)`
: ''}
</Button>
)
}
return (
<Space direction="vertical" size={4}>
<ImagePreviewList
@@ -791,37 +804,37 @@ export default function WorkOrdersPanel() {
render: (_, row) => (
<Space wrap>
<Button onClick={() => openDetailDrawer(row)}></Button>
{['pending_material', 'unassigned', 'open'].includes(row.status) ? (
{row.capabilities?.canEdit ? (
<Button icon={<EditOutlined />} onClick={() => openEditModal(row)}>
</Button>
) : null}
{['pending_material', 'unassigned', 'cancelled'].includes(row.status) ? (
{row.capabilities?.canDelete ? (
<Button danger icon={<DeleteOutlined />} onClick={() => requestDeleteWorkOrder(row)}>
</Button>
) : null}
{['pending_material', 'unassigned', 'open'].includes(row.status) ? (
{row.sharing?.enabled || row.capabilities?.canConfigureSharing ? (
<Button icon={<TeamOutlined />} onClick={() => openSharingConfigModal(row)}>
{row.sharing?.enabled && !row.capabilities?.canConfigureSharing ? '拼单明细' : '拼单配置'}
</Button>
) : null}
{['unassigned', 'open'].includes(row.status) ? (
{row.capabilities?.canAssign ? (
<Button icon={<UserAddOutlined />} onClick={() => openAssignModal(row)}>
</Button>
) : null}
{row.status === 'in_progress' && row.worker ? (
{row.capabilities?.canCancel ? (
<Button danger icon={<UndoOutlined />} onClick={() => openCancelModal(row)}>
</Button>
) : null}
{['pending_material', 'in_progress'].includes(row.status) ? (
{row.capabilities?.canUpdateMaterial ? (
<Button icon={<FormOutlined />} onClick={() => openMaterialModal(row)}>
{row.status === 'in_progress' ? '更新资料' : '补资料'}
</Button>
) : null}
{row.status === 'unassigned' ? (
{row.capabilities?.canPublish ? (
<Button
icon={<SendOutlined />}
disabled={Number(row.rewardAmount || 0) <= 0}
@@ -833,7 +846,7 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.status === 'open' ? (
{row.capabilities?.canPin ? (
<Button
icon={<PushpinOutlined />}
title={row.pinnedAt ? '取消置顶' : '置顶后抢单大厅优先展示'}
@@ -847,7 +860,7 @@ export default function WorkOrdersPanel() {
{row.pinnedAt ? '取消置顶' : '置顶'}
</Button>
) : null}
{row.status === 'open' ? (
{row.capabilities?.canUnpublish ? (
<Button
danger
onClick={() => {
@@ -864,7 +877,7 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.status === 'pending_acceptance' ? (
{row.capabilities?.canAccept ? (
<Button
icon={<CheckOutlined />}
type="primary"
@@ -873,17 +886,17 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.status === 'accepted' && Number(row.pendingUnfreezeAmount || 0) > 0 ? (
{row.capabilities?.canDeduct && Number(row.pendingUnfreezeAmount || 0) > 0 ? (
<Button icon={<WarningOutlined />} danger onClick={() => openDeductModal(row)}>
</Button>
) : null}
{['in_progress', 'pending_acceptance'].includes(row.status) ? (
{row.capabilities?.canMarkProblem ? (
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
</Button>
) : null}
{row.status === 'problem' ? (
{row.capabilities?.canResolveProblem ? (
<Button icon={<FormOutlined />} onClick={() => setResolutionOrder(row)}>
</Button>
@@ -1572,13 +1585,14 @@ export default function WorkOrdersPanel() {
</Modal>
<Modal
title={`拼单配置 · ${sharingOrder?.productName || ''}`}
title={`${sharingOrder?.capabilities?.canConfigureSharing ? '拼单配置' : '拼单明细'} · ${sharingOrder?.productName || ''}`}
open={Boolean(sharingOrder)}
onCancel={() => {
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}
>
<Form.Item
label="启用拼单"
@@ -1689,6 +1704,54 @@ export default function WorkOrdersPanel() {
render: (_, share) =>
share.submittedAt ? formatAdminDateTime(share.submittedAt) : '-',
},
{
title: '验收图',
width: 150,
render: (_, share) => (
<ImagePreviewList
files={Array.isArray(share.acceptance?.files) ? share.acceptance.files : []}
imageUrls={Array.isArray(share.acceptance?.imageUrls) ? share.acceptance.imageUrls : []}
size={44}
maxVisible={2}
/>
),
},
{
title: '备注',
render: (_, share) => String(share.acceptance?.note || '').trim() || '-',
},
{
title: '操作',
width: 110,
render: (_, share) =>
share.status === 'submitted' ? (
<Button
type="primary"
size="small"
onClick={() => {
void runAction(
() => acceptAdminWorkOrderShare(sharingOrder.workOrderId, share.shareId),
'该拼单参与者已验收',
).then((succeeded) => {
if (succeeded) {
void queryClient.invalidateQueries({
queryKey: [
'admin-worker-platform-order-sharing',
sharingOrder.workOrderId,
],
})
}
})
}}
>
</Button>
) : share.status === 'accepted' ? (
<Tag color="green"></Tag>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
]}
/>
)}
@@ -1796,8 +1859,7 @@ function ProductNameEditableCell({
const savingRef = useRef(false)
const clickTimerRef = useRef<number | null>(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<number | null>(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
@@ -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 (
<article key={order.workOrderId} className="worker-hall-mobile-card">
@@ -409,7 +414,8 @@ export default function WorkerHallPage() {
) : null}
{order.sharing?.enabled ? (
<Tag color="purple" className="worker-hall-mobile-tag">
{resolveSharingRemaining(order)}
{order.sharingProgress?.joinedQuantity || 0}/
{order.sharing.totalQuantity} · {resolveSharingRemaining(order)}
</Tag>
) : null}
{Number(order.timeoutMinutes || 0) > 0 ? (
@@ -429,14 +435,11 @@ export default function WorkerHallPage() {
{/* 商品标题与保证金说明 */}
<div className="worker-hall-mobile-card-main">
<strong
className="worker-hall-mobile-card-title"
title={order.productName || '未命名商品'}
>
{order.productName || '未命名商品'}
<strong className="worker-hall-mobile-card-title" title={productName}>
{productName}
</strong>
<div className="worker-hall-mobile-card-meta">
<span>: {formatMoney(order.freezeDepositAmount)}</span>
<span>: {formatMoney(resolveHallDepositAmount(order))}</span>
{order.sharing?.enabled ? (
<span> · {formatMoney(order.sharing.unitReward)}/</span>
) : null}
@@ -448,12 +451,14 @@ export default function WorkerHallPage() {
<div className="worker-hall-mobile-card-price-group">
<span className="worker-hall-mobile-card-price-label"></span>
<strong className="worker-hall-mobile-card-price-val">
{formatMoney(order.rewardAmount)}
{formatMoney(resolveHallRewardAmount(order))}
</strong>
</div>
<div className="worker-hall-mobile-card-btn-group">
{order.sharing?.enabled && !disabledReason ? (
{order.sharing?.enabled &&
(sharingRemaining > 0 || order.myShare) &&
!disabledReason ? (
<Button
type="primary"
size="middle"
@@ -466,16 +471,18 @@ export default function WorkerHallPage() {
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
</Button>
) : null}
<Button
type="primary"
size="middle"
className="worker-hall-mobile-grab-btn"
disabled={Boolean(disabledReason)}
loading={grabbingId === order.workOrderId}
onClick={() => submitGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
{sharingRemaining > 0 || !order.sharing?.enabled ? (
<Button
type="primary"
size="middle"
className="worker-hall-mobile-grab-btn"
disabled={Boolean(disabledReason)}
loading={grabbingId === order.workOrderId}
onClick={() => submitGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
) : null}
</div>
</div>
</article>
@@ -613,6 +620,8 @@ export default function WorkerHallPage() {
<div className="worker-hall-card-grid">
{orders.map((order) => {
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
const productName = resolveHallProductName(order) || '未命名商品'
const sharingRemaining = resolveSharingRemaining(order)
return (
<article key={order.workOrderId} className="worker-hall-card">
@@ -626,11 +635,8 @@ export default function WorkerHallPage() {
</Tag>
) : null}
<strong
className="worker-hall-card-title-text"
title={order.productName || '未命名商品'}
>
{order.productName || '未命名商品'}
<strong className="worker-hall-card-title-text" title={productName}>
{productName}
</strong>
<Typography.Text type="secondary">
{order.categoryName || '默认分类'}
@@ -661,10 +667,10 @@ export default function WorkerHallPage() {
<div className="worker-hall-card-amount-row">
<div className="worker-hall-card-amount">
{formatMoney(order.rewardAmount)}
{formatMoney(resolveHallRewardAmount(order))}
</div>
<Typography.Text type="secondary" className="worker-hall-card-deposit">
{formatMoney(order.freezeDepositAmount)}
{formatMoney(resolveHallDepositAmount(order))}
</Typography.Text>
</div>
@@ -676,7 +682,9 @@ export default function WorkerHallPage() {
<div className="worker-hall-card-actions">
<div className="worker-hall-card-grab-row">
{order.sharing?.enabled && !disabledReason ? (
{order.sharing?.enabled &&
(sharingRemaining > 0 || order.myShare) &&
!disabledReason ? (
<Button
type="primary"
size="large"
@@ -689,15 +697,17 @@ export default function WorkerHallPage() {
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
</Button>
) : null}
<Button
type="primary"
size="large"
disabled={Boolean(disabledReason)}
loading={grabbingId === order.workOrderId}
onClick={() => submitGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
{sharingRemaining > 0 || !order.sharing?.enabled ? (
<Button
type="primary"
size="large"
disabled={Boolean(disabledReason)}
loading={grabbingId === order.workOrderId}
onClick={() => submitGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
) : null}
</div>
</div>
</article>
@@ -713,7 +723,7 @@ export default function WorkerHallPage() {
)}
<Modal
title={`拼单参与 · ${sharingOrder?.productName || ''}`}
title={`拼单参与 · ${sharingOrder ? resolveHallProductName(sharingOrder) : ''}`}
width={isMobile ? '92%' : 520}
open={Boolean(sharingOrder)}
confirmLoading={submittingSharing}
@@ -831,7 +841,7 @@ function resolveGrabDisabledReason(
worker: WorkerUser | undefined,
remainingSlots: number | null,
) {
if (remainingSlots !== null && remainingSlots <= 0) {
if (remainingSlots !== null && remainingSlots <= 0 && !order.myShare) {
return '已达到当前等级最大同时接单量'
}
@@ -840,7 +850,7 @@ function resolveGrabDisabledReason(
}
const availableAmount = Number(worker.wallet.availableAmount || 0)
const freezeAmount = Number(order.freezeDepositAmount || 0)
const freezeAmount = resolveHallDepositAmount(order)
if (availableAmount < freezeAmount) {
return `余额不足,还差 ${formatMoney(freezeAmount - availableAmount)}`
}
@@ -464,6 +464,12 @@ export function acceptAdminWorkOrder(workOrderId: number) {
return apiPost<{ order: WorkOrder }>(`/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,
@@ -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
@@ -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,
)
}
@@ -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)
})