add:新增同一个工单同一人可以多次拼单
This commit is contained in:
@@ -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_%'
|
||||
|
||||
@@ -50,7 +50,11 @@ router.post(
|
||||
requireActiveWorker,
|
||||
createRouteHandler(
|
||||
(req) =>
|
||||
grabWorkerHallOrder(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)),
|
||||
grabWorkerHallOrder(
|
||||
String(req.params.workOrderId || ''),
|
||||
getRequiredWorkerSession(req),
|
||||
req.body || {},
|
||||
),
|
||||
{
|
||||
successMessage: '抢单成功',
|
||||
errorMessage: '抢单失败',
|
||||
|
||||
@@ -77,18 +77,25 @@ export async function createWorkerAcceptanceAdminNotification(input: {
|
||||
productName: string
|
||||
workerName: string
|
||||
imageCount: number
|
||||
/** 拼单份额:每份提交各生成一条待办(按 shareId 去重) */
|
||||
shareId?: number | null
|
||||
}) {
|
||||
const shareId = Number(input.shareId || 0)
|
||||
return createConfiguredWorkerPlatformNotification('acceptance_submitted', {
|
||||
notificationType: 'worker_acceptance_submitted',
|
||||
priority: 'normal',
|
||||
entityType: 'work_order',
|
||||
entityId: input.workOrderId,
|
||||
dedupeKey: acceptanceNotificationKey(input.workOrderId),
|
||||
dedupeKey:
|
||||
shareId > 0
|
||||
? acceptanceNotificationKey(input.workOrderId, shareId)
|
||||
: acceptanceNotificationKey(input.workOrderId),
|
||||
title: '有订单待验收',
|
||||
body: [
|
||||
`订单:${input.workOrderNo || input.workOrderId}`,
|
||||
`商品:${input.productName || '-'}`,
|
||||
`打手:${input.workerName || '-'}`,
|
||||
...(shareId > 0 ? [`份额:#${shareId}`] : []),
|
||||
`验收图片:${input.imageCount} 张`,
|
||||
].join(';'),
|
||||
actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`,
|
||||
@@ -96,6 +103,28 @@ export async function createWorkerAcceptanceAdminNotification(input: {
|
||||
})
|
||||
}
|
||||
|
||||
/** 整单(含拼单全部份额)验收完成:单独一条完成通知,不与待验收待办混用。 */
|
||||
export async function createWorkOrderFullyAcceptedAdminNotification(input: {
|
||||
workOrderId: number
|
||||
workOrderNo: string
|
||||
productName: string
|
||||
}) {
|
||||
return createConfiguredWorkerPlatformNotification('order_accepted', {
|
||||
notificationType: 'worker_order_accepted',
|
||||
priority: 'normal',
|
||||
entityType: 'work_order',
|
||||
entityId: input.workOrderId,
|
||||
dedupeKey: `work_order_accepted:${input.workOrderId}`,
|
||||
title: '订单已全部验收',
|
||||
body: [
|
||||
`订单:${input.workOrderNo || input.workOrderId}`,
|
||||
`商品:${input.productName || '-'}`,
|
||||
].join(';'),
|
||||
actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`,
|
||||
now: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function createWorkerCancelRequestAdminNotification(input: {
|
||||
requestId: number
|
||||
workOrderId: number
|
||||
@@ -279,8 +308,10 @@ async function createConfiguredWorkerPlatformNotification(
|
||||
return notification
|
||||
}
|
||||
|
||||
function acceptanceNotificationKey(workOrderId: number) {
|
||||
return `work_order_acceptance:${workOrderId}`
|
||||
function acceptanceNotificationKey(workOrderId: number, shareId = 0) {
|
||||
return shareId > 0
|
||||
? `work_order_acceptance:${workOrderId}:share:${shareId}`
|
||||
: `work_order_acceptance:${workOrderId}`
|
||||
}
|
||||
|
||||
function formatAmount(amountFen: number) {
|
||||
|
||||
@@ -11,7 +11,10 @@ import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { getWorkerFinanceConfig } from './worker-finance-config-service.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
|
||||
import {
|
||||
createWorkOrderFullyAcceptedAdminNotification,
|
||||
resolveAdminNotificationEntity,
|
||||
} from '../admin/admin-notification-service.js'
|
||||
import {
|
||||
publishWorkerWalletRealtimeChange,
|
||||
publishWorkOrderRealtimeChange,
|
||||
@@ -80,6 +83,11 @@ export async function acceptAdminWorkOrder(workOrderId: number | string, actorNa
|
||||
})
|
||||
}
|
||||
await resolveAdminNotificationEntity('work_order', Number(workOrder.id), 'accepted')
|
||||
await createWorkOrderFullyAcceptedAdminNotification({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workOrderNo: workOrder.work_order_no,
|
||||
productName: workOrder.product_name,
|
||||
})
|
||||
const workerIds = await resolveWorkOrderRealtimeWorkerIds(workOrder)
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), workerIds })
|
||||
for (const workerId of workerIds) publishWorkerWalletRealtimeChange(workerId)
|
||||
@@ -119,6 +127,11 @@ export async function acceptAdminWorkOrderShare(
|
||||
if (workerId > 0) publishWorkerWalletRealtimeChange(workerId)
|
||||
if (result.order.status === WORK_ORDER_STATUS.ACCEPTED) {
|
||||
await resolveAdminNotificationEntity('work_order', Number(workOrderId), 'accepted')
|
||||
await createWorkOrderFullyAcceptedAdminNotification({
|
||||
workOrderId: Number(workOrderId),
|
||||
workOrderNo: result.order.work_order_no,
|
||||
productName: result.order.product_name,
|
||||
})
|
||||
}
|
||||
return { order: mapWorkOrderAdmin(result.order), share: mapWorkOrderShare(result.share) }
|
||||
}
|
||||
|
||||
@@ -423,6 +423,8 @@ export function mapWorkOrderAdmin(
|
||||
|
||||
export type WorkerOrderMapOptions = {
|
||||
isTimedOutForMe?: boolean
|
||||
/** 同一打手在该工单的全部有效份额;不传时回退为 myShare 单条 */
|
||||
myShares?: WorkOrderShareRow[]
|
||||
timeoutNotice?:
|
||||
| {
|
||||
isTimedOut: boolean
|
||||
@@ -435,6 +437,72 @@ export type WorkerOrderMapOptions = {
|
||||
| undefined
|
||||
}
|
||||
|
||||
/** 同一打手在一个工单里的多份份额汇总(列表一行一工单展示用)。 */
|
||||
export type WorkerShareSummary = {
|
||||
quantity: number
|
||||
rewardAmount: number
|
||||
depositAmount: number
|
||||
firstJoinedAt: string | null
|
||||
/** 未完成份额里最早的截止时间;用于父行倒计时,避免只看到其中一份。 */
|
||||
earliestActiveDeadlineAt: string | null
|
||||
}
|
||||
|
||||
export function summarizeWorkerShares(
|
||||
shares: WorkOrderShareRow[],
|
||||
fallbackTimeoutMinutes = 0,
|
||||
): WorkerShareSummary {
|
||||
let quantity = 0
|
||||
let rewardAmount = 0
|
||||
let depositAmount = 0
|
||||
/** 有有效份额时取第一份有效份额的参与时间;只剩已取消份额时回退到最早的取消份额。 */
|
||||
let firstActiveJoinedAtMs = Number.POSITIVE_INFINITY
|
||||
let firstAnyJoinedAtMs = Number.POSITIVE_INFINITY
|
||||
let earliestDeadlineMs = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const share of shares) {
|
||||
const createdAtMs = Date.parse(String(share.created_at || ''))
|
||||
if (Number.isFinite(createdAtMs) && createdAtMs < firstAnyJoinedAtMs) {
|
||||
firstAnyJoinedAtMs = createdAtMs
|
||||
}
|
||||
|
||||
if (share.status === 'cancelled') continue
|
||||
|
||||
if (Number.isFinite(createdAtMs) && createdAtMs < firstActiveJoinedAtMs) {
|
||||
firstActiveJoinedAtMs = createdAtMs
|
||||
}
|
||||
|
||||
quantity += Number(share.quantity || 0)
|
||||
rewardAmount += Number(share.share_reward || 0)
|
||||
depositAmount += Number(share.share_deposit || 0)
|
||||
|
||||
if (share.status === 'joined') {
|
||||
const timeoutMinutes = Number(share.timeout_minutes || fallbackTimeoutMinutes || 0)
|
||||
const deadlineMs = share.deadline_at
|
||||
? Date.parse(String(share.deadline_at))
|
||||
: Number.isFinite(createdAtMs) && timeoutMinutes > 0
|
||||
? createdAtMs + timeoutMinutes * 60_000
|
||||
: Number.NaN
|
||||
if (Number.isFinite(deadlineMs) && deadlineMs < earliestDeadlineMs) {
|
||||
earliestDeadlineMs = deadlineMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
quantity,
|
||||
rewardAmount,
|
||||
depositAmount,
|
||||
firstJoinedAt: Number.isFinite(firstActiveJoinedAtMs)
|
||||
? new Date(firstActiveJoinedAtMs).toISOString()
|
||||
: Number.isFinite(firstAnyJoinedAtMs)
|
||||
? new Date(firstAnyJoinedAtMs).toISOString()
|
||||
: null,
|
||||
earliestActiveDeadlineAt: Number.isFinite(earliestDeadlineMs)
|
||||
? new Date(earliestDeadlineMs).toISOString()
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapWorkOrderForWorker(
|
||||
workOrder: WorkOrderRow,
|
||||
permissions: { depositFreeAmount: number },
|
||||
@@ -461,35 +529,47 @@ export function mapWorkOrderForWorker(
|
||||
const personalStatus = isTimedOutForMe
|
||||
? WORK_ORDER_STATUS.OPEN
|
||||
: resolveWorkerShareStatus(workOrder.status, myShare?.status)
|
||||
|
||||
const workerShares =
|
||||
options?.myShares && options.myShares.length > 0 ? options.myShares : myShare ? [myShare] : []
|
||||
const shareSummary = summarizeWorkerShares(workerShares, Number(workOrder.timeout_minutes || 0))
|
||||
const hasActiveShares = workerShares.some((share) => share.status !== 'cancelled')
|
||||
|
||||
return {
|
||||
...visibleOrder,
|
||||
productName: myShare
|
||||
? resolveWorkerShareProductName(workOrder.product_name, Number(myShare.quantity || 0))
|
||||
productName: hasActiveShares
|
||||
? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity)
|
||||
: visibleOrder.productName,
|
||||
status: personalStatus,
|
||||
rewardAmount: isTimedOutForMe
|
||||
? 0
|
||||
: myShare
|
||||
? Number(myShare.share_reward || 0)
|
||||
: hasActiveShares
|
||||
? shareSummary.rewardAmount
|
||||
: visibleOrder.rewardAmount,
|
||||
freezeDepositAmount: isTimedOutForMe
|
||||
? 0
|
||||
: myShare
|
||||
? Number(myShare.share_deposit || 0)
|
||||
: hasActiveShares
|
||||
? shareSummary.depositAmount
|
||||
: resolveFreezeDepositAmount(workOrder, permissions),
|
||||
/** 用于大厅拼单按当前剩余份数准确计算个人免押后的押金。 */
|
||||
depositFreeAmount: Number(permissions.depositFreeAmount || 0),
|
||||
assignedAt: myShare?.created_at || visibleOrder.assignedAt,
|
||||
assignedAt: shareSummary.firstJoinedAt || visibleOrder.assignedAt,
|
||||
submittedAt: myShare?.submitted_at || visibleOrder.submittedAt,
|
||||
acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt,
|
||||
deadlineAt:
|
||||
myShare && Number(workOrder.timeout_minutes || 0) > 0
|
||||
? new Date(
|
||||
new Date(myShare.created_at).getTime() + Number(workOrder.timeout_minutes) * 60 * 1000,
|
||||
).toISOString()
|
||||
: visibleOrder.deadlineAt,
|
||||
deadlineAt: isTimedOutForMe
|
||||
? visibleOrder.deadlineAt
|
||||
: shareSummary.earliestActiveDeadlineAt ||
|
||||
(myShare && Number(workOrder.timeout_minutes || 0) > 0
|
||||
? String(myShare.deadline_at || '') ||
|
||||
new Date(
|
||||
new Date(myShare.created_at).getTime() +
|
||||
Number(workOrder.timeout_minutes) * 60 * 1000,
|
||||
).toISOString()
|
||||
: visibleOrder.deadlineAt),
|
||||
workerNote,
|
||||
myShare: myShare ? mapWorkOrderShare(myShare) : null,
|
||||
/** 同一打手在该工单的全部有效份额(一行一工单 + 展开份额) */
|
||||
myShares: workerShares.map(mapWorkOrderShare),
|
||||
...(viewedAt === undefined ? {} : { isUnread: !viewedAt }),
|
||||
...(options?.timeoutNotice ? { timeoutNotice: options.timeoutNotice } : {}),
|
||||
}
|
||||
@@ -533,6 +613,11 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
||||
acceptedAt: share.accepted_at,
|
||||
createdAt: share.created_at,
|
||||
updatedAt: share.updated_at,
|
||||
/** 本份额独立截止时间(参与时固化);null = 不限时 */
|
||||
deadlineAt: share.deadline_at || null,
|
||||
timeoutMinutes: Number(share.timeout_minutes || 0),
|
||||
timeoutPolicy: share.timeout_policy || '',
|
||||
cancelledReason: share.cancelled_reason || '',
|
||||
worker: share.worker_id
|
||||
? {
|
||||
workerId: Number(share.worker_id),
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
acceptWorkOrderShareAndSettle,
|
||||
countWorkOrderPendingSharingSubmissions,
|
||||
createWorkOrderEvent,
|
||||
getWorkOrderShare,
|
||||
listWorkOrderSharesByWorkerInOrder,
|
||||
settleOverdueWorkOrder,
|
||||
settleOverdueWorkOrderShare,
|
||||
submitWorkOrderShareAcceptance,
|
||||
@@ -74,9 +74,21 @@ export async function submitWorkerOrderAcceptance(
|
||||
const workerId = Number(session.workerId)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
if (!options.asConsignmentSupplier) {
|
||||
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
|
||||
if (sharingShare) {
|
||||
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
|
||||
const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload)
|
||||
if (target.error === 'required') {
|
||||
throw createHttpError('该工单有多条拼单份额,请指定 shareId', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_share_id_required',
|
||||
})
|
||||
}
|
||||
if (target.error === 'not_found') {
|
||||
throw createHttpError('未找到该拼单份额', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_sharing_share_not_found',
|
||||
})
|
||||
}
|
||||
if (target.share) {
|
||||
return submitWorkerSharingAcceptance(workOrder, target.share, payload, session)
|
||||
}
|
||||
}
|
||||
const canAutoAcceptWithoutEvidence = isFriendGiftAcceptanceMode(workOrder.acceptance_mode)
|
||||
@@ -340,9 +352,21 @@ export async function supplementWorkerAcceptedOrderEvidence(
|
||||
requireActiveWorkerSession(session)
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const sharingShare = await getWorkOrderShare(workOrder.id, session.workerId)
|
||||
if (sharingShare) {
|
||||
return supplementWorkerAcceptedSharingEvidence(workOrder, sharingShare, payload, session)
|
||||
const target = await resolveWorkerTargetShare(workOrder.id, session.workerId, payload)
|
||||
if (target.error === 'required') {
|
||||
throw createHttpError('该工单有多条拼单份额,请指定 shareId', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_share_id_required',
|
||||
})
|
||||
}
|
||||
if (target.error === 'not_found') {
|
||||
throw createHttpError('未找到该拼单份额', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_sharing_share_not_found',
|
||||
})
|
||||
}
|
||||
if (target.share) {
|
||||
return supplementWorkerAcceptedSharingEvidence(workOrder, target.share, payload, session)
|
||||
}
|
||||
if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) {
|
||||
throw createHttpError('只能补充自己的订单验收图片', {
|
||||
@@ -411,16 +435,23 @@ async function submitWorkerSharingAcceptance(
|
||||
})
|
||||
}
|
||||
const now = nowIso()
|
||||
const timeoutMinutes = Number(workOrder.timeout_minutes || 0)
|
||||
const timeoutMinutes = Number(sharingShare.timeout_minutes ?? workOrder.timeout_minutes ?? 0)
|
||||
const snapshotDeadline = String(sharingShare.deadline_at || '').trim()
|
||||
const deadlineMs = snapshotDeadline
|
||||
? new Date(snapshotDeadline).getTime()
|
||||
: new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000
|
||||
if (
|
||||
sharingShare.status === 'joined' &&
|
||||
timeoutMinutes > 0 &&
|
||||
new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000 <= Date.now()
|
||||
Number.isFinite(deadlineMs) &&
|
||||
deadlineMs <= Date.now()
|
||||
) {
|
||||
await settleOverdueWorkOrderShare({
|
||||
workOrderId: workOrder.id,
|
||||
shareId: sharingShare.id,
|
||||
policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy),
|
||||
policy: normalizeWorkOrderTimeoutPolicy(
|
||||
sharingShare.timeout_policy || workOrder.timeout_policy,
|
||||
),
|
||||
now,
|
||||
})
|
||||
publishWorkOrderRealtimeChange({
|
||||
@@ -463,6 +494,7 @@ async function submitWorkerSharingAcceptance(
|
||||
const result = await submitWorkOrderShareAcceptance({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(session.workerId),
|
||||
shareId: Number(sharingShare.id),
|
||||
acceptanceJson: JSON.stringify(acceptance),
|
||||
now,
|
||||
})
|
||||
@@ -509,6 +541,14 @@ async function submitWorkerSharingAcceptance(
|
||||
sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)),
|
||||
}
|
||||
}
|
||||
await createWorkerAcceptanceAdminNotification({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workOrderNo: workOrder.work_order_no,
|
||||
productName: workOrder.product_name,
|
||||
workerName: worker.display_name || worker.username,
|
||||
imageCount: acceptance.imageUrls.length,
|
||||
shareId: Number(sharingShare.id),
|
||||
})
|
||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||
workerIds: [Number(session.workerId)],
|
||||
})
|
||||
@@ -552,6 +592,7 @@ async function supplementWorkerAcceptedSharingEvidence(
|
||||
const updated = await updateWorkOrderShareAcceptanceRecord({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(session.workerId),
|
||||
shareId: Number(sharingShare.id),
|
||||
acceptanceJson: JSON.stringify(acceptance),
|
||||
now,
|
||||
})
|
||||
@@ -575,7 +616,33 @@ async function supplementWorkerAcceptedSharingEvidence(
|
||||
return { share: mapWorkOrderShare(updated || sharingShare) }
|
||||
}
|
||||
|
||||
/** 从提交或草稿载荷中解析验收图片与说明。 */
|
||||
/**
|
||||
* 定位本次操作要处理的份额:
|
||||
* - 传了 shareId:必须是本人该工单下的有效份额(否则 not_found)
|
||||
* - 没传且只有 1 条:直接用
|
||||
* - 没传且有多条:要求前端明确指定(required)
|
||||
* - 没有任何份额:返回空(走整单逻辑)
|
||||
*/
|
||||
async function resolveWorkerTargetShare(
|
||||
workOrderId: number,
|
||||
workerId: number | string,
|
||||
payload: JsonObject,
|
||||
): Promise<{ share: WorkOrderShareRow | null; error: 'required' | 'not_found' | null }> {
|
||||
const requested = Number(payload.shareId ?? payload.workOrderShareId ?? 0)
|
||||
const shares = await listWorkOrderSharesByWorkerInOrder(workOrderId, workerId)
|
||||
if (Number.isFinite(requested) && requested > 0) {
|
||||
const found = shares.find((share) => Number(share.id) === requested) || null
|
||||
return { share: found, error: found ? null : 'not_found' }
|
||||
}
|
||||
if (shares.length === 0) {
|
||||
return { share: null, error: null }
|
||||
}
|
||||
if (shares.length === 1) {
|
||||
return { share: shares[0] ?? null, error: null }
|
||||
}
|
||||
return { share: null, error: 'required' }
|
||||
}
|
||||
|
||||
function resolveAcceptanceImages(payload: JsonObject, draftValue: unknown) {
|
||||
const files = normalizeUploadedFiles(payload.files)
|
||||
const imageUrls = [
|
||||
@@ -606,9 +673,21 @@ export async function saveWorkerOrderAcceptanceDraft(
|
||||
requireActiveWorkerSession(session)
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const workerId = Number(session.workerId)
|
||||
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
|
||||
if (sharingShare) {
|
||||
return saveWorkerSharingAcceptanceDraft(workOrder, sharingShare, payload, session)
|
||||
const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload)
|
||||
if (target.error === 'required') {
|
||||
throw createHttpError('该工单有多条拼单份额,请指定 shareId', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_share_id_required',
|
||||
})
|
||||
}
|
||||
if (target.error === 'not_found') {
|
||||
throw createHttpError('未找到该拼单份额', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_sharing_share_not_found',
|
||||
})
|
||||
}
|
||||
if (target.share) {
|
||||
return saveWorkerSharingAcceptanceDraft(workOrder, target.share, payload, session)
|
||||
}
|
||||
if (Number(workOrder.assigned_worker_id || 0) !== workerId) {
|
||||
throw createHttpError('只能操作自己的订单', {
|
||||
@@ -728,6 +807,7 @@ async function saveWorkerSharingAcceptanceDraft(
|
||||
const share = await updateWorkOrderShareAcceptanceRecord({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(session.workerId),
|
||||
shareId: Number(sharingShare.id),
|
||||
acceptanceJson: JSON.stringify(acceptance),
|
||||
now,
|
||||
})
|
||||
@@ -765,6 +845,7 @@ async function saveWorkerSharingAcceptanceDraft(
|
||||
const share = await updateWorkOrderShareDraftAcceptance({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(session.workerId),
|
||||
shareId: Number(sharingShare.id),
|
||||
draftJson: JSON.stringify(draft),
|
||||
now,
|
||||
})
|
||||
|
||||
@@ -30,12 +30,17 @@ import {
|
||||
publishWorkOrderRealtimeChange,
|
||||
} from '../realtime/realtime-event-service.js'
|
||||
|
||||
export async function grabWorkerHallOrder(workOrderId: number | string, session: WorkerSession) {
|
||||
export async function grabWorkerHallOrder(
|
||||
workOrderId: number | string,
|
||||
session: WorkerSession,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
requireActiveWorkerSession(session)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const permissions = resolveWorkerPermissions(worker)
|
||||
const hallConfig = getWorkerHallConfig()
|
||||
const now = nowIso()
|
||||
const requestId = String(payload.requestId || '').trim()
|
||||
const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0)
|
||||
const visibleAfterIso =
|
||||
visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : ''
|
||||
@@ -58,6 +63,7 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
|
||||
hallCandidateLimit: hallConfig.maxVisibleOrders,
|
||||
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
|
||||
visibleAfterIso,
|
||||
...(requestId ? { requestId } : {}),
|
||||
now,
|
||||
})
|
||||
if (!result.share) {
|
||||
@@ -68,7 +74,11 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
|
||||
hallChanged: true,
|
||||
walletChangedWorkerIds: [Number(worker.id)],
|
||||
})
|
||||
return { order: mapWorkOrderForWorker(workOrder, permissions, result.share) }
|
||||
return {
|
||||
order: mapWorkOrderForWorker(workOrder, permissions, result.share),
|
||||
share: mapWorkOrderShare(result.share),
|
||||
duplicated: result.duplicated,
|
||||
}
|
||||
}
|
||||
|
||||
const activeCount = await countWorkerActiveOrders(worker.id)
|
||||
@@ -139,6 +149,7 @@ export async function joinWorkerSharingOrder(
|
||||
const visibleAfterIso =
|
||||
visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : ''
|
||||
const quantity = normalizePositiveInteger(payload.quantity, 1)
|
||||
const requestId = String(payload.requestId || '').trim()
|
||||
const now = nowIso()
|
||||
const result = await joinWorkOrderShare({
|
||||
workOrderId: Number(workOrderId),
|
||||
@@ -149,6 +160,7 @@ export async function joinWorkerSharingOrder(
|
||||
hallCandidateLimit: hallConfig.maxVisibleOrders,
|
||||
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
|
||||
visibleAfterIso,
|
||||
...(requestId ? { requestId } : {}),
|
||||
now,
|
||||
})
|
||||
if (!result.share) {
|
||||
@@ -159,7 +171,7 @@ export async function joinWorkerSharingOrder(
|
||||
hallChanged: true,
|
||||
walletChangedWorkerIds: [Number(worker.id)],
|
||||
})
|
||||
return { share: mapWorkOrderShare(result.share) }
|
||||
return { share: mapWorkOrderShare(result.share), duplicated: result.duplicated }
|
||||
}
|
||||
|
||||
function resolveWorkOrderDeadlineAt(workOrder: WorkOrderRow, now: string): string | null {
|
||||
|
||||
@@ -118,8 +118,14 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
|
||||
sort: 'worker_claimed_at_desc',
|
||||
})
|
||||
const workOrderIds = items.map((item) => Number(item.id))
|
||||
const myShares = await listWorkerSharesByWorker(worker.id, workOrderIds)
|
||||
const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share]))
|
||||
const workerShares = await listWorkerSharesByWorker(worker.id, workOrderIds)
|
||||
const sharesByOrderId = new Map<number, typeof workerShares>()
|
||||
for (const share of workerShares) {
|
||||
const orderId = Number(share.work_order_id)
|
||||
const current = sharesByOrderId.get(orderId) || []
|
||||
current.push(share)
|
||||
sharesByOrderId.set(orderId, current)
|
||||
}
|
||||
const [
|
||||
notes,
|
||||
views,
|
||||
@@ -152,15 +158,20 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
|
||||
return {
|
||||
items: items.map((item) => {
|
||||
const workOrderId = Number(item.id)
|
||||
const activeShare = myShareByOrderId.get(workOrderId)
|
||||
const activeShares = sharesByOrderId.get(workOrderId) || []
|
||||
// 主份额用于列表摘要:优先未完成(joined/submitted),否则最新一条
|
||||
const activeShare =
|
||||
activeShares.find((share) => ['joined', 'submitted'].includes(String(share.status))) ||
|
||||
activeShares[0] ||
|
||||
null
|
||||
const cancelledShare = cancelledShareByOrderId.get(workOrderId)
|
||||
const timeoutEvent = timeoutEventsByOrderId.get(workOrderId)
|
||||
|
||||
// 判断当前打手是否仍持有该单
|
||||
// 判断当前打手是否仍持有该单(任一有效份额即算持有)
|
||||
const isOwnerCurrently =
|
||||
(item.assigned_worker_id != null &&
|
||||
Number(item.assigned_worker_id) === Number(worker.id)) ||
|
||||
Boolean(activeShare)
|
||||
activeShares.length > 0
|
||||
|
||||
// 若不是当前持有人,但有该打手的超时事件或取消份额,则判定为该打手的已超时历史单
|
||||
const isTimedOutForMe =
|
||||
@@ -211,7 +222,7 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
|
||||
activeShare || cancelledShare || null,
|
||||
noteByOrderId.get(workOrderId) || '',
|
||||
viewedAtByOrderId.get(workOrderId) || null,
|
||||
{ isTimedOutForMe, timeoutNotice },
|
||||
{ isTimedOutForMe, timeoutNotice, myShares: activeShares },
|
||||
),
|
||||
// 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。
|
||||
problemMarked: !isTimedOutForMe && item.status === 'problem',
|
||||
@@ -372,9 +383,13 @@ export async function submitWorkerCancelRequest(
|
||||
})
|
||||
}
|
||||
const now = nowIso()
|
||||
const requestedShareId = Number(payload.shareId ?? payload.workOrderShareId ?? 0)
|
||||
const shareId =
|
||||
Number.isFinite(requestedShareId) && requestedShareId > 0 ? requestedShareId : null
|
||||
const result = await createWorkerCancelRequest({
|
||||
workOrderId: Number(workOrderId),
|
||||
workerId: Number(session.workerId),
|
||||
shareId,
|
||||
reason,
|
||||
now,
|
||||
})
|
||||
|
||||
+2
@@ -7,6 +7,7 @@ export const WORKER_PLATFORM_NOTIFICATION_EVENT_KEYS = [
|
||||
'recharge_requested',
|
||||
'acceptance_submitted',
|
||||
'acceptance_reminded',
|
||||
'order_accepted',
|
||||
'material_required',
|
||||
'cancel_requested',
|
||||
'feedback_submitted',
|
||||
@@ -48,6 +49,7 @@ export function createDefaultWorkerPlatformNotificationConfig(): WorkerPlatformN
|
||||
recharge_requested: createEnabledEventConfig(),
|
||||
acceptance_submitted: createEnabledEventConfig(),
|
||||
acceptance_reminded: createEnabledEventConfig(),
|
||||
order_accepted: createEnabledEventConfig(),
|
||||
material_required: {
|
||||
todoEnabled: false,
|
||||
soundEnabled: false,
|
||||
|
||||
@@ -18,10 +18,11 @@ import {
|
||||
buildWorkOrderWhere,
|
||||
buildWorkerUserWhere,
|
||||
resolveWorkOrderShareCancellationStatus,
|
||||
resolveWorkOrderShareDepositAmount,
|
||||
resolveWorkOrderShareDepositPlan,
|
||||
resolveWorkOrderSharingCancellationPlan,
|
||||
resolveWorkOrderShareJoinQuantity,
|
||||
resolveOpenSharingSyncReward,
|
||||
recomputeWorkOrderSharingStatusWithClient,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import { resolveProblemReturnToWorkerStatus } from '../../repositories/worker-platform/work-order-issue-repo.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||
@@ -1471,25 +1472,63 @@ test('个人免押额度与等级免押额度叠加', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('拼单按打手累计份额扣除个人免押额度', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderShareDepositAmount({
|
||||
test('拼单按打手在母单内的累计份额扣除免押额度', () => {
|
||||
// 首次参与 2 份:累计需求 2000,本次冻结 2000
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderShareDepositPlan({
|
||||
requiredDepositAmount: 10_000,
|
||||
totalQuantity: 10,
|
||||
nextQuantity: 5,
|
||||
depositFreeAmount: 3_000,
|
||||
quantityBefore: 0,
|
||||
nextQuantity: 2,
|
||||
depositFreeAmount: 0,
|
||||
}),
|
||||
2_000,
|
||||
{ cumulativeRequired: 2_000, cumulativeDeposit: 2_000, increment: 2_000 },
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderShareDepositAmount({
|
||||
|
||||
// 已冻结 1500 后再追加 1 份:累计需求 2000,只补差额 500,不回退
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderShareDepositPlan({
|
||||
requiredDepositAmount: 10_000,
|
||||
totalQuantity: 10,
|
||||
nextQuantity: 5,
|
||||
depositFreeAmount: 8_000,
|
||||
currentFrozenDeposit: 1_500,
|
||||
quantityBefore: 1,
|
||||
nextQuantity: 1,
|
||||
depositFreeAmount: 0,
|
||||
frozenDepositSoFar: 1_500,
|
||||
}),
|
||||
1_500,
|
||||
{ cumulativeRequired: 2_000, cumulativeDeposit: 2_000, increment: 500 },
|
||||
)
|
||||
})
|
||||
|
||||
test('拼单免押额度跟着母单走,在整个工单内只抵扣一次', () => {
|
||||
const base = {
|
||||
requiredDepositAmount: 10_000,
|
||||
totalQuantity: 10,
|
||||
depositFreeAmount: 5_000,
|
||||
}
|
||||
// 累计 2 份:需求 2000 < 免押 5000,不用冻结
|
||||
assert.equal(
|
||||
resolveWorkOrderShareDepositPlan({ ...base, quantityBefore: 0, nextQuantity: 2 }).increment,
|
||||
0,
|
||||
)
|
||||
// 累计 4 份:需求 4000 仍低于免押额度,继续不冻结(免押额没有被重复消耗)
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderShareDepositPlan({
|
||||
...base,
|
||||
quantityBefore: 2,
|
||||
nextQuantity: 2,
|
||||
frozenDepositSoFar: 0,
|
||||
}),
|
||||
{ cumulativeRequired: 4_000, cumulativeDeposit: 0, increment: 0 },
|
||||
)
|
||||
// 累计 6 份:需求 6000 超出免押 5000,仅对超出的 1000 冻结
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderShareDepositPlan({
|
||||
...base,
|
||||
quantityBefore: 4,
|
||||
nextQuantity: 2,
|
||||
frozenDepositSoFar: 0,
|
||||
}),
|
||||
{ cumulativeRequired: 6_000, cumulativeDeposit: 1_000, increment: 1_000 },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1759,7 +1798,10 @@ test('打手订单状态筛选使用个人拼单份额状态', () => {
|
||||
|
||||
assert.deepEqual(params, [42, ['in_progress', 'pending_acceptance']])
|
||||
assert.match(joins, /work_order_shares share_source/)
|
||||
assert.match(joins, /worker_share\.worker_id = \$1/)
|
||||
// 份额关联改为按工单取一条代表份额,避免多份拼单把列表/统计行放大。
|
||||
assert.match(joins, /LEFT JOIN LATERAL \(/)
|
||||
assert.match(joins, /wos\.worker_id = \$1/)
|
||||
assert.match(joins, /\) worker_share ON true/)
|
||||
assert.match(whereClause, /WHEN 'joined' THEN 'in_progress'/)
|
||||
assert.match(whereClause, /WHEN 'submitted' THEN 'pending_acceptance'/)
|
||||
assert.match(whereClause, /worker_share\.status/)
|
||||
@@ -3185,3 +3227,131 @@ test('拼单超时处置链路完整性验证', async () => {
|
||||
'settleOverdueWorkOrderShare 必须将超时份额置为 cancelled 并释放/扣除押金',
|
||||
)
|
||||
})
|
||||
|
||||
type ShareAggregate = {
|
||||
allocated: number
|
||||
joined_count: number
|
||||
submitted_count: number
|
||||
accepted_count: number
|
||||
}
|
||||
|
||||
async function runRecompute(
|
||||
workOrder: Partial<WorkOrderRow>,
|
||||
aggregate: ShareAggregate,
|
||||
): Promise<{ status: string | null; updates: unknown[][]; shareQueries: number }> {
|
||||
const updates: unknown[][] = []
|
||||
let shareQueries = 0
|
||||
const client = {
|
||||
query: async (sql: string, params: unknown[] = []) => {
|
||||
if (sql.includes('UPDATE work_orders')) {
|
||||
updates.push(params)
|
||||
return { rows: [{ ...buildWorkOrderRow(workOrder), status: params[0] }] }
|
||||
}
|
||||
if (sql.includes('FROM work_orders')) {
|
||||
return { rows: [buildWorkOrderRow(workOrder)] }
|
||||
}
|
||||
if (sql.includes('FROM work_order_shares')) {
|
||||
shareQueries += 1
|
||||
return { rows: [aggregate] }
|
||||
}
|
||||
return { rows: [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
const updated = await recomputeWorkOrderSharingStatusWithClient(
|
||||
client,
|
||||
1,
|
||||
'2026-10-23T12:00:00.000Z',
|
||||
)
|
||||
return { status: updated ? String(updated.status) : null, updates, shareQueries }
|
||||
}
|
||||
|
||||
test('拼单母单状态由份额集合唯一推导:满额且无待履约份额才转待验收', async () => {
|
||||
const pending = await runRecompute(
|
||||
{ status: 'open', sharing_total_quantity: 10 },
|
||||
{ allocated: 10, joined_count: 0, submitted_count: 2, accepted_count: 0 },
|
||||
)
|
||||
assert.equal(pending.status, 'pending_acceptance')
|
||||
assert.equal(pending.updates.length, 1)
|
||||
|
||||
// 一份超时后另一份仍在履约(joined_count > 0):母单回到 open,不能提前待验收
|
||||
const stillOpen = await runRecompute(
|
||||
{ status: 'pending_acceptance', sharing_total_quantity: 10 },
|
||||
{ allocated: 9, joined_count: 1, submitted_count: 1, accepted_count: 0 },
|
||||
)
|
||||
assert.equal(stillOpen.status, 'open')
|
||||
assert.equal(stillOpen.updates.length, 1)
|
||||
|
||||
// 未满额也必须是 open
|
||||
const partial = await runRecompute(
|
||||
{ status: 'pending_acceptance', sharing_total_quantity: 10 },
|
||||
{ allocated: 4, joined_count: 0, submitted_count: 1, accepted_count: 0 },
|
||||
)
|
||||
assert.equal(partial.status, 'open')
|
||||
})
|
||||
|
||||
test('拼单母单已验收或已取消时 recompute 不回头改状态', async () => {
|
||||
for (const status of ['accepted', 'cancelled']) {
|
||||
const result = await runRecompute(
|
||||
{ status, sharing_total_quantity: 10 },
|
||||
{ allocated: 10, joined_count: 0, submitted_count: 0, accepted_count: 3 },
|
||||
)
|
||||
assert.equal(result.status, status)
|
||||
assert.equal(result.updates.length, 0)
|
||||
assert.equal(result.shareQueries, 0, '终态母单连份额聚合都不该再跑')
|
||||
}
|
||||
})
|
||||
|
||||
test('份额超时/撤单只改份额本身,母单状态统一交给 recompute', () => {
|
||||
const src = fs.readFileSync(
|
||||
new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const blocks = [
|
||||
[
|
||||
'export async function cancelWorkOrderShare(',
|
||||
'export async function settleOverdueWorkOrderShare(',
|
||||
],
|
||||
[
|
||||
'export async function settleOverdueWorkOrderShare(',
|
||||
'export async function cancelWorkOrderSharing(',
|
||||
],
|
||||
]
|
||||
for (const [start, end] of blocks) {
|
||||
const block = src.slice(src.indexOf(start!), src.indexOf(end!))
|
||||
assert.ok(block.length > 0, `${start} 片段必须存在`)
|
||||
assert.ok(
|
||||
block.includes('recomputeWorkOrderSharingStatusWithClient('),
|
||||
`${start} 必须用 recompute 收敛母单状态`,
|
||||
)
|
||||
assert.ok(
|
||||
!/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(block),
|
||||
`${start} 不得自己写母单 status(只能由 recompute 推导)`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参与时间固化截止时间', () => {
|
||||
const src = fs.readFileSync(
|
||||
new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
src.includes('wos.worker_id = $1 AND wos.request_id = $2'),
|
||||
'参与前必须按 (worker_id, request_id) 查既有份额',
|
||||
)
|
||||
assert.ok(src.includes('duplicated: true'), '命中幂等键必须返回 duplicated 标记')
|
||||
assert.ok(
|
||||
src.includes('INSERT INTO work_order_shares (') &&
|
||||
!/UPDATE\s+work_order_shares[\s\S]{0,120}?quantity\s*=\s*quantity\s*\+/i.test(src),
|
||||
'每次参与都必须 INSERT 新份额,不得把份数加到原记录上',
|
||||
)
|
||||
assert.ok(
|
||||
src.includes('timeoutMinutes * 60 * 1000') && src.includes('deadline_at, timeout_minutes'),
|
||||
'deadline 必须按本次参与时间 + 母单当时的 timeout_minutes 固化',
|
||||
)
|
||||
assert.ok(
|
||||
src.includes('quantityBefore') && src.includes('frozenDepositSoFar: frozenBefore'),
|
||||
'押金必须按该打手在本工单的累计份数与已冻结合计计算增量',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -23,6 +23,11 @@ const notificationEventRows: NotificationEventRow[] = [
|
||||
{ key: 'recharge_requested', name: '充值申请', description: '打手提交新的充值申请' },
|
||||
{ key: 'acceptance_submitted', name: '提交验收', description: '打手提交订单验收资料' },
|
||||
{ key: 'acceptance_reminded', name: '催验收', description: '打手在验收后催促处理' },
|
||||
{
|
||||
key: 'order_accepted',
|
||||
name: '整单验收完成',
|
||||
description: '全部份额验收完成(含拼单统一验收)',
|
||||
},
|
||||
{ key: 'material_required', name: '待补资料工单', description: '新工单等待后台补充资料' },
|
||||
{
|
||||
key: 'cancel_requested',
|
||||
|
||||
@@ -114,6 +114,7 @@ import {
|
||||
asRecord,
|
||||
formatMoney,
|
||||
formatSharingShareStatus,
|
||||
formatSharingShareCancelledReason,
|
||||
formatStatus,
|
||||
resolveGiftPhaseColor,
|
||||
resolveSharingShareStatusColor,
|
||||
@@ -3145,6 +3146,7 @@ export default function WorkOrdersPanel() {
|
||||
dataSource={sharingShares}
|
||||
loading={sharingQuery.isFetching}
|
||||
pagination={false}
|
||||
scroll={{ x: 1280 }}
|
||||
columns={[
|
||||
{
|
||||
title: '打手',
|
||||
@@ -3159,11 +3161,38 @@ export default function WorkOrdersPanel() {
|
||||
{
|
||||
title: '状态',
|
||||
render: (_, share) => (
|
||||
<Tag color={resolveSharingShareStatusColor(share.status)}>
|
||||
{formatSharingShareStatus(share.status)}
|
||||
</Tag>
|
||||
<Space size={4} wrap>
|
||||
<Tag color={resolveSharingShareStatusColor(share.status)}>
|
||||
{formatSharingShareStatus(share.status)}
|
||||
</Tag>
|
||||
{share.status === 'cancelled' && share.cancelledReason ? (
|
||||
<Tag>{formatSharingShareCancelledReason(share.cancelledReason)}</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '参与时间',
|
||||
render: (_, share) =>
|
||||
share.createdAt ? formatAdminDateTime(share.createdAt) : '-',
|
||||
},
|
||||
{
|
||||
title: '截止时间',
|
||||
render: (_, share) =>
|
||||
share.deadlineAt ? (
|
||||
<span>
|
||||
{formatAdminDateTime(share.deadlineAt)}
|
||||
{share.timeoutMinutes ? ` · ${share.timeoutMinutes} 分钟` : null}
|
||||
</span>
|
||||
) : (
|
||||
<Typography.Text type="secondary">不限时</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '超时策略',
|
||||
render: (_, share) =>
|
||||
share.timeoutPolicy ? formatTimeoutPolicyLabel(share.timeoutPolicy) : '-',
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
render: (_, share) =>
|
||||
|
||||
@@ -252,6 +252,16 @@ export function formatSharingShareStatus(status: string) {
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
/** 撤单原因:份额级独立撤单后,后台需要一眼看出是谁撤的。 */
|
||||
export function formatSharingShareCancelledReason(reason: string) {
|
||||
const labels: Record<string, string> = {
|
||||
timeout: '超时失效',
|
||||
admin: '后台撤单',
|
||||
worker_request: '打手退单',
|
||||
}
|
||||
return labels[reason] || reason
|
||||
}
|
||||
|
||||
export function formatAcceptanceMode(mode: string) {
|
||||
if (mode === 'friend_gift') return '好友赠送'
|
||||
return '标准验收'
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
resolveHallRewardAmount,
|
||||
} from '@/utils/worker-product-name'
|
||||
import { formatDateTime, formatPublishedElapsedTime } from '@/utils/date-time'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
type HallSummary = {
|
||||
pendingAcceptanceCount: number
|
||||
@@ -314,7 +315,7 @@ export default function WorkerHallPage() {
|
||||
async function submitGrab(order: WorkOrder) {
|
||||
setGrabbingId(order.workOrderId)
|
||||
try {
|
||||
await grabWorkerOrder(order.workOrderId)
|
||||
await grabWorkerOrder(order.workOrderId, { requestId: createRequestId('grab') })
|
||||
message.success('抢单成功,已进入我的订单')
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
@@ -335,6 +336,7 @@ export default function WorkerHallPage() {
|
||||
try {
|
||||
await joinWorkerSharingOrder(sharingOrder.workOrderId, {
|
||||
quantity: sharingQuantity,
|
||||
requestId: createRequestId('share'),
|
||||
})
|
||||
message.success('拼单成功,已进入我的订单')
|
||||
setSharingOrder(null)
|
||||
@@ -346,6 +348,15 @@ export default function WorkerHallPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 我在该工单的份额摘要:参与次数 + 累计份数 */
|
||||
function resolveMyShareSummary(order: WorkOrder): { count: number; quantity: number } {
|
||||
const shares = order.myShares?.length ? order.myShares : order.myShare ? [order.myShare] : []
|
||||
return {
|
||||
count: shares.length,
|
||||
quantity: shares.reduce((total, share) => total + Number(share.quantity || 0), 0),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSharingRemaining(order: WorkOrder): number {
|
||||
return resolveHallRemainingQuantity(order)
|
||||
}
|
||||
@@ -700,18 +711,18 @@ export default function WorkerHallPage() {
|
||||
</div>
|
||||
|
||||
<div className="worker-hall-mobile-card-btn-group">
|
||||
{order.sharing?.enabled &&
|
||||
(sharingRemaining > 0 || order.myShare) &&
|
||||
!disabledReason ? (
|
||||
{order.sharing?.enabled && sharingRemaining > 0 && !disabledReason ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
className="worker-hall-sharing-button"
|
||||
disabled={Boolean(disabledReason) || Boolean(order.myShare)}
|
||||
disabled={Boolean(disabledReason)}
|
||||
onClick={() => openSharingModal(order)}
|
||||
>
|
||||
{order.myShare
|
||||
? `已拼 ${order.myShare.quantity} 份`
|
||||
{resolveMyShareSummary(order).count > 0
|
||||
? `已参与 ${resolveMyShareSummary(order).count} 次 · 共 ${
|
||||
resolveMyShareSummary(order).quantity
|
||||
} 份 / 再拼`
|
||||
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -1152,18 +1163,18 @@ export default function WorkerHallPage() {
|
||||
|
||||
<div className="worker-hall-card-actions">
|
||||
<div className="worker-hall-card-grab-row">
|
||||
{order.sharing?.enabled &&
|
||||
(sharingRemaining > 0 || order.myShare) &&
|
||||
!disabledReason ? (
|
||||
{order.sharing?.enabled && sharingRemaining > 0 && !disabledReason ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="worker-hall-sharing-button"
|
||||
disabled={Boolean(disabledReason) || Boolean(order.myShare)}
|
||||
disabled={Boolean(disabledReason)}
|
||||
onClick={() => openSharingModal(order)}
|
||||
>
|
||||
{order.myShare
|
||||
? `已拼 ${order.myShare.quantity} 份`
|
||||
{resolveMyShareSummary(order).count > 0
|
||||
? `已参与 ${resolveMyShareSummary(order).count} 次 · 共 ${
|
||||
resolveMyShareSummary(order).quantity
|
||||
} 份 / 再拼`
|
||||
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { Alert, Button, Card, Empty, Input, Space, Table, Tabs, Typography } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { Pagination, WorkerOrderOverviewItem, WorkOrder } from '@/types/worker-platform'
|
||||
|
||||
@@ -29,6 +30,7 @@ export function WorkerOrderDesktopList({
|
||||
page,
|
||||
pageSize,
|
||||
columns,
|
||||
renderExpandedRow,
|
||||
loading,
|
||||
searching,
|
||||
refreshing,
|
||||
@@ -49,6 +51,7 @@ export function WorkerOrderDesktopList({
|
||||
page: number
|
||||
pageSize: number
|
||||
columns: TableColumnsType<WorkOrder>
|
||||
renderExpandedRow?: (order: WorkOrder) => ReactNode
|
||||
loading: boolean
|
||||
searching: boolean
|
||||
refreshing: boolean
|
||||
@@ -262,6 +265,15 @@ export function WorkerOrderDesktopList({
|
||||
dataSource={orders}
|
||||
columns={columns}
|
||||
className="worker-orders-table"
|
||||
expandable={
|
||||
renderExpandedRow
|
||||
? {
|
||||
expandedRowRender: renderExpandedRow,
|
||||
rowExpandable: (order) => (order.myShares?.length || 0) > 1,
|
||||
columnWidth: 42,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
locale={{
|
||||
emptyText: error ? (
|
||||
<Empty description={error instanceof Error ? error.message : '读取我的订单失败'} />
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Alert, Button, Empty, Input, Pagination, Spin, Tag } from 'antd'
|
||||
import { Alert, Button, Empty, Input, Pagination, Spin, Tag, Typography } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { DeadlineCountdown } from '@/components/DeadlineCountdown'
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
@@ -53,6 +54,7 @@ export function WorkerOrderMobileList({
|
||||
pagination,
|
||||
page,
|
||||
pageSize,
|
||||
renderShareTable,
|
||||
loading,
|
||||
searching,
|
||||
refreshing,
|
||||
@@ -81,6 +83,7 @@ export function WorkerOrderMobileList({
|
||||
pagination: PaginationData | undefined
|
||||
page: number
|
||||
pageSize: number
|
||||
renderShareTable?: (order: WorkOrder) => ReactNode
|
||||
loading: boolean
|
||||
searching: boolean
|
||||
refreshing: boolean
|
||||
@@ -173,6 +176,7 @@ export function WorkerOrderMobileList({
|
||||
<WorkerOrderMobileCard
|
||||
key={order.workOrderId}
|
||||
order={order}
|
||||
renderShareTable={renderShareTable}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenAcceptance={onOpenAcceptance}
|
||||
onOpenGiftMaterial={onOpenGiftMaterial}
|
||||
@@ -203,6 +207,7 @@ export function WorkerOrderMobileList({
|
||||
|
||||
function WorkerOrderMobileCard({
|
||||
order,
|
||||
renderShareTable,
|
||||
onOpenDetail,
|
||||
onOpenAcceptance,
|
||||
onOpenGiftMaterial,
|
||||
@@ -326,14 +331,27 @@ function WorkerOrderMobileCard({
|
||||
? '押金: 已解除冻结'
|
||||
: `押金: ${formatMoney(order.freezeDepositAmount)}`}
|
||||
</span>
|
||||
{order.myShare ? (
|
||||
{order.myShare || order.myShares?.length ? (
|
||||
<span className="worker-order-mobile-sub-item">
|
||||
已拼 {order.myShare.quantity} 份 ({formatMoney(order.myShare.shareReward)})
|
||||
已参与 {order.myShares?.length || 1} 次 · 共{' '}
|
||||
{order.myShares?.reduce((total, share) => total + share.quantity, 0) ||
|
||||
order.myShare?.quantity ||
|
||||
0}{' '}
|
||||
份
|
||||
</span>
|
||||
) : null}
|
||||
<span className="worker-order-mobile-sub-time">{formatDateTime(order.assignedAt)}</span>
|
||||
</div>
|
||||
|
||||
{(order.myShares?.length || 0) > 1 && renderShareTable ? (
|
||||
<div className="worker-order-mobile-shares">
|
||||
<Typography.Text strong style={{ fontSize: 12 }}>
|
||||
拼单明细({order.myShares?.length} 份)
|
||||
</Typography.Text>
|
||||
{renderShareTable(order)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{order.status === 'problem' && String(order.problemNote || '').trim() ? (
|
||||
<div className="worker-order-mobile-problem-box">
|
||||
<strong>问题:</strong>
|
||||
|
||||
@@ -83,7 +83,9 @@ export function buildWorkerOrderTableColumns({
|
||||
</Space>
|
||||
<Space size={[4, 4]} wrap>
|
||||
{row.categoryName ? <Tag color="blue">{row.categoryName}</Tag> : null}
|
||||
{row.myShare ? <Tag color="purple">拼单</Tag> : null}
|
||||
{row.myShares?.length || row.myShare ? (
|
||||
<Tag color="purple">拼单 {row.myShares?.length || 1} 次</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
@@ -165,9 +167,13 @@ export function buildWorkerOrderTableColumns({
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
押金:{formatMoney(row.freezeDepositAmount)}
|
||||
</Typography.Text>
|
||||
{row.myShare ? (
|
||||
{row.myShare || row.myShares?.length ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.myShare.quantity} 份 · {formatMoney(row.myShare.shareReward)}
|
||||
{row.myShares?.length || 1} 次 ·{' '}
|
||||
{row.myShares?.reduce((total, share) => total + share.quantity, 0) ||
|
||||
row.myShare?.quantity ||
|
||||
0}{' '}
|
||||
份
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -239,48 +245,57 @@ export function buildWorkerOrderTableColumns({
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<div className="worker-order-actions">
|
||||
<Space size={6}>
|
||||
<Tooltip title="查看详情">
|
||||
<Button
|
||||
aria-label="查看详情"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onOpenDetail(row)}
|
||||
/>
|
||||
</Tooltip>
|
||||
{canSubmitGiftMaterial(row) ? (
|
||||
<Button type="primary" onClick={() => onOpenGiftMaterial(row)}>
|
||||
提交资料
|
||||
</Button>
|
||||
) : null}
|
||||
{canEditAcceptanceImages(row) ? (
|
||||
<Button
|
||||
icon={<PictureOutlined />}
|
||||
type={canSubmitAcceptance(row) ? 'primary' : 'default'}
|
||||
onClick={() => onOpenAcceptance(row)}
|
||||
>
|
||||
{hasSubmittedAcceptance(row) ? '补充图片' : '验收'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canRemindAcceptance(row) ? (
|
||||
<Button onClick={() => onRemindAcceptance(row)}>催验收</Button>
|
||||
) : null}
|
||||
{canRequestCancel(row) ? (
|
||||
<Button danger onClick={() => onOpenCancelRequest(row)}>
|
||||
申请取消
|
||||
</Button>
|
||||
) : null}
|
||||
{canSubmitFeedback(row) ? (
|
||||
<Tooltip title="反馈客户资料或订单处理问题">
|
||||
<Button icon={<MessageOutlined />} onClick={() => onOpenFeedback(row)}>
|
||||
反馈
|
||||
</Button>
|
||||
render: (_, row) => {
|
||||
const shareCount = row.myShares?.length || (row.myShare ? 1 : 0)
|
||||
const hasMultipleShares = shareCount > 1
|
||||
return (
|
||||
<div className="worker-order-actions">
|
||||
<Space size={6} wrap>
|
||||
<Tooltip title="查看详情">
|
||||
<Button
|
||||
aria-label="查看详情"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onOpenDetail(row)}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
{canSubmitGiftMaterial(row) ? (
|
||||
<Button type="primary" onClick={() => onOpenGiftMaterial(row)}>
|
||||
提交资料
|
||||
</Button>
|
||||
) : null}
|
||||
{!hasMultipleShares && canEditAcceptanceImages(row) ? (
|
||||
<Button
|
||||
icon={<PictureOutlined />}
|
||||
type={canSubmitAcceptance(row) ? 'primary' : 'default'}
|
||||
onClick={() => onOpenAcceptance(row)}
|
||||
>
|
||||
{hasSubmittedAcceptance(row) ? '补充图片' : '验收'}
|
||||
</Button>
|
||||
) : null}
|
||||
{!hasMultipleShares && canRemindAcceptance(row) ? (
|
||||
<Button onClick={() => onRemindAcceptance(row)}>催验收</Button>
|
||||
) : null}
|
||||
{!hasMultipleShares && canRequestCancel(row) ? (
|
||||
<Button danger onClick={() => onOpenCancelRequest(row)}>
|
||||
申请取消
|
||||
</Button>
|
||||
) : null}
|
||||
{hasMultipleShares ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
展开行查看 {shareCount} 份份额操作
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{canSubmitFeedback(row) ? (
|
||||
<Tooltip title="反馈客户资料或订单处理问题">
|
||||
<Button icon={<MessageOutlined />} onClick={() => onOpenFeedback(row)}>
|
||||
反馈
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { App, Badge, Button, Form, Space, Typography } from 'antd'
|
||||
import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
@@ -22,11 +22,13 @@ import {
|
||||
} from '@/services/worker'
|
||||
import type { UploadedFile, WorkOrder } from '@/types/worker-platform'
|
||||
import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||
import { buildWorkerOrderQuery } from '@/utils/worker-order-query'
|
||||
import { WorkerOrderDesktopList } from './WorkerOrderDesktopList'
|
||||
import { WorkerOrderDetailDrawer } from './WorkerOrderDetailDrawer'
|
||||
import { WorkerOrderMobileList } from './WorkerOrderMobileList'
|
||||
import { WorkerOrderShareTable } from './WorkerOrderShareTable'
|
||||
import {
|
||||
type AcceptanceFormValues,
|
||||
type CancelRequestFormValues,
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
canSupplementAcceptedEvidence,
|
||||
getCopyOrderInfoItems,
|
||||
getMaterialScreenshots,
|
||||
getOrderShares,
|
||||
getUniqueAcceptanceFiles,
|
||||
getWorkerAcceptance,
|
||||
getWorkerDraft,
|
||||
@@ -165,6 +168,15 @@ export default function WorkerOrdersPage() {
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [detailOrder, setDetailOrder] = useState<WorkOrder | null>(null)
|
||||
const [editingOrder, setEditingOrder] = useState<WorkOrder | null>(null)
|
||||
/** 本次验收/补图针对的份额;null = 整单 */
|
||||
const [editingShareId, setEditingShareId] = useState<number | null>(null)
|
||||
/** 多份额时先让打手选择要操作哪一份 */
|
||||
const [sharePicker, setSharePicker] = useState<{
|
||||
order: WorkOrder
|
||||
action: 'accept' | 'cancel'
|
||||
} | null>(null)
|
||||
const [pickedShareId, setPickedShareId] = useState<number>(0)
|
||||
const [cancelShareId, setCancelShareId] = useState<number | null>(null)
|
||||
const [noteOrder, setNoteOrder] = useState<WorkOrder | null>(null)
|
||||
const [cancelRequestOrder, setCancelRequestOrder] = useState<WorkOrder | null>(null)
|
||||
const [feedbackOrder, setFeedbackOrder] = useState<WorkOrder | null>(null)
|
||||
@@ -292,10 +304,41 @@ export default function WorkerOrdersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 把订单对象替换成「指定份额」视角,让验收弹窗读写该份额的验收/草稿。 */
|
||||
function buildShareEditingOrder(order: WorkOrder, shareId: number | null): WorkOrder {
|
||||
if (!shareId) return order
|
||||
const share = getOrderShares(order).find((item) => item.shareId === shareId)
|
||||
if (!share) return order
|
||||
return {
|
||||
...order,
|
||||
myShare: share,
|
||||
// 已验收的份额走补图分支;其它份额沿用母单状态。
|
||||
status: share.status === 'accepted' ? 'accepted' : order.status,
|
||||
}
|
||||
}
|
||||
|
||||
function openAcceptanceModal(order: WorkOrder) {
|
||||
markOrderAsRead(order)
|
||||
setEditingOrder(order)
|
||||
if (canSupplementAcceptedEvidence(order)) {
|
||||
const shares = getOrderShares(order)
|
||||
if (shares.length > 1) {
|
||||
setPickedShareId(shares[0]?.shareId || 0)
|
||||
setSharePicker({ order, action: 'accept' })
|
||||
return
|
||||
}
|
||||
openAcceptanceWithShare(order, shares[0]?.shareId ?? null)
|
||||
}
|
||||
|
||||
/** 子表里直接对某一份份额发起验收/补图。 */
|
||||
function openAcceptanceForShare(order: WorkOrder, shareId: number) {
|
||||
markOrderAsRead(order)
|
||||
openAcceptanceWithShare(order, shareId)
|
||||
}
|
||||
|
||||
function openAcceptanceWithShare(order: WorkOrder, shareId: number | null) {
|
||||
const editingTarget = buildShareEditingOrder(order, shareId)
|
||||
setEditingShareId(shareId)
|
||||
setEditingOrder(editingTarget)
|
||||
if (canSupplementAcceptedEvidence(editingTarget)) {
|
||||
// 已验收补图只上传新增文件,原有验收图片由服务端保留。
|
||||
setAcceptanceFiles([])
|
||||
form.resetFields()
|
||||
@@ -303,9 +346,9 @@ export default function WorkerOrdersPage() {
|
||||
return
|
||||
}
|
||||
// 已提交:以已提交的验收资料为初始内容;未提交:以暂存草稿为初始内容
|
||||
const source = hasSubmittedAcceptance(order)
|
||||
? getWorkerAcceptance(order)
|
||||
: getWorkerDraft(order)
|
||||
const source = hasSubmittedAcceptance(editingTarget)
|
||||
? getWorkerAcceptance(editingTarget)
|
||||
: getWorkerDraft(editingTarget)
|
||||
setAcceptanceFiles(getUniqueAcceptanceFiles(source))
|
||||
form.setFieldsValue({ note: String(source?.note || '') })
|
||||
setDetailOrder(null)
|
||||
@@ -356,6 +399,21 @@ export default function WorkerOrdersPage() {
|
||||
|
||||
function openCancelRequestModal(order: WorkOrder) {
|
||||
markOrderAsRead(order)
|
||||
const shares = getOrderShares(order)
|
||||
if (shares.length > 1) {
|
||||
setPickedShareId(shares[0]?.shareId || 0)
|
||||
setSharePicker({ order, action: 'cancel' })
|
||||
return
|
||||
}
|
||||
setCancelShareId(shares[0]?.shareId ?? null)
|
||||
setCancelRequestOrder(order)
|
||||
cancelRequestForm.resetFields()
|
||||
}
|
||||
|
||||
/** 子表里直接对某一份份额申请取消。 */
|
||||
function openCancelRequestForShare(order: WorkOrder, shareId: number) {
|
||||
markOrderAsRead(order)
|
||||
setCancelShareId(shareId)
|
||||
setCancelRequestOrder(order)
|
||||
cancelRequestForm.resetFields()
|
||||
}
|
||||
@@ -365,6 +423,7 @@ export default function WorkerOrdersPage() {
|
||||
try {
|
||||
await submitWorkerCancelRequest(cancelRequestOrder.workOrderId, {
|
||||
reason: String(values.reason || '').trim(),
|
||||
shareId: cancelShareId,
|
||||
})
|
||||
message.success('取消申请已提交,等待客服审核')
|
||||
setCancelRequestOrder(null)
|
||||
@@ -430,6 +489,7 @@ export default function WorkerOrdersPage() {
|
||||
try {
|
||||
await supplementWorkerAcceptedOrderEvidence(editingOrder.workOrderId, {
|
||||
files: acceptanceFiles,
|
||||
...(editingShareId ? { shareId: editingShareId } : {}),
|
||||
})
|
||||
message.success('验收图片已补充')
|
||||
setEditingOrder(null)
|
||||
@@ -456,6 +516,7 @@ export default function WorkerOrdersPage() {
|
||||
await submitWorkerAcceptance(editingOrder.workOrderId, {
|
||||
note: values.note,
|
||||
files: acceptanceFiles,
|
||||
...(editingShareId ? { shareId: editingShareId } : {}),
|
||||
})
|
||||
message.success(canAutoAccept ? '已自动验收,可在 24 小时内补充图片' : '已提交验收')
|
||||
setEditingOrder(null)
|
||||
@@ -477,6 +538,7 @@ export default function WorkerOrdersPage() {
|
||||
await saveWorkerAcceptanceDraft(editingOrder.workOrderId, {
|
||||
note: values.note,
|
||||
files: acceptanceFiles,
|
||||
...(editingShareId ? { shareId: editingShareId } : {}),
|
||||
})
|
||||
message.success(
|
||||
hasSubmittedAcceptance(editingOrder) ? '验收图片已更新' : '验收图片已暂存,可继续补充',
|
||||
@@ -501,6 +563,20 @@ export default function WorkerOrdersPage() {
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function renderShareTable(order: WorkOrder) {
|
||||
return (
|
||||
<WorkerOrderShareTable
|
||||
order={order}
|
||||
mobile={isMobile}
|
||||
onOpenAcceptance={openAcceptanceForShare}
|
||||
onOpenCancelRequest={openCancelRequestForShare}
|
||||
onRemindAcceptance={(target) => {
|
||||
void submitAcceptanceReminder(target)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const columns = buildWorkerOrderTableColumns({
|
||||
onOpenDetail: openOrderDetail,
|
||||
onOpenAcceptance: openAcceptanceModal,
|
||||
@@ -562,6 +638,7 @@ export default function WorkerOrdersPage() {
|
||||
pagination={pagination}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
renderShareTable={renderShareTable}
|
||||
loading={ordersQuery.isLoading}
|
||||
searching={ordersQuery.isFetching}
|
||||
refreshing={ordersQuery.isFetching || overviewQuery.isFetching}
|
||||
@@ -611,6 +688,7 @@ export default function WorkerOrdersPage() {
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
columns={columns}
|
||||
renderExpandedRow={renderShareTable}
|
||||
loading={ordersQuery.isLoading}
|
||||
searching={ordersQuery.isFetching}
|
||||
refreshing={ordersQuery.isFetching || overviewQuery.isFetching}
|
||||
@@ -695,6 +773,57 @@ export default function WorkerOrdersPage() {
|
||||
onGiftMaterialFilesChange={setGiftMaterialFiles}
|
||||
onSubmitGiftMaterial={submitGiftMaterial}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={Boolean(sharePicker)}
|
||||
title={sharePicker?.action === 'cancel' ? '选择要撤销的份额' : '选择要验收的份额'}
|
||||
okText={sharePicker?.action === 'cancel' ? '提交退单申请' : '继续'}
|
||||
cancelText="取消"
|
||||
onCancel={() => setSharePicker(null)}
|
||||
onOk={() => {
|
||||
if (!sharePicker) return
|
||||
if (sharePicker.action === 'accept') {
|
||||
openAcceptanceWithShare(sharePicker.order, pickedShareId || null)
|
||||
} else {
|
||||
setCancelShareId(pickedShareId || null)
|
||||
setCancelRequestOrder(sharePicker.order)
|
||||
cancelRequestForm.resetFields()
|
||||
}
|
||||
setSharePicker(null)
|
||||
}}
|
||||
>
|
||||
<Radio.Group
|
||||
value={pickedShareId}
|
||||
onChange={(event) => setPickedShareId(Number(event.target.value))}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 10 }}
|
||||
>
|
||||
{sharePicker && sharePicker.action === 'cancel' ? (
|
||||
<Radio value={0}>全部有效份额(整单退单)</Radio>
|
||||
) : null}
|
||||
{sharePicker
|
||||
? getOrderShares(sharePicker.order).map((share) => (
|
||||
<Radio key={share.shareId} value={share.shareId}>
|
||||
<Space size={6} wrap>
|
||||
<span>#{share.shareId}</span>
|
||||
<Tag color="purple">{share.quantity} 份</Tag>
|
||||
<span>
|
||||
{share.status === 'joined'
|
||||
? '进行中'
|
||||
: share.status === 'submitted'
|
||||
? '待验收'
|
||||
: share.status === 'accepted'
|
||||
? '已验收'
|
||||
: share.status}
|
||||
</span>
|
||||
<span style={{ color: '#8c8c8c' }}>
|
||||
{share.deadlineAt ? `${formatDateTime(share.deadlineAt)} 截止` : '不限时'}
|
||||
</span>
|
||||
</Space>
|
||||
</Radio>
|
||||
))
|
||||
: null}
|
||||
</Radio.Group>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
UploadedFile,
|
||||
WorkerAcceptance,
|
||||
WorkOrder,
|
||||
WorkOrderShare,
|
||||
} from '@/types/worker-platform'
|
||||
import { getImageIdentity } from '@/utils/image-identity'
|
||||
|
||||
@@ -33,6 +34,13 @@ export function patchWorkOrderReadState(current: unknown, workOrderId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一打手在该工单的全部有效份额;单份额订单回退为 myShare 一条。 */
|
||||
export function getOrderShares(order: WorkOrder | null | undefined): WorkOrderShare[] {
|
||||
if (!order) return []
|
||||
if (order.myShares?.length) return order.myShares
|
||||
return order.myShare ? [order.myShare] : []
|
||||
}
|
||||
|
||||
/** 好友赠送验收模式(整单,不参与拼单)。 */
|
||||
export function isFriendGiftOrder(order: WorkOrder) {
|
||||
return order.acceptanceMode === 'friend_gift' && !order.myShare
|
||||
|
||||
@@ -220,12 +220,18 @@ export function fetchWorkerHallLeaderboard(period: WorkerLeaderboardPeriod = 'al
|
||||
return apiGet<WorkerLeaderboardResponse>('/api/v1/worker/hall/leaderboard', { period })
|
||||
}
|
||||
|
||||
export function grabWorkerOrder(workOrderId: number) {
|
||||
return apiPost<{ order: WorkOrder }>(`/api/v1/worker/hall/orders/${workOrderId}/grab`)
|
||||
export function grabWorkerOrder(workOrderId: number, payload: { requestId?: string } = {}) {
|
||||
return apiPost<{ order: WorkOrder; duplicated?: boolean }>(
|
||||
`/api/v1/worker/hall/orders/${workOrderId}/grab`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function joinWorkerSharingOrder(workOrderId: number, payload: { quantity: number }) {
|
||||
return apiPost<{ share: WorkOrderShare }>(
|
||||
export function joinWorkerSharingOrder(
|
||||
workOrderId: number,
|
||||
payload: { quantity: number; requestId?: string },
|
||||
) {
|
||||
return apiPost<{ share: WorkOrderShare; duplicated?: boolean }>(
|
||||
`/api/v1/worker/hall/orders/${workOrderId}/join-sharing`,
|
||||
payload,
|
||||
)
|
||||
@@ -249,7 +255,10 @@ export function fetchWorkerCancelRequests() {
|
||||
)
|
||||
}
|
||||
|
||||
export function submitWorkerCancelRequest(workOrderId: number, payload: { reason: string }) {
|
||||
export function submitWorkerCancelRequest(
|
||||
workOrderId: number,
|
||||
payload: { reason: string; shareId?: number | null },
|
||||
) {
|
||||
return apiPost<{ request: WorkerCancelRequest; risk: WorkerCancelRequestRisk }>(
|
||||
`/api/v1/worker/orders/${workOrderId}/cancel-requests`,
|
||||
payload,
|
||||
@@ -294,9 +303,9 @@ export function saveWorkerOrderNote(workOrderId: number, note: string) {
|
||||
|
||||
export function submitWorkerAcceptance(
|
||||
workOrderId: number,
|
||||
payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] },
|
||||
payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number },
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
||||
`/api/v1/worker/orders/${workOrderId}/submit-acceptance`,
|
||||
payload,
|
||||
)
|
||||
@@ -321,9 +330,9 @@ export function remindWorkerAcceptance(workOrderId: number) {
|
||||
|
||||
export function saveWorkerAcceptanceDraft(
|
||||
workOrderId: number,
|
||||
payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] },
|
||||
payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[]; shareId?: number },
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
||||
`/api/v1/worker/orders/${workOrderId}/acceptance-draft`,
|
||||
payload,
|
||||
)
|
||||
@@ -331,9 +340,9 @@ export function saveWorkerAcceptanceDraft(
|
||||
|
||||
export function supplementWorkerAcceptedOrderEvidence(
|
||||
workOrderId: number,
|
||||
payload: { files: UploadedFile[] },
|
||||
payload: { files: UploadedFile[]; shareId?: number },
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>(
|
||||
`/api/v1/worker/orders/${workOrderId}/acceptance-evidence`,
|
||||
payload,
|
||||
)
|
||||
|
||||
@@ -4853,3 +4853,26 @@ body {
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed #bfdbfe;
|
||||
}
|
||||
|
||||
/* 我的订单:一行一工单,展开后的份额子表 */
|
||||
.worker-orders-table .ant-table-expanded-row > td {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.worker-order-share-table .ant-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.worker-order-share-table .ant-table-thead > tr > th {
|
||||
background: #f5f5f5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.worker-order-mobile-shares {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 8px 12px 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #f0f0f0;
|
||||
}
|
||||
|
||||
@@ -297,6 +297,7 @@ export type WorkerPlatformNotificationEventKey =
|
||||
| 'recharge_requested'
|
||||
| 'acceptance_submitted'
|
||||
| 'acceptance_reminded'
|
||||
| 'order_accepted'
|
||||
| 'material_required'
|
||||
| 'cancel_requested'
|
||||
| 'feedback_submitted'
|
||||
@@ -539,6 +540,11 @@ export type WorkOrderShare = {
|
||||
acceptedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/** 本份额独立截止时间(参与时固化);null = 不限时 */
|
||||
deadlineAt?: string | null
|
||||
timeoutMinutes?: number
|
||||
timeoutPolicy?: string
|
||||
cancelledReason?: string
|
||||
worker: null | {
|
||||
workerId: number
|
||||
username: string
|
||||
@@ -620,6 +626,8 @@ export type WorkOrder = {
|
||||
freezeDepositAmount?: number
|
||||
sharing?: WorkOrderSharingConfig
|
||||
myShare?: WorkOrderShare | null
|
||||
/** 同一打手在该工单的全部有效份额(一行一工单 + 展开份额) */
|
||||
myShares?: WorkOrderShare[]
|
||||
sharingProgress?: {
|
||||
joinedQuantity: number
|
||||
pendingSubmissionCount: number
|
||||
|
||||
Reference in New Issue
Block a user