add:新增同一个工单同一人可以多次拼单:拼单明细ui优化
This commit is contained in:
@@ -204,3 +204,40 @@ test('超时统计按工单去重,不按份额/事件行数累加', async () =
|
||||
'超时单数没有按工单去重',
|
||||
)
|
||||
})
|
||||
|
||||
test('份额级问题单:概览优先归 problem,且筛选只认本人被标记的份额', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
|
||||
const shareStart = source.indexOf('share_status_rows AS (')
|
||||
const orderStart = source.indexOf('order_status_rows AS (')
|
||||
assert.ok(shareStart >= 0 && orderStart > shareStart, '缺少 share_status_rows CTE')
|
||||
const shareBlock = source.slice(shareStart, orderStart)
|
||||
|
||||
assert.match(
|
||||
shareBlock,
|
||||
/CASE\s+WHEN wos\.problem_marked_at IS NOT NULL THEN 'problem'/,
|
||||
'份额级问题单必须是概览 CASE 的第一分支(优先于份额状态)',
|
||||
)
|
||||
assert.match(
|
||||
shareBlock,
|
||||
/GROUP BY wos\.work_order_id, wos\.status, f\.has_pending_handling,\s*\(wos\.problem_marked_at IS NOT NULL\)/,
|
||||
'概览聚合必须带上份额问题标记,否则同工单会被合并回原状态',
|
||||
)
|
||||
assert.ok(
|
||||
shareBlock.includes('WHERE wos.worker_id = $1'),
|
||||
'概览份额统计必须限定当前打手,不能把其他打手的问题份额算进来',
|
||||
)
|
||||
|
||||
const existsStart = source.indexOf('export function workerShareProblemExistsExpression(')
|
||||
assert.ok(existsStart >= 0, '缺少按打手判定份额问题的表达式')
|
||||
const existsBlock = source.slice(existsStart, existsStart + 420)
|
||||
assert.match(existsBlock, /wsp\.worker_id = \$\{workerId\}/, '问题份额必须按打手隔离')
|
||||
assert.match(existsBlock, /wsp\.status <> 'cancelled'/, '已取消份额不参与问题单筛选')
|
||||
assert.match(existsBlock, /wsp\.problem_marked_at IS NOT NULL/, '缺少问题标记条件')
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/wo\.status = 'problem'\s*OR \$\{workerShareProblemExistsExpression\(workerSharingId\)\}/,
|
||||
'问题单页签必须同时命中母单问题与份额问题',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -365,6 +365,10 @@ export type WorkOrderShareRow = {
|
||||
timeout_policy?: string
|
||||
/** 取消来源:timeout/worker_request/admin/problem/settlement */
|
||||
cancelled_reason?: string
|
||||
/** 份额级问题单:备注 / 标记时间(NULL = 正常)/ 操作人;不影响母单状态 */
|
||||
problem_note?: string
|
||||
problem_marked_at?: string | null
|
||||
problem_marked_by?: string
|
||||
/** 客户端幂等键 */
|
||||
request_id?: string
|
||||
worker_username?: string
|
||||
|
||||
@@ -3,12 +3,16 @@ import type { PoolClient } from 'pg'
|
||||
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
|
||||
import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from './consignment-deposit-repo.js'
|
||||
import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js'
|
||||
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
|
||||
import {
|
||||
ensureWorkerWalletWithClient,
|
||||
getWorkerWalletWithClient,
|
||||
WORK_ORDER_SHARE_SELECT,
|
||||
} from './shared.js'
|
||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||
import { getOutstandingDepositAmountWithClient } from './work-order-deposit-repo.js'
|
||||
import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js'
|
||||
import type { ProblemWorkOrderResolutionAction, WorkOrderRow } from './types.js'
|
||||
import type { ProblemWorkOrderResolutionAction, WorkOrderRow, WorkOrderShareRow } from './types.js'
|
||||
|
||||
/**
|
||||
* 统计工单的活跃拼单份额(除已取消外都算活跃)。
|
||||
@@ -278,6 +282,294 @@ export async function resolveProblemWorkOrder(input: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台按份额标记问题单。
|
||||
*
|
||||
* 只写份额自身的 `problem_*` 三个字段:
|
||||
* - 母单 `status` / `problem_note` 一律不动(同工单其他打手完全无感);
|
||||
* - 份额状态(joined/submitted/accepted)也不动,问题标记是正交属性;
|
||||
* - 只有该份额持有人会在「我的订单」里看到问题单。
|
||||
*
|
||||
* 母单已 accepted / cancelled / problem 时不允许按份额标记:
|
||||
* 前两者已结算/终止,后者本身就是整单问题单(走整单处置)。
|
||||
*/
|
||||
export async function markWorkOrderShareProblem(input: {
|
||||
workOrderId: number
|
||||
shareId: number
|
||||
note: string
|
||||
now: string
|
||||
actorName?: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
share: WorkOrderShareRow | null
|
||||
failureReason: 'work_order_not_markable' | 'share_not_found' | 'share_status_not_markable' | null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const orderResult = await client.query<WorkOrderRow>(
|
||||
`SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`,
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = orderResult.rows[0] || null
|
||||
if (
|
||||
!workOrder ||
|
||||
['accepted', 'cancelled', 'problem'].includes(String(workOrder.status || ''))
|
||||
) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share: null,
|
||||
failureReason: 'work_order_not_markable' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const shareResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`,
|
||||
[input.shareId, input.workOrderId],
|
||||
)
|
||||
const share = shareResult.rows[0] || null
|
||||
if (!share) {
|
||||
return { order: workOrder, share: null, failureReason: 'share_not_found' as const }
|
||||
}
|
||||
if (share.status === 'cancelled') {
|
||||
return {
|
||||
order: workOrder,
|
||||
share,
|
||||
failureReason: 'share_status_not_markable' as const,
|
||||
}
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET problem_note = $1,
|
||||
problem_marked_at = $2,
|
||||
problem_marked_by = $3,
|
||||
updated_at = $2
|
||||
WHERE id = $4`,
|
||||
[String(input.note || '').trim(), input.now, input.actorName || '', input.shareId],
|
||||
)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'sharing_share_marked_problem',
|
||||
// 母单状态不迁移,事件只记录份额级标记
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
shareId: input.shareId,
|
||||
workerId: Number(share.worker_id || 0),
|
||||
note: String(input.note || '').trim(),
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
|
||||
const updated = await client.query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
|
||||
[input.shareId],
|
||||
)
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
share: updated.rows[0] || null,
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处置份额级问题单:与母单处置同名的四种方式,但只作用于这一份份额。
|
||||
*
|
||||
* - return_to_worker:清标记;已提交的份额回到进行中并清空验收提交(继续代练)。
|
||||
* - reopen / cancel_release:取消该份额并退还押金。
|
||||
* - cancel_deduct:取消该份额并扣除押金。
|
||||
*
|
||||
* 母单 `status` / `problem_note` 一律不写,同工单其他打手不受影响。
|
||||
*/
|
||||
export async function disposeWorkOrderShareProblem(input: {
|
||||
workOrderId: number
|
||||
shareId: number
|
||||
action: ProblemWorkOrderResolutionAction
|
||||
note: string
|
||||
now: string
|
||||
actorName?: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
share: WorkOrderShareRow | null
|
||||
depositAmount: number
|
||||
failureReason:
|
||||
| 'work_order_not_disposable'
|
||||
| 'share_not_found'
|
||||
| 'share_not_marked'
|
||||
| 'share_status_not_disposable'
|
||||
| null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const orderResult = await client.query<WorkOrderRow>(
|
||||
`SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`,
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = orderResult.rows[0] || null
|
||||
if (!workOrder || ['accepted', 'cancelled'].includes(String(workOrder.status || ''))) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share: null,
|
||||
depositAmount: 0,
|
||||
failureReason: 'work_order_not_disposable' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const shareResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`,
|
||||
[input.shareId, input.workOrderId],
|
||||
)
|
||||
const share = shareResult.rows[0] || null
|
||||
if (!share) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share: null,
|
||||
depositAmount: 0,
|
||||
failureReason: 'share_not_found' as const,
|
||||
}
|
||||
}
|
||||
if (!share.problem_marked_at) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share,
|
||||
depositAmount: 0,
|
||||
failureReason: 'share_not_marked' as const,
|
||||
}
|
||||
}
|
||||
// 已验收份额的报酬/押金已结算,只能走售后,不能在这里取消或退回。
|
||||
if (!['joined', 'submitted'].includes(String(share.status || ''))) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share,
|
||||
depositAmount: 0,
|
||||
failureReason: 'share_status_not_disposable' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const workerId = Number(share.worker_id || 0)
|
||||
let depositAmount = 0
|
||||
|
||||
if (input.action === 'return_to_worker') {
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET status = CASE WHEN status = 'submitted' THEN 'joined' ELSE status END,
|
||||
acceptance_json = CASE
|
||||
WHEN status = 'submitted' THEN '{}'::jsonb
|
||||
ELSE acceptance_json
|
||||
END,
|
||||
draft_acceptance_json = CASE
|
||||
WHEN status = 'submitted' THEN '{}'::jsonb
|
||||
ELSE draft_acceptance_json
|
||||
END,
|
||||
submitted_at = CASE WHEN status = 'submitted' THEN NULL ELSE submitted_at END,
|
||||
deadline_at = CASE
|
||||
WHEN status = 'submitted' THEN
|
||||
CASE
|
||||
WHEN timeout_minutes > 0
|
||||
THEN $2::timestamptz + (timeout_minutes * INTERVAL '1 minute')
|
||||
ELSE NULL
|
||||
END
|
||||
ELSE deadline_at
|
||||
END,
|
||||
problem_note = '',
|
||||
problem_marked_at = NULL,
|
||||
problem_marked_by = '',
|
||||
updated_at = $2
|
||||
WHERE id = $1`,
|
||||
[input.shareId, input.now],
|
||||
)
|
||||
} else {
|
||||
const shouldDeduct = input.action === 'cancel_deduct'
|
||||
if (workerId > 0) {
|
||||
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, workerId)
|
||||
depositAmount = Math.min(
|
||||
Number(share.share_deposit || 0),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
if (depositAmount > 0) {
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) + (shouldDeduct ? 0 : depositAmount)
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - depositAmount)
|
||||
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, workerId],
|
||||
)
|
||||
await client.query(
|
||||
`INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`,
|
||||
[
|
||||
workerId,
|
||||
shouldDeduct ? 'deposit_deduction' : 'deposit_release',
|
||||
shouldDeduct ? -depositAmount : depositAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
shouldDeduct ? '份额问题单处置扣除押金' : '份额问题单处置退还押金',
|
||||
JSON.stringify({
|
||||
action: input.action,
|
||||
shareId: input.shareId,
|
||||
note: input.note,
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET status = 'cancelled',
|
||||
cancelled_reason = 'problem',
|
||||
acceptance_json = '{}'::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
submitted_at = NULL,
|
||||
accepted_at = NULL,
|
||||
problem_note = '',
|
||||
problem_marked_at = NULL,
|
||||
problem_marked_by = '',
|
||||
updated_at = $1
|
||||
WHERE id = $2`,
|
||||
[input.now, input.shareId],
|
||||
)
|
||||
}
|
||||
|
||||
// 母单状态保持不变:与「后台单人撤单」同口径,只处置这一份。
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'sharing_share_problem_disposed',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
action: input.action,
|
||||
shareId: input.shareId,
|
||||
workerId,
|
||||
note: input.note,
|
||||
depositAmount,
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
|
||||
const updated = await client.query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
|
||||
[input.shareId],
|
||||
)
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
share: updated.rows[0] || null,
|
||||
depositAmount,
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function settleOverdueWorkOrder(input: {
|
||||
workOrderId: number
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
|
||||
|
||||
@@ -241,11 +241,29 @@ function workerPendingHandlingExistsExpression(workerId: number): string {
|
||||
)`
|
||||
}
|
||||
|
||||
/**
|
||||
* 该打手在本工单是否有被后台标记问题单的份额(份额级问题单)。
|
||||
*
|
||||
* 只影响该份额持有人:母单 `status` 不变,同工单其他打手也看不到。
|
||||
* 多条份额时只要有一份被标记,这张单在「我的订单」里就显示为问题单。
|
||||
*/
|
||||
export function workerShareProblemExistsExpression(workerId: number): string {
|
||||
return `EXISTS (
|
||||
SELECT 1
|
||||
FROM work_order_shares wsp
|
||||
WHERE wsp.work_order_id = wo.id
|
||||
AND wsp.worker_id = ${workerId}
|
||||
AND wsp.status <> 'cancelled'
|
||||
AND wsp.problem_marked_at IS NOT NULL
|
||||
)`
|
||||
}
|
||||
|
||||
export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') {
|
||||
const baseExpression = workerOrderBaseStatusExpression(workerShareAlias)
|
||||
const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0))
|
||||
if (normalizedWorkerId <= 0) return baseExpression
|
||||
const pendingHandlingExists = workerPendingHandlingExistsExpression(normalizedWorkerId)
|
||||
const shareProblemExists = workerShareProblemExistsExpression(normalizedWorkerId)
|
||||
const isTimeoutForMe = `(
|
||||
(wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId})
|
||||
AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted')
|
||||
@@ -258,6 +276,8 @@ export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'wo
|
||||
)`
|
||||
return `CASE
|
||||
WHEN ${isTimeoutForMe} THEN 'open'
|
||||
WHEN (${baseExpression}) IN ('accepted', 'cancelled', 'problem') THEN ${baseExpression}
|
||||
WHEN ${shareProblemExists} THEN 'problem'
|
||||
WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem'
|
||||
ELSE ${baseExpression}
|
||||
END`
|
||||
@@ -688,6 +708,9 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
||||
SELECT
|
||||
wos.work_order_id,
|
||||
CASE
|
||||
-- 份额级问题单:只有该份额持有人会在概览里多出一条 problem 计数;
|
||||
-- 同工单其他打手、母单状态都不受影响。被标记的份额不再计入原状态。
|
||||
WHEN wos.problem_marked_at IS NOT NULL THEN 'problem'
|
||||
-- 待审核取消申请/未处理反馈只把「进行中」的份额归入问题单;
|
||||
-- 已提交待验收的份额必须留在待验收(列表筛选就是这么判的,
|
||||
-- 这里若整体排除会出现「问题单有数字、点进去空列表」)。
|
||||
@@ -703,7 +726,8 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
||||
WHERE wos.worker_id = $1
|
||||
AND wos.status IN ('joined', 'submitted', 'accepted')
|
||||
AND f.wo_status NOT IN ('accepted', 'cancelled', 'problem')
|
||||
GROUP BY wos.work_order_id, wos.status, f.has_pending_handling
|
||||
GROUP BY wos.work_order_id, wos.status, f.has_pending_handling,
|
||||
(wos.problem_marked_at IS NOT NULL)
|
||||
),
|
||||
order_status_rows AS (
|
||||
SELECT
|
||||
@@ -1288,8 +1312,8 @@ export function buildWorkOrderWhere({
|
||||
}
|
||||
} else if (normalizedStatuses.length > 0) {
|
||||
if (workerSharingId && isProblemOnlyStatusFilter(normalizedStatuses)) {
|
||||
// 「仅问题单」短路:问题单 = wo.status 直接是 problem,或 进行中 且挂着
|
||||
// 待审核取消申请/未处理反馈。两条 pending 表极小(生产实测常为 0 行),
|
||||
// 「仅问题单」短路:问题单 = 母单 problem、自己某份份额被后台标记问题单,
|
||||
// 或 进行中 且挂着待审核取消申请/未处理反馈。两条 pending 表极小(生产实测常为 0 行),
|
||||
// 从它们出发反查,避免为了一个通常为空的结果枚举该打手全部可见单再逐行求值
|
||||
// 完整 CASE(重打手 8,586 单 × 逐行探测 ≈ 233MB 缓冲读)。
|
||||
// 注意左臂必须写成 sargable 的 wo.status = 'problem':完整表达式里
|
||||
@@ -1299,6 +1323,7 @@ export function buildWorkOrderWhere({
|
||||
const baseExpression = workerOrderBaseStatusExpression('worker_share')
|
||||
filters.push(`(
|
||||
wo.status = 'problem'
|
||||
OR ${workerShareProblemExistsExpression(workerSharingId)}
|
||||
OR (
|
||||
${baseExpression} = 'in_progress'
|
||||
AND wo.id IN (
|
||||
@@ -1321,6 +1346,7 @@ export function buildWorkOrderWhere({
|
||||
const representativeStatus = `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`
|
||||
// 一个工单可能同时有「进行中 + 待验收」等多份不同状态的份额。
|
||||
// 只看代表份额会让其中一种状态在页签里完全消失,所以只要任一份额命中就展示。
|
||||
// 被后台标记问题单的份额只归问题单页签,不再按原状态出现(问题标记是正交属性)。
|
||||
const anyShareStatus = `(
|
||||
wo.status NOT IN ('accepted', 'cancelled', 'problem')
|
||||
AND EXISTS (
|
||||
@@ -1329,6 +1355,7 @@ export function buildWorkOrderWhere({
|
||||
WHERE ws_filter.work_order_id = wo.id
|
||||
AND ws_filter.worker_id = ${workerSharingId}
|
||||
AND ws_filter.status IN ('joined', 'submitted', 'accepted')
|
||||
AND ws_filter.problem_marked_at IS NULL
|
||||
AND (
|
||||
(
|
||||
ws_filter.status = 'joined'
|
||||
|
||||
Reference in New Issue
Block a user