完善拼单状态与份额验收流程
This commit is contained in:
@@ -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[]) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user