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

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
@@ -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,