add:新增同一个工单同一人可以多次拼单

This commit is contained in:
2026-10-23 14:26:41 +08:00
parent 72a758fd7c
commit bfd2325ac5
28 changed files with 1332 additions and 347 deletions
@@ -92,3 +92,36 @@ test('全仓库只允许保留一处「整表拼单聚合」,且必须是无
'保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删',
)
})
test('我的订单的份额关联按工单取一条,避免多份额把列表行放大', async () => {
const source = stripComments(await readRepoSource())
assert.ok(source.includes('LEFT JOIN LATERAL ('), '缺少按工单取代表份额的 LATERAL 关联')
assert.ok(
!source.includes('LEFT JOIN work_order_shares worker_share'),
'仍在直接 JOIN 份额表:同一工单多份拼单会让「我的订单」出现多行',
)
})
test('我的订单概览按工单计数,金额仍按份额求和', async () => {
const source = stripComments(await readRepoSource())
const start = source.indexOf('worker_base AS (')
assert.ok(start >= 0, '缺少我的订单概览的 worker_base CTE')
const block = source.slice(start, start + 2200)
assert.match(block, /LEFT JOIN LATERAL \(/, '概览的份额关联没有按工单取一条')
assert.match(block, /share_reward_total/, '概览没有汇总多份份额的奖励金额')
assert.ok(
!block.includes('LEFT JOIN work_order_shares wos'),
'概览仍在直接 JOIN 份额表:同一工单 3 份会被统计成 3 单',
)
})
test('超时统计按工单去重,不按份额/事件行数累加', async () => {
const source = stripComments(await readRepoSource())
assert.ok(
source.includes('COUNT(DISTINCT work_order_id)::int AS total'),
'超时单数没有按工单去重',
)
})
@@ -358,6 +358,15 @@ export type WorkOrderShareRow = {
accepted_at: string | null
created_at: string
updated_at: string
/** 本份额截止时间;参与时固化,null = 不限时 */
deadline_at?: string | null
/** 参与时固化的时限分钟数与策略 */
timeout_minutes?: number
timeout_policy?: string
/** 取消来源:timeout/worker_request/admin/problem/settlement */
cancelled_reason?: string
/** 客户端幂等键 */
request_id?: string
worker_username?: string
worker_display_name?: string
}
@@ -643,7 +643,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
wo.id,
wo.status AS wo_status,
wos.status AS wos_status,
wos.id IS NOT NULL AS is_worker_share,
wos.status IS NOT NULL AS is_worker_share,
wo.assigned_worker_id = $1 AS is_assigned_to_me,
EXISTS (
SELECT 1 FROM work_order_events we
@@ -652,17 +652,38 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
AND we.payload_json->>'workerId' = $1::text
) AS has_timeout_for_me,
CASE
WHEN wos.id IS NOT NULL THEN wos.share_reward
WHEN wos.status IS NOT NULL THEN wos.share_reward_total
WHEN wo.assigned_worker_id = $1 THEN wo.reward_amount
ELSE 0
END AS worker_settlement_amount,
wo.acceptance_json
FROM work_orders wo
INNER JOIN visible_orders vo ON vo.work_order_id = wo.id
LEFT JOIN work_order_shares wos
ON wos.work_order_id = wo.id
AND wos.worker_id = $1
AND wos.status != 'cancelled'
LEFT JOIN LATERAL (
SELECT
(
SELECT w2.status
FROM work_order_shares w2
WHERE w2.work_order_id = wo.id
AND w2.worker_id = $1
AND w2.status != 'cancelled'
ORDER BY
CASE w2.status
WHEN 'joined' THEN 0
WHEN 'submitted' THEN 1
WHEN 'accepted' THEN 2
ELSE 3
END,
w2.created_at DESC,
w2.id DESC
LIMIT 1
) AS status,
COALESCE(SUM(wos.share_reward), 0)::int AS share_reward_total
FROM work_order_shares wos
WHERE wos.work_order_id = wo.id
AND wos.worker_id = $1
AND wos.status != 'cancelled'
) wos ON true
),
worker_orders AS (
SELECT
@@ -793,7 +814,12 @@ export interface OverdueWorkOrderShareItem {
shareCreatedAt: string
}
/** 查询已超过接单时限、尚未提交材料的拼单份额(status=joined)。 */
/**
* 查询已到期、尚未提交材料的拼单份额(status=joined)。
*
* 到期判定只看份额自身的 deadline_at(参与时固化),不再用「母单当前配置 + created_at」实时算:
* 母单改时限不会追溯影响已参与份额。
*/
export async function listOverdueWorkOrderShares({
limit = 50,
workerId = 0,
@@ -821,18 +847,17 @@ export async function listOverdueWorkOrderShares({
wos.worker_id,
wos.quantity,
wos.share_deposit,
wo.timeout_minutes,
wo.timeout_policy,
wos.timeout_minutes,
wos.timeout_policy,
wos.created_at AS share_created_at
FROM work_order_shares wos
INNER JOIN work_orders wo ON wo.id = wos.work_order_id
WHERE wos.status = 'joined'
AND wo.sharing_enabled = TRUE
AND wo.timeout_minutes > 0
AND wos.created_at + (wo.timeout_minutes || ' minutes')::interval < NOW()
AND wo.status = 'open'
AND wos.deadline_at IS NOT NULL
AND wos.deadline_at < NOW()
AND wo.status NOT IN ('accepted', 'cancelled')
${workerClause}
ORDER BY wos.created_at ASC
ORDER BY wos.deadline_at ASC
LIMIT $${params.length}`,
params,
)
@@ -848,25 +873,31 @@ export async function listOverdueWorkOrderShares({
}))
}
/**
* 打手当前活跃单数 SQL(按**不同工单**计)。
*
* 同一工单拼多份只占 1 个名额:拼单份额按 work_order_id 去重,避免拆份占满接单上限。
* 事务内外的计数必须共用这一段 SQL,否则会出现「资料页/抢普通单按份额算、
* 拼单入口按工单算」的口径分裂。
*/
const COUNT_WORKER_ACTIVE_ORDERS_SQL = `
SELECT COUNT(*)::int AS total
FROM (
SELECT wo.id
FROM work_orders wo
WHERE wo.assigned_worker_id = $1 AND wo.status = 'in_progress'
UNION
SELECT wos.work_order_id
FROM work_order_shares wos
INNER JOIN work_orders swo ON swo.id = wos.work_order_id
WHERE wos.worker_id = $1
AND wos.status = 'joined'
AND swo.status = 'open'
) AS active_orders
`
export async function countWorkerActiveOrders(workerId: number | string): Promise<number> {
const result = await query<{ total: number }>(
`
SELECT (
SELECT COUNT(*)::int
FROM work_orders
WHERE assigned_worker_id = $1
AND status = 'in_progress'
) + (
SELECT COUNT(*)::int
FROM work_order_shares wos
INNER JOIN work_orders wo ON wo.id = wos.work_order_id
WHERE wos.worker_id = $1
AND wos.status = 'joined'
AND wo.status = 'open'
) AS total
`,
[Number(workerId)],
)
const result = await query<{ total: number }>(COUNT_WORKER_ACTIVE_ORDERS_SQL, [Number(workerId)])
return Number(result.rows[0]?.total || 0)
}
@@ -874,20 +905,7 @@ export async function countWorkerActiveOrdersWithClient(
client: PoolClient,
workerId: number,
): Promise<number> {
const result = await client.query<{ total: number }>(
`
SELECT (
SELECT COUNT(*)::int FROM work_orders
WHERE assigned_worker_id = $1 AND status = 'in_progress'
) + (
SELECT COUNT(*)::int
FROM work_order_shares wos
INNER JOIN work_orders wo ON wo.id = wos.work_order_id
WHERE wos.worker_id = $1 AND wos.status = 'joined' AND wo.status = 'open'
) AS total
`,
[workerId],
)
const result = await client.query<{ total: number }>(COUNT_WORKER_ACTIVE_ORDERS_SQL, [workerId])
return Number(result.rows[0]?.total || 0)
}
@@ -907,7 +925,8 @@ export async function countTimeoutEventsByWorkerIds(
const counts = new Map<number, number>()
const result = await query<{ worker_id: string; total: number }>(
`
SELECT payload_json->>'workerId' AS worker_id, COUNT(*)::int AS total
SELECT payload_json->>'workerId' AS worker_id,
COUNT(DISTINCT work_order_id)::int AS total
FROM work_order_events
WHERE event_type LIKE 'timeout_%'
AND payload_json->>'workerId' IS NOT NULL
@@ -1165,9 +1184,23 @@ export function buildWorkOrderWhere({
WHERE timeout_ev.event_type LIKE 'timeout_%'
AND timeout_ev.payload_json->>'workerId' = ${workerSharePlaceholder}::text
) worker_visible ON worker_visible.work_order_id = wo.id
LEFT JOIN work_order_shares worker_share
ON worker_share.work_order_id = wo.id
AND worker_share.worker_id = ${workerSharePlaceholder}::bigint`)
LEFT JOIN LATERAL (
SELECT wos.status, wos.created_at
FROM work_order_shares wos
WHERE wos.work_order_id = wo.id
AND wos.worker_id = ${workerSharePlaceholder}::bigint
ORDER BY
CASE wos.status
WHEN 'joined' THEN 0
WHEN 'submitted' THEN 1
WHEN 'accepted' THEN 2
WHEN 'cancelled' THEN 3
ELSE 4
END,
wos.created_at DESC,
wos.id DESC
LIMIT 1
) worker_share ON true`)
}
if (useOperationalStatus) {
@@ -10,6 +10,7 @@ import {
import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js'
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
import { recomputeWorkOrderSharingStatusWithClient } from './work-order-share-repo.js'
import {
enqueueDepositUnfreezeWithClient,
getOutstandingDepositAmountWithClient,
@@ -809,6 +810,7 @@ export async function cancelWorkOrderShare(input: {
await client.query(
`UPDATE work_order_shares
SET status = 'cancelled',
cancelled_reason = 'admin',
acceptance_json = '{}'::jsonb,
draft_acceptance_json = '{}'::jsonb,
submitted_at = NULL,
@@ -817,14 +819,11 @@ export async function cancelWorkOrderShare(input: {
WHERE id = $2`,
[input.now, input.shareId],
)
await client.query(
`UPDATE work_orders
SET status = $1,
submitted_at = NULL,
published_at = COALESCE(published_at, $2),
updated_at = $2
WHERE id = $3`,
[nextOrderStatus, input.now, input.workOrderId],
// 母单状态由剩余份额集合推导:撤掉这一份后,其余已提交份额仍可正常待验收
const recomputedOrder = await recomputeWorkOrderSharingStatusWithClient(
client,
input.workOrderId,
input.now,
)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
@@ -832,7 +831,7 @@ export async function cancelWorkOrderShare(input: {
actorId: input.actorName || '',
eventType: 'sharing_share_cancelled_by_admin',
fromStatus: workOrder.status,
toStatus: nextOrderStatus,
toStatus: recomputedOrder?.status || workOrder.status,
payloadJson: JSON.stringify({
shareId: input.shareId,
workerId,
@@ -878,7 +877,11 @@ export async function settleOverdueWorkOrderShare(input: {
[input.workOrderId],
)
const workOrder = orderResult.rows[0] || null
if (!workOrder || workOrder.sharing_enabled !== true || workOrder.status !== 'open') {
if (
!workOrder ||
workOrder.sharing_enabled !== true ||
!['open', 'pending_acceptance', 'in_progress'].includes(String(workOrder.status || ''))
) {
return {
order: workOrder,
share: null,
@@ -901,7 +904,8 @@ export async function settleOverdueWorkOrderShare(input: {
}
}
const timeoutMinutes = Number(workOrder.timeout_minutes || 0)
// 到期判定只看份额自身快照:参与时固化的 deadline_at / timeout_minutes
const timeoutMinutes = Number(share.timeout_minutes ?? workOrder.timeout_minutes ?? 0)
if (timeoutMinutes <= 0) {
return {
order: workOrder,
@@ -910,8 +914,11 @@ export async function settleOverdueWorkOrderShare(input: {
failureReason: 'share_not_overdue',
}
}
const deadlineMs = new Date(share.created_at).getTime() + timeoutMinutes * 60 * 1000
if (deadlineMs > new Date(input.now).getTime()) {
const snapshotDeadline = String(share.deadline_at || '').trim()
const deadlineMs = snapshotDeadline
? new Date(snapshotDeadline).getTime()
: new Date(share.created_at).getTime() + timeoutMinutes * 60 * 1000
if (Number.isFinite(deadlineMs) && deadlineMs > new Date(input.now).getTime()) {
return {
order: workOrder,
share,
@@ -996,6 +1003,7 @@ export async function settleOverdueWorkOrderShare(input: {
await client.query(
`UPDATE work_order_shares
SET status = 'cancelled',
cancelled_reason = 'timeout',
acceptance_json = '{}'::jsonb,
draft_acceptance_json = '{}'::jsonb,
submitted_at = NULL,
@@ -1005,15 +1013,8 @@ export async function settleOverdueWorkOrderShare(input: {
[input.now, input.shareId],
)
await client.query(
`UPDATE work_orders
SET status = 'open',
published_at = COALESCE(published_at, $1),
hall_queued_at = $1,
updated_at = $1
WHERE id = $2`,
[input.now, input.workOrderId],
)
// 母单状态只由份额集合推导:这一份超时不应把其他已提交份额的母单踢回大厅
await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
@@ -1160,6 +1161,7 @@ export async function cancelWorkOrderSharing(input: {
await client.query(
`UPDATE work_order_shares
SET status = 'cancelled',
cancelled_reason = 'admin',
acceptance_json = '{}'::jsonb,
draft_acceptance_json = '{}'::jsonb,
submitted_at = NULL,
@@ -1,3 +1,5 @@
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import {
ensureWorkerWalletWithClient,
@@ -13,14 +15,6 @@ import {
} from './work-order-repo.js'
import type { WorkOrderRow, WorkOrderShareRow } from './types.js'
async function getWorkOrderShareById(shareId: number): Promise<WorkOrderShareRow | null> {
const result = await query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
[shareId],
)
return result.rows[0] || null
}
export async function listWorkOrderShares(
workOrderId: number | string,
): Promise<WorkOrderShareRow[]> {
@@ -90,18 +84,56 @@ export async function countWorkOrderPendingSharingSubmissions(
return Number(result.rows[0]?.total || 0)
}
/**
* 该打手在该工单的「主份额」:优先未完成(joined/submitted)的最新一条,
* 否则取最新一条(含已验收/已取消)。仅用于展示与权限判断,**不要**用它做验收/撤单定位。
*/
export async function getWorkOrderShare(
workOrderId: number | string,
workerId: number | string,
): Promise<WorkOrderShareRow | null> {
const result = await query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT}
WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled' LIMIT 1`,
WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled'
ORDER BY CASE WHEN wos.status IN ('joined', 'submitted') THEN 0 ELSE 1 END, wos.id DESC
LIMIT 1`,
[Number(workOrderId), Number(workerId)],
)
return result.rows[0] || null
}
/** 该打手在该工单的全部有效份额(未取消),按参与顺序返回。 */
export async function listWorkOrderSharesByWorkerInOrder(
workOrderId: number | string,
workerId: number | string,
): Promise<WorkOrderShareRow[]> {
const result = await query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT}
WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled'
ORDER BY wos.id ASC`,
[Number(workOrderId), Number(workerId)],
)
return result.rows
}
/** 按份额 id 读取(可带工单约束,防止跨单操作)。 */
export async function getWorkOrderShareById(
shareId: number | string,
workOrderId?: number | string,
): Promise<WorkOrderShareRow | null> {
const params: unknown[] = [Number(shareId)]
let workOrderClause = ''
if (workOrderId !== undefined && Number(workOrderId) > 0) {
params.push(Number(workOrderId))
workOrderClause = ` AND wos.work_order_id = $${params.length}`
}
const result = await query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1${workOrderClause} LIMIT 1`,
params,
)
return result.rows[0] || null
}
/** 普通拼单使用指定份数,立即抢单则在事务中承接全部剩余份数。 */
export function resolveWorkOrderShareJoinQuantity(input: {
quantity: number
@@ -112,27 +144,41 @@ export function resolveWorkOrderShareJoinQuantity(input: {
}
/**
* 计算打手当前累计拼单份额实际需要冻结的押金。
* 等级与个人叠加后的免押额度针对该打手在本工单的累计份额生效;已经冻结的金额不自动回退。
* 计算「本次参与」应新增冻结的押金。
*
* 口径(已确认):免押额度跟着母单走 —— 在该工单内只抵扣一次。
* cumRequired = round(required × 累计份数 / 总份数)
* cumDeposit = max(0, cumRequired − 免押额度)
* 本次冻结 = max(0, cumDeposit − 该打手在本工单已冻结合计)
*
* alreadyFrozenDeposit 只用于避免重复冻结,不会因为份数变少而回退(既有语义)。
*/
export function resolveWorkOrderShareDepositAmount(input: {
export function resolveWorkOrderShareDepositPlan(input: {
requiredDepositAmount: number
totalQuantity: number
/** 本次参与前,该打手在该工单已参与的有效份数(不含本次) */
quantityBefore: number
/** 本次参与份数 */
nextQuantity: number
depositFreeAmount?: number
currentFrozenDeposit?: number
}) {
/** 该打手在该工单已冻结的押金合计 */
frozenDepositSoFar?: number
}): { cumulativeRequired: number; cumulativeDeposit: number; increment: number } {
const totalQuantity = Math.max(1, Number(input.totalQuantity || 0))
const nextRequiredDeposit = Math.round(
(Math.max(0, Number(input.requiredDepositAmount || 0)) *
Math.max(0, Number(input.nextQuantity || 0))) /
totalQuantity,
const cumulativeQuantity =
Math.max(0, Number(input.quantityBefore || 0)) + Math.max(0, Number(input.nextQuantity || 0))
const cumulativeRequired = Math.round(
(Math.max(0, Number(input.requiredDepositAmount || 0)) * cumulativeQuantity) / totalQuantity,
)
const requiredShareDeposit = Math.max(
const cumulativeDeposit = Math.max(
0,
nextRequiredDeposit - Math.max(0, Number(input.depositFreeAmount || 0)),
cumulativeRequired - Math.max(0, Number(input.depositFreeAmount || 0)),
)
return Math.max(Math.max(0, Number(input.currentFrozenDeposit || 0)), requiredShareDeposit)
const increment = Math.max(
0,
cumulativeDeposit - Math.max(0, Number(input.frozenDepositSoFar || 0)),
)
return { cumulativeRequired, cumulativeDeposit, increment }
}
export async function joinWorkOrderShare(input: {
@@ -141,15 +187,19 @@ export async function joinWorkOrderShare(input: {
quantity: number
/** 立即抢单时在事务锁内承接全部剩余份数。 */
takeRemaining?: boolean
/** 当前打手的有效免押额度(分),按其本次累计拼单份额抵扣。 */
/** 当前打手的有效免押额度(分),按其在本工单的累计份额抵扣一次。 */
depositFreeAmount?: number
maxActiveOrders: number
hallCandidateLimit?: number
hallCategoryLimits?: Record<string, number>
visibleAfterIso?: string
/** 客户端幂等键:同一个打手同一个 requestId 只产生一条份额。 */
requestId?: string
now: string
}): Promise<{
share: WorkOrderShareRow | null
/** true = 命中幂等键,返回既有份额,未重复冻结 */
duplicated: boolean
failureReason:
| 'work_order_not_open'
| 'sharing_disabled'
@@ -173,7 +223,7 @@ export async function joinWorkOrderShare(input: {
)
const workOrder = orderResult.rows[0] || null
if (!workOrder || workOrder.status !== 'open') {
return { share: null, failureReason: 'work_order_not_open' }
return { share: null, duplicated: false, failureReason: 'work_order_not_open' }
}
if (
input.hallCandidateLimit &&
@@ -184,13 +234,26 @@ export async function joinWorkOrderShare(input: {
visibleAfterIso: input.visibleAfterIso,
}))
) {
return { share: null, failureReason: 'work_order_not_open' }
return { share: null, duplicated: false, failureReason: 'work_order_not_open' }
}
if (workOrder.sharing_enabled !== true) {
return { share: null, failureReason: 'sharing_disabled' }
return { share: null, duplicated: false, failureReason: 'sharing_disabled' }
}
if (Number(workOrder.assigned_worker_id || 0) === input.workerId) {
return { share: null, failureReason: 'work_order_owner_conflict' }
return { share: null, duplicated: false, failureReason: 'work_order_owner_conflict' }
}
// 幂等:同一打手同一 requestId 直接返回原份额(不重复冻结、不重复算份数)
const requestId = String(input.requestId || '').trim()
if (requestId) {
const idempotentResult = await client.query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT} WHERE wos.worker_id = $1 AND wos.request_id = $2 LIMIT 1`,
[input.workerId, requestId],
)
const existing = idempotentResult.rows[0] || null
if (existing) {
return { share: existing, duplicated: true, failureReason: null }
}
}
const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1)
@@ -210,83 +273,82 @@ export async function joinWorkOrderShare(input: {
takeRemaining: input.takeRemaining === true,
})
if (requestedQuantity <= 0 || requestedQuantity > remaining) {
return { share: null, failureReason: 'sharing_quantity_full' }
return { share: null, duplicated: false, failureReason: 'sharing_quantity_full' }
}
const existingShareResult = await client.query<WorkOrderShareRow>(
`SELECT *
FROM work_order_shares
WHERE work_order_id = $1 AND worker_id = $2
FOR UPDATE`,
// 该打手在本工单的累计份数与已冻结押金(免押额度按母单只抵扣一次)
const workerAggResult = await client.query<{ quantity_before: number; frozen_before: number }>(
`
SELECT
COALESCE(SUM(quantity), 0)::int AS quantity_before,
COALESCE(SUM(share_deposit), 0)::int AS frozen_before
FROM work_order_shares
WHERE work_order_id = $1 AND worker_id = $2 AND status != 'cancelled'
`,
[input.workOrderId, input.workerId],
)
const existingShare = existingShareResult.rows[0] || null
if (existingShare && (!input.takeRemaining || existingShare.status !== 'joined')) {
return { share: null, failureReason: 'sharing_quantity_full' }
}
if (!existingShare) {
const quantityBefore = Number(workerAggResult.rows[0]?.quantity_before || 0)
const frozenBefore = Number(workerAggResult.rows[0]?.frozen_before || 0)
// 活跃单按「不同工单」计:已经在该工单有份额时不重复占名额
if (quantityBefore <= 0) {
const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId)
if (activeOrderCount >= input.maxActiveOrders) {
return { share: null, failureReason: 'worker_active_order_limit' }
return { share: null, duplicated: false, failureReason: 'worker_active_order_limit' }
}
}
const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0)
const nextQuantity = Number(existingShare?.quantity || 0) + requestedQuantity
const shareReward = nextQuantity * unitReward
const nextShareDeposit = resolveWorkOrderShareDepositAmount({
const shareReward = requestedQuantity * unitReward
const depositPlan = resolveWorkOrderShareDepositPlan({
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
totalQuantity,
nextQuantity,
quantityBefore,
nextQuantity: requestedQuantity,
...(input.depositFreeAmount === undefined
? {}
: { depositFreeAmount: input.depositFreeAmount }),
currentFrozenDeposit: Number(existingShare?.share_deposit || 0),
frozenDepositSoFar: frozenBefore,
})
const depositToFreeze = Math.max(
0,
nextShareDeposit - Number(existingShare?.share_deposit || 0),
)
const depositToFreeze = depositPlan.increment
const wallet = await getWorkerWalletWithClient(client, input.workerId)
const available = Number(wallet?.available_amount || 0)
if (available < depositToFreeze) {
return { share: null, failureReason: 'worker_deposit_insufficient' }
return { share: null, duplicated: false, failureReason: 'worker_deposit_insufficient' }
}
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 }>(
`
// 参与瞬间固化截止时间与超时策略:母单后续改配置不影响这一份
const timeoutMinutes = toPositiveInteger(workOrder.timeout_minutes, 0)
const timeoutPolicy = String(workOrder.timeout_policy || '').trim() || 'reopen'
const deadlineAt =
timeoutMinutes > 0
? new Date(new Date(input.now).getTime() + timeoutMinutes * 60 * 1000).toISOString()
: null
const shareResult = 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)
share_deposit, status, acceptance_json, deadline_at, timeout_minutes,
timeout_policy, request_id, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $8, $9, $10, $11, $11)
RETURNING id
`,
[
input.workOrderId,
input.workerId,
requestedQuantity,
unitReward,
shareReward,
nextShareDeposit,
input.now,
],
)
[
input.workOrderId,
input.workerId,
requestedQuantity,
unitReward,
shareReward,
depositToFreeze,
deadlineAt,
timeoutMinutes,
timeoutPolicy,
requestId,
input.now,
],
)
if (depositToFreeze > 0) {
const nextAvailable = available - depositToFreeze
@@ -313,7 +375,12 @@ export async function joinWorkOrderShare(input: {
nextFrozen,
input.workOrderId,
`拼单冻结押金 ${requestedQuantity} 份`,
JSON.stringify({ workOrderId: input.workOrderId, quantity: requestedQuantity }),
JSON.stringify({
workOrderId: input.workOrderId,
quantity: requestedQuantity,
shareId: Number(shareResult.rows[0]?.id || 0),
cumulativeDeposit: depositPlan.cumulativeDeposit,
}),
input.now,
],
)
@@ -328,8 +395,13 @@ export async function joinWorkOrderShare(input: {
toStatus: 'open',
payloadJson: JSON.stringify({
workerId: input.workerId,
shareId: Number(shareResult.rows[0]?.id || 0),
quantity: requestedQuantity,
takeRemaining: input.takeRemaining === true,
deadlineAt,
timeoutMinutes,
timeoutPolicy,
requestId: requestId || null,
}),
now: input.now,
})
@@ -338,13 +410,14 @@ export async function joinWorkOrderShare(input: {
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
[Number(shareResult.rows[0]?.id || 0)],
)
return { share: share.rows[0] || null, failureReason: null }
return { share: share.rows[0] || null, duplicated: false, failureReason: null }
})
}
export async function submitWorkOrderShareAcceptance(input: {
workOrderId: number
workerId: number
shareId: number
acceptanceJson: string
now: string
}): Promise<{
@@ -352,11 +425,8 @@ export async function submitWorkOrderShareAcceptance(input: {
failureReason: 'share_not_found' | 'share_status_invalid' | 'work_order_status_invalid' | null
}> {
return withTransaction(async (client) => {
const orderResult = await client.query<{
status: string
sharing_total_quantity: number
}>(
`SELECT status, sharing_total_quantity
const orderResult = await client.query<{ status: string }>(
`SELECT status
FROM work_orders
WHERE id = $1
FOR UPDATE`,
@@ -374,10 +444,10 @@ export async function submitWorkOrderShareAcceptance(input: {
`
SELECT id
FROM work_order_shares
WHERE work_order_id = $1 AND worker_id = $2
WHERE id = $3 AND work_order_id = $1 AND worker_id = $2
FOR UPDATE
`,
[input.workOrderId, input.workerId],
[input.workOrderId, input.workerId, input.shareId],
)
const shareId = lockResult.rows[0]?.id || 0
if (!shareId) {
@@ -403,34 +473,7 @@ 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 recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
@@ -439,7 +482,11 @@ export async function submitWorkOrderShareAcceptance(input: {
eventType: 'sharing_acceptance_submitted',
fromStatus: 'joined',
toStatus: 'submitted',
payloadJson: JSON.stringify({ workerId: input.workerId, quantity: share.quantity }),
payloadJson: JSON.stringify({
workerId: input.workerId,
shareId: Number(share.id),
quantity: share.quantity,
}),
now: input.now,
})
const updated = await client.query<WorkOrderShareRow>(
@@ -450,6 +497,86 @@ export async function submitWorkOrderShareAcceptance(input: {
})
}
/**
* 按份额集合推导母单拼单状态(唯一口径,禁止各调用点各自 UPDATE work_orders.status)。
*
* allocated >= total && joined == 0 && submitted > 0 → pending_acceptance
* allocated < total 或 joined > 0 → open
* allocated == 0(全部取消) → open(重新可参与)
* 母单 accepted / cancelled → 不动
*
* 返回更新后的母单行(供调用方回给前端)。
*/
export async function recomputeWorkOrderSharingStatusWithClient(
client: PoolClient,
workOrderId: number,
now: string,
): Promise<WorkOrderRow | null> {
const orderResult = await client.query<WorkOrderRow>(
`SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`,
[workOrderId],
)
const workOrder = orderResult.rows[0] || null
if (!workOrder) return null
if (['accepted', 'cancelled'].includes(String(workOrder.status || ''))) {
return workOrder
}
const aggResult = await client.query<{
allocated: number
joined_count: number
submitted_count: number
accepted_count: number
}>(
`
SELECT
COALESCE(SUM(quantity), 0)::int AS allocated,
COUNT(*) FILTER (WHERE status = 'joined')::int AS joined_count,
COUNT(*) FILTER (WHERE status = 'submitted')::int AS submitted_count,
COUNT(*) FILTER (WHERE status = 'accepted')::int AS accepted_count
FROM work_order_shares
WHERE work_order_id = $1 AND status != 'cancelled'
`,
[workOrderId],
)
const agg = aggResult.rows[0]
const allocated = Number(agg?.allocated || 0)
const joinedCount = Number(agg?.joined_count || 0)
const submittedCount = Number(agg?.submitted_count || 0)
const acceptedCount = Number(agg?.accepted_count || 0)
const total = toPositiveInteger(workOrder.sharing_total_quantity, 1)
const nextStatus =
allocated >= total && joinedCount === 0 && submittedCount > 0 ? 'pending_acceptance' : 'open'
// 已全是 accepted 且母单已验收时不再回退(上面的终态分支已挡住)
if (allocated === 0 && acceptedCount === 0 && workOrder.status === 'open') {
return workOrder
}
if (String(workOrder.status || '') === nextStatus) {
return workOrder
}
const updated = await client.query<WorkOrderRow>(
`
UPDATE work_orders
SET
status = $1,
published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END,
hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END,
submitted_at = CASE
WHEN $1 = 'pending_acceptance' THEN COALESCE(submitted_at, $2)
ELSE submitted_at
END,
updated_at = $2
WHERE id = $3
RETURNING *
`,
[nextStatus, now, workOrderId],
)
return updated.rows[0] || null
}
/** 保存整单验收草稿(暂存图片/说明),不改变工单状态 */
export async function updateWorkOrderDraftAcceptance(input: {
workOrderId: number
@@ -488,13 +615,14 @@ export async function updateWorkOrderAcceptanceRecord(input: {
export async function updateWorkOrderShareDraftAcceptance(input: {
workOrderId: number
workerId: number
shareId: number
draftJson: string
now: string
}): Promise<WorkOrderShareRow | null> {
const result = await query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT}
WHERE wos.work_order_id = $1 AND wos.worker_id = $2`,
[Number(input.workOrderId), Number(input.workerId)],
WHERE wos.id = $3 AND wos.work_order_id = $1 AND wos.worker_id = $2`,
[Number(input.workOrderId), Number(input.workerId), Number(input.shareId)],
)
const share = result.rows[0] || null
if (!share) return null
@@ -513,13 +641,14 @@ export async function updateWorkOrderShareDraftAcceptance(input: {
export async function updateWorkOrderShareAcceptanceRecord(input: {
workOrderId: number
workerId: number
shareId: number
acceptanceJson: string
now: string
}): Promise<WorkOrderShareRow | null> {
const result = await query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT}
WHERE wos.work_order_id = $1 AND wos.worker_id = $2`,
[Number(input.workOrderId), Number(input.workerId)],
WHERE wos.id = $3 AND wos.work_order_id = $1 AND wos.worker_id = $2`,
[Number(input.workOrderId), Number(input.workerId), Number(input.shareId)],
)
const share = result.rows[0] || null
if (!share) return null
@@ -11,6 +11,7 @@ import {
} from './shared.js'
import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js'
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
import { recomputeWorkOrderSharingStatusWithClient } from './work-order-share-repo.js'
import {
getOutstandingDepositAmountWithClient,
getWorkOrderByIdWithClient,
@@ -229,6 +230,8 @@ export async function getPendingCancelRequestOrderIds(
export async function createWorkerCancelRequest(input: {
workOrderId: number
workerId: number
/** 指定份额:只申请撤销这一份;不传(拼单)= 申请撤销我在该工单的全部有效份额 */
shareId?: number | null
reason: string
now: string
}): Promise<{
@@ -254,17 +257,35 @@ export async function createWorkerCancelRequest(input: {
let shareId: number | null = null
if (workOrder.sharing_enabled === true) {
const shareResult = await client.query<WorkOrderShareRow>(
`SELECT * FROM work_order_shares
WHERE work_order_id = $1 AND worker_id = $2
FOR UPDATE`,
[input.workOrderId, input.workerId],
)
const share = shareResult.rows[0] || null
if (!share || !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)) {
return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' }
const requestedShareId = Number(input.shareId || 0)
if (requestedShareId > 0) {
const shareResult = await client.query<WorkOrderShareRow>(
`SELECT * FROM work_order_shares
WHERE id = $3 AND work_order_id = $1 AND worker_id = $2
FOR UPDATE`,
[input.workOrderId, input.workerId, requestedShareId],
)
const share = shareResult.rows[0] || null
if (!share || !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)) {
return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' }
}
shareId = Number(share.id)
} else {
// 整单退单(拼单):至少要有 1 条可撤份额;审核通过时撤销我在该工单的全部有效份额
const sharesResult = await client.query<WorkOrderShareRow>(
`SELECT * FROM work_order_shares
WHERE work_order_id = $1 AND worker_id = $2
FOR UPDATE`,
[input.workOrderId, input.workerId],
)
const cancellable = sharesResult.rows.filter((share) =>
resolveWorkOrderShareCancellationStatus(workOrder.status, share.status),
)
if (cancellable.length === 0) {
return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' }
}
shareId = null
}
shareId = Number(share.id)
} else if (
workOrder.status !== 'in_progress' ||
Number(workOrder.assigned_worker_id || 0) !== input.workerId
@@ -534,6 +555,7 @@ export async function reviewWorkerCancelRequest(input: {
await client.query(
`UPDATE work_order_shares
SET status = 'cancelled',
cancelled_reason = 'worker_request',
acceptance_json = '{}'::jsonb,
draft_acceptance_json = '{}'::jsonb,
submitted_at = NULL,
@@ -542,17 +564,84 @@ export async function reviewWorkerCancelRequest(input: {
WHERE id = $2`,
[input.now, share.id],
)
await client.query(
`UPDATE work_orders
SET status = 'open',
${CLEAR_DELIVERY_RESIDUE_SQL},
${RESET_GIFT_FLOW_SQL},
published_at = COALESCE(published_at, $1),
hall_queued_at = COALESCE(hall_queued_at, $1),
updated_at = $1
WHERE id = $2`,
[input.now, workOrder.id],
await recomputeWorkOrderSharingStatusWithClient(client, workOrder.id, input.now)
} else if (workOrder.sharing_enabled === true) {
// 整单退单(拼单):撤销我在该工单的全部可撤份额,逐份退还(或没收)押金
const sharesResult = await client.query<WorkOrderShareRow>(
`SELECT * FROM work_order_shares
WHERE work_order_id = $1 AND worker_id = $2 AND status IN ('joined', 'submitted')
FOR UPDATE`,
[workOrder.id, request.worker_id],
)
const cancellableShares = sharesResult.rows.filter((share) =>
resolveWorkOrderShareCancellationStatus(workOrder.status, share.status),
)
if (cancellableShares.length === 0) {
return {
request: null,
order: null,
releasedDepositAmount: 0,
levelChanged: false,
accountDisabled: false,
failureReason: 'target_state_changed',
}
}
await ensureWorkerWalletWithClient(client, Number(request.worker_id), input.now)
for (const share of cancellableShares) {
const wallet = await getWorkerWalletWithClient(client, Number(request.worker_id))
const releaseAmount = Math.min(
Number(share.share_deposit || 0),
Number(wallet?.frozen_deposit_amount || 0),
)
if (releaseAmount > 0) {
const nextAvailable =
Number(wallet?.available_amount || 0) + (forfeitGiftDeposit ? 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, updated_at = $3
WHERE worker_id = $4`,
[nextAvailable, nextFrozen, input.now, request.worker_id],
)
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)`,
[
request.worker_id,
forfeitGiftDeposit ? 'deposit_deduction' : 'deposit_release',
forfeitGiftDeposit ? -releaseAmount : releaseAmount,
nextAvailable,
nextFrozen,
request.work_order_id,
forfeitGiftDeposit
? '打手整单退单审核通过扣除拼单押金(已确认加好友)'
: '打手整单退单审核通过退还拼单押金',
JSON.stringify({
requestId: request.id,
shareId: Number(share.id),
reason: request.reason,
}),
input.now,
],
)
releasedDepositAmount += releaseAmount
}
await client.query(
`UPDATE work_order_shares
SET status = 'cancelled',
cancelled_reason = 'worker_request',
acceptance_json = '{}'::jsonb,
draft_acceptance_json = '{}'::jsonb,
submitted_at = NULL,
accepted_at = NULL,
updated_at = $1
WHERE id = $2`,
[input.now, share.id],
)
}
await recomputeWorkOrderSharingStatusWithClient(client, workOrder.id, input.now)
} else {
if (
workOrder.status !== 'in_progress' ||
@@ -317,7 +317,7 @@ export async function getWorkerUserStatistics(
const timeoutResult = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
SELECT COUNT(DISTINCT (e.payload_json->>'workerId', e.work_order_id))::int AS total
FROM work_order_events e
INNER JOIN worker_users wu ON e.payload_json->>'workerId' = wu.id::text
${whereClause ? `${whereClause} AND` : 'WHERE'} e.event_type LIKE 'timeout_%'