add:新增同一个工单同一人可以多次拼单:拼单明细ui优化
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
-- 105_work_order_share_problem.sql
|
||||||
|
-- 目标:后台「拼单明细」支持按份额标记问题单。
|
||||||
|
-- 1. 只影响该份额持有人:母单 work_orders.status / problem_note 一律不写;
|
||||||
|
-- 2. 份额自身状态(joined/submitted/accepted/cancelled)不变,问题标记是正交属性;
|
||||||
|
-- 3. 整单问题单能力保留(母单 problem 状态仍按原逻辑覆盖所有参与者)。
|
||||||
|
--
|
||||||
|
-- 说明:备注选填;problem_marked_at 为空表示未标记;解除标记即清空这三个字段。
|
||||||
|
|
||||||
|
ALTER TABLE work_order_shares
|
||||||
|
ADD COLUMN IF NOT EXISTS problem_note TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS problem_marked_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS problem_marked_by TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN work_order_shares.problem_note IS '份额级问题备注(后台标记问题时填写,选填)';
|
||||||
|
COMMENT ON COLUMN work_order_shares.problem_marked_at IS '份额级问题标记时间;NULL = 正常。仅影响该份额持有人展示';
|
||||||
|
COMMENT ON COLUMN work_order_shares.problem_marked_by IS '标记操作人(后台账号显示名)';
|
||||||
|
|
||||||
|
-- 打手「问题单」页签筛选与概览统计需要按 (worker_id, work_order_id) 探测被标记的份额。
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_work_order_shares_problem_worker
|
||||||
|
ON work_order_shares (worker_id, work_order_id)
|
||||||
|
WHERE problem_marked_at IS NOT NULL;
|
||||||
@@ -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_policy?: string
|
||||||
/** 取消来源:timeout/worker_request/admin/problem/settlement */
|
/** 取消来源:timeout/worker_request/admin/problem/settlement */
|
||||||
cancelled_reason?: string
|
cancelled_reason?: string
|
||||||
|
/** 份额级问题单:备注 / 标记时间(NULL = 正常)/ 操作人;不影响母单状态 */
|
||||||
|
problem_note?: string
|
||||||
|
problem_marked_at?: string | null
|
||||||
|
problem_marked_by?: string
|
||||||
/** 客户端幂等键 */
|
/** 客户端幂等键 */
|
||||||
request_id?: string
|
request_id?: string
|
||||||
worker_username?: string
|
worker_username?: string
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import type { PoolClient } from 'pg'
|
|||||||
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
|
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
|
||||||
import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from './consignment-deposit-repo.js'
|
import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from './consignment-deposit-repo.js'
|
||||||
import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-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 { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||||
import { getOutstandingDepositAmountWithClient } from './work-order-deposit-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 { 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: {
|
export async function settleOverdueWorkOrder(input: {
|
||||||
workOrderId: number
|
workOrderId: number
|
||||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
|
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') {
|
export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') {
|
||||||
const baseExpression = workerOrderBaseStatusExpression(workerShareAlias)
|
const baseExpression = workerOrderBaseStatusExpression(workerShareAlias)
|
||||||
const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0))
|
const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0))
|
||||||
if (normalizedWorkerId <= 0) return baseExpression
|
if (normalizedWorkerId <= 0) return baseExpression
|
||||||
const pendingHandlingExists = workerPendingHandlingExistsExpression(normalizedWorkerId)
|
const pendingHandlingExists = workerPendingHandlingExistsExpression(normalizedWorkerId)
|
||||||
|
const shareProblemExists = workerShareProblemExistsExpression(normalizedWorkerId)
|
||||||
const isTimeoutForMe = `(
|
const isTimeoutForMe = `(
|
||||||
(wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId})
|
(wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId})
|
||||||
AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted')
|
AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted')
|
||||||
@@ -258,6 +276,8 @@ export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'wo
|
|||||||
)`
|
)`
|
||||||
return `CASE
|
return `CASE
|
||||||
WHEN ${isTimeoutForMe} THEN 'open'
|
WHEN ${isTimeoutForMe} THEN 'open'
|
||||||
|
WHEN (${baseExpression}) IN ('accepted', 'cancelled', 'problem') THEN ${baseExpression}
|
||||||
|
WHEN ${shareProblemExists} THEN 'problem'
|
||||||
WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem'
|
WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem'
|
||||||
ELSE ${baseExpression}
|
ELSE ${baseExpression}
|
||||||
END`
|
END`
|
||||||
@@ -688,6 +708,9 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
|||||||
SELECT
|
SELECT
|
||||||
wos.work_order_id,
|
wos.work_order_id,
|
||||||
CASE
|
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
|
WHERE wos.worker_id = $1
|
||||||
AND wos.status IN ('joined', 'submitted', 'accepted')
|
AND wos.status IN ('joined', 'submitted', 'accepted')
|
||||||
AND f.wo_status NOT IN ('accepted', 'cancelled', 'problem')
|
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 (
|
order_status_rows AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1288,8 +1312,8 @@ export function buildWorkOrderWhere({
|
|||||||
}
|
}
|
||||||
} else if (normalizedStatuses.length > 0) {
|
} else if (normalizedStatuses.length > 0) {
|
||||||
if (workerSharingId && isProblemOnlyStatusFilter(normalizedStatuses)) {
|
if (workerSharingId && isProblemOnlyStatusFilter(normalizedStatuses)) {
|
||||||
// 「仅问题单」短路:问题单 = wo.status 直接是 problem,或 进行中 且挂着
|
// 「仅问题单」短路:问题单 = 母单 problem、自己某份份额被后台标记问题单,
|
||||||
// 待审核取消申请/未处理反馈。两条 pending 表极小(生产实测常为 0 行),
|
// 或 进行中 且挂着待审核取消申请/未处理反馈。两条 pending 表极小(生产实测常为 0 行),
|
||||||
// 从它们出发反查,避免为了一个通常为空的结果枚举该打手全部可见单再逐行求值
|
// 从它们出发反查,避免为了一个通常为空的结果枚举该打手全部可见单再逐行求值
|
||||||
// 完整 CASE(重打手 8,586 单 × 逐行探测 ≈ 233MB 缓冲读)。
|
// 完整 CASE(重打手 8,586 单 × 逐行探测 ≈ 233MB 缓冲读)。
|
||||||
// 注意左臂必须写成 sargable 的 wo.status = 'problem':完整表达式里
|
// 注意左臂必须写成 sargable 的 wo.status = 'problem':完整表达式里
|
||||||
@@ -1299,6 +1323,7 @@ export function buildWorkOrderWhere({
|
|||||||
const baseExpression = workerOrderBaseStatusExpression('worker_share')
|
const baseExpression = workerOrderBaseStatusExpression('worker_share')
|
||||||
filters.push(`(
|
filters.push(`(
|
||||||
wo.status = 'problem'
|
wo.status = 'problem'
|
||||||
|
OR ${workerShareProblemExistsExpression(workerSharingId)}
|
||||||
OR (
|
OR (
|
||||||
${baseExpression} = 'in_progress'
|
${baseExpression} = 'in_progress'
|
||||||
AND wo.id IN (
|
AND wo.id IN (
|
||||||
@@ -1321,6 +1346,7 @@ export function buildWorkOrderWhere({
|
|||||||
const representativeStatus = `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`
|
const representativeStatus = `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`
|
||||||
// 一个工单可能同时有「进行中 + 待验收」等多份不同状态的份额。
|
// 一个工单可能同时有「进行中 + 待验收」等多份不同状态的份额。
|
||||||
// 只看代表份额会让其中一种状态在页签里完全消失,所以只要任一份额命中就展示。
|
// 只看代表份额会让其中一种状态在页签里完全消失,所以只要任一份额命中就展示。
|
||||||
|
// 被后台标记问题单的份额只归问题单页签,不再按原状态出现(问题标记是正交属性)。
|
||||||
const anyShareStatus = `(
|
const anyShareStatus = `(
|
||||||
wo.status NOT IN ('accepted', 'cancelled', 'problem')
|
wo.status NOT IN ('accepted', 'cancelled', 'problem')
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
@@ -1329,6 +1355,7 @@ export function buildWorkOrderWhere({
|
|||||||
WHERE ws_filter.work_order_id = wo.id
|
WHERE ws_filter.work_order_id = wo.id
|
||||||
AND ws_filter.worker_id = ${workerSharingId}
|
AND ws_filter.worker_id = ${workerSharingId}
|
||||||
AND ws_filter.status IN ('joined', 'submitted', 'accepted')
|
AND ws_filter.status IN ('joined', 'submitted', 'accepted')
|
||||||
|
AND ws_filter.problem_marked_at IS NULL
|
||||||
AND (
|
AND (
|
||||||
(
|
(
|
||||||
ws_filter.status = 'joined'
|
ws_filter.status = 'joined'
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
cancelAdminWorkOrderSharing,
|
cancelAdminWorkOrderSharing,
|
||||||
deductAdminWorkOrderPendingDeposit,
|
deductAdminWorkOrderPendingDeposit,
|
||||||
markAdminWorkOrderProblem,
|
markAdminWorkOrderProblem,
|
||||||
|
markAdminWorkOrderShareProblem,
|
||||||
resolveAdminProblemWorkOrder,
|
resolveAdminProblemWorkOrder,
|
||||||
|
disposeAdminWorkOrderShareProblem,
|
||||||
} from '../../../services/worker-platform/index.js'
|
} from '../../../services/worker-platform/index.js'
|
||||||
import { createJsonHandler, requireAdminPermission, requireAnyAdminPermission } from '../session.js'
|
import { createJsonHandler, requireAdminPermission, requireAnyAdminPermission } from '../session.js'
|
||||||
|
|
||||||
@@ -161,6 +163,58 @@ router.post(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 份额级问题单:只影响该份额持有人,母单状态不变。
|
||||||
|
router.post(
|
||||||
|
'/worker-platform/orders/:workOrderId/shares/:shareId/problem',
|
||||||
|
requireAdminPermission('worker_order.manage_problem'),
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
markAdminWorkOrderShareProblem(
|
||||||
|
String(req.params.workOrderId || ''),
|
||||||
|
String(req.params.shareId || ''),
|
||||||
|
req.body || {},
|
||||||
|
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: '该份额已标记为问题单,仅影响该打手',
|
||||||
|
errorMessage: '标记份额问题单失败',
|
||||||
|
scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/problem]',
|
||||||
|
audit: (req, data) => ({
|
||||||
|
action: 'work_order_share_marked_problem',
|
||||||
|
targetType: 'work_order_share',
|
||||||
|
targetId: String(req.params.shareId || ''),
|
||||||
|
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 份额级问题单处置:处置方式与母单一致,但只作用于这一份份额,母单状态不变。
|
||||||
|
router.post(
|
||||||
|
'/worker-platform/orders/:workOrderId/shares/:shareId/problem/dispose',
|
||||||
|
requireAdminPermission('worker_order.manage_problem'),
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
disposeAdminWorkOrderShareProblem(
|
||||||
|
String(req.params.workOrderId || ''),
|
||||||
|
String(req.params.shareId || ''),
|
||||||
|
req.body || {},
|
||||||
|
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: '该份额问题单已处置',
|
||||||
|
errorMessage: '处置份额问题单失败',
|
||||||
|
scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/problem/dispose]',
|
||||||
|
audit: (req, data) => ({
|
||||||
|
action: 'work_order_share_problem_disposed',
|
||||||
|
targetType: 'work_order_share',
|
||||||
|
targetId: String(req.params.shareId || ''),
|
||||||
|
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
'/worker-platform/orders/:workOrderId/deduct-pending-deposit',
|
'/worker-platform/orders/:workOrderId/deduct-pending-deposit',
|
||||||
requireAdminPermission('worker_order.deduct_deposit'),
|
requireAdminPermission('worker_order.deduct_deposit'),
|
||||||
|
|||||||
@@ -255,6 +255,9 @@ async function publishWorkOrderSnapshots(input: {
|
|||||||
hallChanged?: boolean
|
hallChanged?: boolean
|
||||||
}) {
|
}) {
|
||||||
const workOrderId = Math.max(0, Number(input.workOrderId) || 0)
|
const workOrderId = Math.max(0, Number(input.workOrderId) || 0)
|
||||||
|
// 调用方显式给出的受影响打手必须同步失效:下面的快照生成要先查库,
|
||||||
|
// 若等都查完再失效,紧接着到达的「我的订单」请求会命中旧缓存(30s TTL)。
|
||||||
|
invalidateWorkerMyOrderCaches(input.workerIds || [])
|
||||||
const workOrder = await getWorkOrderById(workOrderId)
|
const workOrder = await getWorkOrderById(workOrderId)
|
||||||
if (!workOrder) {
|
if (!workOrder) {
|
||||||
// 工单已不存在(删除):调用方传入的打手集合仍需失效缓存,
|
// 工单已不存在(删除):调用方传入的打手集合仍需失效缓存,
|
||||||
@@ -449,7 +452,10 @@ async function publishWorkOrderSnapshots(input: {
|
|||||||
hasTimeoutForMe: Boolean(timeoutEvent),
|
hasTimeoutForMe: Boolean(timeoutEvent),
|
||||||
vipEvidencePending,
|
vipEvidencePending,
|
||||||
}),
|
}),
|
||||||
problemMarked: !isTimedOutForMe && workOrder.status === WORK_ORDER_STATUS.PROBLEM,
|
problemMarked:
|
||||||
|
!isTimedOutForMe &&
|
||||||
|
(workOrder.status === WORK_ORDER_STATUS.PROBLEM ||
|
||||||
|
activeWorkerShares.some((share) => Boolean(share.problem_marked_at))),
|
||||||
pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId),
|
pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId),
|
||||||
pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId),
|
pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId),
|
||||||
materialUpdated:
|
materialUpdated:
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
|||||||
import {
|
import {
|
||||||
createWorkOrderEvent,
|
createWorkOrderEvent,
|
||||||
deleteWorkOrder,
|
deleteWorkOrder,
|
||||||
|
disposeWorkOrderShareProblem,
|
||||||
listWorkOrderShares,
|
listWorkOrderShares,
|
||||||
listWorkOrders,
|
listWorkOrders,
|
||||||
|
markWorkOrderShareProblem,
|
||||||
publishUnassignedWorkOrder,
|
publishUnassignedWorkOrder,
|
||||||
reopenCancelledWorkOrder,
|
reopenCancelledWorkOrder,
|
||||||
resolveProblemWorkOrder,
|
resolveProblemWorkOrder,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
} from './publish-voucher-service.js'
|
} from './publish-voucher-service.js'
|
||||||
import {
|
import {
|
||||||
mapWorkOrderAdmin,
|
mapWorkOrderAdmin,
|
||||||
|
mapWorkOrderShare,
|
||||||
normalizeAmountFen,
|
normalizeAmountFen,
|
||||||
normalizeInteger,
|
normalizeInteger,
|
||||||
normalizeOptionalId,
|
normalizeOptionalId,
|
||||||
@@ -436,3 +439,118 @@ export async function resolveAdminProblemWorkOrder(
|
|||||||
})
|
})
|
||||||
return { order: mapWorkOrderAdmin(updated) }
|
return { order: mapWorkOrderAdmin(updated) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台「拼单明细」按份额标记问题单。
|
||||||
|
*
|
||||||
|
* 只影响该份额持有人:母单状态/备注不动,同工单其他打手无感。
|
||||||
|
* 备注选填;重复标记视为更新备注(幂等)。
|
||||||
|
*/
|
||||||
|
export async function markAdminWorkOrderShareProblem(
|
||||||
|
workOrderId: number | string,
|
||||||
|
shareId: number | string,
|
||||||
|
payload: JsonObject = {},
|
||||||
|
actorName = '',
|
||||||
|
) {
|
||||||
|
const note = String(payload.note || payload.problemNote || '').trim()
|
||||||
|
const result = await markWorkOrderShareProblem({
|
||||||
|
workOrderId: Number(workOrderId),
|
||||||
|
shareId: Number(shareId),
|
||||||
|
note,
|
||||||
|
now: nowIso(),
|
||||||
|
actorName,
|
||||||
|
})
|
||||||
|
if (result.failureReason === 'work_order_not_markable') {
|
||||||
|
throw createHttpError('当前订单状态不能按份额标记问题单', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_share_problem_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (result.failureReason === 'share_not_found' || !result.share) {
|
||||||
|
throw createHttpError('未找到该拼单份额', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'work_order_share_problem_share_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (result.failureReason === 'share_status_not_markable') {
|
||||||
|
throw createHttpError('已撤单的份额不能标记问题单', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_share_problem_share_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 只通知该份额持有人刷新「我的订单」;后台列表由快照广播覆盖。
|
||||||
|
publishWorkOrderRealtimeChange({
|
||||||
|
workOrderId: Number(workOrderId),
|
||||||
|
workerIds: [Number(result.share.worker_id || 0)].filter((workerId) => workerId > 0),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
order: result.order ? mapWorkOrderAdmin(result.order) : null,
|
||||||
|
share: mapWorkOrderShare(result.share),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台「拼单明细」处置份额级问题单。
|
||||||
|
*
|
||||||
|
* 处置方式与母单处置完全一致(退回打手继续代练 / 释放押金回大厅 /
|
||||||
|
* 取消退押金 / 取消扣押金),但只作用于这一份份额:母单状态/备注不动,
|
||||||
|
* 同工单其他打手无感。备注必填,与母单处置同口径。
|
||||||
|
*/
|
||||||
|
export async function disposeAdminWorkOrderShareProblem(
|
||||||
|
workOrderId: number | string,
|
||||||
|
shareId: number | string,
|
||||||
|
payload: JsonObject = {},
|
||||||
|
actorName = '',
|
||||||
|
) {
|
||||||
|
const action = normalizeProblemResolutionAction(payload.action)
|
||||||
|
const note = String(payload.note || '').trim()
|
||||||
|
if (!note) {
|
||||||
|
throw createHttpError('请填写处置备注', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'work_order_share_problem_disposal_note_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await disposeWorkOrderShareProblem({
|
||||||
|
workOrderId: Number(workOrderId),
|
||||||
|
shareId: Number(shareId),
|
||||||
|
action,
|
||||||
|
note,
|
||||||
|
now: nowIso(),
|
||||||
|
actorName,
|
||||||
|
})
|
||||||
|
if (result.failureReason === 'work_order_not_disposable') {
|
||||||
|
throw createHttpError('当前订单状态不能处置份额问题单', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_share_problem_disposal_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (result.failureReason === 'share_not_found' || !result.share) {
|
||||||
|
throw createHttpError('未找到该拼单份额', {
|
||||||
|
statusCode: 404,
|
||||||
|
errorCode: 'work_order_share_problem_share_not_found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (result.failureReason === 'share_not_marked') {
|
||||||
|
throw createHttpError('该份额当前不是问题单', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_share_problem_not_marked',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (result.failureReason === 'share_status_not_disposable') {
|
||||||
|
throw createHttpError('已验收或已取消的份额不能处置,请走售后或单人撤单', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'work_order_share_problem_share_status_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
publishWorkOrderRealtimeChange({
|
||||||
|
workOrderId: Number(workOrderId),
|
||||||
|
workerIds: [Number(result.share.worker_id || 0)].filter((workerId) => workerId > 0),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
order: result.order ? mapWorkOrderAdmin(result.order) : null,
|
||||||
|
share: mapWorkOrderShare(result.share),
|
||||||
|
depositAmount: result.depositAmount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -218,3 +218,33 @@ test('VIP 免审核待补图:只有已验收 + vip_auto + 待补图 + 未到
|
|||||||
['accepted', 'vip_evidence_pending'],
|
['accepted', 'vip_evidence_pending'],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('份额级问题单:被标记的份额只归问题单,同单其他份额仍按原状态归类', () => {
|
||||||
|
const tabs = resolveWorkerOrderStatusTabs({
|
||||||
|
workOrderStatus: 'in_progress',
|
||||||
|
isAssignedWorker: false,
|
||||||
|
shares: [
|
||||||
|
share({ id: 1, status: 'joined', problem_marked_at: '2026-10-23T11:00:00.000Z' }),
|
||||||
|
share({ id: 2, status: 'submitted' }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.deepEqual(tabs.sort(), ['pending_acceptance', 'problem'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('份额级问题单:唯一份额被标记后不得再出现在原状态页签', () => {
|
||||||
|
const tabs = resolveWorkerOrderStatusTabs({
|
||||||
|
workOrderStatus: 'in_progress',
|
||||||
|
isAssignedWorker: true,
|
||||||
|
shares: [share({ id: 1, status: 'joined', problem_marked_at: '2026-10-23T11:00:00.000Z' })],
|
||||||
|
})
|
||||||
|
assert.deepEqual(tabs, ['problem'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('份额级问题单:已验收份额被标记后只归问题单,母单仍为进行中', () => {
|
||||||
|
const tabs = resolveWorkerOrderStatusTabs({
|
||||||
|
workOrderStatus: 'in_progress',
|
||||||
|
isAssignedWorker: true,
|
||||||
|
shares: [share({ id: 1, status: 'accepted', problem_marked_at: '2026-10-23T11:00:00.000Z' })],
|
||||||
|
})
|
||||||
|
assert.deepEqual(tabs, ['problem'])
|
||||||
|
})
|
||||||
|
|||||||
@@ -526,12 +526,21 @@ export function mapWorkOrderForWorker(
|
|||||||
} = mapped
|
} = mapped
|
||||||
const isTimedOutForMe =
|
const isTimedOutForMe =
|
||||||
options?.isTimedOutForMe === true || Boolean(options?.timeoutNotice?.isTimedOut)
|
options?.isTimedOutForMe === true || Boolean(options?.timeoutNotice?.isTimedOut)
|
||||||
const personalStatus = isTimedOutForMe
|
|
||||||
? WORK_ORDER_STATUS.OPEN
|
|
||||||
: resolveWorkerShareStatus(workOrder.status, myShare?.status)
|
|
||||||
|
|
||||||
const workerShares =
|
const workerShares =
|
||||||
options?.myShares && options.myShares.length > 0 ? options.myShares : myShare ? [myShare] : []
|
options?.myShares && options.myShares.length > 0 ? options.myShares : myShare ? [myShare] : []
|
||||||
|
/**
|
||||||
|
* 份额级问题单:该打手在本单只要有一份被后台标记,这张单就显示为问题单
|
||||||
|
* (母单仍是 accepted / cancelled / problem 时以母单为准,与列表 SQL 同序)。
|
||||||
|
*/
|
||||||
|
const problemShare = workerShares.find(
|
||||||
|
(share) => share.status !== 'cancelled' && Boolean(share.problem_marked_at),
|
||||||
|
)
|
||||||
|
const personalStatus = isTimedOutForMe
|
||||||
|
? WORK_ORDER_STATUS.OPEN
|
||||||
|
: problemShare && !['accepted', 'cancelled', 'problem'].includes(String(workOrder.status || ''))
|
||||||
|
? WORK_ORDER_STATUS.PROBLEM
|
||||||
|
: resolveWorkerShareStatus(workOrder.status, myShare?.status)
|
||||||
|
|
||||||
const shareSummary = summarizeWorkerShares(workerShares, Number(workOrder.timeout_minutes || 0))
|
const shareSummary = summarizeWorkerShares(workerShares, Number(workOrder.timeout_minutes || 0))
|
||||||
const hasActiveShares = workerShares.some((share) => share.status !== 'cancelled')
|
const hasActiveShares = workerShares.some((share) => share.status !== 'cancelled')
|
||||||
|
|
||||||
@@ -541,6 +550,13 @@ export function mapWorkOrderForWorker(
|
|||||||
? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity)
|
? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity)
|
||||||
: visibleOrder.productName,
|
: visibleOrder.productName,
|
||||||
status: personalStatus,
|
status: personalStatus,
|
||||||
|
/** 行上展示的问题备注:母单问题单用母单备注,否则用被标记份额的备注 */
|
||||||
|
problemNote:
|
||||||
|
String(workOrder.status || '') === WORK_ORDER_STATUS.PROBLEM
|
||||||
|
? visibleOrder.problemNote
|
||||||
|
: String(problemShare?.problem_note || ''),
|
||||||
|
/** 是否为「份额级问题单」(区别于平台整单标记) */
|
||||||
|
shareProblemMarked: Boolean(problemShare),
|
||||||
rewardAmount: isTimedOutForMe
|
rewardAmount: isTimedOutForMe
|
||||||
? 0
|
? 0
|
||||||
: hasActiveShares
|
: hasActiveShares
|
||||||
@@ -622,6 +638,8 @@ type WorkerShareLike = {
|
|||||||
status?: unknown
|
status?: unknown
|
||||||
created_at?: unknown
|
created_at?: unknown
|
||||||
id?: unknown
|
id?: unknown
|
||||||
|
/** 份额级问题单标记时间;非空表示该份额被后台标记问题单 */
|
||||||
|
problem_marked_at?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -700,18 +718,24 @@ export function resolveWorkerOrderStatusTabs(input: {
|
|||||||
(share) =>
|
(share) =>
|
||||||
share.status === 'joined' || share.status === 'submitted' || share.status === 'accepted',
|
share.status === 'joined' || share.status === 'submitted' || share.status === 'accepted',
|
||||||
)
|
)
|
||||||
|
// 份额级问题单:只要有一份被后台标记,这张单就归问题单(与 SQL 的 workerShareProblemExists 同义)。
|
||||||
|
const problemShare = activeShares.find((share) => Boolean(share.problem_marked_at))
|
||||||
const representative = pickWorkerRepresentativeShare(shares)
|
const representative = pickWorkerRepresentativeShare(shares)
|
||||||
let representativeStatus = representative
|
let representativeStatus = representative
|
||||||
? resolveWorkerShareStatus(workOrderStatus, representative.status)
|
? resolveWorkerShareStatus(workOrderStatus, representative.status)
|
||||||
: workOrderStatus
|
: workOrderStatus
|
||||||
if (input.hasTimeoutForMe && !input.isAssignedWorker && activeShares.length === 0) {
|
if (input.hasTimeoutForMe && !input.isAssignedWorker && activeShares.length === 0) {
|
||||||
representativeStatus = WORK_ORDER_STATUS.OPEN
|
representativeStatus = WORK_ORDER_STATUS.OPEN
|
||||||
|
} else if (problemShare) {
|
||||||
|
representativeStatus = WORK_ORDER_STATUS.PROBLEM
|
||||||
} else if (representativeStatus === WORK_ORDER_STATUS.IN_PROGRESS && input.hasPendingHandling) {
|
} else if (representativeStatus === WORK_ORDER_STATUS.IN_PROGRESS && input.hasPendingHandling) {
|
||||||
representativeStatus = WORK_ORDER_STATUS.PROBLEM
|
representativeStatus = WORK_ORDER_STATUS.PROBLEM
|
||||||
}
|
}
|
||||||
if (representativeStatus) tabs.add(representativeStatus as WorkerOrderStatusTab)
|
if (representativeStatus) tabs.add(representativeStatus as WorkerOrderStatusTab)
|
||||||
|
|
||||||
for (const share of activeShares) {
|
for (const share of activeShares) {
|
||||||
|
// 被标记问题单的份额只归问题单页签,不再按原状态出现(与 SQL 的 anyShare 分支一致)。
|
||||||
|
if (share.problem_marked_at) continue
|
||||||
if (share.status === 'joined') {
|
if (share.status === 'joined') {
|
||||||
// 挂待处理申请/反馈的进行中份额归问题单,不再计入代练中(与 SQL 的 anyShare 分支一致)。
|
// 挂待处理申请/反馈的进行中份额归问题单,不再计入代练中(与 SQL 的 anyShare 分支一致)。
|
||||||
if (!input.hasPendingHandling) tabs.add('in_progress')
|
if (!input.hasPendingHandling) tabs.add('in_progress')
|
||||||
@@ -748,6 +772,11 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
|||||||
shareReward: Number(share.share_reward || 0),
|
shareReward: Number(share.share_reward || 0),
|
||||||
shareDeposit: Number(share.share_deposit || 0),
|
shareDeposit: Number(share.share_deposit || 0),
|
||||||
status: share.status,
|
status: share.status,
|
||||||
|
/** 份额级问题单:只影响该份额持有人,母单状态不变 */
|
||||||
|
problemMarked: Boolean(share.problem_marked_at),
|
||||||
|
problemNote: share.problem_note || '',
|
||||||
|
problemMarkedAt: share.problem_marked_at || null,
|
||||||
|
problemMarkedBy: share.problem_marked_by || '',
|
||||||
acceptance: mapAcceptanceForResponse(share.acceptance_json),
|
acceptance: mapAcceptanceForResponse(share.acceptance_json),
|
||||||
draftAcceptance: mapAcceptanceForResponse(share.draft_acceptance_json),
|
draftAcceptance: mapAcceptanceForResponse(share.draft_acceptance_json),
|
||||||
submittedAt: share.submitted_at,
|
submittedAt: share.submitted_at,
|
||||||
|
|||||||
@@ -225,7 +225,13 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
|
|||||||
{ isTimedOutForMe, timeoutNotice, myShares: activeShares },
|
{ isTimedOutForMe, timeoutNotice, myShares: activeShares },
|
||||||
),
|
),
|
||||||
// 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。
|
// 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。
|
||||||
problemMarked: !isTimedOutForMe && item.status === 'problem',
|
// 份额级问题单也算「平台标记」:母单状态没变,但该打手这份被后台标记了。
|
||||||
|
problemMarked:
|
||||||
|
!isTimedOutForMe &&
|
||||||
|
(item.status === 'problem' ||
|
||||||
|
activeShares.some(
|
||||||
|
(share) => share.status !== 'cancelled' && Boolean(share.problem_marked_at),
|
||||||
|
)),
|
||||||
pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId),
|
pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId),
|
||||||
pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId),
|
pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId),
|
||||||
materialUpdated,
|
materialUpdated,
|
||||||
|
|||||||
@@ -3364,6 +3364,61 @@ test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('份额级问题单标记/处置只写份额字段,不得改母单状态或触发整单重算', () => {
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
const markBlock = src.slice(
|
||||||
|
src.indexOf('export async function markWorkOrderShareProblem('),
|
||||||
|
src.indexOf('export async function disposeWorkOrderShareProblem('),
|
||||||
|
)
|
||||||
|
assert.ok(markBlock.length > 0, 'markWorkOrderShareProblem 片段必须存在')
|
||||||
|
assert.ok(markBlock.includes('UPDATE work_order_shares'), '标记必须写份额表')
|
||||||
|
assert.ok(
|
||||||
|
!/UPDATE\s+work_orders/i.test(markBlock),
|
||||||
|
'份额级问题单不得写母单(含 status / problem_note)',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
!markBlock.includes('recomputeWorkOrderSharingStatusWithClient('),
|
||||||
|
'份额级问题单不得触发整单状态重算',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
markBlock.includes('toStatus: workOrder.status') &&
|
||||||
|
markBlock.includes('fromStatus: workOrder.status'),
|
||||||
|
'标记事件不得记录母单状态迁移',
|
||||||
|
)
|
||||||
|
|
||||||
|
const disposeBlock = src.slice(
|
||||||
|
src.indexOf('export async function disposeWorkOrderShareProblem('),
|
||||||
|
src.indexOf('export async function settleOverdueWorkOrder('),
|
||||||
|
)
|
||||||
|
assert.ok(disposeBlock.length > 0, 'disposeWorkOrderShareProblem 片段必须存在')
|
||||||
|
assert.ok(disposeBlock.includes('UPDATE work_order_shares'), '处置必须写份额表')
|
||||||
|
assert.ok(!/UPDATE\s+work_orders/i.test(disposeBlock), '份额处置不得改母单')
|
||||||
|
assert.ok(
|
||||||
|
!disposeBlock.includes('recomputeWorkOrderSharingStatusWithClient('),
|
||||||
|
'份额处置不得触发整单状态重算',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
disposeBlock.includes('toStatus: workOrder.status') &&
|
||||||
|
disposeBlock.includes('fromStatus: workOrder.status'),
|
||||||
|
'处置事件不得记录母单状态迁移',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
disposeBlock.includes("status = CASE WHEN status = 'submitted' THEN 'joined'"),
|
||||||
|
'退回打手必须把已提交的份额恢复为进行中',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
disposeBlock.includes("shouldDeduct ? 'deposit_deduction' : 'deposit_release'"),
|
||||||
|
'取消类处置必须按方式退还或扣除押金',
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
disposeBlock.includes("cancelled_reason = 'problem'"),
|
||||||
|
'取消类处置必须记录问题单处置来源',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参与时间固化截止时间', () => {
|
test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参与时间固化截止时间', () => {
|
||||||
const src = fs.readFileSync(
|
const src = fs.readFileSync(
|
||||||
new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url),
|
new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url),
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
confirmAdminWorkOrderFriend,
|
confirmAdminWorkOrderFriend,
|
||||||
deductAdminWorkOrderPendingDeposit,
|
deductAdminWorkOrderPendingDeposit,
|
||||||
deleteAdminWorkOrder,
|
deleteAdminWorkOrder,
|
||||||
|
disposeAdminWorkOrderShareProblem,
|
||||||
fetchAdminWorkCategories,
|
fetchAdminWorkCategories,
|
||||||
fetchAdminSession,
|
fetchAdminSession,
|
||||||
fetchAdminWorkOrderSharing,
|
fetchAdminWorkOrderSharing,
|
||||||
@@ -65,6 +66,7 @@ import {
|
|||||||
fetchAdminWorkerUsers,
|
fetchAdminWorkerUsers,
|
||||||
fetchAdminWorkOrders,
|
fetchAdminWorkOrders,
|
||||||
markAdminWorkOrderProblem,
|
markAdminWorkOrderProblem,
|
||||||
|
markAdminWorkOrderShareProblem,
|
||||||
pinAdminWorkOrder,
|
pinAdminWorkOrder,
|
||||||
publishAdminWorkOrder,
|
publishAdminWorkOrder,
|
||||||
reopenAdminCancelledWorkOrder,
|
reopenAdminCancelledWorkOrder,
|
||||||
@@ -241,6 +243,9 @@ export default function WorkOrdersPanel() {
|
|||||||
const canCancelShare = sessionPermissions
|
const canCancelShare = sessionPermissions
|
||||||
? sessionPermissions.includes('worker_order.cancel_share')
|
? sessionPermissions.includes('worker_order.cancel_share')
|
||||||
: hasAdminPermission('worker_order.cancel_share')
|
: hasAdminPermission('worker_order.cancel_share')
|
||||||
|
const canManageShareProblem = sessionPermissions
|
||||||
|
? sessionPermissions.includes('worker_order.manage_problem')
|
||||||
|
: hasAdminPermission('worker_order.manage_problem')
|
||||||
const canEditOrder = sessionPermissions
|
const canEditOrder = sessionPermissions
|
||||||
? sessionPermissions.includes('worker_order.edit')
|
? sessionPermissions.includes('worker_order.edit')
|
||||||
: hasAdminPermission('worker_order.edit')
|
: hasAdminPermission('worker_order.edit')
|
||||||
@@ -298,6 +303,8 @@ export default function WorkOrdersPanel() {
|
|||||||
const [materialPasteText, setMaterialPasteText] = useState('')
|
const [materialPasteText, setMaterialPasteText] = useState('')
|
||||||
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
||||||
const [cancelShare, setCancelShare] = useState<WorkOrderShare | null>(null)
|
const [cancelShare, setCancelShare] = useState<WorkOrderShare | null>(null)
|
||||||
|
const [problemShare, setProblemShare] = useState<WorkOrderShare | null>(null)
|
||||||
|
const [disposeShare, setDisposeShare] = useState<WorkOrderShare | null>(null)
|
||||||
const [cancelSharingOrder, setCancelSharingOrder] = useState<WorkOrder | null>(null)
|
const [cancelSharingOrder, setCancelSharingOrder] = useState<WorkOrder | null>(null)
|
||||||
const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null)
|
const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null)
|
||||||
const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined)
|
const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined)
|
||||||
@@ -310,6 +317,11 @@ export default function WorkOrdersPanel() {
|
|||||||
const [materialForm] = Form.useForm<MaterialFormValues>()
|
const [materialForm] = Form.useForm<MaterialFormValues>()
|
||||||
const [cancelForm] = Form.useForm<CancelOrderFormValues>()
|
const [cancelForm] = Form.useForm<CancelOrderFormValues>()
|
||||||
const [cancelShareForm] = Form.useForm<CancelShareFormValues>()
|
const [cancelShareForm] = Form.useForm<CancelShareFormValues>()
|
||||||
|
const [problemShareForm] = Form.useForm<{ note?: string }>()
|
||||||
|
const [disposeShareForm] = Form.useForm<{
|
||||||
|
action?: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct'
|
||||||
|
note?: string
|
||||||
|
}>()
|
||||||
const [cancelSharingForm] = Form.useForm<CancelSharingFormValues>()
|
const [cancelSharingForm] = Form.useForm<CancelSharingFormValues>()
|
||||||
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
|
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
|
||||||
const [editForm] = Form.useForm<{
|
const [editForm] = Form.useForm<{
|
||||||
@@ -710,6 +722,51 @@ export default function WorkOrdersPanel() {
|
|||||||
cancelShareForm.resetFields()
|
cancelShareForm.resetFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按份额标记问题单:只影响该打手,母单状态不变。备注选填。 */
|
||||||
|
async function submitProblemShare(values: { note?: string }) {
|
||||||
|
if (!sharingOrder || !problemShare) return
|
||||||
|
const workOrderId = sharingOrder.workOrderId
|
||||||
|
const succeeded = await runAction(
|
||||||
|
() =>
|
||||||
|
markAdminWorkOrderShareProblem(
|
||||||
|
workOrderId,
|
||||||
|
problemShare.shareId,
|
||||||
|
String(values.note || '').trim(),
|
||||||
|
),
|
||||||
|
'该份额已标记为问题单,仅影响该打手',
|
||||||
|
)
|
||||||
|
if (!succeeded) return
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-order-sharing', workOrderId],
|
||||||
|
})
|
||||||
|
setProblemShare(null)
|
||||||
|
problemShareForm.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处置份额问题单:方式与母单处置一致,但只作用于这一份,母单状态不变。 */
|
||||||
|
async function submitShareDisposal(values: {
|
||||||
|
action?: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct'
|
||||||
|
note?: string
|
||||||
|
}) {
|
||||||
|
if (!sharingOrder || !disposeShare || !values.action) return
|
||||||
|
const workOrderId = sharingOrder.workOrderId
|
||||||
|
const action = values.action
|
||||||
|
const succeeded = await runAction(
|
||||||
|
() =>
|
||||||
|
disposeAdminWorkOrderShareProblem(workOrderId, disposeShare.shareId, {
|
||||||
|
action,
|
||||||
|
note: String(values.note || '').trim(),
|
||||||
|
}),
|
||||||
|
'该份额问题单已处置',
|
||||||
|
)
|
||||||
|
if (!succeeded) return
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['admin-worker-platform-order-sharing', workOrderId],
|
||||||
|
})
|
||||||
|
setDisposeShare(null)
|
||||||
|
disposeShareForm.resetFields()
|
||||||
|
}
|
||||||
|
|
||||||
async function submitCancelSharing(values: CancelSharingFormValues) {
|
async function submitCancelSharing(values: CancelSharingFormValues) {
|
||||||
if (!cancelSharingOrder || !values.action) return
|
if (!cancelSharingOrder || !values.action) return
|
||||||
const action = values.action
|
const action = values.action
|
||||||
@@ -3023,13 +3080,14 @@ export default function WorkOrdersPanel() {
|
|||||||
sharingOrder?.capabilities?.canConfigureSharing && canConfigureSharing ? undefined : null
|
sharingOrder?.capabilities?.canConfigureSharing && canConfigureSharing ? undefined : null
|
||||||
}
|
}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={720}
|
width={1200}
|
||||||
>
|
>
|
||||||
{sharingOrder ? (
|
{sharingOrder ? (
|
||||||
<Space direction="vertical" size={16} className="full-width">
|
<Space direction="vertical" size={16} className="full-width">
|
||||||
<Form
|
<Form
|
||||||
form={sharingForm}
|
form={sharingForm}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
|
style={{ maxWidth: 760 }}
|
||||||
initialValues={{
|
initialValues={{
|
||||||
enabled: false,
|
enabled: false,
|
||||||
totalQuantity: 1,
|
totalQuantity: 1,
|
||||||
@@ -3146,20 +3204,33 @@ export default function WorkOrdersPanel() {
|
|||||||
dataSource={sharingShares}
|
dataSource={sharingShares}
|
||||||
loading={sharingQuery.isFetching}
|
loading={sharingQuery.isFetching}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 1280 }}
|
tableLayout="fixed"
|
||||||
|
scroll={{ x: 1528 }}
|
||||||
|
rowClassName={(share) =>
|
||||||
|
share.problemMarked
|
||||||
|
? 'admin-share-row-problem'
|
||||||
|
: share.status === 'cancelled'
|
||||||
|
? 'admin-share-row-cancelled'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '打手',
|
title: '打手',
|
||||||
|
width: 132,
|
||||||
|
fixed: 'left',
|
||||||
render: (_, share) =>
|
render: (_, share) =>
|
||||||
share.worker?.displayName || share.worker?.username || '-',
|
share.worker?.displayName || share.worker?.username || '-',
|
||||||
},
|
},
|
||||||
{ title: '数量', dataIndex: 'quantity' },
|
{ title: '数量', dataIndex: 'quantity', width: 64, align: 'center' },
|
||||||
{
|
{
|
||||||
title: '份额报酬',
|
title: '份额报酬',
|
||||||
|
width: 96,
|
||||||
|
align: 'right',
|
||||||
render: (_, share) => formatMoney(share.shareReward),
|
render: (_, share) => formatMoney(share.shareReward),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
|
width: 104,
|
||||||
render: (_, share) => (
|
render: (_, share) => (
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<Tag color={resolveSharingShareStatusColor(share.status)}>
|
<Tag color={resolveSharingShareStatusColor(share.status)}>
|
||||||
@@ -3171,13 +3242,40 @@ export default function WorkOrdersPanel() {
|
|||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '问题',
|
||||||
|
width: 120,
|
||||||
|
render: (_, share) =>
|
||||||
|
share.problemMarked ? (
|
||||||
|
<Space direction="vertical" size={2}>
|
||||||
|
<Tag color="red">问题单</Tag>
|
||||||
|
{String(share.problemNote || '').trim() ? (
|
||||||
|
<Typography.Text
|
||||||
|
type="danger"
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
ellipsis={{ tooltip: share.problemNote }}
|
||||||
|
>
|
||||||
|
{share.problemNote}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
未填备注
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">-</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '参与时间',
|
title: '参与时间',
|
||||||
|
width: 128,
|
||||||
render: (_, share) =>
|
render: (_, share) =>
|
||||||
share.createdAt ? formatAdminDateTime(share.createdAt) : '-',
|
share.createdAt ? formatAdminDateTime(share.createdAt) : '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '截止时间',
|
title: '截止时间',
|
||||||
|
width: 150,
|
||||||
render: (_, share) =>
|
render: (_, share) =>
|
||||||
share.deadlineAt ? (
|
share.deadlineAt ? (
|
||||||
<span>
|
<span>
|
||||||
@@ -3190,17 +3288,19 @@ export default function WorkOrdersPanel() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '超时策略',
|
title: '超时策略',
|
||||||
|
width: 90,
|
||||||
render: (_, share) =>
|
render: (_, share) =>
|
||||||
share.timeoutPolicy ? formatTimeoutPolicyLabel(share.timeoutPolicy) : '-',
|
share.timeoutPolicy ? formatTimeoutPolicyLabel(share.timeoutPolicy) : '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '提交时间',
|
title: '提交时间',
|
||||||
|
width: 128,
|
||||||
render: (_, share) =>
|
render: (_, share) =>
|
||||||
share.submittedAt ? formatAdminDateTime(share.submittedAt) : '-',
|
share.submittedAt ? formatAdminDateTime(share.submittedAt) : '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '验收图',
|
title: '验收图',
|
||||||
width: 150,
|
width: 96,
|
||||||
render: (_, share) => (
|
render: (_, share) => (
|
||||||
<ImagePreviewList
|
<ImagePreviewList
|
||||||
files={
|
files={
|
||||||
@@ -3211,23 +3311,70 @@ export default function WorkOrdersPanel() {
|
|||||||
? share.acceptance.imageUrls
|
? share.acceptance.imageUrls
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
size={44}
|
size={36}
|
||||||
maxVisible={2}
|
maxVisible={2}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '备注',
|
title: '备注',
|
||||||
render: (_, share) => String(share.acceptance?.note || '').trim() || '-',
|
width: 120,
|
||||||
|
render: (_, share) => {
|
||||||
|
const note = String(share.acceptance?.note || '').trim()
|
||||||
|
return note ? (
|
||||||
|
<Typography.Text style={{ fontSize: 12 }} ellipsis={{ tooltip: note }}>
|
||||||
|
{note}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 190,
|
width: 300,
|
||||||
|
fixed: 'right',
|
||||||
render: (_, share) => {
|
render: (_, share) => {
|
||||||
if (share.status === 'accepted') return <Tag color="green">已验收</Tag>
|
|
||||||
if (share.status === 'cancelled') return <Tag>已撤单</Tag>
|
if (share.status === 'cancelled') return <Tag>已撤单</Tag>
|
||||||
|
if (share.status === 'accepted') return <Tag color="green">已验收</Tag>
|
||||||
|
|
||||||
|
// 份额级问题单:标记只影响该打手;已标记时按母单同一套方式处置。
|
||||||
|
// 已验收份额的报酬/押金已结算,不能处置,因此也不提供标记入口。
|
||||||
|
const disposableShare =
|
||||||
|
share.status === 'joined' || share.status === 'submitted'
|
||||||
|
const problemAction =
|
||||||
|
canManageShareProblem && disposableShare ? (
|
||||||
|
share.problemMarked ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setDisposeShare(share)
|
||||||
|
disposeShareForm.setFieldsValue({
|
||||||
|
action: 'return_to_worker',
|
||||||
|
note: '',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
处置问题单
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<WarningOutlined />}
|
||||||
|
title="只标记该参与者的份额;母单状态与其他打手不受影响"
|
||||||
|
onClick={() => {
|
||||||
|
setProblemShare(share)
|
||||||
|
problemShareForm.setFieldsValue({ note: '' })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
标记问题单
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space size={6}>
|
<Space size={6} className="admin-share-actions">
|
||||||
{share.status === 'submitted' ? (
|
{share.status === 'submitted' ? (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -3271,6 +3418,7 @@ export default function WorkOrdersPanel() {
|
|||||||
单人撤单
|
单人撤单
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{problemAction}
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -3315,6 +3463,93 @@ export default function WorkOrdersPanel() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`标记份额问题单 · ${
|
||||||
|
problemShare?.worker?.displayName || problemShare?.worker?.username || ''
|
||||||
|
}`}
|
||||||
|
open={Boolean(problemShare)}
|
||||||
|
okText="确认标记"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onCancel={() => {
|
||||||
|
setProblemShare(null)
|
||||||
|
problemShareForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => problemShareForm.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{problemShare ? (
|
||||||
|
<Form form={problemShareForm} layout="vertical" onFinish={submitProblemShare}>
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="warning"
|
||||||
|
message={`将标记该打手承接的 ${problemShare.quantity} 份拼单为问题单`}
|
||||||
|
description="只有该份额持有人会在「我的订单」里看到问题单与备注;同工单其他打手、母单状态与份额状态都不受影响。整单问题单仍可在订单详情里操作。"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
<Form.Item label="问题备注" name="note">
|
||||||
|
<Input.TextArea rows={3} placeholder="选填,仅该份额持有人可见" maxLength={500} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`处置份额问题单 · ${
|
||||||
|
disposeShare?.worker?.displayName || disposeShare?.worker?.username || ''
|
||||||
|
}`}
|
||||||
|
open={Boolean(disposeShare)}
|
||||||
|
okText="确认处置"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onCancel={() => {
|
||||||
|
setDisposeShare(null)
|
||||||
|
disposeShareForm.resetFields()
|
||||||
|
}}
|
||||||
|
onOk={() => disposeShareForm.submit()}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
{disposeShare ? (
|
||||||
|
<Form
|
||||||
|
form={disposeShareForm}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{ action: 'return_to_worker', note: '' }}
|
||||||
|
onFinish={submitShareDisposal}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="warning"
|
||||||
|
message={`只处置「${
|
||||||
|
disposeShare.worker?.displayName || disposeShare.worker?.username || '该打手'
|
||||||
|
}」的 ${disposeShare.quantity} 份份额`}
|
||||||
|
description="处置方式与母单一致,但只作用于这一份:母单状态、其他打手都不受影响。"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
<Form.Item
|
||||||
|
label="处置方式"
|
||||||
|
name="action"
|
||||||
|
rules={[{ required: true, message: '请选择处置方式' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'return_to_worker', label: '退回打手继续代练' },
|
||||||
|
{ value: 'reopen', label: '释放押金并重新放回大厅' },
|
||||||
|
{ value: 'cancel_release', label: '取消该份额并退还押金' },
|
||||||
|
{ value: 'cancel_deduct', label: '取消该份额并扣除押金' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="处置备注"
|
||||||
|
name="note"
|
||||||
|
rules={[{ required: true, message: '请填写处置备注' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={4} placeholder="如:验收图片与要求不符,退回继续代练" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={`拼单撤单处理 · ${getDisplayOrderNo(cancelSharingOrder)}`}
|
title={`拼单撤单处理 · ${getDisplayOrderNo(cancelSharingOrder)}`}
|
||||||
open={Boolean(cancelSharingOrder)}
|
open={Boolean(cancelSharingOrder)}
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export function formatSharingShareCancelledReason(reason: string) {
|
|||||||
timeout: '超时失效',
|
timeout: '超时失效',
|
||||||
admin: '后台撤单',
|
admin: '后台撤单',
|
||||||
worker_request: '打手退单',
|
worker_request: '打手退单',
|
||||||
|
problem: '问题单处置',
|
||||||
}
|
}
|
||||||
return labels[reason] || reason
|
return labels[reason] || reason
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,43 @@ export function WorkerOrderShareTable({
|
|||||||
return <Tag color={meta.color}>{meta.label}</Tag>
|
return <Tag color={meta.color}>{meta.label}</Tag>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '问题',
|
||||||
|
width: 150,
|
||||||
|
render: (_, share) => {
|
||||||
|
if (!share.problemMarked) {
|
||||||
|
return (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
—
|
||||||
|
</Typography.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const note = String(share.problemNote || '').trim()
|
||||||
|
return (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Tag
|
||||||
|
color="red"
|
||||||
|
title={
|
||||||
|
share.problemMarkedAt
|
||||||
|
? `标记时间:${formatDateTime(share.problemMarkedAt)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
问题单
|
||||||
|
</Tag>
|
||||||
|
{note ? (
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
ellipsis={{ tooltip: note }}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '截止时间',
|
title: '截止时间',
|
||||||
width: 170,
|
width: 170,
|
||||||
@@ -171,7 +208,7 @@ export function WorkerOrderShareTable({
|
|||||||
dataSource={shares}
|
dataSource={shares}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
className="worker-order-share-table"
|
className="worker-order-share-table"
|
||||||
scroll={{ x: mobile ? 820 : 1080 }}
|
scroll={{ x: mobile ? 970 : 1230 }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,12 @@ export function getOrderFlagTags(order: WorkOrder) {
|
|||||||
}
|
}
|
||||||
return flags
|
return flags
|
||||||
}
|
}
|
||||||
if (order.problemMarked) flags.push({ key: 'problem', label: '平台标记', color: 'red' })
|
if (order.problemMarked)
|
||||||
|
flags.push(
|
||||||
|
order.shareProblemMarked
|
||||||
|
? { key: 'share_problem', label: '份额问题', color: 'red' }
|
||||||
|
: { key: 'problem', label: '平台标记', color: 'red' },
|
||||||
|
)
|
||||||
if (order.pendingCancelRequest)
|
if (order.pendingCancelRequest)
|
||||||
flags.push({ key: 'cancel', label: '退单审核中', color: 'orange' })
|
flags.push({ key: 'cancel', label: '退单审核中', color: 'orange' })
|
||||||
if (order.pendingFeedback) flags.push({ key: 'feedback', label: '反馈处理中', color: 'orange' })
|
if (order.pendingFeedback) flags.push({ key: 'feedback', label: '反馈处理中', color: 'orange' })
|
||||||
|
|||||||
@@ -892,6 +892,29 @@ export function cancelAdminWorkOrderShare(
|
|||||||
}>(`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/cancel`, payload)
|
}>(`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/cancel`, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按份额标记问题单:只影响该份额持有人,母单状态不变。备注选填。 */
|
||||||
|
export function markAdminWorkOrderShareProblem(workOrderId: number, shareId: number, note = '') {
|
||||||
|
return apiPost<{ order: WorkOrder; share: WorkOrderShare }>(
|
||||||
|
`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/problem`,
|
||||||
|
{ note },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处置份额级问题单:方式与母单处置一致,但只作用于该份额。 */
|
||||||
|
export function disposeAdminWorkOrderShareProblem(
|
||||||
|
workOrderId: number,
|
||||||
|
shareId: number,
|
||||||
|
payload: {
|
||||||
|
action: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct'
|
||||||
|
note: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return apiPost<{ order: WorkOrder; share: WorkOrderShare; depositAmount: number }>(
|
||||||
|
`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/problem/dispose`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function cancelAdminWorkOrderSharing(
|
export function cancelAdminWorkOrderSharing(
|
||||||
workOrderId: number,
|
workOrderId: number,
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -1607,3 +1607,31 @@
|
|||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 拼单明细:操作按钮保持一行 */
|
||||||
|
.admin-share-actions {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 拼单明细:问题单行强调、已撤单行弱化 */
|
||||||
|
.admin-share-row-problem > td {
|
||||||
|
background: #fff1f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-share-row-problem > td:first-child {
|
||||||
|
box-shadow: inset 3px 0 0 #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-share-row-problem:hover > td {
|
||||||
|
background: #ffe7e6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-share-row-cancelled > td {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-share-row-cancelled:hover > td {
|
||||||
|
background: #f5f5f5 !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -545,6 +545,11 @@ export type WorkOrderShare = {
|
|||||||
timeoutMinutes?: number
|
timeoutMinutes?: number
|
||||||
timeoutPolicy?: string
|
timeoutPolicy?: string
|
||||||
cancelledReason?: string
|
cancelledReason?: string
|
||||||
|
/** 份额级问题单:只影响该份额持有人,母单状态不变 */
|
||||||
|
problemMarked?: boolean
|
||||||
|
problemNote?: string
|
||||||
|
problemMarkedAt?: string | null
|
||||||
|
problemMarkedBy?: string
|
||||||
worker: null | {
|
worker: null | {
|
||||||
workerId: number
|
workerId: number
|
||||||
username: string
|
username: string
|
||||||
@@ -651,6 +656,8 @@ export type WorkOrder = {
|
|||||||
problemNote: string
|
problemNote: string
|
||||||
/** 问题单分组来源角标:平台标记 / 退单审核中 / 反馈处理中 / 资料已更新。 */
|
/** 问题单分组来源角标:平台标记 / 退单审核中 / 反馈处理中 / 资料已更新。 */
|
||||||
problemMarked?: boolean
|
problemMarked?: boolean
|
||||||
|
/** 份额级问题单(后台按份额标记,只有本人可见;母单状态不变) */
|
||||||
|
shareProblemMarked?: boolean
|
||||||
pendingCancelRequest?: boolean
|
pendingCancelRequest?: boolean
|
||||||
pendingFeedback?: boolean
|
pendingFeedback?: boolean
|
||||||
materialUpdated?: boolean
|
materialUpdated?: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user