add:新增同一个工单同一人可以多次拼单
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
-- 后台「接单工单 → 店铺」筛选:按订单归属店铺精确匹配(可多选)。
|
||||
-- 过滤器写法是 BTRIM(o.shop_name) = ANY($n::text[]),普通 btree 无法命中表达式,
|
||||
-- 所以补一个与查询同口径的表达式索引;部分索引省掉空店铺的无效条目。
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_shop_name_trimmed
|
||||
ON orders (BTRIM(shop_name))
|
||||
WHERE BTRIM(shop_name) <> '';
|
||||
|
||||
COMMENT ON INDEX idx_orders_shop_name_trimmed IS
|
||||
'支持后台接单工单按订单店铺(orders.shop_name)精确多选筛选';
|
||||
@@ -0,0 +1,64 @@
|
||||
-- 104_work_order_share_multi_participation.sql
|
||||
-- 目标:同一打手可对同一拼单工单多次参与,每次参与一条 work_order_shares;
|
||||
-- 每份在参与时固化截止时间与超时策略;超时/验收/撤单按份额独立。
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. 只加列 + 回填 + 删「一人一条」唯一键 + 建索引,不拆分历史行、不改历史资金。
|
||||
-- 2. 历史合并份额(quantity > 1)保持原样,结算公式 quantity × unit_reward 完全兼容。
|
||||
-- 3. request_id 为客户端幂等键,唯一索引只约束非空值。
|
||||
|
||||
ALTER TABLE work_order_shares
|
||||
ADD COLUMN IF NOT EXISTS deadline_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS timeout_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS timeout_policy TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS cancelled_reason TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS request_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN work_order_shares.deadline_at IS '本份额截止时间:参与时 = created_at + 母单当时 timeout_minutes;0 分钟为 NULL';
|
||||
COMMENT ON COLUMN work_order_shares.timeout_minutes IS '参与时固化的时限(分钟),母单后续改配置不影响';
|
||||
COMMENT ON COLUMN work_order_shares.timeout_policy IS '参与时固化的超时策略 reopen/cancel_release/cancel_deduct';
|
||||
COMMENT ON COLUMN work_order_shares.cancelled_reason IS '取消来源:timeout/worker_request/admin/problem/settlement';
|
||||
COMMENT ON COLUMN work_order_shares.request_id IS '客户端幂等键(一次点击一个),重复请求返回原份额';
|
||||
|
||||
-- 回填历史份额:按当时母单配置一次性固化
|
||||
UPDATE work_order_shares s
|
||||
SET timeout_minutes = COALESCE(w.timeout_minutes, 0),
|
||||
timeout_policy = COALESCE(NULLIF(w.timeout_policy, ''), 'reopen'),
|
||||
deadline_at = CASE
|
||||
WHEN COALESCE(w.timeout_minutes, 0) > 0
|
||||
THEN s.created_at + (w.timeout_minutes || ' minutes')::interval
|
||||
ELSE NULL
|
||||
END
|
||||
FROM work_orders w
|
||||
WHERE w.id = s.work_order_id
|
||||
AND s.timeout_minutes = 0;
|
||||
|
||||
-- 删除「同一工单同一打手只能一条」约束(否则第二次参与报 23505)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'work_order_shares_work_order_id_worker_id_key'
|
||||
) THEN
|
||||
ALTER TABLE work_order_shares
|
||||
DROP CONSTRAINT work_order_shares_work_order_id_worker_id_key;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 到期扫描:只覆盖 joined 份额,走部分索引
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_shares_due
|
||||
ON work_order_shares (deadline_at)
|
||||
WHERE status = 'joined';
|
||||
|
||||
-- 打手在某工单的份额列表(我的订单展开、押金累计求和)
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_shares_worker_order
|
||||
ON work_order_shares (worker_id, work_order_id, created_at DESC);
|
||||
|
||||
-- 母单份额集合推导(recompute 母单状态、剩余份数)
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_shares_order_status
|
||||
ON work_order_shares (work_order_id, status, created_at ASC);
|
||||
|
||||
-- 幂等键:同一打手同一个 request_id 只能产生一条份额
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_work_order_shares_worker_request
|
||||
ON work_order_shares (worker_id, request_id)
|
||||
WHERE request_id <> '';
|
||||
@@ -66,8 +66,25 @@ test('大厅候选集(buildHallCandidateIdsSql)不再聚合整表拼单', as
|
||||
test('全仓库只允许保留一处「整表拼单聚合」,且必须是无打手范围的全表统计分支', async () => {
|
||||
const raw = await readRepoSource()
|
||||
const source = stripComments(raw)
|
||||
|
||||
// 我的订单概览里按「工单 + 份额状态」聚合金额,必须限定到当前打手;
|
||||
// 它不是整表聚合,先单独摘出来校验,避免被下面的全表聚合守卫误判。
|
||||
const shareStatusStart = source.indexOf('share_status_rows AS (')
|
||||
const shareStatusEnd = source.indexOf('order_status_rows AS (')
|
||||
const workerShareAggregation =
|
||||
shareStatusStart >= 0 && shareStatusEnd > shareStatusStart
|
||||
? source.slice(shareStatusStart, shareStatusEnd)
|
||||
: ''
|
||||
assert.ok(workerShareAggregation, '缺少我的订单概览的份额状态聚合')
|
||||
|
||||
const sourceWithoutWorkerShareAggregation =
|
||||
shareStatusStart >= 0 && shareStatusEnd > shareStatusStart
|
||||
? source.slice(0, shareStatusStart) + source.slice(shareStatusEnd)
|
||||
: source
|
||||
const globalAggregations = [
|
||||
...source.matchAll(/GROUP BY\s+wos\.work_order_id|GROUP BY\s+work_order_id/g),
|
||||
...sourceWithoutWorkerShareAggregation.matchAll(
|
||||
/GROUP BY\s+wos\.work_order_id|GROUP BY\s+work_order_id/g,
|
||||
),
|
||||
]
|
||||
|
||||
assert.equal(
|
||||
@@ -78,15 +95,28 @@ test('全仓库只允许保留一处「整表拼单聚合」,且必须是无
|
||||
)
|
||||
|
||||
const onlyRemaining = globalAggregations[0]!
|
||||
const around = source.slice(Math.max(0, onlyRemaining.index - 900), onlyRemaining.index + 200)
|
||||
const around = sourceWithoutWorkerShareAggregation.slice(
|
||||
Math.max(0, (onlyRemaining.index ?? 0) - 900),
|
||||
(onlyRemaining.index ?? 0) + 200,
|
||||
)
|
||||
assert.ok(
|
||||
around.includes('operational_share_summary'),
|
||||
'保留的那处整表聚合不在 operational_share_summary 分支里,需要确认是否有意为之',
|
||||
)
|
||||
|
||||
// 注释在 stripComments 里被去掉了,所以断言理由时用原文
|
||||
const rawIndex = raw.indexOf('GROUP BY wos.work_order_id')
|
||||
const rawAround = raw.slice(Math.max(0, rawIndex - 1200), rawIndex + 200)
|
||||
// 注释在 stripComments 里被去掉了,所以断言理由时用原文。
|
||||
// 注意:raw 里前面还有 share_status_rows 的工单内聚合,必须找 operational 那一处。
|
||||
const rawMatches = [...raw.matchAll(/GROUP BY\s+wos\.work_order_id/g)]
|
||||
const operationalRaw = rawMatches.find((match) =>
|
||||
raw
|
||||
.slice(Math.max(0, (match.index ?? 0) - 1200), (match.index ?? 0) + 200)
|
||||
.includes('operational_share_summary'),
|
||||
)
|
||||
assert.ok(operationalRaw, '找不到 operational_share_summary 的整表聚合')
|
||||
const rawAround = raw.slice(
|
||||
Math.max(0, (operationalRaw.index ?? 0) - 1200),
|
||||
(operationalRaw.index ?? 0) + 200,
|
||||
)
|
||||
assert.ok(
|
||||
rawAround.includes('无打手范围时一次聚合全部拼单'),
|
||||
'保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删',
|
||||
@@ -105,18 +135,67 @@ test('我的订单的份额关联按工单取一条,避免多份额把列表
|
||||
|
||||
test('我的订单概览按工单计数,金额仍按份额求和', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
const start = source.indexOf('worker_base AS (')
|
||||
assert.ok(start >= 0, '缺少我的订单概览的 worker_base CTE')
|
||||
const start = source.indexOf('order_flags AS (')
|
||||
assert.ok(start >= 0, '缺少我的订单概览的 order_flags CTE')
|
||||
|
||||
const block = source.slice(start, start + 2200)
|
||||
assert.match(block, /LEFT JOIN LATERAL \(/, '概览的份额关联没有按工单取一条')
|
||||
assert.match(block, /share_reward_total/, '概览没有汇总多份份额的奖励金额')
|
||||
const block = source.slice(start, start + 4200)
|
||||
assert.match(block, /share_status_rows AS \(/, '概览没有按份额状态拆分金额')
|
||||
assert.match(block, /order_status_rows AS \(/, '概览没有聚合成工单状态行')
|
||||
assert.match(block, /SUM\(wos\.share_reward\)/, '概览没有汇总份额奖励')
|
||||
assert.match(
|
||||
block,
|
||||
/GROUP BY wos\.work_order_id, wos\.status/,
|
||||
'份额金额没有按「工单 + 份额状态」聚合',
|
||||
)
|
||||
assert.ok(
|
||||
!block.includes('LEFT JOIN work_order_shares wos'),
|
||||
'概览仍在直接 JOIN 份额表:同一工单 3 份会被统计成 3 单',
|
||||
)
|
||||
})
|
||||
|
||||
test('多份额混合状态:任一状态命中即可出现在对应页签', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
|
||||
assert.ok(source.includes("ws_filter.status = 'submitted'"), '待验收筛选没有检查任一份额')
|
||||
assert.ok(source.includes("'pending_acceptance' = ANY"), '待验收状态没有参与任一份额匹配')
|
||||
})
|
||||
|
||||
/**
|
||||
* 概览与列表必须是同一把尺子:列表里「待审核取消申请/未处理反馈」只把进行中的份额
|
||||
* 归入问题单,已提交待验收的份额照旧留在待验收。
|
||||
* 概览旧写法把这类工单的份额整体排除、再由第二分支记成问题单,于是出现
|
||||
* 「问题单卡片有数字、点进去列表是空的」「待验收列表有单、卡片不计数」。
|
||||
*/
|
||||
test('概览与列表同口径:待处理申请只把进行中份额归问题单', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
|
||||
const shareStart = source.indexOf('share_status_rows AS (')
|
||||
assert.ok(shareStart >= 0, '缺少 share_status_rows CTE')
|
||||
const shareBlock = source.slice(shareStart, shareStart + 1600)
|
||||
assert.match(
|
||||
shareBlock,
|
||||
/WHEN wos\.status = 'joined' AND f\.has_pending_handling THEN 'problem'/,
|
||||
'待处理申请只应把进行中的份额归问题单',
|
||||
)
|
||||
assert.ok(
|
||||
!shareBlock.includes('AND NOT f.has_pending_handling'),
|
||||
'概览不能整体排除有待处理申请的份额,否则已提交待验收会从页签里消失',
|
||||
)
|
||||
|
||||
const orderStart = source.indexOf('order_status_rows AS (', shareStart)
|
||||
assert.ok(orderStart > shareStart, '缺少 order_status_rows CTE')
|
||||
const orderBlock = source.slice(orderStart, orderStart + 1600)
|
||||
assert.ok(
|
||||
!/OR f\.has_pending_handling\b/.test(orderBlock),
|
||||
'第二分支不能无条件把有待处理申请的工单塞进问题单(会产生重复/错页签计数)',
|
||||
)
|
||||
assert.match(
|
||||
orderBlock,
|
||||
/WHEN f\.has_pending_handling AND f\.wo_status = 'in_progress'/,
|
||||
'无份额的指派单也要按「进行中才归问题单」判定',
|
||||
)
|
||||
})
|
||||
|
||||
test('超时统计按工单去重,不按份额/事件行数累加', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
|
||||
|
||||
@@ -223,25 +223,29 @@ export function workerOrderBaseStatusExpression(workerShareAlias = 'worker_share
|
||||
* 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单,
|
||||
* 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。
|
||||
*/
|
||||
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 = `(
|
||||
function workerPendingHandlingExistsExpression(workerId: number): string {
|
||||
return `(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM worker_order_cancel_requests wcr
|
||||
WHERE wcr.work_order_id = wo.id
|
||||
AND wcr.worker_id = ${normalizedWorkerId}
|
||||
AND wcr.worker_id = ${workerId}
|
||||
AND wcr.status = 'pending'
|
||||
) OR EXISTS (
|
||||
SELECT 1
|
||||
FROM worker_order_feedbacks wf
|
||||
WHERE wf.work_order_id = wo.id
|
||||
AND wf.worker_id = ${normalizedWorkerId}
|
||||
AND wf.worker_id = ${workerId}
|
||||
AND wf.status = 'pending'
|
||||
)
|
||||
)`
|
||||
}
|
||||
|
||||
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 isTimeoutForMe = `(
|
||||
(wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId})
|
||||
AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted')
|
||||
@@ -638,12 +642,12 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
||||
WHERE timeout_ev.event_type LIKE 'timeout_%'
|
||||
AND timeout_ev.payload_json->>'workerId' = $1::text
|
||||
),
|
||||
worker_base AS (
|
||||
order_flags AS (
|
||||
SELECT
|
||||
wo.id,
|
||||
wo.status AS wo_status,
|
||||
wos.status AS wos_status,
|
||||
wos.status IS NOT NULL AS is_worker_share,
|
||||
wo.reward_amount,
|
||||
wo.acceptance_json,
|
||||
wo.assigned_worker_id = $1 AS is_assigned_to_me,
|
||||
EXISTS (
|
||||
SELECT 1 FROM work_order_events we
|
||||
@@ -651,88 +655,92 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
||||
AND we.event_type LIKE 'timeout_%'
|
||||
AND we.payload_json->>'workerId' = $1::text
|
||||
) AS has_timeout_for_me,
|
||||
CASE
|
||||
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
|
||||
(
|
||||
EXISTS (
|
||||
SELECT 1 FROM worker_order_cancel_requests wcr
|
||||
WHERE wcr.work_order_id = wo.id
|
||||
AND wcr.worker_id = $1
|
||||
AND wcr.status = 'pending'
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM worker_order_feedbacks wf
|
||||
WHERE wf.work_order_id = wo.id
|
||||
AND wf.worker_id = $1
|
||||
AND wf.status = 'pending'
|
||||
)
|
||||
) AS has_pending_handling,
|
||||
COALESCE((
|
||||
SELECT SUM(wos.share_reward)
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = wo.id
|
||||
AND wos.worker_id = $1
|
||||
AND wos.status != 'cancelled'
|
||||
), 0)::int AS share_reward_total,
|
||||
EXISTS (
|
||||
SELECT 1 FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = wo.id
|
||||
AND wos.worker_id = $1
|
||||
AND wos.status != 'cancelled'
|
||||
) AS is_worker_share
|
||||
FROM work_orders wo
|
||||
INNER JOIN visible_orders vo ON vo.work_order_id = wo.id
|
||||
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 (
|
||||
share_status_rows AS (
|
||||
SELECT
|
||||
wos.work_order_id,
|
||||
CASE
|
||||
WHEN NOT is_assigned_to_me AND (NOT is_worker_share OR wos_status = 'cancelled' OR wos_status IS NULL) AND has_timeout_for_me
|
||||
THEN 'open'
|
||||
WHEN (
|
||||
CASE
|
||||
WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem')
|
||||
THEN CASE wos_status
|
||||
WHEN 'joined' THEN 'in_progress'
|
||||
WHEN 'submitted' THEN 'pending_acceptance'
|
||||
WHEN 'accepted' THEN 'accepted'
|
||||
WHEN 'cancelled' THEN 'cancelled'
|
||||
ELSE wo_status
|
||||
END
|
||||
ELSE wo_status
|
||||
END
|
||||
) = 'in_progress' AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM worker_order_cancel_requests wcr
|
||||
WHERE wcr.work_order_id = worker_base.id
|
||||
AND wcr.worker_id = $1
|
||||
AND wcr.status = 'pending'
|
||||
) OR EXISTS (
|
||||
SELECT 1
|
||||
FROM worker_order_feedbacks wf
|
||||
WHERE wf.work_order_id = worker_base.id
|
||||
AND wf.worker_id = $1
|
||||
AND wf.status = 'pending'
|
||||
)
|
||||
) THEN 'problem'
|
||||
ELSE CASE
|
||||
WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem')
|
||||
THEN CASE wos_status
|
||||
WHEN 'joined' THEN 'in_progress'
|
||||
WHEN 'submitted' THEN 'pending_acceptance'
|
||||
WHEN 'accepted' THEN 'accepted'
|
||||
WHEN 'cancelled' THEN 'cancelled'
|
||||
ELSE wo_status
|
||||
END
|
||||
ELSE wo_status
|
||||
END
|
||||
-- 待审核取消申请/未处理反馈只把「进行中」的份额归入问题单;
|
||||
-- 已提交待验收的份额必须留在待验收(列表筛选就是这么判的,
|
||||
-- 这里若整体排除会出现「问题单有数字、点进去空列表」)。
|
||||
WHEN wos.status = 'joined' AND f.has_pending_handling THEN 'problem'
|
||||
WHEN wos.status = 'joined' THEN 'in_progress'
|
||||
WHEN wos.status = 'submitted' THEN 'pending_acceptance'
|
||||
WHEN wos.status = 'accepted' THEN 'accepted'
|
||||
END AS status,
|
||||
SUM(wos.share_reward)::int AS amount,
|
||||
(ARRAY_AGG(wos.acceptance_json ORDER BY wos.id DESC))[1] AS acceptance_json
|
||||
FROM work_order_shares wos
|
||||
INNER JOIN order_flags f ON f.id = wos.work_order_id
|
||||
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
|
||||
),
|
||||
order_status_rows AS (
|
||||
SELECT
|
||||
work_order_id,
|
||||
status,
|
||||
amount,
|
||||
acceptance_json,
|
||||
is_worker_share,
|
||||
worker_settlement_amount
|
||||
FROM worker_base
|
||||
TRUE AS is_worker_share
|
||||
FROM share_status_rows
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
f.id AS work_order_id,
|
||||
CASE
|
||||
WHEN f.has_timeout_for_me
|
||||
AND NOT f.is_assigned_to_me
|
||||
AND NOT f.is_worker_share
|
||||
THEN 'open'
|
||||
WHEN f.wo_status IN ('accepted', 'cancelled', 'problem')
|
||||
THEN f.wo_status
|
||||
WHEN f.has_pending_handling AND f.wo_status = 'in_progress'
|
||||
THEN 'problem'
|
||||
ELSE f.wo_status
|
||||
END AS status,
|
||||
CASE
|
||||
WHEN f.is_worker_share THEN f.share_reward_total
|
||||
WHEN f.is_assigned_to_me THEN f.reward_amount
|
||||
ELSE 0
|
||||
END AS amount,
|
||||
f.acceptance_json,
|
||||
f.is_worker_share
|
||||
FROM order_flags f
|
||||
WHERE
|
||||
f.wo_status IN ('accepted', 'cancelled', 'problem')
|
||||
OR (f.has_timeout_for_me AND NOT f.is_assigned_to_me AND NOT f.is_worker_share)
|
||||
OR NOT f.is_worker_share
|
||||
)
|
||||
SELECT
|
||||
status,
|
||||
@@ -742,7 +750,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
||||
CASE
|
||||
WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted')
|
||||
OR (status = 'open' AND is_worker_share)
|
||||
THEN worker_settlement_amount
|
||||
THEN amount
|
||||
ELSE 0
|
||||
END
|
||||
),
|
||||
@@ -755,7 +763,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
||||
AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
|
||||
)::int AS vip_evidence_pending_count,
|
||||
COALESCE(
|
||||
SUM(worker_settlement_amount) FILTER (
|
||||
SUM(amount) FILTER (
|
||||
WHERE status = 'accepted'
|
||||
AND acceptance_json->>'reviewMode' = 'vip_auto'
|
||||
AND acceptance_json->>'evidenceStatus' = 'pending'
|
||||
@@ -763,8 +771,41 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
||||
),
|
||||
0
|
||||
)::int AS vip_evidence_pending_settlement_amount
|
||||
FROM worker_orders
|
||||
FROM order_status_rows
|
||||
GROUP BY status
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'all' AS status,
|
||||
COUNT(DISTINCT work_order_id)::int AS order_count,
|
||||
COALESCE(
|
||||
SUM(
|
||||
CASE
|
||||
WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted')
|
||||
OR (status = 'open' AND is_worker_share)
|
||||
THEN amount
|
||||
ELSE 0
|
||||
END
|
||||
),
|
||||
0
|
||||
)::int AS settlement_amount,
|
||||
COUNT(*) FILTER (
|
||||
WHERE status = 'accepted'
|
||||
AND acceptance_json->>'reviewMode' = 'vip_auto'
|
||||
AND acceptance_json->>'evidenceStatus' = 'pending'
|
||||
AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
|
||||
)::int AS vip_evidence_pending_count,
|
||||
COALESCE(
|
||||
SUM(amount) FILTER (
|
||||
WHERE status = 'accepted'
|
||||
AND acceptance_json->>'reviewMode' = 'vip_auto'
|
||||
AND acceptance_json->>'evidenceStatus' = 'pending'
|
||||
AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
|
||||
),
|
||||
0
|
||||
)::int AS vip_evidence_pending_settlement_amount
|
||||
FROM order_status_rows
|
||||
`,
|
||||
[normalizedWorkerId],
|
||||
)
|
||||
@@ -1277,9 +1318,35 @@ export function buildWorkOrderWhere({
|
||||
params.push(normalizedStatuses)
|
||||
const statusesPlaceholder = `$${params.length}`
|
||||
if (workerSharingId) {
|
||||
filters.push(
|
||||
`${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`,
|
||||
)
|
||||
const representativeStatus = `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`
|
||||
// 一个工单可能同时有「进行中 + 待验收」等多份不同状态的份额。
|
||||
// 只看代表份额会让其中一种状态在页签里完全消失,所以只要任一份额命中就展示。
|
||||
const anyShareStatus = `(
|
||||
wo.status NOT IN ('accepted', 'cancelled', 'problem')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM work_order_shares ws_filter
|
||||
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.status = 'joined'
|
||||
AND 'in_progress' = ANY(${statusesPlaceholder}::text[])
|
||||
AND NOT ${workerPendingHandlingExistsExpression(workerSharingId)}
|
||||
)
|
||||
OR (
|
||||
ws_filter.status = 'submitted'
|
||||
AND 'pending_acceptance' = ANY(${statusesPlaceholder}::text[])
|
||||
)
|
||||
OR (
|
||||
ws_filter.status = 'accepted'
|
||||
AND 'accepted' = ANY(${statusesPlaceholder}::text[])
|
||||
)
|
||||
)
|
||||
)
|
||||
)`
|
||||
filters.push(`(${representativeStatus} OR ${anyShareStatus})`)
|
||||
} else {
|
||||
filters.push(
|
||||
`${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
/**
|
||||
* 回归:同一打手在同一拼单工单里有多份份额时,活跃接单数只应计 1 单。
|
||||
*
|
||||
* 之前 countWorkerActiveOrders 仍是「指派单数 + 拼单份额行数」直接相加,
|
||||
* 打手资料页、抢普通单和后台指派会把同工单的多份拼单算成多单,提前撞接单上限。
|
||||
* 事务内版本已经去重,这里把「事务内外共用同一段计数 SQL」钉死,防止再次分裂。
|
||||
*/
|
||||
async function readRepoSource() {
|
||||
return readFile(new URL('./work-order-query-repo.ts', import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
/** 去掉 SQL 行注释与 TS 行注释,避免注释里的旧写法被误判成代码。 */
|
||||
function stripComments(source: string) {
|
||||
return source
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/--.*$/, '').replace(/\/\/.*$/, ''))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
test('活跃接单数按工单去重,同一工单多份拼单只占 1 个名额', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
const start = source.indexOf('const COUNT_WORKER_ACTIVE_ORDERS_SQL')
|
||||
assert.ok(start >= 0, '缺少共享的活跃单计数 SQL 常量')
|
||||
|
||||
const sql = source.slice(start, start + 800)
|
||||
assert.match(sql, /UNION/, '活跃单计数必须用 UNION 按工单去重')
|
||||
assert.match(sql, /work_order_shares/, '活跃单计数必须包含拼单份额')
|
||||
assert.match(sql, /swo\.status = 'open'/, '只统计母单仍在大厅的拼单份额')
|
||||
})
|
||||
|
||||
test('事务内外共用同一段活跃单计数 SQL,避免口径分裂', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
|
||||
for (const fn of ['countWorkerActiveOrders', 'countWorkerActiveOrdersWithClient']) {
|
||||
const start = source.indexOf(`export async function ${fn}`)
|
||||
assert.ok(start >= 0, `缺少函数 ${fn}`)
|
||||
|
||||
const block = source.slice(start, start + 700)
|
||||
assert.ok(
|
||||
block.includes('COUNT_WORKER_ACTIVE_ORDERS_SQL'),
|
||||
`${fn} 没有复用共享的活跃单计数 SQL`,
|
||||
)
|
||||
assert.ok(!block.includes(') + ('), `${fn} 仍在把「指派单数 + 拼单份额行数」直接相加`)
|
||||
}
|
||||
})
|
||||
@@ -3,9 +3,13 @@ import type { Response } from 'express'
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
|
||||
import {
|
||||
getLatestMaterialEventAtByOrderIds,
|
||||
getPendingCancelRequestOrderIds,
|
||||
getPendingFeedbackOrderIds,
|
||||
getWorkerFinanceRequestById,
|
||||
getWorkerUserById,
|
||||
getWorkOrderById,
|
||||
listWorkerTimeoutEventsByOrderIds,
|
||||
listWorkerWorkOrderNotes,
|
||||
listWorkerWorkOrderViews,
|
||||
listWorkOrderShares,
|
||||
@@ -23,10 +27,13 @@ import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js'
|
||||
import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js'
|
||||
import {
|
||||
isWorkOrderSharingFilled,
|
||||
isWorkerOrderVipEvidencePending,
|
||||
mapFinanceRequest,
|
||||
mapWorkOrderAdmin,
|
||||
mapWorkOrderForWorker,
|
||||
mapWorkerUser,
|
||||
pickWorkerRepresentativeShare,
|
||||
resolveWorkerOrderStatusTabs,
|
||||
resolveWorkerPermissions,
|
||||
} from '../worker-platform/mappers.js'
|
||||
import { invalidateWorkerMyOrderCaches } from '../worker-platform/worker-my-order-cache.js'
|
||||
@@ -336,33 +343,121 @@ async function publishWorkOrderSnapshots(input: {
|
||||
})
|
||||
}
|
||||
|
||||
// 资料更新时间只与工单有关,提到循环外只查一次(参与人数多时省掉 N 次查询)。
|
||||
const materialEventAt =
|
||||
(await getLatestMaterialEventAtByOrderIds([workOrderId])).get(workOrderId) || ''
|
||||
|
||||
for (const workerId of new Set(
|
||||
(input.workerIds || []).map((item) => Number(item)).filter(Boolean),
|
||||
)) {
|
||||
const worker = await getWorkerUserById(workerId)
|
||||
if (!worker) continue
|
||||
const [notes, views] = await Promise.all([
|
||||
listWorkerWorkOrderNotes(workerId, [workOrderId]),
|
||||
listWorkerWorkOrderViews(workerId, [workOrderId]),
|
||||
])
|
||||
const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || ''
|
||||
const viewedAt =
|
||||
views.find((item) => Number(item.work_order_id) === workOrderId)?.viewed_at || null
|
||||
const share = shares.find((item) => Number(item.worker_id) === workerId)
|
||||
|
||||
// 同一打手在同一工单可能有多份份额(多次拼单),必须按「全部份额」还原这一行:
|
||||
// 旧实现用 shares.find(...) 只取最早一条,多份额时会把可见性、个人状态、金额都算错,
|
||||
// 还会让实时补丁把行从错误的页签删掉(打手端偶发「页签里没有数据」)。
|
||||
const isAssignedWorker = Number(workOrder.assigned_worker_id || 0) === workerId
|
||||
const workerOrderVisible = isAssignedWorker || Boolean(share && share.status !== 'cancelled')
|
||||
const workerShares = shares.filter((item) => Number(item.worker_id) === workerId)
|
||||
const activeWorkerShares = workerShares.filter((item) => item.status !== 'cancelled')
|
||||
const representativeShare = pickWorkerRepresentativeShare(
|
||||
activeWorkerShares.length > 0 ? activeWorkerShares : workerShares,
|
||||
)
|
||||
const cancelledShare = workerShares
|
||||
.filter((item) => item.status === 'cancelled')
|
||||
.sort((left, right) => Number(right.id) - Number(left.id))[0]
|
||||
const isOwnerCurrently = isAssignedWorker || activeWorkerShares.length > 0
|
||||
const workerOrderVisible = isOwnerCurrently
|
||||
|
||||
// 只有「已不持有该单」时才需要判断超时归属,避免给正常在办的单多打一次查询。
|
||||
const needsTimeoutCheck = !isOwnerCurrently
|
||||
const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, timeoutEventsByOrderId] =
|
||||
await Promise.all([
|
||||
listWorkerWorkOrderNotes(workerId, [workOrderId]),
|
||||
listWorkerWorkOrderViews(workerId, [workOrderId]),
|
||||
getPendingCancelRequestOrderIds(workerId, [workOrderId]),
|
||||
getPendingFeedbackOrderIds(workerId, [workOrderId]),
|
||||
needsTimeoutCheck
|
||||
? listWorkerTimeoutEventsByOrderIds(workerId, [workOrderId])
|
||||
: Promise.resolve(new Map<number, { createdAt: string }>()),
|
||||
])
|
||||
const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || ''
|
||||
const view = views.find((item) => Number(item.work_order_id) === workOrderId)
|
||||
const viewedAt = view?.viewed_at || null
|
||||
const timeoutEvent = timeoutEventsByOrderId.get(workOrderId)
|
||||
const isTimedOutForMe =
|
||||
!isOwnerCurrently && Boolean(timeoutEvent || (cancelledShare && workOrder.sharing_enabled))
|
||||
|
||||
const timeoutNotice = isTimedOutForMe
|
||||
? {
|
||||
isTimedOut: true,
|
||||
timedOutAt:
|
||||
timeoutEvent?.createdAt ||
|
||||
(workOrder.deadline_at ? String(workOrder.deadline_at) : null) ||
|
||||
workOrder.updated_at,
|
||||
warningMessage:
|
||||
(workOrder.assigned_worker_id != null &&
|
||||
Number(workOrder.assigned_worker_id) !== workerId) ||
|
||||
['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes(
|
||||
workOrder.status,
|
||||
)
|
||||
? '该订单已超时返回大厅并被其他打手接手,请勿继续在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。'
|
||||
: '该订单已超时退回抢单大厅,您已失去接单资格,请勿在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。',
|
||||
isTakenByOther:
|
||||
(workOrder.assigned_worker_id != null &&
|
||||
Number(workOrder.assigned_worker_id) !== workerId) ||
|
||||
['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes(
|
||||
workOrder.status,
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const hasPendingHandling =
|
||||
pendingCancelOrderIds.has(workOrderId) || pendingFeedbackOrderIds.has(workOrderId)
|
||||
const materialSeenAt = view?.material_seen_at || ''
|
||||
const vipEvidencePending = isWorkerOrderVipEvidencePending(workOrder)
|
||||
|
||||
publishRealtimeEvent({
|
||||
type: 'work_order.changed',
|
||||
entityId: workOrderId,
|
||||
scopes: ['worker_my_orders'],
|
||||
operation: workerOrderVisible ? 'upsert' : 'remove',
|
||||
data: mapWorkOrderForWorker(
|
||||
workOrder,
|
||||
resolveWorkerPermissions(worker),
|
||||
share,
|
||||
note,
|
||||
viewedAt,
|
||||
),
|
||||
data: {
|
||||
...mapWorkOrderForWorker(
|
||||
workOrder,
|
||||
resolveWorkerPermissions(worker),
|
||||
representativeShare,
|
||||
note,
|
||||
viewedAt,
|
||||
{
|
||||
isTimedOutForMe,
|
||||
...(timeoutNotice ? { timeoutNotice } : {}),
|
||||
// 与列表一致:只带未取消份额,按 id DESC(参与先后倒序)
|
||||
myShares: [...activeWorkerShares].sort(
|
||||
(left, right) => Number(right.id) - Number(left.id),
|
||||
),
|
||||
},
|
||||
),
|
||||
/**
|
||||
* 这张单在该打手「我的订单」里属于哪些状态页签。
|
||||
* 多份额工单可同时属于代练中/待验收,单个 status 字段表达不了,前端据此判归属。
|
||||
*/
|
||||
workerStatuses: resolveWorkerOrderStatusTabs({
|
||||
workOrderStatus: workOrder.status,
|
||||
isAssignedWorker,
|
||||
shares: workerShares,
|
||||
hasPendingHandling,
|
||||
hasTimeoutForMe: Boolean(timeoutEvent),
|
||||
vipEvidencePending,
|
||||
}),
|
||||
problemMarked: !isTimedOutForMe && workOrder.status === WORK_ORDER_STATUS.PROBLEM,
|
||||
pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId),
|
||||
pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId),
|
||||
materialUpdated:
|
||||
!isTimedOutForMe &&
|
||||
Boolean(materialEventAt) &&
|
||||
(!materialSeenAt ||
|
||||
new Date(materialEventAt).getTime() > new Date(materialSeenAt).getTime()),
|
||||
},
|
||||
workerIds: [workerId],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ export {
|
||||
} from './worker-finance-mappers.js'
|
||||
|
||||
export {
|
||||
isWorkerOrderVipEvidencePending,
|
||||
mapCollectLookupOrder,
|
||||
mapWorkCategory,
|
||||
mapWorkOrderAdmin,
|
||||
@@ -91,6 +92,8 @@ export {
|
||||
mapWorkOrderPublic,
|
||||
mapWorkOrderShare,
|
||||
mapWorkOrderSharingWorkers,
|
||||
pickWorkerRepresentativeShare,
|
||||
resolveWorkerOrderStatusTabs,
|
||||
resolveWorkOrderOperationalStatus,
|
||||
resolveWorkerShareProductName,
|
||||
resolveWorkerShareStatus,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { WorkOrderShareRow } from '../../repositories/worker-platform/types.js'
|
||||
import {
|
||||
isWorkerOrderVipEvidencePending,
|
||||
pickWorkerRepresentativeShare,
|
||||
resolveWorkerOrderStatusTabs,
|
||||
summarizeWorkerShares,
|
||||
} from './work-order-display-mappers.js'
|
||||
|
||||
function share(overrides: Record<string, unknown>): WorkOrderShareRow {
|
||||
return {
|
||||
id: 1,
|
||||
work_order_id: 1,
|
||||
worker_id: 1,
|
||||
quantity: 1,
|
||||
unit_reward: 10,
|
||||
share_reward: 10,
|
||||
share_deposit: 0,
|
||||
status: 'joined',
|
||||
deadline_at: null,
|
||||
timeout_minutes: 0,
|
||||
created_at: '2026-10-23T10:00:00.000Z',
|
||||
...overrides,
|
||||
} as unknown as WorkOrderShareRow
|
||||
}
|
||||
|
||||
test('同一工单多份份额:数量/奖励/押金按份额求和,已取消份额不计入金额', () => {
|
||||
const summary = summarizeWorkerShares([
|
||||
share({
|
||||
id: 1,
|
||||
quantity: 2,
|
||||
share_reward: 20,
|
||||
share_deposit: 5,
|
||||
status: 'joined',
|
||||
created_at: '2026-10-23T10:00:00.000Z',
|
||||
deadline_at: '2026-10-23T12:00:00.000Z',
|
||||
}),
|
||||
share({
|
||||
id: 2,
|
||||
quantity: 3,
|
||||
share_reward: 30,
|
||||
share_deposit: 5,
|
||||
status: 'joined',
|
||||
created_at: '2026-10-23T10:10:00.000Z',
|
||||
deadline_at: '2026-10-23T11:00:00.000Z',
|
||||
}),
|
||||
share({
|
||||
id: 3,
|
||||
quantity: 9,
|
||||
share_reward: 90,
|
||||
share_deposit: 99,
|
||||
status: 'cancelled',
|
||||
created_at: '2026-10-23T09:00:00.000Z',
|
||||
}),
|
||||
])
|
||||
|
||||
assert.equal(summary.quantity, 5)
|
||||
assert.equal(summary.rewardAmount, 50)
|
||||
assert.equal(summary.depositAmount, 10)
|
||||
// 有有效份额时,接单时间取第一份有效份额,不取更早的已取消记录。
|
||||
assert.equal(summary.firstJoinedAt, '2026-10-23T10:00:00.000Z')
|
||||
// 父行倒计时取未完成份额中最早的截止时间。
|
||||
assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T11:00:00.000Z')
|
||||
})
|
||||
|
||||
test('历史份额没有 deadline_at 时,用份额自己的 timeout_minutes 兜底', () => {
|
||||
const summary = summarizeWorkerShares([
|
||||
share({
|
||||
id: 1,
|
||||
quantity: 2,
|
||||
share_reward: 20,
|
||||
status: 'joined',
|
||||
created_at: '2026-10-23T10:00:00.000Z',
|
||||
deadline_at: null,
|
||||
timeout_minutes: 30,
|
||||
}),
|
||||
])
|
||||
|
||||
assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T10:30:00.000Z')
|
||||
})
|
||||
|
||||
test('只剩已取消份额时,金额归零但保留最早参与时间', () => {
|
||||
const summary = summarizeWorkerShares([
|
||||
share({
|
||||
id: 1,
|
||||
quantity: 2,
|
||||
share_reward: 20,
|
||||
status: 'cancelled',
|
||||
created_at: '2026-10-23T09:00:00.000Z',
|
||||
}),
|
||||
share({
|
||||
id: 2,
|
||||
quantity: 3,
|
||||
share_reward: 30,
|
||||
status: 'cancelled',
|
||||
created_at: '2026-10-23T09:30:00.000Z',
|
||||
}),
|
||||
])
|
||||
|
||||
assert.equal(summary.quantity, 0)
|
||||
assert.equal(summary.rewardAmount, 0)
|
||||
assert.equal(summary.depositAmount, 0)
|
||||
assert.equal(summary.firstJoinedAt, '2026-10-23T09:00:00.000Z')
|
||||
assert.equal(summary.earliestActiveDeadlineAt, null)
|
||||
})
|
||||
|
||||
test('代表份额与列表 LATERAL 同序:joined > submitted > accepted > cancelled,再按创建时间/id 倒序', () => {
|
||||
const shares = [
|
||||
share({ id: 1, status: 'cancelled', created_at: '2026-10-23T09:00:00.000Z' }),
|
||||
share({ id: 2, status: 'submitted', created_at: '2026-10-23T09:30:00.000Z' }),
|
||||
share({ id: 3, status: 'joined', created_at: '2026-10-23T10:00:00.000Z' }),
|
||||
]
|
||||
assert.equal(pickWorkerRepresentativeShare(shares)?.id, 3)
|
||||
|
||||
// 同状态取更晚创建的一份(旧实现取的是「最早一条」,多份额时必然挑错)
|
||||
const sameStatus = [
|
||||
share({ id: 10, status: 'submitted', created_at: '2026-10-23T09:00:00.000Z' }),
|
||||
share({ id: 11, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }),
|
||||
share({ id: 12, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }),
|
||||
]
|
||||
assert.equal(pickWorkerRepresentativeShare(sameStatus)?.id, 12)
|
||||
})
|
||||
|
||||
test('多份额工单(一份进行中 + 一份待验收)必须同时属于两个页签', () => {
|
||||
const tabs = resolveWorkerOrderStatusTabs({
|
||||
workOrderStatus: 'in_progress',
|
||||
isAssignedWorker: false,
|
||||
shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })],
|
||||
})
|
||||
assert.deepEqual(tabs.sort(), ['in_progress', 'pending_acceptance'])
|
||||
})
|
||||
|
||||
test('有待审核取消申请/未处理反馈时,进行中份额归问题单,但待验收份额仍留在待验收', () => {
|
||||
const tabs = resolveWorkerOrderStatusTabs({
|
||||
workOrderStatus: 'in_progress',
|
||||
isAssignedWorker: false,
|
||||
shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })],
|
||||
hasPendingHandling: true,
|
||||
})
|
||||
assert.deepEqual(tabs.sort(), ['pending_acceptance', 'problem'])
|
||||
})
|
||||
|
||||
test('母单进入终态后只按母单状态归类(与 SQL 关闭 anyShare 分支一致)', () => {
|
||||
const tabs = resolveWorkerOrderStatusTabs({
|
||||
workOrderStatus: 'problem',
|
||||
isAssignedWorker: true,
|
||||
shares: [share({ id: 1, status: 'submitted' })],
|
||||
})
|
||||
assert.deepEqual(tabs, ['problem'])
|
||||
})
|
||||
|
||||
test('份额全部取消时只归已取消,不得混进待验收', () => {
|
||||
const tabs = resolveWorkerOrderStatusTabs({
|
||||
workOrderStatus: 'pending_acceptance',
|
||||
isAssignedWorker: true,
|
||||
shares: [share({ id: 1, status: 'cancelled' })],
|
||||
})
|
||||
assert.deepEqual(tabs, ['cancelled'])
|
||||
})
|
||||
|
||||
test('没有份额的指派单按母单状态归类;超时退回后归已超时', () => {
|
||||
assert.deepEqual(
|
||||
resolveWorkerOrderStatusTabs({
|
||||
workOrderStatus: 'pending_acceptance',
|
||||
isAssignedWorker: true,
|
||||
shares: [],
|
||||
}),
|
||||
['pending_acceptance'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
resolveWorkerOrderStatusTabs({
|
||||
workOrderStatus: 'pending_acceptance',
|
||||
isAssignedWorker: false,
|
||||
shares: [share({ id: 1, status: 'cancelled' })],
|
||||
hasTimeoutForMe: true,
|
||||
}),
|
||||
['open'],
|
||||
)
|
||||
})
|
||||
|
||||
test('VIP 免审核待补图:只有已验收 + vip_auto + 待补图 + 未到期才归入该页签', () => {
|
||||
const now = Date.parse('2026-10-23T10:00:00.000Z')
|
||||
const base = {
|
||||
status: 'accepted',
|
||||
acceptance_json: {
|
||||
reviewMode: 'vip_auto',
|
||||
evidenceStatus: 'pending',
|
||||
evidenceDueAt: '2026-10-24T10:00:00.000Z',
|
||||
},
|
||||
}
|
||||
assert.equal(isWorkerOrderVipEvidencePending(base, now), true)
|
||||
assert.equal(
|
||||
isWorkerOrderVipEvidencePending(
|
||||
{ ...base, acceptance_json: { ...base.acceptance_json, evidenceStatus: 'submitted' } },
|
||||
now,
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
isWorkerOrderVipEvidencePending(
|
||||
{
|
||||
...base,
|
||||
acceptance_json: { ...base.acceptance_json, evidenceDueAt: '2026-10-22T10:00:00.000Z' },
|
||||
},
|
||||
now,
|
||||
),
|
||||
false,
|
||||
)
|
||||
assert.deepEqual(
|
||||
resolveWorkerOrderStatusTabs({
|
||||
workOrderStatus: 'accepted',
|
||||
isAssignedWorker: true,
|
||||
shares: [share({ id: 1, status: 'accepted' })],
|
||||
vipEvidencePending: true,
|
||||
}).sort(),
|
||||
['accepted', 'vip_evidence_pending'],
|
||||
)
|
||||
})
|
||||
@@ -597,6 +597,147 @@ export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: u
|
||||
return workOrderStatus
|
||||
}
|
||||
|
||||
/** 打手端「我的订单」状态页签键,与 buildWorkOrderWhere 的打手状态筛选同口径。 */
|
||||
export type WorkerOrderStatusTab =
|
||||
| 'in_progress'
|
||||
| 'pending_acceptance'
|
||||
| 'problem'
|
||||
| 'open'
|
||||
| 'accepted'
|
||||
| 'cancelled'
|
||||
| 'vip_evidence_pending'
|
||||
|
||||
/** 母单进入终态后,列表只按母单状态归类(任一命中分支会被 wo.status NOT IN (...) 关掉)。 */
|
||||
const TERMINAL_WORK_ORDER_STATUSES = new Set(['accepted', 'cancelled', 'problem'])
|
||||
|
||||
/** 代表份额优先级,与 SQL LATERAL 的 CASE 顺序一致:joined > submitted > accepted > cancelled。 */
|
||||
const WORKER_SHARE_STATUS_PRIORITY: Record<string, number> = {
|
||||
joined: 0,
|
||||
submitted: 1,
|
||||
accepted: 2,
|
||||
cancelled: 3,
|
||||
}
|
||||
|
||||
type WorkerShareLike = {
|
||||
status?: unknown
|
||||
created_at?: unknown
|
||||
id?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一打手在一个工单里的「代表份额」。
|
||||
*
|
||||
* 实时广播过去用 `shares.find(worker_id === ...)`(= 最早一条),同一打手多次拼单时会挑到
|
||||
* 已取消/进行中的那份,进而把快照算成「不在可见范围」或错误的个人状态,导致打手端某个
|
||||
* 状态页签的行被实时补丁删掉(表现为「待验收有时候没有数据」)。
|
||||
* 这里与 `work-order-query-repo` 的 LATERAL 排序完全一致:
|
||||
* status 优先级 ASC, created_at DESC, id DESC
|
||||
*/
|
||||
export function pickWorkerRepresentativeShare<T extends WorkerShareLike>(
|
||||
shares: readonly T[],
|
||||
): T | null {
|
||||
let representative: T | null = null
|
||||
for (const share of shares) {
|
||||
if (!representative) {
|
||||
representative = share
|
||||
continue
|
||||
}
|
||||
const priority = WORKER_SHARE_STATUS_PRIORITY[String(share.status || '')] ?? 4
|
||||
const representativePriority =
|
||||
WORKER_SHARE_STATUS_PRIORITY[String(representative.status || '')] ?? 4
|
||||
if (priority !== representativePriority) {
|
||||
if (priority < representativePriority) representative = share
|
||||
continue
|
||||
}
|
||||
const createdMs = Date.parse(String(share.created_at || ''))
|
||||
const representativeCreatedMs = Date.parse(String(representative.created_at || ''))
|
||||
if (Number.isFinite(createdMs) && Number.isFinite(representativeCreatedMs)) {
|
||||
if (createdMs !== representativeCreatedMs) {
|
||||
if (createdMs > representativeCreatedMs) representative = share
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (Number(share.id || 0) > Number(representative.id || 0)) representative = share
|
||||
}
|
||||
return representative
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个工单在该打手「我的订单」里会出现在哪些状态页签。
|
||||
*
|
||||
* 为什么需要它:多份额工单可以同时属于「代练中」和「待验收」(一份 joined + 一份 submitted),
|
||||
* 但实时快照只能带一个 `status` 字段。前端旧逻辑只要「快照状态 ≠ 当前页签」就把行从缓存删掉,
|
||||
* 于是同一张单在其中一个页签凭空消失。广播里带上完整页签集合后,前端按集合判归属即可。
|
||||
*
|
||||
* 口径逐条对齐 `buildWorkOrderWhere`(打手状态筛选):
|
||||
* - 母单 accepted/cancelled/problem:只看代表状态(母单状态);
|
||||
* - 其余:代表状态 + 「任一有效份额命中」(joined 需无待处理申请,submitted/accepted 直接命中);
|
||||
* - 有该打手的超时事件且他已不持有订单:代表状态记 open。
|
||||
*/
|
||||
export function resolveWorkerOrderStatusTabs(input: {
|
||||
workOrderStatus: string
|
||||
isAssignedWorker: boolean
|
||||
/** 该打手在该工单的全部份额(含已取消),用于还原代表份额。 */
|
||||
shares?: readonly WorkerShareLike[]
|
||||
/** 存在待审核取消申请或未处理反馈(进行中的份额会归入问题单)。 */
|
||||
hasPendingHandling?: boolean
|
||||
/** 该打手在这张单上有超时事件。 */
|
||||
hasTimeoutForMe?: boolean
|
||||
/** 命中「VIP 免审核待补图」筛选(列表里它是独立筛选,不参与 status 参数)。 */
|
||||
vipEvidencePending?: boolean
|
||||
}): WorkerOrderStatusTab[] {
|
||||
const tabs = new Set<WorkerOrderStatusTab>()
|
||||
const workOrderStatus = String(input.workOrderStatus || '')
|
||||
if (input.vipEvidencePending) tabs.add('vip_evidence_pending')
|
||||
|
||||
if (TERMINAL_WORK_ORDER_STATUSES.has(workOrderStatus)) {
|
||||
tabs.add(workOrderStatus as WorkerOrderStatusTab)
|
||||
return [...tabs]
|
||||
}
|
||||
|
||||
const shares = input.shares || []
|
||||
const activeShares = shares.filter(
|
||||
(share) =>
|
||||
share.status === 'joined' || share.status === 'submitted' || share.status === 'accepted',
|
||||
)
|
||||
const representative = pickWorkerRepresentativeShare(shares)
|
||||
let representativeStatus = representative
|
||||
? resolveWorkerShareStatus(workOrderStatus, representative.status)
|
||||
: workOrderStatus
|
||||
if (input.hasTimeoutForMe && !input.isAssignedWorker && activeShares.length === 0) {
|
||||
representativeStatus = WORK_ORDER_STATUS.OPEN
|
||||
} else if (representativeStatus === WORK_ORDER_STATUS.IN_PROGRESS && input.hasPendingHandling) {
|
||||
representativeStatus = WORK_ORDER_STATUS.PROBLEM
|
||||
}
|
||||
if (representativeStatus) tabs.add(representativeStatus as WorkerOrderStatusTab)
|
||||
|
||||
for (const share of activeShares) {
|
||||
if (share.status === 'joined') {
|
||||
// 挂待处理申请/反馈的进行中份额归问题单,不再计入代练中(与 SQL 的 anyShare 分支一致)。
|
||||
if (!input.hasPendingHandling) tabs.add('in_progress')
|
||||
} else if (share.status === 'submitted') {
|
||||
tabs.add('pending_acceptance')
|
||||
} else if (share.status === 'accepted') {
|
||||
tabs.add('accepted')
|
||||
}
|
||||
}
|
||||
|
||||
return [...tabs]
|
||||
}
|
||||
|
||||
/** 「VIP 免审核,待补图」判定,口径同 `buildWorkOrderWhere` 的 vipEvidencePendingWhere。 */
|
||||
export function isWorkerOrderVipEvidencePending(
|
||||
workOrder: Pick<WorkOrderRow, 'status' | 'acceptance_json'>,
|
||||
nowMs = Date.now(),
|
||||
): boolean {
|
||||
if (String(workOrder.status || '') !== WORK_ORDER_STATUS.ACCEPTED) return false
|
||||
const acceptance = mapAcceptanceForResponse(workOrder.acceptance_json) as Record<string, unknown>
|
||||
if (String(acceptance.reviewMode || '') !== 'vip_auto') return false
|
||||
if (String(acceptance.evidenceStatus || '') !== 'pending') return false
|
||||
const dueAtMs = Date.parse(String(acceptance.evidenceDueAt || ''))
|
||||
return Number.isFinite(dueAtMs) && dueAtMs > nowMs
|
||||
}
|
||||
|
||||
export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
||||
return {
|
||||
shareId: Number(share.id),
|
||||
|
||||
@@ -245,25 +245,31 @@ export async function getWorkerMyOrderOverview(session: WorkerSession) {
|
||||
buildMyOrderCacheKey(worker.id, 'overview', 0),
|
||||
() => getWorkerOrderOverview(worker.id),
|
||||
)
|
||||
const items = rows.map((row) => ({
|
||||
const statusRows = rows.filter((row) => row.status !== 'all')
|
||||
const items = statusRows.map((row) => ({
|
||||
status: row.status,
|
||||
orderCount: row.orderCount,
|
||||
settlementAmount: row.settlementAmount,
|
||||
}))
|
||||
const vipEvidencePending = rows.reduce(
|
||||
const vipEvidencePending = statusRows.reduce(
|
||||
(summary, row) => ({
|
||||
orderCount: summary.orderCount + row.vipEvidencePendingCount,
|
||||
settlementAmount: summary.settlementAmount + row.vipEvidencePendingSettlementAmount,
|
||||
}),
|
||||
{ orderCount: 0, settlementAmount: 0 },
|
||||
)
|
||||
const all = items.reduce(
|
||||
(summary, item) => ({
|
||||
orderCount: summary.orderCount + item.orderCount,
|
||||
settlementAmount: summary.settlementAmount + item.settlementAmount,
|
||||
}),
|
||||
{ orderCount: 0, settlementAmount: 0 },
|
||||
)
|
||||
// 混合状态的多份额工单会同时出现在多个页签,不能再用各页签相加当「全部订单」,
|
||||
// 否则同一工单会被重复计数;优先使用 SQL 里按 distinct 工单算好的 all 行。
|
||||
const allRow = rows.find((row) => row.status === 'all')
|
||||
const all = allRow
|
||||
? { orderCount: allRow.orderCount, settlementAmount: allRow.settlementAmount }
|
||||
: items.reduce(
|
||||
(summary, item) => ({
|
||||
orderCount: summary.orderCount + item.orderCount,
|
||||
settlementAmount: summary.settlementAmount + item.settlementAmount,
|
||||
}),
|
||||
{ orderCount: 0, settlementAmount: 0 },
|
||||
)
|
||||
return {
|
||||
items: [
|
||||
...items,
|
||||
|
||||
Reference in New Issue
Block a user