Merge branch 'dev1023' into dev
解决 9 个冲突文件,并修复合并后遗留问题: - 大厅候选集:采用 FIFO 窗口 + 全部置顶单 UNION(列表与抢单/拼单复核同源) - 拼单份额:采用多次参与模型(每次参与 INSERT 新份额、requestId 幂等), 取消后可重新参与;移除 HEAD 已废弃的单行 rejoin 逻辑与对应测试 - 工单迁移与成本中心 103–108 编号撞车:合并重写为 109_work_order_share_participation / 110_work_order_hall_and_shop_indexes; 临时库全量执行与重跑幂等验证通过(10 列、唯一键已删、7 个索引) - 实时补丁:保留个性化字段、置顶重排、分页总数同步;补回 orderTimelineVisible 类型 - 打手大厅:保留 memo 组件化,移植「再拼一次」多份额按钮逻辑 - 全项目表格统一 PercentTable:WorkerOrderShareTable 改造 验证:backend typecheck/lint/check:sql/test(833 pass)、 frontend typecheck/lint/test(172 pass)/build 全绿。
This commit is contained in:
@@ -129,6 +129,15 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
// 打手:7 天空闲过期,登录后最多 30 天必须重新登录。
|
||||
sessionIdleTtlHours: 168,
|
||||
sessionAbsoluteTtlHours: 720,
|
||||
acceptanceVideo: {
|
||||
enabled: true,
|
||||
maxDurationSeconds: 30,
|
||||
maxSizeMb: 12,
|
||||
targetHeight: 720,
|
||||
bitrateKbps: 1200,
|
||||
audioBitrateKbps: 64,
|
||||
fps: 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,26 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
'skuMapping',
|
||||
]),
|
||||
corsOriginsEnv('CORS_ALLOWED_ORIGINS', ['cors', 'allowedOrigins']),
|
||||
// 验收视频(选填):只走 .env,后台没有配置界面。
|
||||
booleanEnv('WORKER_ACCEPTANCE_VIDEO_ENABLED', ['worker', 'acceptanceVideo', 'enabled']),
|
||||
integerEnv('WORKER_ACCEPTANCE_VIDEO_MAX_DURATION_SECONDS', [
|
||||
'worker',
|
||||
'acceptanceVideo',
|
||||
'maxDurationSeconds',
|
||||
]),
|
||||
integerEnv('WORKER_ACCEPTANCE_VIDEO_MAX_SIZE_MB', ['worker', 'acceptanceVideo', 'maxSizeMb']),
|
||||
integerEnv('WORKER_ACCEPTANCE_VIDEO_TARGET_HEIGHT', [
|
||||
'worker',
|
||||
'acceptanceVideo',
|
||||
'targetHeight',
|
||||
]),
|
||||
integerEnv('WORKER_ACCEPTANCE_VIDEO_BITRATE_KBPS', ['worker', 'acceptanceVideo', 'bitrateKbps']),
|
||||
integerEnv('WORKER_ACCEPTANCE_VIDEO_AUDIO_BITRATE_KBPS', [
|
||||
'worker',
|
||||
'acceptanceVideo',
|
||||
'audioBitrateKbps',
|
||||
]),
|
||||
integerEnv('WORKER_ACCEPTANCE_VIDEO_FPS', ['worker', 'acceptanceVideo', 'fps']),
|
||||
]
|
||||
|
||||
export function applyEnvOverrides(
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
-- 109_work_order_share_participation.sql
|
||||
-- 工单拼单份额模型:多次参与 + 份额级问题标记 + 撤单原因/份数。
|
||||
-- (由原 110/111/113/114 四个增量迁移合并重写,纯新增列/索引,不改历史结构。)
|
||||
--
|
||||
-- 目标:
|
||||
-- 1. 同一打手可对同一拼单工单多次参与,每次参与一条 work_order_shares;
|
||||
-- 每份在参与时固化截止时间与超时策略,超时 / 验收 / 撤单按份额独立。
|
||||
-- 2. 后台可对单个份额标记问题单:只影响该份额持有人,母单状态与其他参与者不变。
|
||||
-- 3. 撤单 / 撤份额的原因与份数落库,列表可直接展示「撤 N 份 · 原因」。
|
||||
--
|
||||
-- 兼容性:
|
||||
-- - 只加列 + 回填 + 删「一人一条」唯一键 + 建索引,不拆分历史行、不改历史资金;
|
||||
-- - 历史合并份额(quantity > 1)保持原样,结算公式 quantity × unit_reward 完全兼容;
|
||||
-- - DDL 全部幂等(IF NOT EXISTS / 受控 DROP),可安全重跑。
|
||||
|
||||
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 '',
|
||||
-- 取消来源枚举:timeout / worker_request / admin / problem / settlement
|
||||
ADD COLUMN IF NOT EXISTS cancelled_reason TEXT NOT NULL DEFAULT '',
|
||||
-- 客户端幂等键(一次点击一个);唯一索引只约束非空值
|
||||
ADD COLUMN IF NOT EXISTS request_id TEXT NOT NULL DEFAULT '',
|
||||
-- 份额级问题标记(与份额状态正交;problem_marked_at 为 NULL = 正常)
|
||||
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 '',
|
||||
-- 后台撤单 / 撤份额:原因只做展示;份数 0 = 从未撤过
|
||||
ADD COLUMN IF NOT EXISTS cancel_note TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS cancel_quantity INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
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
|
||||
'客户端幂等键(一次点击一个),重复请求返回原份额';
|
||||
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
|
||||
'标记操作人(后台账号显示名)';
|
||||
COMMENT ON COLUMN work_order_shares.cancel_note IS
|
||||
'后台撤单/撤份额时填写的原因(自由文本,选填),仅用于展示';
|
||||
COMMENT ON COLUMN work_order_shares.cancel_quantity IS
|
||||
'被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过),仅用于展示';
|
||||
|
||||
-- 回填历史份额:按当时母单配置一次性固化(只碰尚未固化的历史行)。
|
||||
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 <> '';
|
||||
|
||||
-- 打手「问题单」页签筛选与概览统计:按 (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;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 110_work_order_hall_and_shop_indexes.sql
|
||||
-- 工单相关热查询的部分索引(由原 103/106 两个索引迁移合并重写)。
|
||||
--
|
||||
-- 两处查询都已经有明确的 SQL 口径,这里只补与口径完全一致的索引:
|
||||
-- 1. 后台接单工单「按店铺筛选」:BTRIM(o.shop_name) = ANY($n::text[]),
|
||||
-- 普通 btree 无法命中表达式,必须建同表达式索引;
|
||||
-- 2. 抢单大厅候选集:FIFO 窗口之外还要无条件并入全部置顶单,
|
||||
-- 置顶单通常只有个位数,用「open 且 pinned_at 非空」的部分索引即可。
|
||||
-- 全部 IF NOT EXISTS,可安全重跑。
|
||||
|
||||
-- 后台「接单工单 → 店铺」筛选(可多选):表达式索引 + 部分索引省掉空店铺条目。
|
||||
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)精确多选筛选';
|
||||
|
||||
-- 抢单大厅置顶候选集:只索引处于 open 且已置顶的工单 id,避免扫全表。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_hall_pinned
|
||||
ON work_orders (id)
|
||||
WHERE status = 'open' AND pinned_at IS NOT NULL;
|
||||
|
||||
COMMENT ON INDEX idx_work_orders_hall_pinned IS
|
||||
'抢单大厅置顶候选集:open 且 pinned_at 非空的工单 id';
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { hasAcceptanceEvidence } from './work-order-acceptance-evidence.js'
|
||||
|
||||
const VIDEO = { url: 'https://example.com/worker-acceptance-video/a.mp4', durationSeconds: 12 }
|
||||
|
||||
test('验收证据:只有图片算有证据', () => {
|
||||
assert.equal(hasAcceptanceEvidence({ imageUrls: ['https://example.com/a.jpg'] }), true)
|
||||
})
|
||||
|
||||
test('验收证据:只有视频也算有证据(视频视同图片)', () => {
|
||||
assert.equal(hasAcceptanceEvidence({ imageUrls: [], video: VIDEO }), true)
|
||||
})
|
||||
|
||||
test('验收证据:图片与视频都有算有证据', () => {
|
||||
assert.equal(
|
||||
hasAcceptanceEvidence({ imageUrls: ['https://example.com/a.jpg'], video: VIDEO }),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('验收证据:图片视频都没有则没有证据', () => {
|
||||
assert.equal(hasAcceptanceEvidence({ imageUrls: [], video: null }), false)
|
||||
assert.equal(hasAcceptanceEvidence({}), false)
|
||||
assert.equal(hasAcceptanceEvidence({ imageUrls: undefined, video: undefined }), false)
|
||||
})
|
||||
|
||||
test('验收证据:兼容非数组的 imageUrls(脏数据按无图片处理)', () => {
|
||||
assert.equal(hasAcceptanceEvidence({ imageUrls: null, video: VIDEO }), true)
|
||||
assert.equal(hasAcceptanceEvidence({ imageUrls: null, video: null }), false)
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 验收证据口径:验收图片与验收视频至少上传一个。
|
||||
*
|
||||
* 口径(已确认):有视频时视同有图片,其它逻辑完全按「已上传图片」走——
|
||||
* 打手端提交/保存验收的必填校验、VIP 免审核的 `evidenceStatus` / `evidenceDueAt`
|
||||
* 判定都复用这个函数,避免整单与拼单份额两处口径漂移。
|
||||
*/
|
||||
export function hasAcceptanceEvidence(input: {
|
||||
imageUrls?: readonly unknown[] | null
|
||||
video?: unknown
|
||||
}): boolean {
|
||||
const imageUrls = input.imageUrls
|
||||
const imageCount = Array.isArray(imageUrls) ? imageUrls.length : 0
|
||||
return imageCount > 0 || Boolean(input.video)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveShareReduceTargetQuantity } from './work-order-share-quantity.js'
|
||||
|
||||
test('按份数撤份额:正常扣减', () => {
|
||||
assert.deepEqual(resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 4 }), {
|
||||
nextQuantity: 5,
|
||||
failureReason: null,
|
||||
})
|
||||
assert.deepEqual(resolveShareReduceTargetQuantity({ currentQuantity: 2, reduceQuantity: 1 }), {
|
||||
nextQuantity: 1,
|
||||
failureReason: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('按份数撤份额:不能撤光、不能撤 0、不能超量', () => {
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 1, reduceQuantity: 1 }).failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 0 }).failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: -2 }).failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 9 }).failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 10 }).failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
})
|
||||
|
||||
test('按份数撤份额:非法输入(非数字/小数/空)一律拒绝', () => {
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: Number.NaN, reduceQuantity: 4 })
|
||||
.failureReason,
|
||||
'invalid_quantity',
|
||||
)
|
||||
assert.equal(
|
||||
resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 4.7 }).nextQuantity,
|
||||
5,
|
||||
'小数向下取整后按 4 份处理',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 后台按份数撤销拼单份额:校验并算出保留下来的份数。
|
||||
*
|
||||
* 口径(已确认):
|
||||
* - 只能部分撤销,撤到 0 份走「单人撤单」,所以 `1 ≤ 撤掉份数 ≤ 当前份数 − 1`;
|
||||
* - 押金不随份数减少而退还(保持原冻结额),这里只算数量。
|
||||
*/
|
||||
export function resolveShareReduceTargetQuantity(input: {
|
||||
currentQuantity: number
|
||||
reduceQuantity: number
|
||||
}): { nextQuantity: number; failureReason: 'invalid_quantity' | null } {
|
||||
const current = Math.floor(Number(input.currentQuantity) || 0)
|
||||
const reduce = Math.floor(Number(input.reduceQuantity) || 0)
|
||||
if (!Number.isFinite(current) || !Number.isFinite(reduce)) {
|
||||
return { nextQuantity: 0, failureReason: 'invalid_quantity' }
|
||||
}
|
||||
if (current < 2 || reduce < 1 || reduce > current - 1) {
|
||||
return { nextQuantity: 0, failureReason: 'invalid_quantity' }
|
||||
}
|
||||
return { nextQuantity: current - reduce, failureReason: null }
|
||||
}
|
||||
@@ -62,10 +62,13 @@ export async function createRealtimeEvent(input: {
|
||||
export async function listRealtimeEventsAfter(eventId: number, limit = 500) {
|
||||
const result = await query<RealtimeEventRow>(
|
||||
`
|
||||
SELECT * FROM realtime_events
|
||||
WHERE id > $1
|
||||
SELECT * FROM (
|
||||
SELECT * FROM realtime_events
|
||||
WHERE id > $1
|
||||
ORDER BY id DESC
|
||||
LIMIT $2
|
||||
) replay_window
|
||||
ORDER BY id ASC
|
||||
LIMIT $2
|
||||
`,
|
||||
[Math.max(0, Number(eventId) || 0), Math.min(500, Math.max(1, Number(limit) || 500))],
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ test('未指定打手的运营状态统计使用一次全表拼单汇总', () =>
|
||||
assert.doesNotMatch(query.joins, /worker_visible/)
|
||||
})
|
||||
|
||||
test('大厅容量先确定全局候选集合,再应用页面筛选条件', () => {
|
||||
test('大厅容量先确定全局候选集合(FIFO 窗口 + 全部置顶单),再应用页面筛选条件', () => {
|
||||
const query = buildWorkOrderWhere({
|
||||
statuses: ['open'],
|
||||
keyword: '测试商品',
|
||||
@@ -120,10 +120,10 @@ test('大厅容量先确定全局候选集合,再应用页面筛选条件', ()
|
||||
})
|
||||
|
||||
assert.match(query.whereClause, /candidate\.status = 'open'/)
|
||||
assert.match(
|
||||
query.whereClause,
|
||||
/ORDER BY candidate\.pinned_at DESC NULLS LAST, candidate\.hall_queued_at ASC NULLS LAST/,
|
||||
)
|
||||
// 置顶单不参与 FIFO 截断:UNION 无条件并入候选集,窗口外也能抢/拼。
|
||||
assert.match(query.whereClause, /UNION/)
|
||||
assert.match(query.whereClause, /pinned\.status = 'open'/)
|
||||
assert.match(query.whereClause, /pinned\.pinned_at IS NOT NULL/)
|
||||
assert.match(query.whereClause, /LIMIT \$4/)
|
||||
const candidateSql = query.whereClause.slice(query.whereClause.indexOf('candidate.status'))
|
||||
assert.doesNotMatch(candidateSql, /candidate\.category_id|candidate.*测试商品/)
|
||||
|
||||
@@ -63,11 +63,46 @@ test('大厅候选集(buildHallCandidateIdsSql)不再聚合整表拼单', as
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 生产现象:置顶单 WK26102208267560(FIFO 队列第 92 名,窗口只有 50)在列表里看得到,
|
||||
* 点「拼单」却报「该订单当前不可拼单」。根因是候选集只截 FIFO 队首 N 个,
|
||||
* 漏掉了置顶单,而抢单/拼单在事务内用同一段 SQL 复核容量。
|
||||
* 置顶是运营显式优先级,必须无条件并入候选集,否则置顶功能形同虚设。
|
||||
*/
|
||||
test('大厅候选集必须无条件并进置顶单(置顶单排在 FIFO 窗口外也要能抢/拼)', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
const start = source.indexOf('function buildHallCandidateIdsSql')
|
||||
const hallSql = source.slice(start, start + 4200)
|
||||
|
||||
assert.ok(hallSql.includes('UNION'), '候选集没有把置顶单并进 FIFO 窗口')
|
||||
assert.ok(
|
||||
hallSql.includes('pinned.pinned_at IS NOT NULL'),
|
||||
'候选集缺少「status=open 且 pinned_at 非空」的置顶分支',
|
||||
)
|
||||
})
|
||||
|
||||
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,17 +113,149 @@ 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('无打手范围时一次聚合全部拼单'),
|
||||
'保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删',
|
||||
)
|
||||
})
|
||||
|
||||
test('我的订单的份额关联按工单取一条,避免多份额把列表行放大', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
|
||||
assert.ok(source.includes('LEFT JOIN LATERAL ('), '缺少按工单取代表份额的 LATERAL 关联')
|
||||
assert.ok(
|
||||
!source.includes('LEFT JOIN work_order_shares worker_share'),
|
||||
'仍在直接 JOIN 份额表:同一工单多份拼单会让「我的订单」出现多行',
|
||||
)
|
||||
})
|
||||
|
||||
test('我的订单概览按工单计数,金额仍按份额求和', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
const start = source.indexOf('order_flags AS (')
|
||||
assert.ok(start >= 0, '缺少我的订单概览的 order_flags CTE')
|
||||
|
||||
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())
|
||||
|
||||
assert.ok(
|
||||
source.includes('COUNT(DISTINCT work_order_id)::int AS total'),
|
||||
'超时单数没有按工单去重',
|
||||
)
|
||||
})
|
||||
|
||||
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\)\}/,
|
||||
'问题单页签必须同时命中母单问题与份额问题',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -358,6 +358,23 @@ export type WorkOrderShareRow = {
|
||||
accepted_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
/** 本份额截止时间;参与时固化,null = 不限时 */
|
||||
deadline_at?: string | null
|
||||
/** 参与时固化的时限分钟数与策略 */
|
||||
timeout_minutes?: number
|
||||
timeout_policy?: string
|
||||
/** 取消来源:timeout/worker_request/admin/problem/settlement */
|
||||
cancelled_reason?: string
|
||||
/** 后台撤单/撤份额时填写的原因(自由文本,选填),仅用于列表展示 */
|
||||
cancel_note?: string
|
||||
/** 被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过) */
|
||||
cancel_quantity?: number
|
||||
/** 份额级问题单:备注 / 标记时间(NULL = 正常)/ 操作人;不影响母单状态 */
|
||||
problem_note?: string
|
||||
problem_marked_at?: string | null
|
||||
problem_marked_by?: string
|
||||
/** 客户端幂等键 */
|
||||
request_id?: string
|
||||
worker_username?: string
|
||||
worker_display_name?: string
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ import type { PoolClient } from 'pg'
|
||||
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
|
||||
import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from './consignment-deposit-repo.js'
|
||||
import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js'
|
||||
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
|
||||
import {
|
||||
ensureWorkerWalletWithClient,
|
||||
getWorkerWalletWithClient,
|
||||
WORK_ORDER_SHARE_SELECT,
|
||||
} from './shared.js'
|
||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||
import { getOutstandingDepositAmountWithClient } from './work-order-deposit-repo.js'
|
||||
import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js'
|
||||
import type { ProblemWorkOrderResolutionAction, WorkOrderRow } from './types.js'
|
||||
import type { ProblemWorkOrderResolutionAction, WorkOrderRow, WorkOrderShareRow } from './types.js'
|
||||
|
||||
/**
|
||||
* 统计工单的活跃拼单份额(除已取消外都算活跃)。
|
||||
@@ -278,6 +282,294 @@ export async function resolveProblemWorkOrder(input: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台按份额标记问题单。
|
||||
*
|
||||
* 只写份额自身的 `problem_*` 三个字段:
|
||||
* - 母单 `status` / `problem_note` 一律不动(同工单其他打手完全无感);
|
||||
* - 份额状态(joined/submitted/accepted)也不动,问题标记是正交属性;
|
||||
* - 只有该份额持有人会在「我的订单」里看到问题单。
|
||||
*
|
||||
* 母单已 accepted / cancelled / problem 时不允许按份额标记:
|
||||
* 前两者已结算/终止,后者本身就是整单问题单(走整单处置)。
|
||||
*/
|
||||
export async function markWorkOrderShareProblem(input: {
|
||||
workOrderId: number
|
||||
shareId: number
|
||||
note: string
|
||||
now: string
|
||||
actorName?: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
share: WorkOrderShareRow | null
|
||||
failureReason: 'work_order_not_markable' | 'share_not_found' | 'share_status_not_markable' | null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const orderResult = await client.query<WorkOrderRow>(
|
||||
`SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`,
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = orderResult.rows[0] || null
|
||||
if (
|
||||
!workOrder ||
|
||||
['accepted', 'cancelled', 'problem'].includes(String(workOrder.status || ''))
|
||||
) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share: null,
|
||||
failureReason: 'work_order_not_markable' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const shareResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`,
|
||||
[input.shareId, input.workOrderId],
|
||||
)
|
||||
const share = shareResult.rows[0] || null
|
||||
if (!share) {
|
||||
return { order: workOrder, share: null, failureReason: 'share_not_found' as const }
|
||||
}
|
||||
if (share.status === 'cancelled') {
|
||||
return {
|
||||
order: workOrder,
|
||||
share,
|
||||
failureReason: 'share_status_not_markable' as const,
|
||||
}
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET problem_note = $1,
|
||||
problem_marked_at = $2,
|
||||
problem_marked_by = $3,
|
||||
updated_at = $2
|
||||
WHERE id = $4`,
|
||||
[String(input.note || '').trim(), input.now, input.actorName || '', input.shareId],
|
||||
)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'sharing_share_marked_problem',
|
||||
// 母单状态不迁移,事件只记录份额级标记
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
shareId: input.shareId,
|
||||
workerId: Number(share.worker_id || 0),
|
||||
note: String(input.note || '').trim(),
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
|
||||
const updated = await client.query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
|
||||
[input.shareId],
|
||||
)
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
share: updated.rows[0] || null,
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处置份额级问题单:与母单处置同名的四种方式,但只作用于这一份份额。
|
||||
*
|
||||
* - return_to_worker:清标记;已提交的份额回到进行中并清空验收提交(继续代练)。
|
||||
* - reopen / cancel_release:取消该份额并退还押金。
|
||||
* - cancel_deduct:取消该份额并扣除押金。
|
||||
*
|
||||
* 母单 `status` / `problem_note` 一律不写,同工单其他打手不受影响。
|
||||
*/
|
||||
export async function disposeWorkOrderShareProblem(input: {
|
||||
workOrderId: number
|
||||
shareId: number
|
||||
action: ProblemWorkOrderResolutionAction
|
||||
note: string
|
||||
now: string
|
||||
actorName?: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
share: WorkOrderShareRow | null
|
||||
depositAmount: number
|
||||
failureReason:
|
||||
| 'work_order_not_disposable'
|
||||
| 'share_not_found'
|
||||
| 'share_not_marked'
|
||||
| 'share_status_not_disposable'
|
||||
| null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const orderResult = await client.query<WorkOrderRow>(
|
||||
`SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`,
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = orderResult.rows[0] || null
|
||||
if (!workOrder || ['accepted', 'cancelled'].includes(String(workOrder.status || ''))) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share: null,
|
||||
depositAmount: 0,
|
||||
failureReason: 'work_order_not_disposable' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const shareResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`,
|
||||
[input.shareId, input.workOrderId],
|
||||
)
|
||||
const share = shareResult.rows[0] || null
|
||||
if (!share) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share: null,
|
||||
depositAmount: 0,
|
||||
failureReason: 'share_not_found' as const,
|
||||
}
|
||||
}
|
||||
if (!share.problem_marked_at) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share,
|
||||
depositAmount: 0,
|
||||
failureReason: 'share_not_marked' as const,
|
||||
}
|
||||
}
|
||||
// 已验收份额的报酬/押金已结算,只能走售后,不能在这里取消或退回。
|
||||
if (!['joined', 'submitted'].includes(String(share.status || ''))) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share,
|
||||
depositAmount: 0,
|
||||
failureReason: 'share_status_not_disposable' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const workerId = Number(share.worker_id || 0)
|
||||
let depositAmount = 0
|
||||
|
||||
if (input.action === 'return_to_worker') {
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET status = CASE WHEN status = 'submitted' THEN 'joined' ELSE status END,
|
||||
acceptance_json = CASE
|
||||
WHEN status = 'submitted' THEN '{}'::jsonb
|
||||
ELSE acceptance_json
|
||||
END,
|
||||
draft_acceptance_json = CASE
|
||||
WHEN status = 'submitted' THEN '{}'::jsonb
|
||||
ELSE draft_acceptance_json
|
||||
END,
|
||||
submitted_at = CASE WHEN status = 'submitted' THEN NULL ELSE submitted_at END,
|
||||
deadline_at = CASE
|
||||
WHEN status = 'submitted' THEN
|
||||
CASE
|
||||
WHEN timeout_minutes > 0
|
||||
THEN $2::timestamptz + (timeout_minutes * INTERVAL '1 minute')
|
||||
ELSE NULL
|
||||
END
|
||||
ELSE deadline_at
|
||||
END,
|
||||
problem_note = '',
|
||||
problem_marked_at = NULL,
|
||||
problem_marked_by = '',
|
||||
updated_at = $2
|
||||
WHERE id = $1`,
|
||||
[input.shareId, input.now],
|
||||
)
|
||||
} else {
|
||||
const shouldDeduct = input.action === 'cancel_deduct'
|
||||
if (workerId > 0) {
|
||||
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, workerId)
|
||||
depositAmount = Math.min(
|
||||
Number(share.share_deposit || 0),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
if (depositAmount > 0) {
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) + (shouldDeduct ? 0 : depositAmount)
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - depositAmount)
|
||||
await client.query(
|
||||
`UPDATE worker_wallets
|
||||
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
|
||||
WHERE worker_id = $4`,
|
||||
[nextAvailable, nextFrozen, input.now, workerId],
|
||||
)
|
||||
await client.query(
|
||||
`INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`,
|
||||
[
|
||||
workerId,
|
||||
shouldDeduct ? 'deposit_deduction' : 'deposit_release',
|
||||
shouldDeduct ? -depositAmount : depositAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
shouldDeduct ? '份额问题单处置扣除押金' : '份额问题单处置退还押金',
|
||||
JSON.stringify({
|
||||
action: input.action,
|
||||
shareId: input.shareId,
|
||||
note: input.note,
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET status = 'cancelled',
|
||||
cancelled_reason = 'problem',
|
||||
acceptance_json = '{}'::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
submitted_at = NULL,
|
||||
accepted_at = NULL,
|
||||
problem_note = '',
|
||||
problem_marked_at = NULL,
|
||||
problem_marked_by = '',
|
||||
updated_at = $1
|
||||
WHERE id = $2`,
|
||||
[input.now, input.shareId],
|
||||
)
|
||||
}
|
||||
|
||||
// 母单状态保持不变:与「后台单人撤单」同口径,只处置这一份。
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'sharing_share_problem_disposed',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
action: input.action,
|
||||
shareId: input.shareId,
|
||||
workerId,
|
||||
note: input.note,
|
||||
depositAmount,
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
|
||||
const updated = await client.query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
|
||||
[input.shareId],
|
||||
)
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
share: updated.rows[0] || null,
|
||||
depositAmount,
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function settleOverdueWorkOrder(input: {
|
||||
workOrderId: number
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
|
||||
|
||||
@@ -223,25 +223,47 @@ 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'
|
||||
)
|
||||
)`
|
||||
}
|
||||
|
||||
/**
|
||||
* 该打手在本工单是否有被后台标记问题单的份额(份额级问题单)。
|
||||
*
|
||||
* 只影响该份额持有人:母单 `status` 不变,同工单其他打手也看不到。
|
||||
* 多条份额时只要有一份被标记,这张单在「我的订单」里就显示为问题单。
|
||||
*/
|
||||
export function workerShareProblemExistsExpression(workerId: number): string {
|
||||
return `EXISTS (
|
||||
SELECT 1
|
||||
FROM work_order_shares wsp
|
||||
WHERE wsp.work_order_id = wo.id
|
||||
AND wsp.worker_id = ${workerId}
|
||||
AND wsp.status <> 'cancelled'
|
||||
AND wsp.problem_marked_at IS NOT NULL
|
||||
)`
|
||||
}
|
||||
|
||||
export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') {
|
||||
const baseExpression = workerOrderBaseStatusExpression(workerShareAlias)
|
||||
const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0))
|
||||
if (normalizedWorkerId <= 0) return baseExpression
|
||||
const pendingHandlingExists = workerPendingHandlingExistsExpression(normalizedWorkerId)
|
||||
const shareProblemExists = workerShareProblemExistsExpression(normalizedWorkerId)
|
||||
const isTimeoutForMe = `(
|
||||
(wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId})
|
||||
AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted')
|
||||
@@ -254,6 +276,8 @@ export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'wo
|
||||
)`
|
||||
return `CASE
|
||||
WHEN ${isTimeoutForMe} THEN 'open'
|
||||
WHEN (${baseExpression}) IN ('accepted', 'cancelled', 'problem') THEN ${baseExpression}
|
||||
WHEN ${shareProblemExists} THEN 'problem'
|
||||
WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem'
|
||||
ELSE ${baseExpression}
|
||||
END`
|
||||
@@ -638,12 +662,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.id 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,67 +675,96 @@ 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.id IS NOT NULL THEN wos.share_reward
|
||||
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 work_order_shares wos
|
||||
ON wos.work_order_id = wo.id
|
||||
AND wos.worker_id = $1
|
||||
AND wos.status != 'cancelled'
|
||||
),
|
||||
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
|
||||
-- 份额级问题单:只有该份额持有人会在概览里多出一条 problem 计数;
|
||||
-- 同工单其他打手、母单状态都不受影响。被标记的份额不再计入原状态。
|
||||
WHEN wos.problem_marked_at IS NOT NULL THEN 'problem'
|
||||
-- 待审核取消申请/未处理反馈只把「进行中」的份额归入问题单;
|
||||
-- 已提交待验收的份额必须留在待验收(列表筛选就是这么判的,
|
||||
-- 这里若整体排除会出现「问题单有数字、点进去空列表」)。
|
||||
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,
|
||||
(wos.problem_marked_at IS NOT NULL)
|
||||
),
|
||||
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,
|
||||
@@ -721,7 +774,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
|
||||
),
|
||||
@@ -734,7 +787,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'
|
||||
@@ -742,8 +795,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],
|
||||
)
|
||||
@@ -793,7 +879,12 @@ export interface OverdueWorkOrderShareItem {
|
||||
shareCreatedAt: string
|
||||
}
|
||||
|
||||
/** 查询已超过接单时限、尚未提交材料的拼单份额(status=joined)。 */
|
||||
/**
|
||||
* 查询已到期、尚未提交材料的拼单份额(status=joined)。
|
||||
*
|
||||
* 到期判定只看份额自身的 deadline_at(参与时固化),不再用「母单当前配置 + created_at」实时算:
|
||||
* 母单改时限不会追溯影响已参与份额。
|
||||
*/
|
||||
export async function listOverdueWorkOrderShares({
|
||||
limit = 50,
|
||||
workerId = 0,
|
||||
@@ -821,18 +912,17 @@ export async function listOverdueWorkOrderShares({
|
||||
wos.worker_id,
|
||||
wos.quantity,
|
||||
wos.share_deposit,
|
||||
wo.timeout_minutes,
|
||||
wo.timeout_policy,
|
||||
wos.timeout_minutes,
|
||||
wos.timeout_policy,
|
||||
wos.created_at AS share_created_at
|
||||
FROM work_order_shares wos
|
||||
INNER JOIN work_orders wo ON wo.id = wos.work_order_id
|
||||
WHERE wos.status = 'joined'
|
||||
AND wo.sharing_enabled = TRUE
|
||||
AND wo.timeout_minutes > 0
|
||||
AND wos.created_at + (wo.timeout_minutes || ' minutes')::interval < NOW()
|
||||
AND wo.status = 'open'
|
||||
AND wos.deadline_at IS NOT NULL
|
||||
AND wos.deadline_at < NOW()
|
||||
AND wo.status NOT IN ('accepted', 'cancelled')
|
||||
${workerClause}
|
||||
ORDER BY wos.created_at ASC
|
||||
ORDER BY wos.deadline_at ASC
|
||||
LIMIT $${params.length}`,
|
||||
params,
|
||||
)
|
||||
@@ -848,25 +938,31 @@ export async function listOverdueWorkOrderShares({
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 打手当前活跃单数 SQL(按**不同工单**计)。
|
||||
*
|
||||
* 同一工单拼多份只占 1 个名额:拼单份额按 work_order_id 去重,避免拆份占满接单上限。
|
||||
* 事务内外的计数必须共用这一段 SQL,否则会出现「资料页/抢普通单按份额算、
|
||||
* 拼单入口按工单算」的口径分裂。
|
||||
*/
|
||||
const COUNT_WORKER_ACTIVE_ORDERS_SQL = `
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM (
|
||||
SELECT wo.id
|
||||
FROM work_orders wo
|
||||
WHERE wo.assigned_worker_id = $1 AND wo.status = 'in_progress'
|
||||
UNION
|
||||
SELECT wos.work_order_id
|
||||
FROM work_order_shares wos
|
||||
INNER JOIN work_orders swo ON swo.id = wos.work_order_id
|
||||
WHERE wos.worker_id = $1
|
||||
AND wos.status = 'joined'
|
||||
AND swo.status = 'open'
|
||||
) AS active_orders
|
||||
`
|
||||
|
||||
export async function countWorkerActiveOrders(workerId: number | string): Promise<number> {
|
||||
const result = await query<{ total: number }>(
|
||||
`
|
||||
SELECT (
|
||||
SELECT COUNT(*)::int
|
||||
FROM work_orders
|
||||
WHERE assigned_worker_id = $1
|
||||
AND status = 'in_progress'
|
||||
) + (
|
||||
SELECT COUNT(*)::int
|
||||
FROM work_order_shares wos
|
||||
INNER JOIN work_orders wo ON wo.id = wos.work_order_id
|
||||
WHERE wos.worker_id = $1
|
||||
AND wos.status = 'joined'
|
||||
AND wo.status = 'open'
|
||||
) AS total
|
||||
`,
|
||||
[Number(workerId)],
|
||||
)
|
||||
const result = await query<{ total: number }>(COUNT_WORKER_ACTIVE_ORDERS_SQL, [Number(workerId)])
|
||||
return Number(result.rows[0]?.total || 0)
|
||||
}
|
||||
|
||||
@@ -874,20 +970,7 @@ export async function countWorkerActiveOrdersWithClient(
|
||||
client: PoolClient,
|
||||
workerId: number,
|
||||
): Promise<number> {
|
||||
const result = await client.query<{ total: number }>(
|
||||
`
|
||||
SELECT (
|
||||
SELECT COUNT(*)::int FROM work_orders
|
||||
WHERE assigned_worker_id = $1 AND status = 'in_progress'
|
||||
) + (
|
||||
SELECT COUNT(*)::int
|
||||
FROM work_order_shares wos
|
||||
INNER JOIN work_orders wo ON wo.id = wos.work_order_id
|
||||
WHERE wos.worker_id = $1 AND wos.status = 'joined' AND wo.status = 'open'
|
||||
) AS total
|
||||
`,
|
||||
[workerId],
|
||||
)
|
||||
const result = await client.query<{ total: number }>(COUNT_WORKER_ACTIVE_ORDERS_SQL, [workerId])
|
||||
return Number(result.rows[0]?.total || 0)
|
||||
}
|
||||
|
||||
@@ -907,7 +990,8 @@ export async function countTimeoutEventsByWorkerIds(
|
||||
const counts = new Map<number, number>()
|
||||
const result = await query<{ worker_id: string; total: number }>(
|
||||
`
|
||||
SELECT payload_json->>'workerId' AS worker_id, COUNT(*)::int AS total
|
||||
SELECT payload_json->>'workerId' AS worker_id,
|
||||
COUNT(DISTINCT work_order_id)::int AS total
|
||||
FROM work_order_events
|
||||
WHERE event_type LIKE 'timeout_%'
|
||||
AND payload_json->>'workerId' IS NOT NULL
|
||||
@@ -1165,9 +1249,23 @@ export function buildWorkOrderWhere({
|
||||
WHERE timeout_ev.event_type LIKE 'timeout_%'
|
||||
AND timeout_ev.payload_json->>'workerId' = ${workerSharePlaceholder}::text
|
||||
) worker_visible ON worker_visible.work_order_id = wo.id
|
||||
LEFT JOIN work_order_shares worker_share
|
||||
ON worker_share.work_order_id = wo.id
|
||||
AND worker_share.worker_id = ${workerSharePlaceholder}::bigint`)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT wos.status, wos.created_at
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = wo.id
|
||||
AND wos.worker_id = ${workerSharePlaceholder}::bigint
|
||||
ORDER BY
|
||||
CASE wos.status
|
||||
WHEN 'joined' THEN 0
|
||||
WHEN 'submitted' THEN 1
|
||||
WHEN 'accepted' THEN 2
|
||||
WHEN 'cancelled' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
wos.created_at DESC,
|
||||
wos.id DESC
|
||||
LIMIT 1
|
||||
) worker_share ON true`)
|
||||
}
|
||||
|
||||
if (useOperationalStatus) {
|
||||
@@ -1214,8 +1312,8 @@ export function buildWorkOrderWhere({
|
||||
}
|
||||
} else if (normalizedStatuses.length > 0) {
|
||||
if (workerSharingId && isProblemOnlyStatusFilter(normalizedStatuses)) {
|
||||
// 「仅问题单」短路:问题单 = wo.status 直接是 problem,或 进行中 且挂着
|
||||
// 待审核取消申请/未处理反馈。两条 pending 表极小(生产实测常为 0 行),
|
||||
// 「仅问题单」短路:问题单 = 母单 problem、自己某份份额被后台标记问题单,
|
||||
// 或 进行中 且挂着待审核取消申请/未处理反馈。两条 pending 表极小(生产实测常为 0 行),
|
||||
// 从它们出发反查,避免为了一个通常为空的结果枚举该打手全部可见单再逐行求值
|
||||
// 完整 CASE(重打手 8,586 单 × 逐行探测 ≈ 233MB 缓冲读)。
|
||||
// 注意左臂必须写成 sargable 的 wo.status = 'problem':完整表达式里
|
||||
@@ -1225,6 +1323,7 @@ export function buildWorkOrderWhere({
|
||||
const baseExpression = workerOrderBaseStatusExpression('worker_share')
|
||||
filters.push(`(
|
||||
wo.status = 'problem'
|
||||
OR ${workerShareProblemExistsExpression(workerSharingId)}
|
||||
OR (
|
||||
${baseExpression} = 'in_progress'
|
||||
AND wo.id IN (
|
||||
@@ -1244,9 +1343,37 @@ 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.problem_marked_at IS NULL
|
||||
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[])`,
|
||||
@@ -1312,7 +1439,7 @@ export function buildWorkOrderWhere({
|
||||
if (normalizedShopNames.length > 0) {
|
||||
// 店铺口径是「订单归属店铺」:工单只存 order_id,必须回查 orders.shop_name。
|
||||
// 下拉选项本身就是真实值,这里做精确匹配,并用 BTRIM 兼容历史脏数据的前后空格
|
||||
// (表达式索引见 103_work_order_shop_filter_index.sql)。
|
||||
// (表达式索引见 110_work_order_hall_and_shop_indexes.sql)。
|
||||
params.push(normalizedShopNames)
|
||||
filters.push(`wo.order_id IN (
|
||||
SELECT shop_order.id
|
||||
@@ -1464,59 +1591,92 @@ export function buildWorkOrderWhere({
|
||||
}
|
||||
}
|
||||
|
||||
/** 大厅容量先选出全局队首(置顶单优先占位),分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */
|
||||
/**
|
||||
* 大厅候选集:FIFO 队首 N 个 + 全部置顶单。
|
||||
*
|
||||
* 旧实现只按 `hall_queued_at ASC, id ASC` 取全局队首 N 个,置顶单若入厅时间偏早
|
||||
* (排在队列 N 名之后)就不在候选集里:列表端按 `pinned_at DESC` 排序、置顶广播
|
||||
* 又会让它出现在打手眼前,但抢单/拼单在事务内复核容量时会判「不在候选集」,
|
||||
* 前端表现为「置顶订单当前不可抢 / 不可拼单」(生产已复现:5 个置顶单里 4 个
|
||||
* 排在 FIFO 窗口 50 名之外)。置顶是运营显式给的最高优先级,必须无条件进入
|
||||
* 候选集;置顶名额不占用 FIFO 容量,保持原队列语义不变。
|
||||
*/
|
||||
function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceholder: string) {
|
||||
return `
|
||||
SELECT ranked.id
|
||||
SELECT visible.id
|
||||
FROM (
|
||||
SELECT
|
||||
candidate.id,
|
||||
candidate.category_id,
|
||||
candidate.hall_queued_at,
|
||||
candidate.pinned_at,
|
||||
COALESCE(candidate_wc.parent_id, candidate.category_id) AS primary_category_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(candidate_wc.parent_id, candidate.category_id)
|
||||
ORDER BY candidate.pinned_at DESC NULLS LAST, candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
|
||||
) AS primary_category_rank,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY candidate.category_id
|
||||
ORDER BY candidate.pinned_at DESC NULLS LAST, candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
|
||||
) AS sub_category_rank
|
||||
FROM work_orders candidate
|
||||
LEFT JOIN work_categories candidate_wc ON candidate_wc.id = candidate.category_id
|
||||
WHERE candidate.status = 'open'
|
||||
AND (
|
||||
candidate.sharing_enabled IS NOT TRUE
|
||||
-- 只按候选工单取拼单份额。历史写法是先把整张 work_order_shares 聚合一遍
|
||||
-- (GROUP BY work_order_id)再 join:生产实测那次聚合 22,987 行 / 35ms / 5,025 buffer,
|
||||
-- 而候选只有 38 笔,聚合结果 99% 被丢掉;而且它是这条 COUNT 的主成本
|
||||
-- (EXPLAIN: Aggregate 82.8ms → Index Only Scan + GroupAggregate 35ms)。
|
||||
-- 关联子查询走 idx_work_order_shares_work_order,每笔候选一次索引探测即可。
|
||||
OR COALESCE((
|
||||
SELECT SUM(wos.quantity)
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = candidate.id
|
||||
AND wos.status != 'cancelled'
|
||||
), 0) < candidate.sharing_total_quantity
|
||||
(
|
||||
SELECT ranked.id
|
||||
FROM (
|
||||
SELECT
|
||||
candidate.id,
|
||||
candidate.category_id,
|
||||
candidate.hall_queued_at,
|
||||
COALESCE(candidate_wc.parent_id, candidate.category_id) AS primary_category_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(candidate_wc.parent_id, candidate.category_id)
|
||||
ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
|
||||
) AS primary_category_rank,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY candidate.category_id
|
||||
ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
|
||||
) AS sub_category_rank
|
||||
FROM work_orders candidate
|
||||
LEFT JOIN work_categories candidate_wc ON candidate_wc.id = candidate.category_id
|
||||
WHERE candidate.status = 'open'
|
||||
AND (
|
||||
candidate.sharing_enabled IS NOT TRUE
|
||||
-- 只按候选工单取拼单份额。历史写法是先把整张 work_order_shares 聚合一遍
|
||||
-- (GROUP BY work_order_id)再 join:生产实测那次聚合 22,987 行 / 35ms / 5,025 buffer,
|
||||
-- 而候选只有 38 笔,聚合结果 99% 被丢掉;而且它是这条 COUNT 的主成本
|
||||
-- (EXPLAIN: Aggregate 82.8ms → Index Only Scan + GroupAggregate 35ms)。
|
||||
-- 关联子查询走 idx_work_order_shares_work_order,每笔候选一次索引探测即可。
|
||||
OR COALESCE((
|
||||
SELECT SUM(wos.quantity)
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = candidate.id
|
||||
AND wos.status != 'cancelled'
|
||||
), 0) < candidate.sharing_total_quantity
|
||||
)
|
||||
) ranked
|
||||
WHERE ranked.primary_category_rank <= COALESCE(
|
||||
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.primary_category_id::text, '')::int,
|
||||
${limitPlaceholder}
|
||||
)
|
||||
) ranked
|
||||
WHERE ranked.primary_category_rank <= COALESCE(
|
||||
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.primary_category_id::text, '')::int,
|
||||
${limitPlaceholder}
|
||||
)
|
||||
AND (
|
||||
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '') IS NULL
|
||||
OR ranked.category_id = ranked.primary_category_id
|
||||
OR ranked.sub_category_rank <= NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int
|
||||
)
|
||||
-- 置顶单必须占住最终候选集的席位,不能被后面的新单挤出 limit。
|
||||
ORDER BY ranked.pinned_at DESC NULLS LAST, ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC
|
||||
LIMIT ${limitPlaceholder}
|
||||
AND (
|
||||
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '') IS NULL
|
||||
OR ranked.category_id = ranked.primary_category_id
|
||||
OR ranked.sub_category_rank <= NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int
|
||||
)
|
||||
ORDER BY ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC
|
||||
LIMIT ${limitPlaceholder}
|
||||
)
|
||||
UNION
|
||||
(
|
||||
-- 置顶单无条件入选:不参与 FIFO 截断,也不受分类容量限制。
|
||||
-- 部分索引 idx_work_orders_hall_pinned 支撑这段(见 110 迁移)。
|
||||
SELECT pinned.id
|
||||
FROM work_orders pinned
|
||||
WHERE pinned.status = 'open'
|
||||
AND pinned.pinned_at IS NOT NULL
|
||||
AND (
|
||||
pinned.sharing_enabled IS NOT TRUE
|
||||
OR COALESCE((
|
||||
SELECT SUM(wos.quantity)
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = pinned.id
|
||||
AND wos.status != 'cancelled'
|
||||
), 0) < pinned.sharing_total_quantity
|
||||
)
|
||||
)
|
||||
) visible
|
||||
`
|
||||
}
|
||||
|
||||
/** 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 */
|
||||
/**
|
||||
* 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。
|
||||
* 候选集与列表接口同源(FIFO 窗口 + 全部置顶单),所以置顶单即使排在窗口外也能正常抢单/拼单。
|
||||
*/
|
||||
export async function isWorkOrderWithinHallCapacityWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js'
|
||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||
import { recomputeWorkOrderSharingStatusWithClient } from './work-order-share-repo.js'
|
||||
import {
|
||||
enqueueDepositUnfreezeWithClient,
|
||||
getOutstandingDepositAmountWithClient,
|
||||
@@ -708,7 +709,13 @@ export async function acceptWorkOrderShareAndSettle(input: {
|
||||
})
|
||||
}
|
||||
|
||||
/** 后台撤销单个拼单份额,释放该打手押金并把份数重新放回大厅。 */
|
||||
/**
|
||||
* 后台撤销单个拼单份额:退还该打手押金、释放这一份的占用。
|
||||
*
|
||||
* **不改母工单状态**:后台撤单常用于剔除错误参与者/纠错,撤掉一份不应把
|
||||
* 待验收(或进行中)的母单踢回大厅;需要改母单状态时走「拼单撤单」的
|
||||
* 退回大厅/取消整单显式操作。
|
||||
*/
|
||||
export async function cancelWorkOrderShare(input: {
|
||||
workOrderId: number
|
||||
shareId: number
|
||||
@@ -809,30 +816,27 @@ export async function cancelWorkOrderShare(input: {
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET status = 'cancelled',
|
||||
cancelled_reason = 'admin',
|
||||
cancel_note = $2,
|
||||
cancel_quantity = COALESCE(quantity, 0),
|
||||
acceptance_json = '{}'::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
submitted_at = NULL,
|
||||
accepted_at = NULL,
|
||||
updated_at = $1
|
||||
WHERE id = $2`,
|
||||
[input.now, input.shareId],
|
||||
)
|
||||
await client.query(
|
||||
`UPDATE work_orders
|
||||
SET status = $1,
|
||||
submitted_at = NULL,
|
||||
published_at = COALESCE(published_at, $2),
|
||||
updated_at = $2
|
||||
WHERE id = $3`,
|
||||
[nextOrderStatus, input.now, input.workOrderId],
|
||||
[input.now, String(input.reason || '').trim(), input.shareId],
|
||||
)
|
||||
// 母单状态保持不变:这里只取消这一份份额,不再按剩余份额重推母单状态。
|
||||
// (旧实现会 recompute:待验收的母单撤掉一份后 allocated < total → 变回 open,
|
||||
// 等于整单被踢回抢单大厅,与「后台撤单只影响该份额」的预期不符。)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'sharing_share_cancelled_by_admin',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: nextOrderStatus,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
shareId: input.shareId,
|
||||
workerId,
|
||||
@@ -878,7 +882,11 @@ export async function settleOverdueWorkOrderShare(input: {
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = orderResult.rows[0] || null
|
||||
if (!workOrder || workOrder.sharing_enabled !== true || workOrder.status !== 'open') {
|
||||
if (
|
||||
!workOrder ||
|
||||
workOrder.sharing_enabled !== true ||
|
||||
!['open', 'pending_acceptance', 'in_progress'].includes(String(workOrder.status || ''))
|
||||
) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share: null,
|
||||
@@ -901,7 +909,8 @@ export async function settleOverdueWorkOrderShare(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutMinutes = Number(workOrder.timeout_minutes || 0)
|
||||
// 到期判定只看份额自身快照:参与时固化的 deadline_at / timeout_minutes
|
||||
const timeoutMinutes = Number(share.timeout_minutes ?? workOrder.timeout_minutes ?? 0)
|
||||
if (timeoutMinutes <= 0) {
|
||||
return {
|
||||
order: workOrder,
|
||||
@@ -910,8 +919,11 @@ export async function settleOverdueWorkOrderShare(input: {
|
||||
failureReason: 'share_not_overdue',
|
||||
}
|
||||
}
|
||||
const deadlineMs = new Date(share.created_at).getTime() + timeoutMinutes * 60 * 1000
|
||||
if (deadlineMs > new Date(input.now).getTime()) {
|
||||
const snapshotDeadline = String(share.deadline_at || '').trim()
|
||||
const deadlineMs = snapshotDeadline
|
||||
? new Date(snapshotDeadline).getTime()
|
||||
: new Date(share.created_at).getTime() + timeoutMinutes * 60 * 1000
|
||||
if (Number.isFinite(deadlineMs) && deadlineMs > new Date(input.now).getTime()) {
|
||||
return {
|
||||
order: workOrder,
|
||||
share,
|
||||
@@ -996,6 +1008,7 @@ export async function settleOverdueWorkOrderShare(input: {
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET status = 'cancelled',
|
||||
cancelled_reason = 'timeout',
|
||||
acceptance_json = '{}'::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
submitted_at = NULL,
|
||||
@@ -1005,15 +1018,8 @@ export async function settleOverdueWorkOrderShare(input: {
|
||||
[input.now, input.shareId],
|
||||
)
|
||||
|
||||
await client.query(
|
||||
`UPDATE work_orders
|
||||
SET status = 'open',
|
||||
published_at = COALESCE(published_at, $1),
|
||||
hall_queued_at = $1,
|
||||
updated_at = $1
|
||||
WHERE id = $2`,
|
||||
[input.now, input.workOrderId],
|
||||
)
|
||||
// 母单状态只由份额集合推导:这一份超时不应把其他已提交份额的母单踢回大厅
|
||||
await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now)
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
@@ -1160,6 +1166,7 @@ export async function cancelWorkOrderSharing(input: {
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET status = 'cancelled',
|
||||
cancelled_reason = 'admin',
|
||||
acceptance_json = '{}'::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
submitted_at = NULL,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { resolveShareReduceTargetQuantity } from '../../domain/work-order-share-quantity.js'
|
||||
import {
|
||||
ensureWorkerWalletWithClient,
|
||||
getWorkerWalletWithClient,
|
||||
@@ -9,18 +12,11 @@ import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import {
|
||||
countWorkerActiveOrdersWithClient,
|
||||
getWorkOrderById,
|
||||
getWorkOrderByIdWithClient,
|
||||
isWorkOrderWithinHallCapacityWithClient,
|
||||
} from './work-order-repo.js'
|
||||
import type { WorkOrderRow, WorkOrderShareRow } from './types.js'
|
||||
|
||||
async function getWorkOrderShareById(shareId: number): Promise<WorkOrderShareRow | null> {
|
||||
const result = await query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
|
||||
[shareId],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listWorkOrderShares(
|
||||
workOrderId: number | string,
|
||||
): Promise<WorkOrderShareRow[]> {
|
||||
@@ -90,18 +86,56 @@ export async function countWorkOrderPendingSharingSubmissions(
|
||||
return Number(result.rows[0]?.total || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 该打手在该工单的「主份额」:优先未完成(joined/submitted)的最新一条,
|
||||
* 否则取最新一条(含已验收/已取消)。仅用于展示与权限判断,**不要**用它做验收/撤单定位。
|
||||
*/
|
||||
export async function getWorkOrderShare(
|
||||
workOrderId: number | string,
|
||||
workerId: number | string,
|
||||
): Promise<WorkOrderShareRow | null> {
|
||||
const result = await query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT}
|
||||
WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled' LIMIT 1`,
|
||||
WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled'
|
||||
ORDER BY CASE WHEN wos.status IN ('joined', 'submitted') THEN 0 ELSE 1 END, wos.id DESC
|
||||
LIMIT 1`,
|
||||
[Number(workOrderId), Number(workerId)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/** 该打手在该工单的全部有效份额(未取消),按参与顺序返回。 */
|
||||
export async function listWorkOrderSharesByWorkerInOrder(
|
||||
workOrderId: number | string,
|
||||
workerId: number | string,
|
||||
): Promise<WorkOrderShareRow[]> {
|
||||
const result = await query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT}
|
||||
WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled'
|
||||
ORDER BY wos.id ASC`,
|
||||
[Number(workOrderId), Number(workerId)],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
/** 按份额 id 读取(可带工单约束,防止跨单操作)。 */
|
||||
export async function getWorkOrderShareById(
|
||||
shareId: number | string,
|
||||
workOrderId?: number | string,
|
||||
): Promise<WorkOrderShareRow | null> {
|
||||
const params: unknown[] = [Number(shareId)]
|
||||
let workOrderClause = ''
|
||||
if (workOrderId !== undefined && Number(workOrderId) > 0) {
|
||||
params.push(Number(workOrderId))
|
||||
workOrderClause = ` AND wos.work_order_id = $${params.length}`
|
||||
}
|
||||
const result = await query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1${workOrderClause} LIMIT 1`,
|
||||
params,
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
/** 普通拼单使用指定份数,立即抢单则在事务中承接全部剩余份数。 */
|
||||
export function resolveWorkOrderShareJoinQuantity(input: {
|
||||
quantity: number
|
||||
@@ -112,45 +146,41 @@ export function resolveWorkOrderShareJoinQuantity(input: {
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一打手再次参与同一拼单工单时的处理方式。
|
||||
* 份额表有 UNIQUE(work_order_id, worker_id),取消的份额会保留用于追溯,
|
||||
* 所以重新拼单必须复用该行并重置为一条全新份额,不能直接插入。
|
||||
* 计算「本次参与」应新增冻结的押金。
|
||||
*
|
||||
* 口径(已确认):免押额度跟着母单走 —— 在该工单内只抵扣一次。
|
||||
* cumRequired = round(required × 累计份数 / 总份数)
|
||||
* cumDeposit = max(0, cumRequired − 免押额度)
|
||||
* 本次冻结 = max(0, cumDeposit − 该打手在本工单已冻结合计)
|
||||
*
|
||||
* alreadyFrozenDeposit 只用于避免重复冻结,不会因为份数变少而回退(既有语义)。
|
||||
*/
|
||||
export function resolveWorkOrderShareJoinMode(input: {
|
||||
existingShareStatus?: string | null
|
||||
takeRemaining?: boolean
|
||||
}): 'create' | 'append' | 'rejoin' | 'reject' {
|
||||
const status = String(input.existingShareStatus || '')
|
||||
if (!status) return 'create'
|
||||
// 取消即结束该次参与,允许打手把同一行重新启用为份额。
|
||||
if (status === 'cancelled') return 'rejoin'
|
||||
// 立即抢单承接剩余份数时才允许在未提交的份额上追加。
|
||||
if (status === 'joined' && input.takeRemaining === true) return 'append'
|
||||
return 'reject'
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算打手当前累计拼单份额实际需要冻结的押金。
|
||||
* 等级与个人叠加后的免押额度针对该打手在本工单的累计份额生效;已经冻结的金额不自动回退。
|
||||
*/
|
||||
export function resolveWorkOrderShareDepositAmount(input: {
|
||||
export function resolveWorkOrderShareDepositPlan(input: {
|
||||
requiredDepositAmount: number
|
||||
totalQuantity: number
|
||||
/** 本次参与前,该打手在该工单已参与的有效份数(不含本次) */
|
||||
quantityBefore: number
|
||||
/** 本次参与份数 */
|
||||
nextQuantity: number
|
||||
depositFreeAmount?: number
|
||||
currentFrozenDeposit?: number
|
||||
}) {
|
||||
/** 该打手在该工单已冻结的押金合计 */
|
||||
frozenDepositSoFar?: number
|
||||
}): { cumulativeRequired: number; cumulativeDeposit: number; increment: number } {
|
||||
const totalQuantity = Math.max(1, Number(input.totalQuantity || 0))
|
||||
const nextRequiredDeposit = Math.round(
|
||||
(Math.max(0, Number(input.requiredDepositAmount || 0)) *
|
||||
Math.max(0, Number(input.nextQuantity || 0))) /
|
||||
totalQuantity,
|
||||
const cumulativeQuantity =
|
||||
Math.max(0, Number(input.quantityBefore || 0)) + Math.max(0, Number(input.nextQuantity || 0))
|
||||
const cumulativeRequired = Math.round(
|
||||
(Math.max(0, Number(input.requiredDepositAmount || 0)) * cumulativeQuantity) / totalQuantity,
|
||||
)
|
||||
const requiredShareDeposit = Math.max(
|
||||
const cumulativeDeposit = Math.max(
|
||||
0,
|
||||
nextRequiredDeposit - Math.max(0, Number(input.depositFreeAmount || 0)),
|
||||
cumulativeRequired - Math.max(0, Number(input.depositFreeAmount || 0)),
|
||||
)
|
||||
return Math.max(Math.max(0, Number(input.currentFrozenDeposit || 0)), requiredShareDeposit)
|
||||
const increment = Math.max(
|
||||
0,
|
||||
cumulativeDeposit - Math.max(0, Number(input.frozenDepositSoFar || 0)),
|
||||
)
|
||||
return { cumulativeRequired, cumulativeDeposit, increment }
|
||||
}
|
||||
|
||||
export async function joinWorkOrderShare(input: {
|
||||
@@ -159,22 +189,24 @@ export async function joinWorkOrderShare(input: {
|
||||
quantity: number
|
||||
/** 立即抢单时在事务锁内承接全部剩余份数。 */
|
||||
takeRemaining?: boolean
|
||||
/** 当前打手的有效免押额度(分),按其本次累计拼单份额抵扣。 */
|
||||
/** 当前打手的有效免押额度(分),按其在本工单的累计份额抵扣一次。 */
|
||||
depositFreeAmount?: number
|
||||
maxActiveOrders: number
|
||||
hallCandidateLimit?: number
|
||||
hallCategoryLimits?: Record<string, number>
|
||||
visibleAfterIso?: string
|
||||
/** 客户端幂等键:同一个打手同一个 requestId 只产生一条份额。 */
|
||||
requestId?: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
share: WorkOrderShareRow | null
|
||||
/** true = 命中幂等键,返回既有份额,未重复冻结 */
|
||||
duplicated: boolean
|
||||
failureReason:
|
||||
| 'work_order_not_open'
|
||||
| 'sharing_disabled'
|
||||
| 'work_order_owner_conflict'
|
||||
| 'sharing_quantity_full'
|
||||
| 'worker_share_exists'
|
||||
| 'worker_share_locked'
|
||||
| 'worker_active_order_limit'
|
||||
| 'worker_deposit_insufficient'
|
||||
| null
|
||||
@@ -193,7 +225,7 @@ export async function joinWorkOrderShare(input: {
|
||||
)
|
||||
const workOrder = orderResult.rows[0] || null
|
||||
if (!workOrder || workOrder.status !== 'open') {
|
||||
return { share: null, failureReason: 'work_order_not_open' }
|
||||
return { share: null, duplicated: false, failureReason: 'work_order_not_open' }
|
||||
}
|
||||
if (
|
||||
input.hallCandidateLimit &&
|
||||
@@ -204,13 +236,26 @@ export async function joinWorkOrderShare(input: {
|
||||
visibleAfterIso: input.visibleAfterIso,
|
||||
}))
|
||||
) {
|
||||
return { share: null, failureReason: 'work_order_not_open' }
|
||||
return { share: null, duplicated: false, failureReason: 'work_order_not_open' }
|
||||
}
|
||||
if (workOrder.sharing_enabled !== true) {
|
||||
return { share: null, failureReason: 'sharing_disabled' }
|
||||
return { share: null, duplicated: false, failureReason: 'sharing_disabled' }
|
||||
}
|
||||
if (Number(workOrder.assigned_worker_id || 0) === input.workerId) {
|
||||
return { share: null, failureReason: 'work_order_owner_conflict' }
|
||||
return { share: null, duplicated: false, failureReason: 'work_order_owner_conflict' }
|
||||
}
|
||||
|
||||
// 幂等:同一打手同一 requestId 直接返回原份额(不重复冻结、不重复算份数)
|
||||
const requestId = String(input.requestId || '').trim()
|
||||
if (requestId) {
|
||||
const idempotentResult = await client.query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT} WHERE wos.worker_id = $1 AND wos.request_id = $2 LIMIT 1`,
|
||||
[input.workerId, requestId],
|
||||
)
|
||||
const existing = idempotentResult.rows[0] || null
|
||||
if (existing) {
|
||||
return { share: existing, duplicated: true, failureReason: null }
|
||||
}
|
||||
}
|
||||
|
||||
const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1)
|
||||
@@ -230,122 +275,82 @@ export async function joinWorkOrderShare(input: {
|
||||
takeRemaining: input.takeRemaining === true,
|
||||
})
|
||||
if (requestedQuantity <= 0 || requestedQuantity > remaining) {
|
||||
return { share: null, failureReason: 'sharing_quantity_full' }
|
||||
return { share: null, duplicated: false, failureReason: 'sharing_quantity_full' }
|
||||
}
|
||||
|
||||
// 取消的份额也一并查出:UNIQUE(work_order_id, worker_id) 下只能复用该行重新加入。
|
||||
const existingShareResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT *
|
||||
FROM work_order_shares
|
||||
WHERE work_order_id = $1 AND worker_id = $2
|
||||
FOR UPDATE`,
|
||||
// 该打手在本工单的累计份数与已冻结押金(免押额度按母单只抵扣一次)
|
||||
const workerAggResult = await client.query<{ quantity_before: number; frozen_before: number }>(
|
||||
`
|
||||
SELECT
|
||||
COALESCE(SUM(quantity), 0)::int AS quantity_before,
|
||||
COALESCE(SUM(share_deposit), 0)::int AS frozen_before
|
||||
FROM work_order_shares
|
||||
WHERE work_order_id = $1 AND worker_id = $2 AND status != 'cancelled'
|
||||
`,
|
||||
[input.workOrderId, input.workerId],
|
||||
)
|
||||
const existingShare = existingShareResult.rows[0] || null
|
||||
const joinMode = resolveWorkOrderShareJoinMode({
|
||||
existingShareStatus: existingShare?.status ?? null,
|
||||
takeRemaining: input.takeRemaining === true,
|
||||
})
|
||||
if (joinMode === 'reject') {
|
||||
return {
|
||||
share: null,
|
||||
failureReason:
|
||||
existingShare?.status === 'joined' ? 'worker_share_exists' : 'worker_share_locked',
|
||||
}
|
||||
}
|
||||
const revivingCancelledShare = joinMode === 'rejoin'
|
||||
if (joinMode === 'create' || revivingCancelledShare) {
|
||||
const quantityBefore = Number(workerAggResult.rows[0]?.quantity_before || 0)
|
||||
const frozenBefore = Number(workerAggResult.rows[0]?.frozen_before || 0)
|
||||
|
||||
// 活跃单按「不同工单」计:已经在该工单有份额时不重复占名额
|
||||
if (quantityBefore <= 0) {
|
||||
const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId)
|
||||
if (activeOrderCount >= input.maxActiveOrders) {
|
||||
return { share: null, failureReason: 'worker_active_order_limit' }
|
||||
return { share: null, duplicated: false, failureReason: 'worker_active_order_limit' }
|
||||
}
|
||||
}
|
||||
|
||||
const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0)
|
||||
// 取消的份额已释放份数与押金,重新加入时按一条全新份额计算。
|
||||
const currentQuantity = revivingCancelledShare ? 0 : Number(existingShare?.quantity || 0)
|
||||
const currentFrozenDeposit = revivingCancelledShare
|
||||
? 0
|
||||
: Number(existingShare?.share_deposit || 0)
|
||||
const nextQuantity = currentQuantity + requestedQuantity
|
||||
const shareReward = nextQuantity * unitReward
|
||||
const nextShareDeposit = resolveWorkOrderShareDepositAmount({
|
||||
const shareReward = requestedQuantity * unitReward
|
||||
const depositPlan = resolveWorkOrderShareDepositPlan({
|
||||
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
||||
totalQuantity,
|
||||
nextQuantity,
|
||||
quantityBefore,
|
||||
nextQuantity: requestedQuantity,
|
||||
...(input.depositFreeAmount === undefined
|
||||
? {}
|
||||
: { depositFreeAmount: input.depositFreeAmount }),
|
||||
currentFrozenDeposit,
|
||||
frozenDepositSoFar: frozenBefore,
|
||||
})
|
||||
const depositToFreeze = Math.max(0, nextShareDeposit - currentFrozenDeposit)
|
||||
const depositToFreeze = depositPlan.increment
|
||||
|
||||
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||
const available = Number(wallet?.available_amount || 0)
|
||||
if (available < depositToFreeze) {
|
||||
return { share: null, failureReason: 'worker_deposit_insufficient' }
|
||||
return { share: null, duplicated: false, failureReason: 'worker_deposit_insufficient' }
|
||||
}
|
||||
|
||||
let shareId = 0
|
||||
if (!existingShare) {
|
||||
const inserted = await client.query<{ id: number }>(
|
||||
`
|
||||
// 参与瞬间固化截止时间与超时策略:母单后续改配置不影响这一份
|
||||
const timeoutMinutes = toPositiveInteger(workOrder.timeout_minutes, 0)
|
||||
const timeoutPolicy = String(workOrder.timeout_policy || '').trim() || 'reopen'
|
||||
const deadlineAt =
|
||||
timeoutMinutes > 0
|
||||
? new Date(new Date(input.now).getTime() + timeoutMinutes * 60 * 1000).toISOString()
|
||||
: null
|
||||
|
||||
const shareResult = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO work_order_shares (
|
||||
work_order_id, worker_id, quantity, unit_reward, share_reward,
|
||||
share_deposit, status, acceptance_json, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $7)
|
||||
share_deposit, status, acceptance_json, deadline_at, timeout_minutes,
|
||||
timeout_policy, request_id, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $8, $9, $10, $11, $11)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
input.workOrderId,
|
||||
input.workerId,
|
||||
requestedQuantity,
|
||||
unitReward,
|
||||
shareReward,
|
||||
nextShareDeposit,
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
shareId = Number(inserted.rows[0]?.id || 0)
|
||||
} else if (revivingCancelledShare) {
|
||||
const revived = await client.query<{ id: number }>(
|
||||
`
|
||||
UPDATE work_order_shares
|
||||
SET
|
||||
status = 'joined',
|
||||
quantity = $1,
|
||||
unit_reward = $2,
|
||||
share_reward = $3,
|
||||
share_deposit = $4,
|
||||
acceptance_json = '{}'::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
submitted_at = NULL,
|
||||
accepted_at = NULL,
|
||||
created_at = $5,
|
||||
updated_at = $5
|
||||
WHERE id = $6
|
||||
RETURNING id
|
||||
`,
|
||||
[nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id],
|
||||
)
|
||||
shareId = Number(revived.rows[0]?.id || 0)
|
||||
} else {
|
||||
const appended = await client.query<{ id: number }>(
|
||||
`
|
||||
UPDATE work_order_shares
|
||||
SET
|
||||
quantity = $1,
|
||||
unit_reward = $2,
|
||||
share_reward = $3,
|
||||
share_deposit = $4,
|
||||
updated_at = $5
|
||||
WHERE id = $6
|
||||
RETURNING id
|
||||
`,
|
||||
[nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id],
|
||||
)
|
||||
shareId = Number(appended.rows[0]?.id || 0)
|
||||
}
|
||||
[
|
||||
input.workOrderId,
|
||||
input.workerId,
|
||||
requestedQuantity,
|
||||
unitReward,
|
||||
shareReward,
|
||||
depositToFreeze,
|
||||
deadlineAt,
|
||||
timeoutMinutes,
|
||||
timeoutPolicy,
|
||||
requestId,
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
|
||||
if (depositToFreeze > 0) {
|
||||
const nextAvailable = available - depositToFreeze
|
||||
@@ -372,7 +377,12 @@ export async function joinWorkOrderShare(input: {
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
`拼单冻结押金 ${requestedQuantity} 份`,
|
||||
JSON.stringify({ workOrderId: input.workOrderId, quantity: requestedQuantity }),
|
||||
JSON.stringify({
|
||||
workOrderId: input.workOrderId,
|
||||
quantity: requestedQuantity,
|
||||
shareId: Number(shareResult.rows[0]?.id || 0),
|
||||
cumulativeDeposit: depositPlan.cumulativeDeposit,
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
@@ -387,24 +397,146 @@ export async function joinWorkOrderShare(input: {
|
||||
toStatus: 'open',
|
||||
payloadJson: JSON.stringify({
|
||||
workerId: input.workerId,
|
||||
shareId: Number(shareResult.rows[0]?.id || 0),
|
||||
quantity: requestedQuantity,
|
||||
takeRemaining: input.takeRemaining === true,
|
||||
rejoined: revivingCancelledShare,
|
||||
deadlineAt,
|
||||
timeoutMinutes,
|
||||
timeoutPolicy,
|
||||
requestId: requestId || null,
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
|
||||
const share = await client.query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
|
||||
[shareId],
|
||||
[Number(shareResult.rows[0]?.id || 0)],
|
||||
)
|
||||
return { share: share.rows[0] || null, failureReason: null }
|
||||
return { share: share.rows[0] || null, duplicated: false, failureReason: null }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台按份数撤销某个打手的部分份额。
|
||||
*
|
||||
* 只改这一行的数量与份额报酬:
|
||||
* - 大厅余量 = 总份数 − Σ 有效份额数量,所以减少的份数会自动回到大厅;
|
||||
* - 母单状态不动(与「单人撤单」同口径);
|
||||
* - 押金按要求保持原冻结额,不做退还;
|
||||
* - 验收资料(验收图/视频/备注)留在原份额上,不清空。
|
||||
*/
|
||||
export async function reduceWorkOrderShareQuantity(input: {
|
||||
workOrderId: number
|
||||
shareId: number
|
||||
reduceQuantity: number
|
||||
note?: string
|
||||
actorName?: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
share: WorkOrderShareRow | null
|
||||
failureReason:
|
||||
| 'work_order_not_reducible'
|
||||
| 'share_not_found'
|
||||
| 'share_status_not_reducible'
|
||||
| 'share_quantity_invalid'
|
||||
| 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 ||
|
||||
!['open', 'in_progress', 'pending_acceptance'].includes(String(workOrder.status || ''))
|
||||
) {
|
||||
return { order: workOrder, share: null, failureReason: 'work_order_not_reducible' 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 (!['joined', 'submitted'].includes(String(share.status || ''))) {
|
||||
return { order: workOrder, share, failureReason: 'share_status_not_reducible' as const }
|
||||
}
|
||||
|
||||
const target = resolveShareReduceTargetQuantity({
|
||||
currentQuantity: Number(share.quantity || 0),
|
||||
reduceQuantity: input.reduceQuantity,
|
||||
})
|
||||
if (target.failureReason || target.nextQuantity <= 0) {
|
||||
return { order: workOrder, share, failureReason: 'share_quantity_invalid' as const }
|
||||
}
|
||||
|
||||
const unitReward = toPositiveInteger(share.unit_reward, 0)
|
||||
const nextRewardAmount = target.nextQuantity * unitReward
|
||||
// 原因只做展示:本次没填就保留上一次填的,避免后续撤份额把已有说明覆盖成空
|
||||
const nextCancelNote = String(input.note || '').trim() || String(share.cancel_note || '')
|
||||
// 撤销份数累计:同一份额多次撤份额时,列表显示总共撤了多少份
|
||||
const nextCancelQuantity =
|
||||
Math.max(0, Number(share.cancel_quantity || 0)) + input.reduceQuantity
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET quantity = $1, share_reward = $2, cancel_note = $3,
|
||||
cancel_quantity = $4, updated_at = $5
|
||||
WHERE id = $6`,
|
||||
[
|
||||
target.nextQuantity,
|
||||
nextRewardAmount,
|
||||
nextCancelNote,
|
||||
nextCancelQuantity,
|
||||
input.now,
|
||||
input.shareId,
|
||||
],
|
||||
)
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'sharing_share_quantity_reduced',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
shareId: input.shareId,
|
||||
workerId: Number(share.worker_id || 0),
|
||||
fromQuantity: Number(share.quantity || 0),
|
||||
toQuantity: target.nextQuantity,
|
||||
reduceQuantity: input.reduceQuantity,
|
||||
rewardBefore: Number(share.share_reward || 0),
|
||||
rewardAfter: nextRewardAmount,
|
||||
// 押金口径:本次保持原冻结额,不退不补
|
||||
shareDeposit: Number(share.share_deposit || 0),
|
||||
note: String(input.note || '').trim(),
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
|
||||
const updatedShare = await client.query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
|
||||
[input.shareId],
|
||||
)
|
||||
// 必须回读聚合口径的母单(active_share_quantity 等),否则前端拿到的拼单进度会归零
|
||||
const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId)
|
||||
return {
|
||||
order: updatedOrder || workOrder,
|
||||
share: updatedShare.rows[0] || null,
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function submitWorkOrderShareAcceptance(input: {
|
||||
workOrderId: number
|
||||
workerId: number
|
||||
shareId: number
|
||||
acceptanceJson: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
@@ -412,11 +544,8 @@ export async function submitWorkOrderShareAcceptance(input: {
|
||||
failureReason: 'share_not_found' | 'share_status_invalid' | 'work_order_status_invalid' | null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const orderResult = await client.query<{
|
||||
status: string
|
||||
sharing_total_quantity: number
|
||||
}>(
|
||||
`SELECT status, sharing_total_quantity
|
||||
const orderResult = await client.query<{ status: string }>(
|
||||
`SELECT status
|
||||
FROM work_orders
|
||||
WHERE id = $1
|
||||
FOR UPDATE`,
|
||||
@@ -434,10 +563,10 @@ export async function submitWorkOrderShareAcceptance(input: {
|
||||
`
|
||||
SELECT id
|
||||
FROM work_order_shares
|
||||
WHERE work_order_id = $1 AND worker_id = $2
|
||||
WHERE id = $3 AND work_order_id = $1 AND worker_id = $2
|
||||
FOR UPDATE
|
||||
`,
|
||||
[input.workOrderId, input.workerId],
|
||||
[input.workOrderId, input.workerId, input.shareId],
|
||||
)
|
||||
const shareId = lockResult.rows[0]?.id || 0
|
||||
if (!shareId) {
|
||||
@@ -463,34 +592,7 @@ export async function submitWorkOrderShareAcceptance(input: {
|
||||
[input.acceptanceJson, input.now, Number(share.id)],
|
||||
)
|
||||
|
||||
const progressResult = await client.query<{
|
||||
joined_quantity: number
|
||||
pending_submission_count: number
|
||||
}>(
|
||||
`SELECT
|
||||
COALESCE(SUM(quantity), 0)::int AS joined_quantity,
|
||||
COUNT(*) FILTER (WHERE status = 'joined')::int AS pending_submission_count
|
||||
FROM work_order_shares
|
||||
WHERE work_order_id = $1
|
||||
AND status != 'cancelled'`,
|
||||
[input.workOrderId],
|
||||
)
|
||||
const progress = progressResult.rows[0]
|
||||
const allQuantityAllocated =
|
||||
Number(progress?.joined_quantity || 0) >=
|
||||
toPositiveInteger(workOrder.sharing_total_quantity, 1)
|
||||
if (allQuantityAllocated && Number(progress?.pending_submission_count || 0) === 0) {
|
||||
await client.query(
|
||||
`UPDATE work_orders
|
||||
SET
|
||||
status = 'pending_acceptance',
|
||||
submitted_at = COALESCE(submitted_at, $1),
|
||||
updated_at = $1
|
||||
WHERE id = $2
|
||||
AND status = 'open'`,
|
||||
[input.now, input.workOrderId],
|
||||
)
|
||||
}
|
||||
await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now)
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
@@ -499,7 +601,11 @@ export async function submitWorkOrderShareAcceptance(input: {
|
||||
eventType: 'sharing_acceptance_submitted',
|
||||
fromStatus: 'joined',
|
||||
toStatus: 'submitted',
|
||||
payloadJson: JSON.stringify({ workerId: input.workerId, quantity: share.quantity }),
|
||||
payloadJson: JSON.stringify({
|
||||
workerId: input.workerId,
|
||||
shareId: Number(share.id),
|
||||
quantity: share.quantity,
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
const updated = await client.query<WorkOrderShareRow>(
|
||||
@@ -510,6 +616,86 @@ export async function submitWorkOrderShareAcceptance(input: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 按份额集合推导母单拼单状态(唯一口径,禁止各调用点各自 UPDATE work_orders.status)。
|
||||
*
|
||||
* allocated >= total && joined == 0 && submitted > 0 → pending_acceptance
|
||||
* allocated < total 或 joined > 0 → open
|
||||
* allocated == 0(全部取消) → open(重新可参与)
|
||||
* 母单 accepted / cancelled → 不动
|
||||
*
|
||||
* 返回更新后的母单行(供调用方回给前端)。
|
||||
*/
|
||||
export async function recomputeWorkOrderSharingStatusWithClient(
|
||||
client: PoolClient,
|
||||
workOrderId: number,
|
||||
now: string,
|
||||
): Promise<WorkOrderRow | null> {
|
||||
const orderResult = await client.query<WorkOrderRow>(
|
||||
`SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`,
|
||||
[workOrderId],
|
||||
)
|
||||
const workOrder = orderResult.rows[0] || null
|
||||
if (!workOrder) return null
|
||||
if (['accepted', 'cancelled'].includes(String(workOrder.status || ''))) {
|
||||
return workOrder
|
||||
}
|
||||
|
||||
const aggResult = await client.query<{
|
||||
allocated: number
|
||||
joined_count: number
|
||||
submitted_count: number
|
||||
accepted_count: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
COALESCE(SUM(quantity), 0)::int AS allocated,
|
||||
COUNT(*) FILTER (WHERE status = 'joined')::int AS joined_count,
|
||||
COUNT(*) FILTER (WHERE status = 'submitted')::int AS submitted_count,
|
||||
COUNT(*) FILTER (WHERE status = 'accepted')::int AS accepted_count
|
||||
FROM work_order_shares
|
||||
WHERE work_order_id = $1 AND status != 'cancelled'
|
||||
`,
|
||||
[workOrderId],
|
||||
)
|
||||
const agg = aggResult.rows[0]
|
||||
const allocated = Number(agg?.allocated || 0)
|
||||
const joinedCount = Number(agg?.joined_count || 0)
|
||||
const submittedCount = Number(agg?.submitted_count || 0)
|
||||
const acceptedCount = Number(agg?.accepted_count || 0)
|
||||
const total = toPositiveInteger(workOrder.sharing_total_quantity, 1)
|
||||
|
||||
const nextStatus =
|
||||
allocated >= total && joinedCount === 0 && submittedCount > 0 ? 'pending_acceptance' : 'open'
|
||||
|
||||
// 已全是 accepted 且母单已验收时不再回退(上面的终态分支已挡住)
|
||||
if (allocated === 0 && acceptedCount === 0 && workOrder.status === 'open') {
|
||||
return workOrder
|
||||
}
|
||||
if (String(workOrder.status || '') === nextStatus) {
|
||||
return workOrder
|
||||
}
|
||||
|
||||
const updated = await client.query<WorkOrderRow>(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
status = $1,
|
||||
published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END,
|
||||
hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END,
|
||||
submitted_at = CASE
|
||||
WHEN $1 = 'pending_acceptance' THEN COALESCE(submitted_at, $2)
|
||||
ELSE submitted_at
|
||||
END,
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
`,
|
||||
[nextStatus, now, workOrderId],
|
||||
)
|
||||
return updated.rows[0] || null
|
||||
}
|
||||
|
||||
/** 保存整单验收草稿(暂存图片/说明),不改变工单状态 */
|
||||
export async function updateWorkOrderDraftAcceptance(input: {
|
||||
workOrderId: number
|
||||
@@ -548,13 +734,14 @@ export async function updateWorkOrderAcceptanceRecord(input: {
|
||||
export async function updateWorkOrderShareDraftAcceptance(input: {
|
||||
workOrderId: number
|
||||
workerId: number
|
||||
shareId: number
|
||||
draftJson: string
|
||||
now: string
|
||||
}): Promise<WorkOrderShareRow | null> {
|
||||
const result = await query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT}
|
||||
WHERE wos.work_order_id = $1 AND wos.worker_id = $2`,
|
||||
[Number(input.workOrderId), Number(input.workerId)],
|
||||
WHERE wos.id = $3 AND wos.work_order_id = $1 AND wos.worker_id = $2`,
|
||||
[Number(input.workOrderId), Number(input.workerId), Number(input.shareId)],
|
||||
)
|
||||
const share = result.rows[0] || null
|
||||
if (!share) return null
|
||||
@@ -573,13 +760,14 @@ export async function updateWorkOrderShareDraftAcceptance(input: {
|
||||
export async function updateWorkOrderShareAcceptanceRecord(input: {
|
||||
workOrderId: number
|
||||
workerId: number
|
||||
shareId: number
|
||||
acceptanceJson: string
|
||||
now: string
|
||||
}): Promise<WorkOrderShareRow | null> {
|
||||
const result = await query<WorkOrderShareRow>(
|
||||
`${WORK_ORDER_SHARE_SELECT}
|
||||
WHERE wos.work_order_id = $1 AND wos.worker_id = $2`,
|
||||
[Number(input.workOrderId), Number(input.workerId)],
|
||||
WHERE wos.id = $3 AND wos.work_order_id = $1 AND wos.worker_id = $2`,
|
||||
[Number(input.workOrderId), Number(input.workerId), Number(input.shareId)],
|
||||
)
|
||||
const share = result.rows[0] || null
|
||||
if (!share) return null
|
||||
|
||||
@@ -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} 仍在把「指派单数 + 拼单份额行数」直接相加`)
|
||||
}
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './shared.js'
|
||||
import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js'
|
||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import { recomputeWorkOrderSharingStatusWithClient } from './work-order-share-repo.js'
|
||||
import {
|
||||
getOutstandingDepositAmountWithClient,
|
||||
getWorkOrderByIdWithClient,
|
||||
@@ -229,6 +230,8 @@ export async function getPendingCancelRequestOrderIds(
|
||||
export async function createWorkerCancelRequest(input: {
|
||||
workOrderId: number
|
||||
workerId: number
|
||||
/** 指定份额:只申请撤销这一份;不传(拼单)= 申请撤销我在该工单的全部有效份额 */
|
||||
shareId?: number | null
|
||||
reason: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
@@ -254,17 +257,35 @@ export async function createWorkerCancelRequest(input: {
|
||||
|
||||
let shareId: number | null = null
|
||||
if (workOrder.sharing_enabled === true) {
|
||||
const shareResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT * FROM work_order_shares
|
||||
WHERE work_order_id = $1 AND worker_id = $2
|
||||
FOR UPDATE`,
|
||||
[input.workOrderId, input.workerId],
|
||||
)
|
||||
const share = shareResult.rows[0] || null
|
||||
if (!share || !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)) {
|
||||
return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' }
|
||||
const requestedShareId = Number(input.shareId || 0)
|
||||
if (requestedShareId > 0) {
|
||||
const shareResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT * FROM work_order_shares
|
||||
WHERE id = $3 AND work_order_id = $1 AND worker_id = $2
|
||||
FOR UPDATE`,
|
||||
[input.workOrderId, input.workerId, requestedShareId],
|
||||
)
|
||||
const share = shareResult.rows[0] || null
|
||||
if (!share || !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)) {
|
||||
return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' }
|
||||
}
|
||||
shareId = Number(share.id)
|
||||
} else {
|
||||
// 整单退单(拼单):至少要有 1 条可撤份额;审核通过时撤销我在该工单的全部有效份额
|
||||
const sharesResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT * FROM work_order_shares
|
||||
WHERE work_order_id = $1 AND worker_id = $2
|
||||
FOR UPDATE`,
|
||||
[input.workOrderId, input.workerId],
|
||||
)
|
||||
const cancellable = sharesResult.rows.filter((share) =>
|
||||
resolveWorkOrderShareCancellationStatus(workOrder.status, share.status),
|
||||
)
|
||||
if (cancellable.length === 0) {
|
||||
return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' }
|
||||
}
|
||||
shareId = null
|
||||
}
|
||||
shareId = Number(share.id)
|
||||
} else if (
|
||||
workOrder.status !== 'in_progress' ||
|
||||
Number(workOrder.assigned_worker_id || 0) !== input.workerId
|
||||
@@ -534,6 +555,7 @@ export async function reviewWorkerCancelRequest(input: {
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET status = 'cancelled',
|
||||
cancelled_reason = 'worker_request',
|
||||
acceptance_json = '{}'::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
submitted_at = NULL,
|
||||
@@ -542,17 +564,84 @@ export async function reviewWorkerCancelRequest(input: {
|
||||
WHERE id = $2`,
|
||||
[input.now, share.id],
|
||||
)
|
||||
await client.query(
|
||||
`UPDATE work_orders
|
||||
SET status = 'open',
|
||||
${CLEAR_DELIVERY_RESIDUE_SQL},
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
published_at = COALESCE(published_at, $1),
|
||||
hall_queued_at = COALESCE(hall_queued_at, $1),
|
||||
updated_at = $1
|
||||
WHERE id = $2`,
|
||||
[input.now, workOrder.id],
|
||||
await recomputeWorkOrderSharingStatusWithClient(client, workOrder.id, input.now)
|
||||
} else if (workOrder.sharing_enabled === true) {
|
||||
// 整单退单(拼单):撤销我在该工单的全部可撤份额,逐份退还(或没收)押金
|
||||
const sharesResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT * FROM work_order_shares
|
||||
WHERE work_order_id = $1 AND worker_id = $2 AND status IN ('joined', 'submitted')
|
||||
FOR UPDATE`,
|
||||
[workOrder.id, request.worker_id],
|
||||
)
|
||||
const cancellableShares = sharesResult.rows.filter((share) =>
|
||||
resolveWorkOrderShareCancellationStatus(workOrder.status, share.status),
|
||||
)
|
||||
if (cancellableShares.length === 0) {
|
||||
return {
|
||||
request: null,
|
||||
order: null,
|
||||
releasedDepositAmount: 0,
|
||||
levelChanged: false,
|
||||
accountDisabled: false,
|
||||
failureReason: 'target_state_changed',
|
||||
}
|
||||
}
|
||||
await ensureWorkerWalletWithClient(client, Number(request.worker_id), input.now)
|
||||
for (const share of cancellableShares) {
|
||||
const wallet = await getWorkerWalletWithClient(client, Number(request.worker_id))
|
||||
const releaseAmount = Math.min(
|
||||
Number(share.share_deposit || 0),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
if (releaseAmount > 0) {
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) + (forfeitGiftDeposit ? 0 : releaseAmount)
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount)
|
||||
await client.query(
|
||||
`UPDATE worker_wallets
|
||||
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
|
||||
WHERE worker_id = $4`,
|
||||
[nextAvailable, nextFrozen, input.now, request.worker_id],
|
||||
)
|
||||
await client.query(
|
||||
`INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`,
|
||||
[
|
||||
request.worker_id,
|
||||
forfeitGiftDeposit ? 'deposit_deduction' : 'deposit_release',
|
||||
forfeitGiftDeposit ? -releaseAmount : releaseAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
request.work_order_id,
|
||||
forfeitGiftDeposit
|
||||
? '打手整单退单审核通过扣除拼单押金(已确认加好友)'
|
||||
: '打手整单退单审核通过退还拼单押金',
|
||||
JSON.stringify({
|
||||
requestId: request.id,
|
||||
shareId: Number(share.id),
|
||||
reason: request.reason,
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
releasedDepositAmount += releaseAmount
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE work_order_shares
|
||||
SET status = 'cancelled',
|
||||
cancelled_reason = 'worker_request',
|
||||
acceptance_json = '{}'::jsonb,
|
||||
draft_acceptance_json = '{}'::jsonb,
|
||||
submitted_at = NULL,
|
||||
accepted_at = NULL,
|
||||
updated_at = $1
|
||||
WHERE id = $2`,
|
||||
[input.now, share.id],
|
||||
)
|
||||
}
|
||||
await recomputeWorkOrderSharingStatusWithClient(client, workOrder.id, input.now)
|
||||
} else {
|
||||
if (
|
||||
workOrder.status !== 'in_progress' ||
|
||||
|
||||
@@ -317,7 +317,7 @@ export async function getWorkerUserStatistics(
|
||||
|
||||
const timeoutResult = await query<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
SELECT COUNT(DISTINCT (e.payload_json->>'workerId', e.work_order_id))::int AS total
|
||||
FROM work_order_events e
|
||||
INNER JOIN worker_users wu ON e.payload_json->>'workerId' = wu.id::text
|
||||
${whereClause ? `${whereClause} AND` : 'WHERE'} e.event_type LIKE 'timeout_%'
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
cancelAdminWorkOrderSharing,
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
markAdminWorkOrderProblem,
|
||||
markAdminWorkOrderShareProblem,
|
||||
reduceAdminWorkOrderShareQuantity,
|
||||
resolveAdminProblemWorkOrder,
|
||||
disposeAdminWorkOrderShareProblem,
|
||||
} from '../../../services/worker-platform/index.js'
|
||||
import { createJsonHandler, requireAdminPermission, requireAnyAdminPermission } from '../session.js'
|
||||
|
||||
@@ -148,7 +151,7 @@ router.post(
|
||||
req.adminSession?.displayName || req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '拼单参与者已撤单,份数已退回大厅',
|
||||
successMessage: '拼单参与者已撤单,押金已退还,母单状态保持不变',
|
||||
errorMessage: '撤销拼单参与者失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/cancel]',
|
||||
audit: (req, data) => ({
|
||||
@@ -161,6 +164,84 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
// 按份数撤销部分份额:减少的份数自动回到大厅,押金保持原冻结额。
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/shares/:shareId/reduce-quantity',
|
||||
requireAdminPermission('worker_order.cancel_share'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
reduceAdminWorkOrderShareQuantity(
|
||||
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/reduce-quantity]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_share_quantity_reduced',
|
||||
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',
|
||||
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(
|
||||
'/worker-platform/orders/:workOrderId/deduct-pending-deposit',
|
||||
requireAdminPermission('worker_order.deduct_deposit'),
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import multer from 'multer'
|
||||
|
||||
import { getMaxUploadSizeBytes } from '../services/file-storage/file-storage-service.js'
|
||||
import {
|
||||
getMaxUploadSizeBytes,
|
||||
getVideoUploadSizeLimitBytes,
|
||||
} from '../services/file-storage/file-storage-service.js'
|
||||
import { createHttpError, sendRouteError } from '../utils/http.js'
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: getMaxUploadSizeBytes(),
|
||||
// 先按较大的验收视频上限收流,具体类型再在 service 内按图片/视频分别校验。
|
||||
fileSize: Math.max(getMaxUploadSizeBytes(), getVideoUploadSizeLimitBytes()),
|
||||
files: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -50,7 +50,11 @@ router.post(
|
||||
requireActiveWorker,
|
||||
createRouteHandler(
|
||||
(req) =>
|
||||
grabWorkerHallOrder(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)),
|
||||
grabWorkerHallOrder(
|
||||
String(req.params.workOrderId || ''),
|
||||
getRequiredWorkerSession(req),
|
||||
req.body || {},
|
||||
),
|
||||
{
|
||||
successMessage: '抢单成功',
|
||||
errorMessage: '抢单失败',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
||||
|
||||
import {
|
||||
getWorkerAfterSalesCaseSummary,
|
||||
getWorkerAcceptanceConfig,
|
||||
getWorkerAnnouncement,
|
||||
getWorkerCancelRequestSummary,
|
||||
getWorkerMyOrderEvents,
|
||||
@@ -54,6 +55,17 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
// 验收弹窗的压缩参数(时长上限/目标高度/码率等),后台可改,打手端每次打开弹窗拉取。
|
||||
router.get(
|
||||
'/acceptance/config',
|
||||
requireActiveWorker,
|
||||
createRouteHandler(() => getWorkerAcceptanceConfig(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取验收配置失败',
|
||||
scope: '[worker/acceptance/config]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/announcement',
|
||||
requireActiveWorker,
|
||||
|
||||
@@ -134,6 +134,8 @@ router.post(
|
||||
scene: req.body?.scene || 'worker-acceptance',
|
||||
uploaderType: 'worker',
|
||||
uploaderId: String(session.workerId),
|
||||
// 验收视频:容器解析不到时长时用前端上报值兜底(见 resolveVideoDuration)。
|
||||
durationSeconds: req.body?.durationSeconds,
|
||||
})
|
||||
},
|
||||
{
|
||||
|
||||
@@ -77,19 +77,51 @@ export async function createWorkerAcceptanceAdminNotification(input: {
|
||||
productName: string
|
||||
workerName: string
|
||||
imageCount: number
|
||||
/** 是否提交了验收视频:视频与图片至少一个,通知里要体现,避免显示成“0 张图片”。 */
|
||||
hasVideo?: boolean
|
||||
/** 拼单份额:每份提交各生成一条待办(按 shareId 去重) */
|
||||
shareId?: number | null
|
||||
}) {
|
||||
const shareId = Number(input.shareId || 0)
|
||||
return createConfiguredWorkerPlatformNotification('acceptance_submitted', {
|
||||
notificationType: 'worker_acceptance_submitted',
|
||||
priority: 'normal',
|
||||
entityType: 'work_order',
|
||||
entityId: input.workOrderId,
|
||||
dedupeKey: acceptanceNotificationKey(input.workOrderId),
|
||||
dedupeKey:
|
||||
shareId > 0
|
||||
? acceptanceNotificationKey(input.workOrderId, shareId)
|
||||
: acceptanceNotificationKey(input.workOrderId),
|
||||
title: '有订单待验收',
|
||||
body: [
|
||||
`订单:${input.workOrderNo || input.workOrderId}`,
|
||||
`商品:${input.productName || '-'}`,
|
||||
`打手:${input.workerName || '-'}`,
|
||||
...(shareId > 0 ? [`份额:#${shareId}`] : []),
|
||||
`验收图片:${input.imageCount} 张`,
|
||||
...(input.hasVideo ? ['验收视频:已上传'] : []),
|
||||
].join(';'),
|
||||
actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`,
|
||||
now: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 整单(含拼单全部份额)验收完成:单独一条完成通知,不与待验收待办混用。 */
|
||||
export async function createWorkOrderFullyAcceptedAdminNotification(input: {
|
||||
workOrderId: number
|
||||
workOrderNo: string
|
||||
productName: string
|
||||
}) {
|
||||
return createConfiguredWorkerPlatformNotification('order_accepted', {
|
||||
notificationType: 'worker_order_accepted',
|
||||
priority: 'normal',
|
||||
entityType: 'work_order',
|
||||
entityId: input.workOrderId,
|
||||
dedupeKey: `work_order_accepted:${input.workOrderId}`,
|
||||
title: '订单已全部验收',
|
||||
body: [
|
||||
`订单:${input.workOrderNo || input.workOrderId}`,
|
||||
`商品:${input.productName || '-'}`,
|
||||
].join(';'),
|
||||
actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`,
|
||||
now: new Date().toISOString(),
|
||||
@@ -279,8 +311,10 @@ async function createConfiguredWorkerPlatformNotification(
|
||||
return notification
|
||||
}
|
||||
|
||||
function acceptanceNotificationKey(workOrderId: number) {
|
||||
return `work_order_acceptance:${workOrderId}`
|
||||
function acceptanceNotificationKey(workOrderId: number, shareId = 0) {
|
||||
return shareId > 0
|
||||
? `work_order_acceptance:${workOrderId}:share:${shareId}`
|
||||
: `work_order_acceptance:${workOrderId}`
|
||||
}
|
||||
|
||||
function formatAmount(amountFen: number) {
|
||||
|
||||
@@ -8,7 +8,9 @@ import { createFileAsset, type FileAssetRow } from '../../repositories/file-asse
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { getObjectStorage, normalizeObjectKey } from './object-storage.js'
|
||||
import { readVideoDurationSeconds } from './video-metadata.js'
|
||||
import { logWarn } from '../../utils/logger.js'
|
||||
import { getWorkerAcceptanceConfig } from '../worker-platform/worker-acceptance-config-service.js'
|
||||
|
||||
/**
|
||||
* libvips 并发与操作缓存调优(进程级,必须早于任何 sharp 调用执行)。
|
||||
@@ -41,7 +43,21 @@ const IMAGE_VARIANT_MEDIUM = 'medium'
|
||||
const THUMB_MAX_SIDE = 480
|
||||
const MEDIUM_MAX_SIDE = 1280
|
||||
|
||||
const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf'])
|
||||
/**
|
||||
* 验收视频:前端已用 Canvas + MediaRecorder 压到 720p/1.2Mbps,这里只接受这两种容器。
|
||||
* 对象 key 统一落在 `worker-acceptance-video/` 前缀下,验收服务据此校验来源。
|
||||
*/
|
||||
const VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm'])
|
||||
const ACCEPTANCE_VIDEO_SCENE = 'worker-acceptance-video'
|
||||
|
||||
const ALLOWED_CONTENT_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
])
|
||||
|
||||
const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
|
||||
@@ -53,6 +69,8 @@ export type UploadedFileDto = {
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
/** 仅视频:解析到的时长(秒);WebM 未写 Duration 时为客户端上报值 */
|
||||
durationSeconds?: number
|
||||
}
|
||||
|
||||
type UploadFileAssetInput = {
|
||||
@@ -60,6 +78,8 @@ type UploadFileAssetInput = {
|
||||
scene: unknown
|
||||
uploaderType: string
|
||||
uploaderId: string
|
||||
/** 前端读取到的视频时长(秒),仅在容器里解析不到时作为兜底 */
|
||||
durationSeconds?: unknown
|
||||
}
|
||||
|
||||
export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
@@ -71,23 +91,31 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
})
|
||||
}
|
||||
|
||||
const maxUploadSize = getMaxUploadSizeBytes()
|
||||
if (file.buffer.length > maxUploadSize) {
|
||||
throw createHttpError(`文件不能超过 ${runtimeConfig.storage.maxUploadSizeMb}MB`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_too_large',
|
||||
})
|
||||
}
|
||||
|
||||
const contentType = normalizeContentType(file.mimetype, file.buffer)
|
||||
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
|
||||
throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP 或 PDF', {
|
||||
throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP、PDF 或 MP4/WebM 视频', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_type_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const scene = normalizeScene(input.scene)
|
||||
const isVideo = VIDEO_CONTENT_TYPES.has(contentType)
|
||||
// 视频按验收配置单独限制体积/时长;图片与 PDF 仍走全局体积上限。
|
||||
const videoDurationSeconds = isVideo
|
||||
? resolveVideoDuration(file.buffer, contentType, input.durationSeconds)
|
||||
: 0
|
||||
if (!isVideo) {
|
||||
const maxUploadSize = getMaxUploadSizeBytes()
|
||||
if (file.buffer.length > maxUploadSize) {
|
||||
throw createHttpError(`文件不能超过 ${runtimeConfig.storage.maxUploadSizeMb}MB`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_too_large',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 视频统一落到验收视频目录,验收服务据此校验来源,避免引用到其它场景的对象。
|
||||
const scene = isVideo ? ACCEPTANCE_VIDEO_SCENE : normalizeScene(input.scene)
|
||||
const objectKey = createObjectKey(scene, file.originalname, contentType)
|
||||
const storage = getObjectStorage()
|
||||
const uploadedKeys: string[] = []
|
||||
@@ -147,6 +175,7 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
metadataJson: JSON.stringify({
|
||||
originalMimeType: file.mimetype,
|
||||
originalSize: file.size,
|
||||
...(isVideo ? { durationSeconds: videoDurationSeconds } : {}),
|
||||
}),
|
||||
now,
|
||||
})
|
||||
@@ -155,15 +184,18 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
}
|
||||
|
||||
return {
|
||||
file: mapUploadedFile(asset, {
|
||||
objectKey,
|
||||
fileUrl,
|
||||
thumbnailUrl,
|
||||
mediumUrl,
|
||||
filename: file.originalname,
|
||||
contentType,
|
||||
size: file.buffer.length,
|
||||
}),
|
||||
file: {
|
||||
...mapUploadedFile(asset, {
|
||||
objectKey,
|
||||
fileUrl,
|
||||
thumbnailUrl,
|
||||
mediumUrl,
|
||||
filename: file.originalname,
|
||||
contentType,
|
||||
size: file.buffer.length,
|
||||
}),
|
||||
...(isVideo ? { durationSeconds: videoDurationSeconds } : {}),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
const cleanupResults = await Promise.allSettled(
|
||||
@@ -212,6 +244,11 @@ export function getMaxUploadSizeBytes() {
|
||||
return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
|
||||
}
|
||||
|
||||
/** 验收视频的体积上限(按后台配置),multer 需要用它做流式兜底。 */
|
||||
export function getVideoUploadSizeLimitBytes() {
|
||||
return Math.max(1, Number(getWorkerAcceptanceConfig().maxSizeMb || 12)) * 1024 * 1024
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件链接:不再带签名与有效期。
|
||||
*
|
||||
@@ -356,9 +393,70 @@ function detectContentType(data: Buffer) {
|
||||
if (data.length >= 4 && data.subarray(0, 4).toString('ascii') === '%PDF') {
|
||||
return 'application/pdf'
|
||||
}
|
||||
// MP4/MOV 家族:偏移 4 处是 4 字节 box 类型 `ftyp`。
|
||||
if (
|
||||
data.length >= 12 &&
|
||||
data.subarray(4, 8).toString('ascii') === 'ftyp' &&
|
||||
data.subarray(8, 10).toString('ascii') !== 'qt'
|
||||
) {
|
||||
return 'video/mp4'
|
||||
}
|
||||
// WebM/Matroska:EBML 头 1A 45 DF A3。
|
||||
if (
|
||||
data.length >= 4 &&
|
||||
data[0] === 0x1a &&
|
||||
data[1] === 0x45 &&
|
||||
data[2] === 0xdf &&
|
||||
data[3] === 0xa3
|
||||
) {
|
||||
return 'video/webm'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 验收视频兜底校验:功能开关、体积、时长。
|
||||
*
|
||||
* 时长优先用容器解析值(硬证据),解析不到(MediaRecorder 的 WebM 常常不写 Duration)
|
||||
* 才回退客户端上报值;两者都没有则拒绝,避免放过完全未知长度的视频。
|
||||
*/
|
||||
function resolveVideoDuration(
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
declaredDuration: unknown,
|
||||
): number {
|
||||
const config = getWorkerAcceptanceConfig()
|
||||
if (!config.enabled) {
|
||||
throw createHttpError('验收视频功能已关闭', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_video_disabled',
|
||||
})
|
||||
}
|
||||
const maxBytes = config.maxSizeMb * 1024 * 1024
|
||||
if (buffer.length > maxBytes) {
|
||||
throw createHttpError(`验收视频不能超过 ${config.maxSizeMb}MB`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_video_too_large',
|
||||
})
|
||||
}
|
||||
const parsed = readVideoDurationSeconds(buffer, contentType)
|
||||
const declared = Number(declaredDuration)
|
||||
const duration = parsed ?? (Number.isFinite(declared) && declared > 0 ? declared : 0)
|
||||
if (!duration) {
|
||||
throw createHttpError('无法读取视频时长,请重新选择视频', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_video_duration_unknown',
|
||||
})
|
||||
}
|
||||
if (duration > config.maxDurationSeconds + 0.5) {
|
||||
throw createHttpError(`视频时长不能超过 ${config.maxDurationSeconds} 秒`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_video_too_long',
|
||||
})
|
||||
}
|
||||
return Math.round(duration * 10) / 10
|
||||
}
|
||||
|
||||
function normalizeScene(value: unknown) {
|
||||
const scene = String(value || '')
|
||||
.trim()
|
||||
@@ -382,6 +480,8 @@ function extensionForContentType(contentType: string) {
|
||||
if (contentType === 'image/png') return '.png'
|
||||
if (contentType === 'image/webp') return '.webp'
|
||||
if (contentType === 'application/pdf') return '.pdf'
|
||||
if (contentType === 'video/mp4') return '.mp4'
|
||||
if (contentType === 'video/webm') return '.webm'
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { readVideoDurationSeconds } from './video-metadata.js'
|
||||
|
||||
/** MP4 box:size(4) + type(4) + payload */
|
||||
function mp4Box(type: string, payload: Buffer): Buffer {
|
||||
const header = Buffer.alloc(8)
|
||||
header.writeUInt32BE(payload.length + 8, 0)
|
||||
header.write(type, 4, 4, 'ascii')
|
||||
return Buffer.concat([header, payload])
|
||||
}
|
||||
|
||||
function mvhdV0(timescale: number, duration: number): Buffer {
|
||||
// version/flags(4) + creation(4) + modification(4) + timescale(4) + duration(4)
|
||||
const payload = Buffer.alloc(20)
|
||||
payload.writeUInt32BE(timescale, 12)
|
||||
payload.writeUInt32BE(duration, 16)
|
||||
return mp4Box('mvhd', payload)
|
||||
}
|
||||
|
||||
function ebmlId(id: number, length: number): Buffer {
|
||||
const buffer = Buffer.alloc(length)
|
||||
let rest = id
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
buffer[index] = rest % 256
|
||||
rest = Math.floor(rest / 256)
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
/** 只支持 1 字节长度(测试数据都很小)。 */
|
||||
function ebmlElement(id: number, idLength: number, payload: Buffer): Buffer {
|
||||
return Buffer.concat([ebmlId(id, idLength), Buffer.from([0x80 | payload.length]), payload])
|
||||
}
|
||||
|
||||
test('MP4:从 moov/mvhd 读出时长(30 秒)', () => {
|
||||
const mp4 = mp4Box('moov', mvhdV0(1000, 30_000))
|
||||
const seconds = readVideoDurationSeconds(mp4, 'video/mp4')
|
||||
assert.ok(seconds !== null)
|
||||
assert.equal(Math.round(seconds as number), 30)
|
||||
})
|
||||
|
||||
test('MP4:mvhd 缺失或损坏时返回 null(交给客户端上报值兜底)', () => {
|
||||
assert.equal(readVideoDurationSeconds(Buffer.from('not-a-video'), 'video/mp4'), null)
|
||||
const brokenMvhd = mp4Box('moov', mp4Box('mvhd', Buffer.alloc(4)))
|
||||
assert.equal(readVideoDurationSeconds(brokenMvhd, 'video/mp4'), null)
|
||||
})
|
||||
|
||||
test('WebM:从 Segment/Info 的 TimecodeScale + Duration 读出时长(30 秒)', () => {
|
||||
const duration = Buffer.alloc(8)
|
||||
duration.writeDoubleBE(30_000, 0) // 30000 * 1ms
|
||||
const timecodeScale = Buffer.from([0x0f, 0x42, 0x40]) // 1,000,000ns
|
||||
const info = ebmlElement(
|
||||
0x1549a966,
|
||||
4,
|
||||
Buffer.concat([ebmlElement(0x2ad7b1, 3, timecodeScale), ebmlElement(0x4489, 2, duration)]),
|
||||
)
|
||||
const segment = ebmlElement(0x18538067, 4, info)
|
||||
const seconds = readVideoDurationSeconds(segment, 'video/webm')
|
||||
assert.ok(seconds !== null)
|
||||
assert.equal(Math.round(seconds as number), 30)
|
||||
})
|
||||
|
||||
test('WebM:MediaRecorder 常见的"没有 Duration"返回 null', () => {
|
||||
const info = ebmlElement(0x1549a966, 4, ebmlElement(0x2ad7b1, 3, Buffer.from([0x0f, 0x42, 0x40])))
|
||||
const segment = ebmlElement(0x18538067, 4, info)
|
||||
assert.equal(readVideoDurationSeconds(segment, 'video/webm'), null)
|
||||
})
|
||||
|
||||
test('非视频类型不做解析', () => {
|
||||
assert.equal(readVideoDurationSeconds(Buffer.from('%PDF-1.4'), 'application/pdf'), null)
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 视频时长解析(不依赖 ffprobe)。
|
||||
*
|
||||
* 验收视频由前端 Canvas + MediaRecorder 重编码后上传,服务端仍要做一次兜底校验:
|
||||
* 时长超过配置上限直接拒绝,避免"前端改了参数/绕过页面"后上传超长视频。
|
||||
*
|
||||
* 覆盖两种交付格式:
|
||||
* - MP4:moov/mvhd 里的 timescale + duration(最可靠);
|
||||
* - WebM:Segment/Info 下的 TimecodeScale + Duration。
|
||||
* MediaRecorder 生成的 WebM 在部分浏览器里不写 Duration(值为 0 或缺失),
|
||||
* 此时返回 null,由调用方回退到客户端上报的时长 + 体积上限。
|
||||
*/
|
||||
export function readVideoDurationSeconds(data: Buffer, contentType: string): number | null {
|
||||
const normalized = String(contentType || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
try {
|
||||
if (normalized === 'video/mp4') return readMp4DurationSeconds(data)
|
||||
if (normalized === 'video/webm') return readWebmDurationSeconds(data)
|
||||
} catch {
|
||||
// 损坏/截断的文件按"时长未知"处理,交给调用方决定。
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type Mp4Box = { payloadStart: number; payloadEnd: number }
|
||||
|
||||
function readMp4DurationSeconds(data: Buffer): number | null {
|
||||
const moov = findMp4Box(data, 0, data.length, 'moov')
|
||||
if (!moov) return null
|
||||
const mvhd = findMp4Box(data, moov.payloadStart, moov.payloadEnd, 'mvhd')
|
||||
if (!mvhd) return null
|
||||
|
||||
const payload = mvhd.payloadStart
|
||||
const version = data[payload]
|
||||
let timescale = 0
|
||||
let duration = 0
|
||||
if (version === 1) {
|
||||
if (payload + 32 > mvhd.payloadEnd) return null
|
||||
timescale = data.readUInt32BE(payload + 4 + 8 + 8)
|
||||
duration = Number(data.readBigUInt64BE(payload + 4 + 8 + 8 + 4))
|
||||
} else {
|
||||
if (payload + 20 > mvhd.payloadEnd) return null
|
||||
timescale = data.readUInt32BE(payload + 4 + 4 + 4)
|
||||
duration = data.readUInt32BE(payload + 4 + 4 + 4 + 4)
|
||||
}
|
||||
if (!timescale || !Number.isFinite(duration) || duration <= 0) return null
|
||||
const seconds = duration / timescale
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds : null
|
||||
}
|
||||
|
||||
function findMp4Box(data: Buffer, start: number, end: number, type: string): Mp4Box | null {
|
||||
let offset = start
|
||||
while (offset + 8 <= end) {
|
||||
let size = data.readUInt32BE(offset)
|
||||
const boxType = data.toString('ascii', offset + 4, offset + 8)
|
||||
let headerSize = 8
|
||||
if (size === 1) {
|
||||
if (offset + 16 > end) return null
|
||||
size = Number(data.readBigUInt64BE(offset + 8))
|
||||
headerSize = 16
|
||||
} else if (size === 0) {
|
||||
size = end - offset
|
||||
}
|
||||
if (size < headerSize || offset + size > end) return null
|
||||
if (boxType === type) {
|
||||
return { payloadStart: offset + headerSize, payloadEnd: offset + size }
|
||||
}
|
||||
offset += size
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const EBML_ID_SEGMENT = 0x18538067
|
||||
const EBML_ID_INFO = 0x1549a966
|
||||
const EBML_ID_TIMECODE_SCALE = 0x2ad7b1
|
||||
const EBML_ID_DURATION = 0x4489
|
||||
const DEFAULT_TIMECODE_SCALE_NS = 1_000_000
|
||||
|
||||
type EbmlElement = { payloadStart: number; payloadEnd: number }
|
||||
|
||||
function readWebmDurationSeconds(data: Buffer): number | null {
|
||||
const segment = findEbmlChild(data, 0, data.length, EBML_ID_SEGMENT)
|
||||
if (!segment) return null
|
||||
const info = findEbmlChild(data, segment.payloadStart, segment.payloadEnd, EBML_ID_INFO)
|
||||
if (!info) return null
|
||||
|
||||
const durationElement = findEbmlChild(data, info.payloadStart, info.payloadEnd, EBML_ID_DURATION)
|
||||
if (!durationElement) return null
|
||||
const durationValue = readEbmlFloat(data, durationElement)
|
||||
if (!Number.isFinite(durationValue) || durationValue <= 0) return null
|
||||
|
||||
const scaleElement = findEbmlChild(
|
||||
data,
|
||||
info.payloadStart,
|
||||
info.payloadEnd,
|
||||
EBML_ID_TIMECODE_SCALE,
|
||||
)
|
||||
const scaleNs = scaleElement
|
||||
? readEbmlUint(data, scaleElement) || DEFAULT_TIMECODE_SCALE_NS
|
||||
: DEFAULT_TIMECODE_SCALE_NS
|
||||
// Duration 的单位是 TimecodeScale(纳秒),换算成秒。
|
||||
const seconds = (durationValue * scaleNs) / 1_000_000_000
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds : null
|
||||
}
|
||||
|
||||
function findEbmlChild(
|
||||
data: Buffer,
|
||||
start: number,
|
||||
end: number,
|
||||
targetId: number,
|
||||
): EbmlElement | null {
|
||||
let offset = start
|
||||
while (offset < end) {
|
||||
const idInfo = readEbmlId(data, offset, end)
|
||||
if (!idInfo) return null
|
||||
const sizeInfo = readEbmlSize(data, idInfo.next, end)
|
||||
if (!sizeInfo) return null
|
||||
const payloadStart = sizeInfo.next
|
||||
const payloadEnd = sizeInfo.unknown ? end : Math.min(end, payloadStart + sizeInfo.size)
|
||||
if (idInfo.id === targetId) return { payloadStart, payloadEnd }
|
||||
if (payloadEnd <= payloadStart) return null
|
||||
offset = payloadEnd
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function readEbmlId(
|
||||
data: Buffer,
|
||||
offset: number,
|
||||
end: number,
|
||||
): { id: number; next: number } | null {
|
||||
if (offset >= end) return null
|
||||
const first = data[offset] as number
|
||||
let length = 0
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
if (first & (0x80 >> index)) {
|
||||
length = index + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!length || offset + length > end) return null
|
||||
let id = 0
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
id = id * 256 + (data[offset + index] as number)
|
||||
}
|
||||
return { id, next: offset + length }
|
||||
}
|
||||
|
||||
function readEbmlSize(
|
||||
data: Buffer,
|
||||
offset: number,
|
||||
end: number,
|
||||
): { size: number; next: number; unknown: boolean } | null {
|
||||
if (offset >= end) return null
|
||||
const first = data[offset] as number
|
||||
let length = 0
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
if (first & (0x80 >> index)) {
|
||||
length = index + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!length || offset + length > end) return null
|
||||
const mask = 0xff >> length
|
||||
let value = first & mask
|
||||
let unknown = value === mask
|
||||
for (let index = 1; index < length; index += 1) {
|
||||
const byte = data[offset + index] as number
|
||||
if (byte !== 0xff) unknown = false
|
||||
value = value * 256 + byte
|
||||
}
|
||||
return { size: value, next: offset + length, unknown }
|
||||
}
|
||||
|
||||
function readEbmlFloat(data: Buffer, element: EbmlElement): number {
|
||||
const size = element.payloadEnd - element.payloadStart
|
||||
if (size === 4) return data.readFloatBE(element.payloadStart)
|
||||
if (size === 8) return data.readDoubleBE(element.payloadStart)
|
||||
return Number.NaN
|
||||
}
|
||||
|
||||
function readEbmlUint(data: Buffer, element: EbmlElement): number {
|
||||
const size = element.payloadEnd - element.payloadStart
|
||||
if (size <= 0 || size > 6) return 0
|
||||
let value = 0
|
||||
for (let index = 0; index < size; index += 1) {
|
||||
value = value * 256 + (data[element.payloadStart + index] as number)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -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,11 +27,14 @@ import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js'
|
||||
import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js'
|
||||
import {
|
||||
isWorkOrderSharingFilled,
|
||||
isWorkerOrderVipEvidencePending,
|
||||
mapFinanceRequest,
|
||||
mapWorkOrderAdmin,
|
||||
mapWorkOrderForHall,
|
||||
mapWorkOrderForWorker,
|
||||
mapWorkerUser,
|
||||
pickWorkerRepresentativeShare,
|
||||
resolveWorkerOrderStatusTabs,
|
||||
resolveWorkerPermissions,
|
||||
} from '../worker-platform/mappers.js'
|
||||
import { invalidateWorkerMyOrderCaches } from '../worker-platform/worker-my-order-cache.js'
|
||||
@@ -79,6 +86,24 @@ let nextSubscriberId = 1
|
||||
let heartbeatTimer: NodeJS.Timeout | null = null
|
||||
let publishQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
/**
|
||||
* 单次补发的事件上限,与 realtime_events 表保留的条数一致
|
||||
* (见 realtime-event-repo 的清理策略:只保留最近 500 条)。
|
||||
*/
|
||||
export const REALTIME_REPLAY_EVENT_LIMIT = 500
|
||||
|
||||
/**
|
||||
* 是否应该补发历史事件。
|
||||
*
|
||||
* 只有断线重连带上了 Last-Event-ID(游标 > 0)才补发。新连接(游标 = 0)**不补发**:
|
||||
* 补发的每一条都携带「事件发生那一刻」的工单快照,直接打进刚由列表接口加载好的缓存,
|
||||
* 会把已涨价/已拼满的订单覆盖回旧值(表现为「进大厅显示旧单价旧进度,点一次刷新才对」)。
|
||||
*/
|
||||
export function shouldReplayRealtimeEvents(lastEventId: number): boolean {
|
||||
const normalized = Number(lastEventId)
|
||||
return Number.isFinite(normalized) && normalized > 0
|
||||
}
|
||||
|
||||
/** 建立后台实时事件流,事件快照用于前端定点更新缓存。 */
|
||||
export function openAdminRealtimeStream(response: Response, lastEventId = 0) {
|
||||
return openRealtimeStream(response, null, lastEventId)
|
||||
@@ -249,6 +274,9 @@ async function publishWorkOrderSnapshots(input: {
|
||||
hallChanged?: boolean
|
||||
}) {
|
||||
const workOrderId = Math.max(0, Number(input.workOrderId) || 0)
|
||||
// 调用方显式给出的受影响打手必须同步失效:下面的快照生成要先查库,
|
||||
// 若等都查完再失效,紧接着到达的「我的订单」请求会命中旧缓存(30s TTL)。
|
||||
invalidateWorkerMyOrderCaches(input.workerIds || [])
|
||||
const workOrder = await getWorkOrderById(workOrderId)
|
||||
if (!workOrder) {
|
||||
// 工单已不存在(删除):调用方传入的打手集合仍需失效缓存,
|
||||
@@ -338,33 +366,124 @@ 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 ||
|
||||
activeWorkerShares.some((share) => Boolean(share.problem_marked_at))),
|
||||
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],
|
||||
})
|
||||
}
|
||||
@@ -429,7 +548,10 @@ function openRealtimeStream(response: Response, workerId: number | null, lastEve
|
||||
connectedAt: new Date().toISOString(),
|
||||
lastEventId: Math.max(0, Number(lastEventId) || 0),
|
||||
})
|
||||
void replayEvents(subscriber, Math.max(0, Number(lastEventId) || 0))
|
||||
// 新连接不补发历史事件,只有断线重连(带游标)才回放缺失的增量。
|
||||
if (shouldReplayRealtimeEvents(lastEventId)) {
|
||||
void replayEvents(subscriber, Number(lastEventId))
|
||||
}
|
||||
ensureHeartbeat()
|
||||
|
||||
response.on('close', () => {
|
||||
@@ -439,7 +561,16 @@ function openRealtimeStream(response: Response, workerId: number | null, lastEve
|
||||
}
|
||||
|
||||
async function replayEvents(subscriber: RealtimeSubscriber, lastEventId: number) {
|
||||
const events = await listRealtimeEventsAfter(lastEventId)
|
||||
const events = await listRealtimeEventsAfter(lastEventId, REALTIME_REPLAY_EVENT_LIMIT)
|
||||
// 补发窗口被拿满 = 更早的事件已经从事件表里滚掉(断线太久)。
|
||||
// 这时逐条套用旧快照只会把列表写回旧值,直接让客户端整体重拉一次。
|
||||
if (events.length >= REALTIME_REPLAY_EVENT_LIMIT) {
|
||||
writeEvent(subscriber, 'resync', {
|
||||
headEventId: Number(events[events.length - 1]?.id || 0),
|
||||
reason: 'replay_window_overflow',
|
||||
})
|
||||
return
|
||||
}
|
||||
for (const row of events) {
|
||||
const event = mapStoredEvent(row)
|
||||
if (event.eventId <= lastEventId || !isEventVisibleToSubscriber(event, subscriber)) continue
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { shouldReplayRealtimeEvents } from './realtime-event-service.js'
|
||||
|
||||
/**
|
||||
* 回归:整页刷新后大厅列表重复、可抢订单数越刷越多。
|
||||
*
|
||||
* 根因是新连接(lastEventId=0)也会补发最近 500 条历史事件。大厅 upsert 广播带的是
|
||||
* 当时的工单快照,被重新打进刚加载的列表后,已抢走/已出队的订单会复活并被插到第 1 页,
|
||||
* 前端还会按「插入行」把 pagination.total 加一。
|
||||
*/
|
||||
test('新连接(lastEventId=0)不补发历史事件,避免旧大厅快照复活订单', () => {
|
||||
assert.equal(shouldReplayRealtimeEvents(0), false)
|
||||
assert.equal(shouldReplayRealtimeEvents(Number.NaN), false)
|
||||
assert.equal(shouldReplayRealtimeEvents(-1), false)
|
||||
})
|
||||
|
||||
test('断线重连(带 Last-Event-ID)才补发事件', () => {
|
||||
assert.equal(shouldReplayRealtimeEvents(1), true)
|
||||
assert.equal(shouldReplayRealtimeEvents(3993659), true)
|
||||
})
|
||||
|
||||
test('补发窗口取最新的一段,而不是最旧的一段', () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../repositories/realtime-event-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const block = source.slice(
|
||||
source.indexOf('export async function listRealtimeEventsAfter('),
|
||||
source.indexOf('\n}', source.indexOf('export async function listRealtimeEventsAfter(')),
|
||||
)
|
||||
assert.ok(block.length > 0, 'listRealtimeEventsAfter 片段必须存在')
|
||||
assert.ok(
|
||||
/ORDER BY id DESC\s+LIMIT \$2\s+\)\s*replay_window\s+ORDER BY id ASC/s.test(block),
|
||||
'补发必须「内层 id DESC 取最新 N 条、外层 id ASC 回放」;否则断线久了窗口会停在旧事件上',
|
||||
)
|
||||
})
|
||||
|
||||
test('补发窗口溢出时改为让客户端整体重拉', () => {
|
||||
const source = fs.readFileSync(new URL('./realtime-event-service.ts', import.meta.url), 'utf8')
|
||||
const replayBlock = source.slice(
|
||||
source.indexOf('async function replayEvents('),
|
||||
source.indexOf('function mapStoredEvent('),
|
||||
)
|
||||
assert.ok(replayBlock.length > 0, 'replayEvents 片段必须存在')
|
||||
assert.ok(
|
||||
replayBlock.includes('events.length >= REALTIME_REPLAY_EVENT_LIMIT'),
|
||||
'补发窗口被拿满时必须判定为溢出,不能继续逐条套用旧快照',
|
||||
)
|
||||
assert.ok(
|
||||
replayBlock.includes("'resync'"),
|
||||
'溢出时要给客户端发 resync,让列表整体重拉(接口才是权威数据源)',
|
||||
)
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createWorkOrderEvent,
|
||||
getWorkCategoryById,
|
||||
listWorkOrdersByPlatformOrderId,
|
||||
listWorkProductRules,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
normalizeRequirementFields,
|
||||
} from './mappers.js'
|
||||
import { rememberAdminMockCustomShop } from './admin-mock-custom-shop-service.js'
|
||||
import { resolveMockWorkOrderProductRule } from './mock-work-order-rule-match.js'
|
||||
import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js'
|
||||
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
|
||||
|
||||
@@ -64,6 +66,14 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
||||
const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true'
|
||||
const fields = normalizeRequirementFields(payload.fields)
|
||||
const { platformOrderId, productName, shop } = input
|
||||
// Mock 工单同样要挂到接单模板上:后台「同步模板价格」按 product_rule_id 精确匹配,
|
||||
// 不绑定的话改模板价永远命中不到 Mock 单(真实单在 sync-worker-orders-service 已绑定)。
|
||||
// 这里只做商品名文本匹配,命中多条同分视为歧义、宁可不绑定。
|
||||
const productRuleMatch = resolveMockWorkOrderProductRule({
|
||||
rules: await listWorkProductRules({ enabled: true }),
|
||||
productName,
|
||||
categoryId,
|
||||
})
|
||||
// 订单号全局唯一:与自动同步的真实工单也不能撞号,否则打手按单号回填资料会命中多条。
|
||||
const existingOrders = await listWorkOrdersByPlatformOrderId(platformOrderId)
|
||||
if (existingOrders.length > 0) {
|
||||
@@ -104,6 +114,7 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
||||
}
|
||||
const workOrder = await createWorkOrder({
|
||||
workOrderNo: generateWorkOrderNo(),
|
||||
productRuleId: productRuleMatch.rule ? Number(productRuleMatch.rule.id) : null,
|
||||
platformOrderId,
|
||||
productName,
|
||||
categoryId,
|
||||
@@ -143,6 +154,9 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
||||
rewardAmount,
|
||||
sellerId: String(shop.sellerId || '').trim(),
|
||||
shopName: String(shop.shopName || '').trim(),
|
||||
productRuleId: productRuleMatch.rule ? Number(productRuleMatch.rule.id) : 0,
|
||||
productRuleKey: productRuleMatch.rule ? String(productRuleMatch.rule.rule_key || '') : '',
|
||||
productRuleMatch: productRuleMatch.reason,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
@@ -5,13 +5,17 @@ import {
|
||||
cancelWorkOrderShare,
|
||||
cancelWorkOrderSharing,
|
||||
countWorkOrderPendingSharingSubmissions,
|
||||
reduceWorkOrderShareQuantity,
|
||||
updateWorkOrder,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { getWorkerFinanceConfig } from './worker-finance-config-service.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
|
||||
import {
|
||||
createWorkOrderFullyAcceptedAdminNotification,
|
||||
resolveAdminNotificationEntity,
|
||||
} from '../admin/admin-notification-service.js'
|
||||
import {
|
||||
publishWorkerWalletRealtimeChange,
|
||||
publishWorkOrderRealtimeChange,
|
||||
@@ -80,6 +84,11 @@ export async function acceptAdminWorkOrder(workOrderId: number | string, actorNa
|
||||
})
|
||||
}
|
||||
await resolveAdminNotificationEntity('work_order', Number(workOrder.id), 'accepted')
|
||||
await createWorkOrderFullyAcceptedAdminNotification({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workOrderNo: workOrder.work_order_no,
|
||||
productName: workOrder.product_name,
|
||||
})
|
||||
const workerIds = await resolveWorkOrderRealtimeWorkerIds(workOrder)
|
||||
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), workerIds })
|
||||
for (const workerId of workerIds) publishWorkerWalletRealtimeChange(workerId)
|
||||
@@ -119,6 +128,11 @@ export async function acceptAdminWorkOrderShare(
|
||||
if (workerId > 0) publishWorkerWalletRealtimeChange(workerId)
|
||||
if (result.order.status === WORK_ORDER_STATUS.ACCEPTED) {
|
||||
await resolveAdminNotificationEntity('work_order', Number(workOrderId), 'accepted')
|
||||
await createWorkOrderFullyAcceptedAdminNotification({
|
||||
workOrderId: Number(workOrderId),
|
||||
workOrderNo: result.order.work_order_no,
|
||||
productName: result.order.product_name,
|
||||
})
|
||||
}
|
||||
return { order: mapWorkOrderAdmin(result.order), share: mapWorkOrderShare(result.share) }
|
||||
}
|
||||
@@ -176,6 +190,70 @@ export async function cancelAdminWorkOrderShare(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台按份数撤销某个打手的部分份额。
|
||||
*
|
||||
* 减少的份数会随「大厅余量 = 总份数 − Σ有效份额数量」自动回到大厅;押金保持原冻结额,
|
||||
* 母单状态与验收资料都不动(之后仍可对该份额「单独验收」)。
|
||||
*/
|
||||
export async function reduceAdminWorkOrderShareQuantity(
|
||||
workOrderId: number | string,
|
||||
shareId: number | string,
|
||||
payload: JsonObject = {},
|
||||
actorName = '',
|
||||
) {
|
||||
const reduceQuantity = Math.floor(Number(payload.reduceQuantity ?? payload.quantity ?? 0))
|
||||
if (!Number.isFinite(reduceQuantity) || reduceQuantity <= 0) {
|
||||
throw createHttpError('请填写要撤掉的份数', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_sharing_share_reduce_quantity_required',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await reduceWorkOrderShareQuantity({
|
||||
workOrderId: Number(workOrderId),
|
||||
shareId: Number(shareId),
|
||||
reduceQuantity,
|
||||
note: String(payload.note || payload.reason || '').trim(),
|
||||
actorName,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (result.failureReason === 'work_order_not_reducible') {
|
||||
throw createHttpError('当前拼单状态不能撤销份额', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_share_reduce_status_invalid',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'share_not_found') {
|
||||
throw createHttpError('未找到该拼单参与记录', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_sharing_share_not_found',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'share_status_not_reducible' || !result.share || !result.order) {
|
||||
throw createHttpError('只有已参与或已提交验收的拼单份额可以撤销份数', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_share_reduce_share_status_invalid',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'share_quantity_invalid') {
|
||||
throw createHttpError('撤掉的份数必须在 1 到「当前份数 − 1」之间;要全部撤销请用「单人撤单」', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_sharing_share_reduce_quantity_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrderId),
|
||||
workerIds: await resolveWorkOrderRealtimeWorkerIds(result.order),
|
||||
hallChanged: true,
|
||||
})
|
||||
return {
|
||||
order: mapWorkOrderAdmin(result.order),
|
||||
share: mapWorkOrderShare(result.share),
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelAdminWorkOrderSharing(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
|
||||
@@ -2,8 +2,10 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
createWorkOrderEvent,
|
||||
deleteWorkOrder,
|
||||
disposeWorkOrderShareProblem,
|
||||
listWorkOrderShares,
|
||||
listWorkOrders,
|
||||
markWorkOrderShareProblem,
|
||||
publishUnassignedWorkOrder,
|
||||
reopenCancelledWorkOrder,
|
||||
resolveProblemWorkOrder,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
} from './publish-voucher-service.js'
|
||||
import {
|
||||
mapWorkOrderAdmin,
|
||||
mapWorkOrderShare,
|
||||
normalizeAmountFen,
|
||||
normalizeInteger,
|
||||
normalizeOptionalId,
|
||||
@@ -436,3 +439,118 @@ export async function resolveAdminProblemWorkOrder(
|
||||
})
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,10 @@ export {
|
||||
acceptAdminWorkOrders,
|
||||
cancelAdminWorkOrderShare,
|
||||
cancelAdminWorkOrderSharing,
|
||||
reduceAdminWorkOrderShareQuantity,
|
||||
} from './admin-work-order-acceptance-service.js'
|
||||
export * from './worker-product-match-config-service.js'
|
||||
export * from './worker-announcement-config-service.js'
|
||||
export * from './worker-hall-config-service.js'
|
||||
export * from './worker-acceptance-config-service.js'
|
||||
export * from './sync-worker-orders-service.js'
|
||||
|
||||
@@ -83,6 +83,7 @@ export {
|
||||
} from './worker-finance-mappers.js'
|
||||
|
||||
export {
|
||||
isWorkerOrderVipEvidencePending,
|
||||
mapCollectLookupOrder,
|
||||
mapWorkCategory,
|
||||
mapWorkOrderAdmin,
|
||||
@@ -92,6 +93,8 @@ export {
|
||||
mapWorkOrderPublic,
|
||||
mapWorkOrderShare,
|
||||
mapWorkOrderSharingWorkers,
|
||||
pickWorkerRepresentativeShare,
|
||||
resolveWorkerOrderStatusTabs,
|
||||
resolveWorkOrderOperationalStatus,
|
||||
resolveWorkerShareProductName,
|
||||
resolveWorkerShareStatus,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { WorkProductRuleRow } from '../../repositories/worker-platform/types.js'
|
||||
import { resolveMockWorkOrderProductRule } from './mock-work-order-rule-match.js'
|
||||
|
||||
function rule(
|
||||
input: Partial<WorkProductRuleRow> & { id: number; product_name: string },
|
||||
): WorkProductRuleRow {
|
||||
return {
|
||||
id: input.id,
|
||||
rule_key: input.rule_key ?? `rule-${input.id}`,
|
||||
product_name: input.product_name,
|
||||
category_id: input.category_id ?? null,
|
||||
enabled: input.enabled ?? true,
|
||||
sort_order: input.sort_order ?? 100,
|
||||
unit_price_fen: input.unit_price_fen ?? 0,
|
||||
reward_amount: input.reward_amount ?? 0,
|
||||
sharing_enabled: input.sharing_enabled ?? false,
|
||||
sharing_total_quantity: input.sharing_total_quantity ?? 1,
|
||||
sharing_unit_reward: input.sharing_unit_reward ?? 0,
|
||||
sharing_auto_from_order: input.sharing_auto_from_order ?? false,
|
||||
timeout_minutes: input.timeout_minutes ?? 0,
|
||||
timeout_policy: input.timeout_policy ?? 'reopen',
|
||||
} as WorkProductRuleRow
|
||||
}
|
||||
|
||||
test('Mock 模板匹配:商品名互相包含即命中(忽略空白)', () => {
|
||||
const rules = [rule({ id: 9, product_name: '指挥官密钥', category_id: 1, unit_price_fen: 1100 })]
|
||||
const result = resolveMockWorkOrderProductRule({
|
||||
rules,
|
||||
productName: '指挥官密钥10个',
|
||||
categoryId: 1,
|
||||
})
|
||||
assert.equal(result.reason, 'matched')
|
||||
assert.equal(result.rule?.id, 9)
|
||||
|
||||
const withSpaces = resolveMockWorkOrderProductRule({
|
||||
rules,
|
||||
productName: ' 指挥官 密钥 3个 ',
|
||||
categoryId: 1,
|
||||
})
|
||||
assert.equal(withSpaces.rule?.id, 9)
|
||||
})
|
||||
|
||||
test('Mock 模板匹配:同分类优先,其次更具体的商品名', () => {
|
||||
const rules = [
|
||||
rule({ id: 1, product_name: '指挥官密钥', category_id: 5, sort_order: 1 }),
|
||||
rule({ id: 2, product_name: '指挥官密钥', category_id: 1, sort_order: 9 }),
|
||||
rule({ id: 3, product_name: '指挥官密钥-皮肤链接', category_id: 1, sort_order: 9 }),
|
||||
]
|
||||
const base = resolveMockWorkOrderProductRule({
|
||||
rules,
|
||||
productName: '指挥官密钥10个',
|
||||
categoryId: 1,
|
||||
})
|
||||
assert.equal(base.rule?.id, 2, '同分类的模板必须优先于其他分类')
|
||||
|
||||
const specific = resolveMockWorkOrderProductRule({
|
||||
rules,
|
||||
productName: '指挥官密钥-皮肤链接1个',
|
||||
categoryId: 1,
|
||||
})
|
||||
assert.equal(specific.rule?.id, 3, '更长的商品名(更具体)优先')
|
||||
})
|
||||
|
||||
test('Mock 模板匹配:歧义或未命中不绑定', () => {
|
||||
const ambiguous = resolveMockWorkOrderProductRule({
|
||||
rules: [
|
||||
rule({ id: 1, product_name: '指挥官密钥', category_id: 1 }),
|
||||
rule({ id: 2, product_name: '指挥官密钥', category_id: 1 }),
|
||||
],
|
||||
productName: '指挥官密钥10个',
|
||||
categoryId: 1,
|
||||
})
|
||||
assert.equal(ambiguous.rule, null)
|
||||
assert.equal(ambiguous.reason, 'ambiguous')
|
||||
|
||||
const unmatched = resolveMockWorkOrderProductRule({
|
||||
rules: [rule({ id: 9, product_name: '指挥官密钥', category_id: 1 })],
|
||||
productName: '指挥官9999',
|
||||
categoryId: 1,
|
||||
})
|
||||
assert.equal(unmatched.rule, null)
|
||||
assert.equal(unmatched.reason, 'unmatched')
|
||||
|
||||
const empty = resolveMockWorkOrderProductRule({ rules: [], productName: ' ', categoryId: 1 })
|
||||
assert.equal(empty.reason, 'unmatched')
|
||||
})
|
||||
|
||||
test('Mock 模板匹配:停用的模板不参与匹配', () => {
|
||||
const result = resolveMockWorkOrderProductRule({
|
||||
rules: [rule({ id: 9, product_name: '指挥官密钥', category_id: 1, enabled: false })],
|
||||
productName: '指挥官密钥10个',
|
||||
categoryId: 1,
|
||||
})
|
||||
assert.equal(result.rule, null)
|
||||
assert.equal(result.reason, 'unmatched')
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { WorkProductRuleRow } from '../../repositories/worker-platform/types.js'
|
||||
|
||||
/**
|
||||
* Mock 工单的接单模板匹配(文本匹配)。
|
||||
*
|
||||
* 快手真实单走 `resolveMatchingProductRule`(依赖 sellerId/itemId/skuId 等平台字段),
|
||||
* 而本地 Mock 单只有商品名和分类,所以这里用**商品名文本**做确定性匹配,
|
||||
* 让 Mock 单也能挂上 `product_rule_id`,从而被「同步模板价格」命中。
|
||||
*
|
||||
* 规则:
|
||||
* - 只考虑启用中的模板;
|
||||
* - 模板商品名与 Mock 商品名互相包含即视为命中(忽略大小写/空白);
|
||||
* - 同分类优先,其次更长的商品名(更具体)优先,最后按 sort_order;
|
||||
* - 同分类且同长度的多条模板属于歧义,宁可不绑定,避免把 Mock 单挂到错误模板上。
|
||||
*/
|
||||
export type MockWorkOrderRuleMatchReason = 'matched' | 'unmatched' | 'ambiguous'
|
||||
|
||||
export type MockWorkOrderRuleMatch = {
|
||||
rule: WorkProductRuleRow | null
|
||||
reason: MockWorkOrderRuleMatchReason
|
||||
candidates: string[]
|
||||
}
|
||||
|
||||
export function normalizeMockProductMatchText(value: unknown): string {
|
||||
return String(value || '')
|
||||
.replace(/[\s\u3000]+/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function resolveMockWorkOrderProductRule(input: {
|
||||
rules: WorkProductRuleRow[]
|
||||
productName: string
|
||||
categoryId?: number | null
|
||||
}): MockWorkOrderRuleMatch {
|
||||
const productName = normalizeMockProductMatchText(input.productName)
|
||||
if (!productName) return { rule: null, reason: 'unmatched', candidates: [] }
|
||||
const categoryId = Number(input.categoryId || 0)
|
||||
|
||||
const scored = input.rules
|
||||
.filter((rule) => rule.enabled !== false)
|
||||
.map((rule) => {
|
||||
const ruleName = normalizeMockProductMatchText(rule.product_name)
|
||||
if (!ruleName) return null
|
||||
if (!productName.includes(ruleName) && !ruleName.includes(productName)) return null
|
||||
return {
|
||||
rule,
|
||||
categoryMatch: categoryId > 0 && Number(rule.category_id || 0) === categoryId ? 1 : 0,
|
||||
nameLength: ruleName.length,
|
||||
sortOrder: Number(rule.sort_order || 100),
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
|
||||
if (scored.length === 0) return { rule: null, reason: 'unmatched', candidates: [] }
|
||||
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
b.categoryMatch - a.categoryMatch ||
|
||||
b.nameLength - a.nameLength ||
|
||||
a.sortOrder - b.sortOrder ||
|
||||
Number(a.rule.id) - Number(b.rule.id),
|
||||
)
|
||||
const [best] = scored
|
||||
if (!best) return { rule: null, reason: 'unmatched', candidates: [] }
|
||||
const candidates = scored.map((item) => String(item.rule.rule_key || item.rule.id))
|
||||
|
||||
const equallySpecific = scored.filter(
|
||||
(item) => item.categoryMatch === best.categoryMatch && item.nameLength === best.nameLength,
|
||||
)
|
||||
if (new Set(equallySpecific.map((item) => Number(item.rule.id))).size > 1) {
|
||||
return { rule: null, reason: 'ambiguous', candidates }
|
||||
}
|
||||
|
||||
return { rule: best.rule, reason: 'matched', candidates }
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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'],
|
||||
)
|
||||
})
|
||||
|
||||
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'])
|
||||
})
|
||||
@@ -428,6 +428,8 @@ export function mapWorkOrderAdmin(
|
||||
|
||||
export type WorkerOrderMapOptions = {
|
||||
isTimedOutForMe?: boolean
|
||||
/** 同一打手在该工单的全部有效份额;不传时回退为 myShare 单条 */
|
||||
myShares?: WorkOrderShareRow[]
|
||||
timeoutNotice?:
|
||||
| {
|
||||
isTimedOut: boolean
|
||||
@@ -440,6 +442,72 @@ export type WorkerOrderMapOptions = {
|
||||
| undefined
|
||||
}
|
||||
|
||||
/** 同一打手在一个工单里的多份份额汇总(列表一行一工单展示用)。 */
|
||||
export type WorkerShareSummary = {
|
||||
quantity: number
|
||||
rewardAmount: number
|
||||
depositAmount: number
|
||||
firstJoinedAt: string | null
|
||||
/** 未完成份额里最早的截止时间;用于父行倒计时,避免只看到其中一份。 */
|
||||
earliestActiveDeadlineAt: string | null
|
||||
}
|
||||
|
||||
export function summarizeWorkerShares(
|
||||
shares: WorkOrderShareRow[],
|
||||
fallbackTimeoutMinutes = 0,
|
||||
): WorkerShareSummary {
|
||||
let quantity = 0
|
||||
let rewardAmount = 0
|
||||
let depositAmount = 0
|
||||
/** 有有效份额时取第一份有效份额的参与时间;只剩已取消份额时回退到最早的取消份额。 */
|
||||
let firstActiveJoinedAtMs = Number.POSITIVE_INFINITY
|
||||
let firstAnyJoinedAtMs = Number.POSITIVE_INFINITY
|
||||
let earliestDeadlineMs = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const share of shares) {
|
||||
const createdAtMs = Date.parse(String(share.created_at || ''))
|
||||
if (Number.isFinite(createdAtMs) && createdAtMs < firstAnyJoinedAtMs) {
|
||||
firstAnyJoinedAtMs = createdAtMs
|
||||
}
|
||||
|
||||
if (share.status === 'cancelled') continue
|
||||
|
||||
if (Number.isFinite(createdAtMs) && createdAtMs < firstActiveJoinedAtMs) {
|
||||
firstActiveJoinedAtMs = createdAtMs
|
||||
}
|
||||
|
||||
quantity += Number(share.quantity || 0)
|
||||
rewardAmount += Number(share.share_reward || 0)
|
||||
depositAmount += Number(share.share_deposit || 0)
|
||||
|
||||
if (share.status === 'joined') {
|
||||
const timeoutMinutes = Number(share.timeout_minutes || fallbackTimeoutMinutes || 0)
|
||||
const deadlineMs = share.deadline_at
|
||||
? Date.parse(String(share.deadline_at))
|
||||
: Number.isFinite(createdAtMs) && timeoutMinutes > 0
|
||||
? createdAtMs + timeoutMinutes * 60_000
|
||||
: Number.NaN
|
||||
if (Number.isFinite(deadlineMs) && deadlineMs < earliestDeadlineMs) {
|
||||
earliestDeadlineMs = deadlineMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
quantity,
|
||||
rewardAmount,
|
||||
depositAmount,
|
||||
firstJoinedAt: Number.isFinite(firstActiveJoinedAtMs)
|
||||
? new Date(firstActiveJoinedAtMs).toISOString()
|
||||
: Number.isFinite(firstAnyJoinedAtMs)
|
||||
? new Date(firstAnyJoinedAtMs).toISOString()
|
||||
: null,
|
||||
earliestActiveDeadlineAt: Number.isFinite(earliestDeadlineMs)
|
||||
? new Date(earliestDeadlineMs).toISOString()
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapWorkOrderForWorker(
|
||||
workOrder: WorkOrderRow,
|
||||
permissions: { depositFreeAmount: number },
|
||||
@@ -463,38 +531,66 @@ export function mapWorkOrderForWorker(
|
||||
} = mapped
|
||||
const isTimedOutForMe =
|
||||
options?.isTimedOutForMe === true || Boolean(options?.timeoutNotice?.isTimedOut)
|
||||
const workerShares =
|
||||
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
|
||||
: resolveWorkerShareStatus(workOrder.status, myShare?.status)
|
||||
: 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 hasActiveShares = workerShares.some((share) => share.status !== 'cancelled')
|
||||
|
||||
return {
|
||||
...visibleOrder,
|
||||
productName: myShare
|
||||
? resolveWorkerShareProductName(workOrder.product_name, Number(myShare.quantity || 0))
|
||||
productName: hasActiveShares
|
||||
? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity)
|
||||
: visibleOrder.productName,
|
||||
status: personalStatus,
|
||||
/** 行上展示的问题备注:母单问题单用母单备注,否则用被标记份额的备注 */
|
||||
problemNote:
|
||||
String(workOrder.status || '') === WORK_ORDER_STATUS.PROBLEM
|
||||
? visibleOrder.problemNote
|
||||
: String(problemShare?.problem_note || ''),
|
||||
/** 是否为「份额级问题单」(区别于平台整单标记) */
|
||||
shareProblemMarked: Boolean(problemShare),
|
||||
rewardAmount: isTimedOutForMe
|
||||
? 0
|
||||
: myShare
|
||||
? Number(myShare.share_reward || 0)
|
||||
: hasActiveShares
|
||||
? shareSummary.rewardAmount
|
||||
: visibleOrder.rewardAmount,
|
||||
freezeDepositAmount: isTimedOutForMe
|
||||
? 0
|
||||
: myShare
|
||||
? Number(myShare.share_deposit || 0)
|
||||
: hasActiveShares
|
||||
? shareSummary.depositAmount
|
||||
: resolveFreezeDepositAmount(workOrder, permissions),
|
||||
/** 用于大厅拼单按当前剩余份数准确计算个人免押后的押金。 */
|
||||
depositFreeAmount: Number(permissions.depositFreeAmount || 0),
|
||||
assignedAt: myShare?.created_at || visibleOrder.assignedAt,
|
||||
assignedAt: shareSummary.firstJoinedAt || visibleOrder.assignedAt,
|
||||
submittedAt: myShare?.submitted_at || visibleOrder.submittedAt,
|
||||
acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt,
|
||||
deadlineAt:
|
||||
myShare && Number(workOrder.timeout_minutes || 0) > 0
|
||||
? new Date(
|
||||
new Date(myShare.created_at).getTime() + Number(workOrder.timeout_minutes) * 60 * 1000,
|
||||
).toISOString()
|
||||
: visibleOrder.deadlineAt,
|
||||
deadlineAt: isTimedOutForMe
|
||||
? visibleOrder.deadlineAt
|
||||
: shareSummary.earliestActiveDeadlineAt ||
|
||||
(myShare && Number(workOrder.timeout_minutes || 0) > 0
|
||||
? String(myShare.deadline_at || '') ||
|
||||
new Date(
|
||||
new Date(myShare.created_at).getTime() +
|
||||
Number(workOrder.timeout_minutes) * 60 * 1000,
|
||||
).toISOString()
|
||||
: visibleOrder.deadlineAt),
|
||||
workerNote,
|
||||
myShare: myShare ? mapWorkOrderShare(myShare) : null,
|
||||
/** 同一打手在该工单的全部有效份额(一行一工单 + 展开份额) */
|
||||
myShares: workerShares.map(mapWorkOrderShare),
|
||||
...(viewedAt === undefined ? {} : { isUnread: !viewedAt }),
|
||||
...(options?.timeoutNotice ? { timeoutNotice: options.timeoutNotice } : {}),
|
||||
}
|
||||
@@ -512,6 +608,7 @@ export function mapWorkOrderForHall(
|
||||
permissions: { depositFreeAmount: number },
|
||||
options: {
|
||||
myShare?: WorkOrderShareRow | null
|
||||
myShares?: WorkOrderShareRow[]
|
||||
sharingProgress?: { joinedQuantity: number; pendingSubmissionCount: number }
|
||||
} = {},
|
||||
) {
|
||||
@@ -528,6 +625,7 @@ export function mapWorkOrderForHall(
|
||||
}
|
||||
const gift = 'gift' in mapped && mapped.gift ? mapped.gift : null
|
||||
const myShare = options.myShare
|
||||
const myShares = options.myShares || (myShare ? [myShare] : [])
|
||||
return {
|
||||
workOrderId: mapped.workOrderId,
|
||||
workOrderNo: mapped.workOrderNo,
|
||||
@@ -576,6 +674,16 @@ export function mapWorkOrderForHall(
|
||||
status: myShare.status,
|
||||
}
|
||||
: null,
|
||||
myShares: myShares.map((s) => ({
|
||||
shareId: Number(s.id),
|
||||
workOrderId: Number(s.work_order_id),
|
||||
workerId: Number(s.worker_id),
|
||||
quantity: Number(s.quantity || 0),
|
||||
unitReward: Number(s.unit_reward || 0),
|
||||
shareReward: Number(s.share_reward || 0),
|
||||
shareDeposit: Number(s.share_deposit || 0),
|
||||
status: s.status,
|
||||
})),
|
||||
// 关键词搜索依赖这些字段;不返回完整 material,避免把 1~2KB 的 JSON 和图片数组带进大厅。
|
||||
material: {
|
||||
collect: { fields: keywordFields },
|
||||
@@ -611,6 +719,155 @@ 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
|
||||
/** 份额级问题单标记时间;非空表示该份额被后台标记问题单 */
|
||||
problem_marked_at?: 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',
|
||||
)
|
||||
// 份额级问题单:只要有一份被后台标记,这张单就归问题单(与 SQL 的 workerShareProblemExists 同义)。
|
||||
const problemShare = activeShares.find((share) => Boolean(share.problem_marked_at))
|
||||
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 (problemShare) {
|
||||
representativeStatus = WORK_ORDER_STATUS.PROBLEM
|
||||
} 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) {
|
||||
// 被标记问题单的份额只归问题单页签,不再按原状态出现(与 SQL 的 anyShare 分支一致)。
|
||||
if (share.problem_marked_at) continue
|
||||
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),
|
||||
@@ -621,12 +878,26 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) {
|
||||
shareReward: Number(share.share_reward || 0),
|
||||
shareDeposit: Number(share.share_deposit || 0),
|
||||
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),
|
||||
draftAcceptance: mapAcceptanceForResponse(share.draft_acceptance_json),
|
||||
submittedAt: share.submitted_at,
|
||||
acceptedAt: share.accepted_at,
|
||||
createdAt: share.created_at,
|
||||
updatedAt: share.updated_at,
|
||||
/** 本份额独立截止时间(参与时固化);null = 不限时 */
|
||||
deadlineAt: share.deadline_at || null,
|
||||
timeoutMinutes: Number(share.timeout_minutes || 0),
|
||||
timeoutPolicy: share.timeout_policy || '',
|
||||
cancelledReason: share.cancelled_reason || '',
|
||||
/** 后台撤单/撤份额时填写的原因(自由文本,选填) */
|
||||
cancelNote: share.cancel_note || '',
|
||||
/** 被撤掉的份数:撤份额累计,单人撤单为整份份数;0 = 未撤过 */
|
||||
cancelQuantity: Math.max(0, Number(share.cancel_quantity || 0)),
|
||||
worker: share.worker_id
|
||||
? {
|
||||
workerId: Number(share.worker_id),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
createDefaultWorkerAcceptanceConfig,
|
||||
normalizeWorkerAcceptanceConfig,
|
||||
} from './worker-acceptance-config-service.js'
|
||||
|
||||
test('验收视频配置:空值回落到默认值(30 秒 / 12MB / 720p)', () => {
|
||||
const config = normalizeWorkerAcceptanceConfig(undefined)
|
||||
assert.deepEqual(config, createDefaultWorkerAcceptanceConfig())
|
||||
assert.equal(config.enabled, true)
|
||||
assert.equal(config.maxDurationSeconds, 30)
|
||||
assert.equal(config.maxSizeMb, 12)
|
||||
assert.equal(config.targetHeight, 720)
|
||||
})
|
||||
|
||||
test('验收视频配置:数值被夹在允许区间内', () => {
|
||||
const tooSmall = normalizeWorkerAcceptanceConfig({
|
||||
maxDurationSeconds: 1,
|
||||
maxSizeMb: 0,
|
||||
targetHeight: 100,
|
||||
bitrateKbps: 10,
|
||||
audioBitrateKbps: 1,
|
||||
fps: 1,
|
||||
})
|
||||
assert.equal(tooSmall.maxDurationSeconds, 5)
|
||||
assert.equal(tooSmall.maxSizeMb, 1)
|
||||
assert.equal(tooSmall.targetHeight, 360)
|
||||
assert.equal(tooSmall.bitrateKbps, 300)
|
||||
assert.equal(tooSmall.audioBitrateKbps, 32)
|
||||
assert.equal(tooSmall.fps, 15)
|
||||
|
||||
const tooLarge = normalizeWorkerAcceptanceConfig({
|
||||
maxDurationSeconds: 999,
|
||||
maxSizeMb: 999,
|
||||
targetHeight: 4320,
|
||||
bitrateKbps: 99999,
|
||||
audioBitrateKbps: 999,
|
||||
fps: 120,
|
||||
})
|
||||
assert.equal(tooLarge.maxDurationSeconds, 120)
|
||||
assert.equal(tooLarge.maxSizeMb, 50)
|
||||
assert.equal(tooLarge.targetHeight, 1080)
|
||||
assert.equal(tooLarge.bitrateKbps, 4000)
|
||||
assert.equal(tooLarge.audioBitrateKbps, 128)
|
||||
assert.equal(tooLarge.fps, 30)
|
||||
})
|
||||
|
||||
test('验收视频配置:支持字符串开关与数字字符串(.env 读出来都是字符串)', () => {
|
||||
const config = normalizeWorkerAcceptanceConfig({
|
||||
enabled: 'false',
|
||||
maxDurationSeconds: '45',
|
||||
maxSizeMb: '8',
|
||||
targetHeight: '540',
|
||||
})
|
||||
assert.equal(config.enabled, false)
|
||||
assert.equal(config.maxDurationSeconds, 45)
|
||||
assert.equal(config.maxSizeMb, 8)
|
||||
assert.equal(config.targetHeight, 540)
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
|
||||
/**
|
||||
* 打手端「验收视频」配置。
|
||||
*
|
||||
* 只从 `.env`(`WORKER_ACCEPTANCE_VIDEO_*`)读取,**后台不提供配置界面**:
|
||||
* 视频压缩参数属于部署/运维级参数,改动频率低,放在配置文件里更容易随环境一起版本化。
|
||||
*
|
||||
* 前端按这些参数在本机压缩,服务端用同一份配置做时长/体积兜底校验,避免两边口径不一致。
|
||||
*/
|
||||
export type WorkerAcceptanceConfig = {
|
||||
enabled: boolean
|
||||
maxDurationSeconds: number
|
||||
maxSizeMb: number
|
||||
targetHeight: number
|
||||
bitrateKbps: number
|
||||
audioBitrateKbps: number
|
||||
fps: number
|
||||
}
|
||||
|
||||
export const WORKER_ACCEPTANCE_CONFIG_LIMITS = {
|
||||
maxDurationSeconds: { min: 5, max: 120 },
|
||||
maxSizeMb: { min: 1, max: 50 },
|
||||
targetHeight: { min: 360, max: 1080 },
|
||||
bitrateKbps: { min: 300, max: 4000 },
|
||||
audioBitrateKbps: { min: 32, max: 128 },
|
||||
fps: { min: 15, max: 30 },
|
||||
} as const
|
||||
|
||||
export function createDefaultWorkerAcceptanceConfig(): WorkerAcceptanceConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
maxDurationSeconds: 30,
|
||||
maxSizeMb: 12,
|
||||
targetHeight: 720,
|
||||
bitrateKbps: 1200,
|
||||
audioBitrateKbps: 64,
|
||||
fps: 30,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkerAcceptanceConfig(): WorkerAcceptanceConfig {
|
||||
return normalizeWorkerAcceptanceConfig(runtimeConfig.worker.acceptanceVideo)
|
||||
}
|
||||
|
||||
export function normalizeWorkerAcceptanceConfig(rawValue: unknown): WorkerAcceptanceConfig {
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const fallback = createDefaultWorkerAcceptanceConfig()
|
||||
return {
|
||||
enabled: readBoolean(source.enabled, fallback.enabled),
|
||||
maxDurationSeconds: readInt(
|
||||
source.maxDurationSeconds,
|
||||
WORKER_ACCEPTANCE_CONFIG_LIMITS.maxDurationSeconds,
|
||||
fallback.maxDurationSeconds,
|
||||
),
|
||||
maxSizeMb: readInt(
|
||||
source.maxSizeMb,
|
||||
WORKER_ACCEPTANCE_CONFIG_LIMITS.maxSizeMb,
|
||||
fallback.maxSizeMb,
|
||||
),
|
||||
targetHeight: readInt(
|
||||
source.targetHeight,
|
||||
WORKER_ACCEPTANCE_CONFIG_LIMITS.targetHeight,
|
||||
fallback.targetHeight,
|
||||
),
|
||||
bitrateKbps: readInt(
|
||||
source.bitrateKbps,
|
||||
WORKER_ACCEPTANCE_CONFIG_LIMITS.bitrateKbps,
|
||||
fallback.bitrateKbps,
|
||||
),
|
||||
audioBitrateKbps: readInt(
|
||||
source.audioBitrateKbps,
|
||||
WORKER_ACCEPTANCE_CONFIG_LIMITS.audioBitrateKbps,
|
||||
fallback.audioBitrateKbps,
|
||||
),
|
||||
fps: readInt(source.fps, WORKER_ACCEPTANCE_CONFIG_LIMITS.fps, fallback.fps),
|
||||
}
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback: boolean) {
|
||||
if (value === undefined || value === null || value === '') return fallback
|
||||
if (typeof value === 'boolean') return value
|
||||
const normalized = String(value).trim().toLowerCase()
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
function readInt(value: unknown, range: { min: number; max: number }, fallback: number): number {
|
||||
// 空值走默认值;有值(哪怕是 0)则夹到合法区间,避免误填 0 导致"上传全部被拒"。
|
||||
if (value === undefined || value === null || String(value).trim() === '') return fallback
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.min(range.max, Math.max(range.min, Math.floor(parsed)))
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hasAcceptanceEvidence } from '../../domain/work-order-acceptance-evidence.js'
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
isFriendGiftAcceptanceMode,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
acceptWorkOrderShareAndSettle,
|
||||
countWorkOrderPendingSharingSubmissions,
|
||||
createWorkOrderEvent,
|
||||
getWorkOrderShare,
|
||||
listWorkOrderSharesByWorkerInOrder,
|
||||
settleOverdueWorkOrder,
|
||||
settleOverdueWorkOrderShare,
|
||||
submitWorkOrderShareAcceptance,
|
||||
@@ -25,6 +26,10 @@ import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { addHours, nowIso } from '../../utils/time.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
import {
|
||||
extractObjectKeyFromFileUrl,
|
||||
normalizeStoredFileUrl,
|
||||
} from '../file-storage/file-storage-service.js'
|
||||
import {
|
||||
createWorkerAcceptanceAdminNotification,
|
||||
getWorkerAcceptanceReminder,
|
||||
@@ -43,6 +48,7 @@ import {
|
||||
resolveWorkerPermissions,
|
||||
} from './mappers.js'
|
||||
import { getWorkerFinanceConfig } from './worker-finance-config-service.js'
|
||||
import { getWorkerAcceptanceConfig } from './worker-acceptance-config-service.js'
|
||||
import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js'
|
||||
import {
|
||||
getRequiredWorkOrder,
|
||||
@@ -74,9 +80,21 @@ export async function submitWorkerOrderAcceptance(
|
||||
const workerId = Number(session.workerId)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
if (!options.asConsignmentSupplier) {
|
||||
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
|
||||
if (sharingShare) {
|
||||
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
|
||||
const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload)
|
||||
if (target.error === 'required') {
|
||||
throw createHttpError('该工单有多条拼单份额,请指定 shareId', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_share_id_required',
|
||||
})
|
||||
}
|
||||
if (target.error === 'not_found') {
|
||||
throw createHttpError('未找到该拼单份额', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_sharing_share_not_found',
|
||||
})
|
||||
}
|
||||
if (target.share) {
|
||||
return submitWorkerSharingAcceptance(workOrder, target.share, payload, session)
|
||||
}
|
||||
}
|
||||
const canAutoAcceptWithoutEvidence = isFriendGiftAcceptanceMode(workOrder.acceptance_mode)
|
||||
@@ -137,8 +155,11 @@ export async function submitWorkerOrderAcceptance(
|
||||
payload,
|
||||
workOrder.draft_acceptance_json,
|
||||
)
|
||||
if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) {
|
||||
throw createHttpError('请上传验收图片', {
|
||||
const video = resolveAcceptanceVideo(payload, workOrder.draft_acceptance_json)
|
||||
// 验收图片与验收视频至少上传一个;有视频时视同有图片,后续口径完全一致。
|
||||
const hasEvidence = hasAcceptanceEvidence({ imageUrls, video })
|
||||
if (!hasEvidence && !canAutoAcceptWithoutEvidence) {
|
||||
throw createHttpError('请上传验收图片或验收视频', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_image_required',
|
||||
})
|
||||
@@ -147,13 +168,13 @@ export async function submitWorkerOrderAcceptance(
|
||||
note,
|
||||
files,
|
||||
imageUrls: [...new Set(imageUrls)],
|
||||
...(video ? { video } : {}),
|
||||
submittedAt: now,
|
||||
...(canAutoAcceptWithoutEvidence
|
||||
? {
|
||||
reviewMode: 'vip_auto',
|
||||
evidenceStatus: imageUrls.length > 0 ? 'complete' : 'pending',
|
||||
evidenceDueAt:
|
||||
imageUrls.length > 0 ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS),
|
||||
evidenceStatus: hasEvidence ? 'complete' : 'pending',
|
||||
evidenceDueAt: hasEvidence ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
@@ -173,6 +194,7 @@ export async function submitWorkerOrderAcceptance(
|
||||
toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
|
||||
payloadJson: JSON.stringify({
|
||||
imageCount: imageUrls.length,
|
||||
hasVideo: Boolean(video),
|
||||
autoAccepted: canAutoAcceptWithoutEvidence,
|
||||
evidenceDueAt: acceptance.evidenceDueAt || null,
|
||||
}),
|
||||
@@ -232,6 +254,7 @@ export async function submitWorkerOrderAcceptance(
|
||||
productName: workOrder.product_name,
|
||||
workerName: worker.display_name || worker.username,
|
||||
imageCount: imageUrls.length,
|
||||
hasVideo: Boolean(video),
|
||||
})
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrder.id),
|
||||
@@ -283,6 +306,7 @@ export async function remindWorkerOrderAcceptance(
|
||||
productName: workOrder.product_name,
|
||||
workerName: worker.display_name || worker.username,
|
||||
imageCount: normalizeStringArray(safeParseJson(workOrder.acceptance_json).imageUrls).length,
|
||||
hasVideo: Boolean(safeParseJson(workOrder.acceptance_json).video),
|
||||
})
|
||||
notification = await getWorkerAcceptanceReminder(Number(workOrder.id))
|
||||
}
|
||||
@@ -340,9 +364,21 @@ export async function supplementWorkerAcceptedOrderEvidence(
|
||||
requireActiveWorkerSession(session)
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const sharingShare = await getWorkOrderShare(workOrder.id, session.workerId)
|
||||
if (sharingShare) {
|
||||
return supplementWorkerAcceptedSharingEvidence(workOrder, sharingShare, payload, session)
|
||||
const target = await resolveWorkerTargetShare(workOrder.id, session.workerId, payload)
|
||||
if (target.error === 'required') {
|
||||
throw createHttpError('该工单有多条拼单份额,请指定 shareId', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_share_id_required',
|
||||
})
|
||||
}
|
||||
if (target.error === 'not_found') {
|
||||
throw createHttpError('未找到该拼单份额', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_sharing_share_not_found',
|
||||
})
|
||||
}
|
||||
if (target.share) {
|
||||
return supplementWorkerAcceptedSharingEvidence(workOrder, target.share, payload, session)
|
||||
}
|
||||
if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) {
|
||||
throw createHttpError('只能补充自己的订单验收图片', {
|
||||
@@ -360,8 +396,11 @@ export async function supplementWorkerAcceptedOrderEvidence(
|
||||
const now = nowIso()
|
||||
const previous = safeParseJson(workOrder.acceptance_json)
|
||||
const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files)
|
||||
if (!mergedEvidence.changed) {
|
||||
throw createHttpError('请上传新的验收图片', {
|
||||
// 已提交后只允许替换视频,不允许删除证据。
|
||||
const payloadVideo = resolveAcceptanceVideoFromPayload(payload)
|
||||
const video = payloadVideo || readAcceptanceVideoLenient(previous)
|
||||
if (!mergedEvidence.changed && !payloadVideo) {
|
||||
throw createHttpError('请上传新的验收图片或新的验收视频', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_evidence_supplement_image_required',
|
||||
})
|
||||
@@ -370,6 +409,7 @@ export async function supplementWorkerAcceptedOrderEvidence(
|
||||
...previous,
|
||||
files: mergedEvidence.files,
|
||||
imageUrls: mergedEvidence.imageUrls,
|
||||
...(video ? { video } : {}),
|
||||
evidenceStatus: 'complete',
|
||||
evidenceSupplementedAt: now,
|
||||
updatedAt: now,
|
||||
@@ -389,6 +429,7 @@ export async function supplementWorkerAcceptedOrderEvidence(
|
||||
payloadJson: JSON.stringify({
|
||||
addedImageCount: mergedEvidence.addedFileCount,
|
||||
totalImageCount: mergedEvidence.imageUrls.length,
|
||||
hasVideo: Boolean(video),
|
||||
}),
|
||||
now,
|
||||
})
|
||||
@@ -411,16 +452,23 @@ async function submitWorkerSharingAcceptance(
|
||||
})
|
||||
}
|
||||
const now = nowIso()
|
||||
const timeoutMinutes = Number(workOrder.timeout_minutes || 0)
|
||||
const timeoutMinutes = Number(sharingShare.timeout_minutes ?? workOrder.timeout_minutes ?? 0)
|
||||
const snapshotDeadline = String(sharingShare.deadline_at || '').trim()
|
||||
const deadlineMs = snapshotDeadline
|
||||
? new Date(snapshotDeadline).getTime()
|
||||
: new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000
|
||||
if (
|
||||
sharingShare.status === 'joined' &&
|
||||
timeoutMinutes > 0 &&
|
||||
new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000 <= Date.now()
|
||||
Number.isFinite(deadlineMs) &&
|
||||
deadlineMs <= Date.now()
|
||||
) {
|
||||
await settleOverdueWorkOrderShare({
|
||||
workOrderId: workOrder.id,
|
||||
shareId: sharingShare.id,
|
||||
policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy),
|
||||
policy: normalizeWorkOrderTimeoutPolicy(
|
||||
sharingShare.timeout_policy || workOrder.timeout_policy,
|
||||
),
|
||||
now,
|
||||
})
|
||||
publishWorkOrderRealtimeChange({
|
||||
@@ -440,8 +488,11 @@ async function submitWorkerSharingAcceptance(
|
||||
payload,
|
||||
sharingShare.draft_acceptance_json,
|
||||
)
|
||||
if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) {
|
||||
throw createHttpError('请上传验收图片', {
|
||||
const video = resolveAcceptanceVideo(payload, sharingShare.draft_acceptance_json)
|
||||
// 验收图片与验收视频至少上传一个;有视频时视同有图片,后续口径完全一致。
|
||||
const hasEvidence = hasAcceptanceEvidence({ imageUrls, video })
|
||||
if (!hasEvidence && !canAutoAcceptWithoutEvidence) {
|
||||
throw createHttpError('请上传验收图片或验收视频', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_image_required',
|
||||
})
|
||||
@@ -450,19 +501,20 @@ async function submitWorkerSharingAcceptance(
|
||||
note,
|
||||
files,
|
||||
imageUrls: [...new Set(imageUrls)],
|
||||
...(video ? { video } : {}),
|
||||
submittedAt: now,
|
||||
...(canAutoAcceptWithoutEvidence
|
||||
? {
|
||||
reviewMode: 'vip_auto',
|
||||
evidenceStatus: imageUrls.length > 0 ? 'complete' : 'pending',
|
||||
evidenceDueAt:
|
||||
imageUrls.length > 0 ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS),
|
||||
evidenceStatus: hasEvidence ? 'complete' : 'pending',
|
||||
evidenceDueAt: hasEvidence ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
const result = await submitWorkOrderShareAcceptance({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(session.workerId),
|
||||
shareId: Number(sharingShare.id),
|
||||
acceptanceJson: JSON.stringify(acceptance),
|
||||
now,
|
||||
})
|
||||
@@ -509,6 +561,15 @@ async function submitWorkerSharingAcceptance(
|
||||
sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)),
|
||||
}
|
||||
}
|
||||
await createWorkerAcceptanceAdminNotification({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workOrderNo: workOrder.work_order_no,
|
||||
productName: workOrder.product_name,
|
||||
workerName: worker.display_name || worker.username,
|
||||
imageCount: acceptance.imageUrls.length,
|
||||
hasVideo: Boolean(acceptance.video),
|
||||
shareId: Number(sharingShare.id),
|
||||
})
|
||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||
workerIds: [Number(session.workerId)],
|
||||
})
|
||||
@@ -535,8 +596,10 @@ async function supplementWorkerAcceptedSharingEvidence(
|
||||
const now = nowIso()
|
||||
const previous = safeParseJson(sharingShare.acceptance_json)
|
||||
const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files)
|
||||
if (!mergedEvidence.changed) {
|
||||
throw createHttpError('请上传新的验收图片', {
|
||||
const payloadVideo = resolveAcceptanceVideoFromPayload(payload)
|
||||
const video = payloadVideo || readAcceptanceVideoLenient(previous)
|
||||
if (!mergedEvidence.changed && !payloadVideo) {
|
||||
throw createHttpError('请上传新的验收图片或新的验收视频', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_sharing_evidence_supplement_image_required',
|
||||
})
|
||||
@@ -545,6 +608,7 @@ async function supplementWorkerAcceptedSharingEvidence(
|
||||
...previous,
|
||||
files: mergedEvidence.files,
|
||||
imageUrls: mergedEvidence.imageUrls,
|
||||
...(video ? { video } : {}),
|
||||
evidenceStatus: 'complete',
|
||||
evidenceSupplementedAt: now,
|
||||
updatedAt: now,
|
||||
@@ -552,6 +616,7 @@ async function supplementWorkerAcceptedSharingEvidence(
|
||||
const updated = await updateWorkOrderShareAcceptanceRecord({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(session.workerId),
|
||||
shareId: Number(sharingShare.id),
|
||||
acceptanceJson: JSON.stringify(acceptance),
|
||||
now,
|
||||
})
|
||||
@@ -566,6 +631,7 @@ async function supplementWorkerAcceptedSharingEvidence(
|
||||
workerId: Number(session.workerId),
|
||||
addedImageCount: mergedEvidence.addedFileCount,
|
||||
totalImageCount: mergedEvidence.imageUrls.length,
|
||||
hasVideo: Boolean(video),
|
||||
}),
|
||||
now,
|
||||
})
|
||||
@@ -575,7 +641,160 @@ async function supplementWorkerAcceptedSharingEvidence(
|
||||
return { share: mapWorkOrderShare(updated || sharingShare) }
|
||||
}
|
||||
|
||||
/** 从提交或草稿载荷中解析验收图片与说明。 */
|
||||
/**
|
||||
* 定位本次操作要处理的份额:
|
||||
* - 传了 shareId:必须是本人该工单下的有效份额(否则 not_found)
|
||||
* - 没传且只有 1 条:直接用
|
||||
* - 没传且有多条:要求前端明确指定(required)
|
||||
* - 没有任何份额:返回空(走整单逻辑)
|
||||
*/
|
||||
async function resolveWorkerTargetShare(
|
||||
workOrderId: number,
|
||||
workerId: number | string,
|
||||
payload: JsonObject,
|
||||
): Promise<{ share: WorkOrderShareRow | null; error: 'required' | 'not_found' | null }> {
|
||||
const requested = Number(payload.shareId ?? payload.workOrderShareId ?? 0)
|
||||
const shares = await listWorkOrderSharesByWorkerInOrder(workOrderId, workerId)
|
||||
if (Number.isFinite(requested) && requested > 0) {
|
||||
const found = shares.find((share) => Number(share.id) === requested) || null
|
||||
return { share: found, error: found ? null : 'not_found' }
|
||||
}
|
||||
if (shares.length === 0) {
|
||||
return { share: null, error: null }
|
||||
}
|
||||
if (shares.length === 1) {
|
||||
return { share: shares[0] ?? null, error: null }
|
||||
}
|
||||
return { share: null, error: 'required' }
|
||||
}
|
||||
|
||||
const ACCEPTANCE_VIDEO_SCENE = 'worker-acceptance-video'
|
||||
const ACCEPTANCE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm'])
|
||||
|
||||
/**
|
||||
* 归一化验收视频(可选、单个)。
|
||||
*
|
||||
* - 只接受本系统上传到 `worker-acceptance-video/` 的对象,避免把其它场景的文件当验收证据;
|
||||
* - 格式/时长/体积按后台配置再校验一次(上传接口已校验,这里防篡改 payload);
|
||||
* - `allowRemove` 只在草稿阶段为 true:显式传 null 表示删除;已提交/补图阶段只能替换,不能删证据。
|
||||
*/
|
||||
function resolveAcceptanceVideo(
|
||||
payload: JsonObject,
|
||||
fallback: unknown,
|
||||
options: { allowRemove?: boolean } = {},
|
||||
): Record<string, unknown> | null {
|
||||
const hasVideoKey = Object.prototype.hasOwnProperty.call(payload, 'video')
|
||||
const rawValue = (payload as Record<string, unknown>).video
|
||||
if (hasVideoKey && rawValue === null) {
|
||||
return options.allowRemove ? null : readAcceptanceVideoLenient(fallback)
|
||||
}
|
||||
return resolveAcceptanceVideoFromPayload(payload) ?? readAcceptanceVideoLenient(fallback)
|
||||
}
|
||||
|
||||
/** 只取本次请求里带的视频(没有则为 null),用于判断"这次是否真的提交了新视频"。 */
|
||||
function resolveAcceptanceVideoFromPayload(payload: JsonObject): Record<string, unknown> | null {
|
||||
const hasVideoKey = Object.prototype.hasOwnProperty.call(payload, 'video')
|
||||
const rawValue = (payload as Record<string, unknown>).video
|
||||
if (!hasVideoKey || rawValue === undefined || rawValue === null || rawValue === '') return null
|
||||
return readAcceptanceVideoStrict(rawValue)
|
||||
}
|
||||
|
||||
function readAcceptanceVideoStrict(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw createHttpError('验收视频参数无效,请重新上传', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_video_invalid',
|
||||
})
|
||||
}
|
||||
const source = value as JsonObject
|
||||
const rawUrl = String(source.url || '').trim()
|
||||
const objectKey =
|
||||
String(source.objectKey || source.object_key || '').trim() ||
|
||||
extractObjectKeyFromFileUrl(rawUrl)
|
||||
if (!objectKey || !objectKey.startsWith(`${ACCEPTANCE_VIDEO_SCENE}/`)) {
|
||||
throw createHttpError('验收视频来源无效,请重新上传', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_video_invalid',
|
||||
})
|
||||
}
|
||||
const contentType = String(source.contentType || source.content_type || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!ACCEPTANCE_VIDEO_CONTENT_TYPES.has(contentType)) {
|
||||
throw createHttpError('验收视频格式不支持,请重新上传', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_video_invalid',
|
||||
})
|
||||
}
|
||||
const config = getWorkerAcceptanceConfig()
|
||||
if (!config.enabled) {
|
||||
throw createHttpError('验收视频功能已关闭', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_video_disabled',
|
||||
})
|
||||
}
|
||||
const durationSeconds = Number(source.durationSeconds ?? source.duration_seconds ?? 0)
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||
throw createHttpError('无法读取视频时长,请重新上传', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_video_invalid',
|
||||
})
|
||||
}
|
||||
if (durationSeconds > config.maxDurationSeconds + 0.5) {
|
||||
throw createHttpError(`视频时长不能超过 ${config.maxDurationSeconds} 秒`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_video_too_long',
|
||||
})
|
||||
}
|
||||
const size = Number(source.size ?? 0)
|
||||
if (Number.isFinite(size) && size > config.maxSizeMb * 1024 * 1024) {
|
||||
throw createHttpError(`验收视频不能超过 ${config.maxSizeMb}MB`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_video_too_large',
|
||||
})
|
||||
}
|
||||
return {
|
||||
objectKey,
|
||||
url: normalizeStoredFileUrl(rawUrl, objectKey),
|
||||
filename: String(source.filename || '').trim(),
|
||||
contentType,
|
||||
size: Number.isFinite(size) && size > 0 ? Math.floor(size) : 0,
|
||||
durationSeconds: Math.round(durationSeconds * 10) / 10,
|
||||
width: readPositiveInt(source.width),
|
||||
height: readPositiveInt(source.height),
|
||||
}
|
||||
}
|
||||
|
||||
/** 读已落库的视频(不再校验,避免历史数据因配置收紧而读不出来)。 */
|
||||
function readAcceptanceVideoLenient(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
const source = value as JsonObject
|
||||
const rawUrl = String(source.url || '').trim()
|
||||
const objectKey =
|
||||
String(source.objectKey || source.object_key || '').trim() ||
|
||||
extractObjectKeyFromFileUrl(rawUrl)
|
||||
const durationSeconds = Number(source.durationSeconds ?? source.duration_seconds ?? 0)
|
||||
if (!objectKey || !Number.isFinite(durationSeconds) || durationSeconds <= 0) return null
|
||||
const size = Number(source.size ?? 0)
|
||||
return {
|
||||
objectKey,
|
||||
url: normalizeStoredFileUrl(rawUrl, objectKey),
|
||||
filename: String(source.filename || '').trim(),
|
||||
contentType: String(source.contentType || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
size: Number.isFinite(size) && size > 0 ? Math.floor(size) : 0,
|
||||
durationSeconds: Math.round(durationSeconds * 10) / 10,
|
||||
width: readPositiveInt(source.width),
|
||||
height: readPositiveInt(source.height),
|
||||
}
|
||||
}
|
||||
|
||||
function readPositiveInt(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0
|
||||
}
|
||||
|
||||
function resolveAcceptanceImages(payload: JsonObject, draftValue: unknown) {
|
||||
const files = normalizeUploadedFiles(payload.files)
|
||||
const imageUrls = [
|
||||
@@ -606,9 +825,21 @@ export async function saveWorkerOrderAcceptanceDraft(
|
||||
requireActiveWorkerSession(session)
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const workerId = Number(session.workerId)
|
||||
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
|
||||
if (sharingShare) {
|
||||
return saveWorkerSharingAcceptanceDraft(workOrder, sharingShare, payload, session)
|
||||
const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload)
|
||||
if (target.error === 'required') {
|
||||
throw createHttpError('该工单有多条拼单份额,请指定 shareId', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_share_id_required',
|
||||
})
|
||||
}
|
||||
if (target.error === 'not_found') {
|
||||
throw createHttpError('未找到该拼单份额', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_order_sharing_share_not_found',
|
||||
})
|
||||
}
|
||||
if (target.share) {
|
||||
return saveWorkerSharingAcceptanceDraft(workOrder, target.share, payload, session)
|
||||
}
|
||||
if (Number(workOrder.assigned_worker_id || 0) !== workerId) {
|
||||
throw createHttpError('只能操作自己的订单', {
|
||||
@@ -621,17 +852,21 @@ export async function saveWorkerOrderAcceptanceDraft(
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
|
||||
if (workOrder.status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE) {
|
||||
if (imageUrls.length === 0) {
|
||||
throw createHttpError('请至少保留一张验收图片', {
|
||||
const previous = safeParseJson(workOrder.acceptance_json)
|
||||
// 已提交后只允许替换视频,不允许删除证据。
|
||||
const video = resolveAcceptanceVideo(payload, previous)
|
||||
// 验收图片与验收视频至少保留一个。
|
||||
if (!hasAcceptanceEvidence({ imageUrls, video })) {
|
||||
throw createHttpError('请至少保留一张验收图片或一段验收视频', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_image_required',
|
||||
})
|
||||
}
|
||||
const previous = safeParseJson(workOrder.acceptance_json)
|
||||
const acceptance = {
|
||||
note: String(payload.note ?? previous.note ?? '').trim(),
|
||||
files,
|
||||
imageUrls: [...new Set(imageUrls)],
|
||||
...(video ? { video } : {}),
|
||||
submittedAt: previous.submittedAt || workOrder.submitted_at || now,
|
||||
updatedAt: now,
|
||||
}
|
||||
@@ -647,7 +882,7 @@ export async function saveWorkerOrderAcceptanceDraft(
|
||||
eventType: 'acceptance_updated',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({ imageCount: imageUrls.length }),
|
||||
payloadJson: JSON.stringify({ imageCount: imageUrls.length, hasVideo: Boolean(video) }),
|
||||
now,
|
||||
})
|
||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||
@@ -666,10 +901,12 @@ export async function saveWorkerOrderAcceptanceDraft(
|
||||
errorCode: 'work_order_draft_status_invalid',
|
||||
})
|
||||
}
|
||||
const draftVideo = resolveAcceptanceVideo(payload, {}, { allowRemove: true })
|
||||
const draft = {
|
||||
note: String(payload.note || '').trim(),
|
||||
files,
|
||||
imageUrls: [...new Set(imageUrls)],
|
||||
...(draftVideo ? { video: draftVideo } : {}),
|
||||
updatedAt: now,
|
||||
}
|
||||
const updated = await updateWorkOrderDraftAcceptance({
|
||||
@@ -684,7 +921,10 @@ export async function saveWorkerOrderAcceptanceDraft(
|
||||
eventType: 'acceptance_draft_saved',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({ imageCount: imageUrls.length }),
|
||||
payloadJson: JSON.stringify({
|
||||
imageCount: imageUrls.length,
|
||||
hasVideo: Boolean(draftVideo),
|
||||
}),
|
||||
now,
|
||||
})
|
||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||
@@ -711,23 +951,28 @@ async function saveWorkerSharingAcceptanceDraft(
|
||||
const { files, imageUrls } = resolveAcceptanceImages(payload, {})
|
||||
|
||||
if (sharingShare.status === 'submitted') {
|
||||
if (imageUrls.length === 0) {
|
||||
throw createHttpError('请至少保留一张验收图片', {
|
||||
const previous = safeParseJson(sharingShare.acceptance_json)
|
||||
// 已提交后只允许替换视频,不允许删除证据。
|
||||
const video = resolveAcceptanceVideo(payload, previous)
|
||||
// 验收图片与验收视频至少保留一个。
|
||||
if (!hasAcceptanceEvidence({ imageUrls, video })) {
|
||||
throw createHttpError('请至少保留一张验收图片或一段验收视频', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_image_required',
|
||||
})
|
||||
}
|
||||
const previous = safeParseJson(sharingShare.acceptance_json)
|
||||
const acceptance = {
|
||||
note: String(payload.note ?? previous.note ?? '').trim(),
|
||||
files,
|
||||
imageUrls: [...new Set(imageUrls)],
|
||||
...(video ? { video } : {}),
|
||||
submittedAt: previous.submittedAt || sharingShare.submitted_at || now,
|
||||
updatedAt: now,
|
||||
}
|
||||
const share = await updateWorkOrderShareAcceptanceRecord({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(session.workerId),
|
||||
shareId: Number(sharingShare.id),
|
||||
acceptanceJson: JSON.stringify(acceptance),
|
||||
now,
|
||||
})
|
||||
@@ -741,6 +986,7 @@ async function saveWorkerSharingAcceptanceDraft(
|
||||
payloadJson: JSON.stringify({
|
||||
workerId: Number(session.workerId),
|
||||
imageCount: imageUrls.length,
|
||||
hasVideo: Boolean(video),
|
||||
}),
|
||||
now,
|
||||
})
|
||||
@@ -756,15 +1002,18 @@ async function saveWorkerSharingAcceptanceDraft(
|
||||
errorCode: 'work_order_sharing_draft_status_invalid',
|
||||
})
|
||||
}
|
||||
const draftVideo = resolveAcceptanceVideo(payload, {}, { allowRemove: true })
|
||||
const draft = {
|
||||
note: String(payload.note || '').trim(),
|
||||
files,
|
||||
imageUrls: [...new Set(imageUrls)],
|
||||
...(draftVideo ? { video: draftVideo } : {}),
|
||||
updatedAt: now,
|
||||
}
|
||||
const share = await updateWorkOrderShareDraftAcceptance({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(session.workerId),
|
||||
shareId: Number(sharingShare.id),
|
||||
draftJson: JSON.stringify(draft),
|
||||
now,
|
||||
})
|
||||
@@ -778,6 +1027,7 @@ async function saveWorkerSharingAcceptanceDraft(
|
||||
payloadJson: JSON.stringify({
|
||||
workerId: Number(session.workerId),
|
||||
imageCount: imageUrls.length,
|
||||
hasVideo: Boolean(draftVideo),
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
throwGrabWorkOrderFailure,
|
||||
} from './mappers.js'
|
||||
import { getWorkerHallConfig } from './worker-hall-config-service.js'
|
||||
import { invalidateWorkerHallListCache } from './worker-hall-service.js'
|
||||
import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js'
|
||||
import {
|
||||
getRequiredWorkOrder,
|
||||
@@ -30,12 +31,17 @@ import {
|
||||
publishWorkOrderRealtimeChange,
|
||||
} from '../realtime/realtime-event-service.js'
|
||||
|
||||
export async function grabWorkerHallOrder(workOrderId: number | string, session: WorkerSession) {
|
||||
export async function grabWorkerHallOrder(
|
||||
workOrderId: number | string,
|
||||
session: WorkerSession,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
requireActiveWorkerSession(session)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const permissions = resolveWorkerPermissions(worker)
|
||||
const hallConfig = getWorkerHallConfig()
|
||||
const now = nowIso()
|
||||
const requestId = String(payload.requestId || '').trim()
|
||||
const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0)
|
||||
const visibleAfterIso =
|
||||
visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : ''
|
||||
@@ -58,17 +64,25 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
|
||||
hallCandidateLimit: hallConfig.maxVisibleOrders,
|
||||
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
|
||||
visibleAfterIso,
|
||||
...(requestId ? { requestId } : {}),
|
||||
now,
|
||||
})
|
||||
if (!result.share) {
|
||||
throw resolveWorkOrderShareJoinError(result.failureReason)
|
||||
}
|
||||
// 参与成功后立刻失效该打手的大厅缓存:前端紧接着会重拉列表,
|
||||
// 2s 缓存若命中会返回「参与前」的行(没有 myShare),按钮就不会变成「再拼一次」。
|
||||
invalidateWorkerHallListCache([worker.id])
|
||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||
workerIds: [Number(worker.id)],
|
||||
hallChanged: true,
|
||||
walletChangedWorkerIds: [Number(worker.id)],
|
||||
})
|
||||
return { order: mapWorkOrderForWorker(workOrder, permissions, result.share) }
|
||||
return {
|
||||
order: mapWorkOrderForWorker(workOrder, permissions, result.share),
|
||||
share: mapWorkOrderShare(result.share),
|
||||
duplicated: result.duplicated,
|
||||
}
|
||||
}
|
||||
|
||||
const activeCount = await countWorkerActiveOrders(worker.id)
|
||||
@@ -102,6 +116,10 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
|
||||
throwGrabWorkOrderFailure(grabbed.failureReason)
|
||||
}
|
||||
|
||||
// 抢单成功后同样立刻失效大厅缓存:否则紧接着的列表重拉可能命中 2s 旧缓存,
|
||||
// 把已经抢走的单又显示出来(用户会以为还能再抢)。
|
||||
invalidateWorkerHallListCache([worker.id])
|
||||
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'worker',
|
||||
@@ -139,6 +157,7 @@ export async function joinWorkerSharingOrder(
|
||||
const visibleAfterIso =
|
||||
visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : ''
|
||||
const quantity = normalizePositiveInteger(payload.quantity, 1)
|
||||
const requestId = String(payload.requestId || '').trim()
|
||||
const now = nowIso()
|
||||
const result = await joinWorkOrderShare({
|
||||
workOrderId: Number(workOrderId),
|
||||
@@ -149,17 +168,20 @@ export async function joinWorkerSharingOrder(
|
||||
hallCandidateLimit: hallConfig.maxVisibleOrders,
|
||||
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
|
||||
visibleAfterIso,
|
||||
...(requestId ? { requestId } : {}),
|
||||
now,
|
||||
})
|
||||
if (!result.share) {
|
||||
throw resolveWorkOrderShareJoinError(result.failureReason)
|
||||
}
|
||||
// 同上:拼单成功后要让紧接着的列表请求看到最新份额。
|
||||
invalidateWorkerHallListCache([worker.id])
|
||||
await publishWorkOrderParticipantChange(Number(workOrderId), {
|
||||
workerIds: [Number(worker.id)],
|
||||
hallChanged: true,
|
||||
walletChangedWorkerIds: [Number(worker.id)],
|
||||
})
|
||||
return { share: mapWorkOrderShare(result.share) }
|
||||
return { share: mapWorkOrderShare(result.share), duplicated: result.duplicated }
|
||||
}
|
||||
|
||||
function resolveWorkOrderDeadlineAt(workOrder: WorkOrderRow, now: string): string | null {
|
||||
@@ -174,8 +196,6 @@ function resolveWorkOrderShareJoinError(
|
||||
| 'sharing_disabled'
|
||||
| 'work_order_owner_conflict'
|
||||
| 'sharing_quantity_full'
|
||||
| 'worker_share_exists'
|
||||
| 'worker_share_locked'
|
||||
| 'worker_active_order_limit'
|
||||
| 'worker_deposit_insufficient'
|
||||
| null,
|
||||
@@ -185,8 +205,6 @@ function resolveWorkOrderShareJoinError(
|
||||
sharing_disabled: '该订单未启用拼单',
|
||||
work_order_owner_conflict: '你已整单接取该订单,无需拼单',
|
||||
sharing_quantity_full: '拼单数量已满或超出剩余数量',
|
||||
worker_share_exists: '你已参与该拼单,剩余份数可用「立即抢单」追加',
|
||||
worker_share_locked: '你的拼单份额已提交或已验收,不能再追加份数',
|
||||
worker_active_order_limit: '已达到当前等级最大同时接单量',
|
||||
worker_deposit_insufficient: '余额不足,无法冻结所需押金',
|
||||
}
|
||||
|
||||
@@ -75,13 +75,17 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
|
||||
const hasSharingOrders = items.some((item) => item.sharing_enabled === true)
|
||||
const orderIds = items.map((item) => Number(item.id))
|
||||
const shares = hasSharingOrders ? await listWorkOrderSharesByOrderIds(orderIds) : []
|
||||
const myShareByOrderId = new Map(
|
||||
shares
|
||||
.filter(
|
||||
(share) => share.status !== 'cancelled' && Number(share.worker_id) === Number(worker.id),
|
||||
)
|
||||
.map((share) => [Number(share.work_order_id), share]),
|
||||
)
|
||||
// 同一打手可能在一个工单里有多份份额(多次拼单)。大厅卡片靠这些字段判断
|
||||
// 是否已参与(按钮显示「再拼一次」),所以必须按工单收集全部有效份额。
|
||||
const mySharesByOrderId = new Map<number, typeof shares>()
|
||||
for (const share of shares) {
|
||||
if (share.status === 'cancelled') continue
|
||||
if (Number(share.worker_id) !== Number(worker.id)) continue
|
||||
const orderId = Number(share.work_order_id)
|
||||
const current = mySharesByOrderId.get(orderId) || []
|
||||
current.push(share)
|
||||
mySharesByOrderId.set(orderId, current)
|
||||
}
|
||||
const sharingProgressByOrderId = new Map<
|
||||
number,
|
||||
{ joinedQuantity: number; pendingSubmissionCount: number }
|
||||
@@ -100,8 +104,12 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
|
||||
return {
|
||||
items: items.map((item) => {
|
||||
const orderId = Number(item.id)
|
||||
const myShares = mySharesByOrderId.get(orderId) || []
|
||||
// `listWorkOrderSharesByOrderIds` 按 id ASC,最后一条即最近一次参与。
|
||||
const myShare = myShares[myShares.length - 1] || null
|
||||
return mapWorkOrderForHall(item, permissions, {
|
||||
myShare: myShareByOrderId.get(orderId) || null,
|
||||
myShare,
|
||||
myShares,
|
||||
sharingProgress: sharingProgressByOrderId.get(orderId) || {
|
||||
joinedQuantity: 0,
|
||||
pendingSubmissionCount: 0,
|
||||
@@ -164,3 +172,26 @@ export function maskWorkerLeaderboardDisplayName(value: unknown): string {
|
||||
const firstCharacter = Array.from(String(value || '').trim())[0]
|
||||
return firstCharacter ? `${firstCharacter}****` : '****'
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐出指定打手的大厅列表缓存。
|
||||
*
|
||||
* 大厅列表缓存 TTL 只有 2s,但拼单成功后的即时重拉正好落在窗口内:
|
||||
* 拿回来的是「参与前」的行(没有 myShare、拼单进度也是旧的),按钮就停在
|
||||
* 「拼单 ¥x/份」不变。参与/抢单成功后按打手整体失效,保证紧接着的列表是新数据。
|
||||
* 缓存键形如 `[workerId,page,pageSize,keyword,categoryId]`,用 `[id,` 作前缀可避免 123/1234 误伤。
|
||||
*/
|
||||
export function invalidateWorkerHallListCache(
|
||||
workerIds: Array<number | string | null | undefined>,
|
||||
): number {
|
||||
const normalized = new Set(
|
||||
workerIds
|
||||
.map((item) => Number(item || 0))
|
||||
.filter((workerId) => Number.isFinite(workerId) && workerId > 0),
|
||||
)
|
||||
let evicted = 0
|
||||
for (const workerId of normalized) {
|
||||
evicted += hallListCache.evictByPrefix(`[${workerId},`)
|
||||
}
|
||||
return evicted
|
||||
}
|
||||
|
||||
@@ -118,8 +118,14 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
|
||||
sort: 'worker_claimed_at_desc',
|
||||
})
|
||||
const workOrderIds = items.map((item) => Number(item.id))
|
||||
const myShares = await listWorkerSharesByWorker(worker.id, workOrderIds)
|
||||
const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share]))
|
||||
const workerShares = await listWorkerSharesByWorker(worker.id, workOrderIds)
|
||||
const sharesByOrderId = new Map<number, typeof workerShares>()
|
||||
for (const share of workerShares) {
|
||||
const orderId = Number(share.work_order_id)
|
||||
const current = sharesByOrderId.get(orderId) || []
|
||||
current.push(share)
|
||||
sharesByOrderId.set(orderId, current)
|
||||
}
|
||||
const [
|
||||
notes,
|
||||
views,
|
||||
@@ -152,15 +158,20 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
|
||||
return {
|
||||
items: items.map((item) => {
|
||||
const workOrderId = Number(item.id)
|
||||
const activeShare = myShareByOrderId.get(workOrderId)
|
||||
const activeShares = sharesByOrderId.get(workOrderId) || []
|
||||
// 主份额用于列表摘要:优先未完成(joined/submitted),否则最新一条
|
||||
const activeShare =
|
||||
activeShares.find((share) => ['joined', 'submitted'].includes(String(share.status))) ||
|
||||
activeShares[0] ||
|
||||
null
|
||||
const cancelledShare = cancelledShareByOrderId.get(workOrderId)
|
||||
const timeoutEvent = timeoutEventsByOrderId.get(workOrderId)
|
||||
|
||||
// 判断当前打手是否仍持有该单
|
||||
// 判断当前打手是否仍持有该单(任一有效份额即算持有)
|
||||
const isOwnerCurrently =
|
||||
(item.assigned_worker_id != null &&
|
||||
Number(item.assigned_worker_id) === Number(worker.id)) ||
|
||||
Boolean(activeShare)
|
||||
activeShares.length > 0
|
||||
|
||||
// 若不是当前持有人,但有该打手的超时事件或取消份额,则判定为该打手的已超时历史单
|
||||
const isTimedOutForMe =
|
||||
@@ -211,10 +222,16 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
|
||||
activeShare || cancelledShare || null,
|
||||
noteByOrderId.get(workOrderId) || '',
|
||||
viewedAtByOrderId.get(workOrderId) || null,
|
||||
{ isTimedOutForMe, timeoutNotice },
|
||||
{ isTimedOutForMe, timeoutNotice, myShares: activeShares },
|
||||
),
|
||||
// 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。
|
||||
problemMarked: !isTimedOutForMe && item.status === 'problem',
|
||||
// 份额级问题单也算「平台标记」:母单状态没变,但该打手这份被后台标记了。
|
||||
problemMarked:
|
||||
!isTimedOutForMe &&
|
||||
(item.status === 'problem' ||
|
||||
activeShares.some(
|
||||
(share) => share.status !== 'cancelled' && Boolean(share.problem_marked_at),
|
||||
)),
|
||||
pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId),
|
||||
pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId),
|
||||
materialUpdated,
|
||||
@@ -234,25 +251,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,
|
||||
@@ -372,9 +395,13 @@ export async function submitWorkerCancelRequest(
|
||||
})
|
||||
}
|
||||
const now = nowIso()
|
||||
const requestedShareId = Number(payload.shareId ?? payload.workOrderShareId ?? 0)
|
||||
const shareId =
|
||||
Number.isFinite(requestedShareId) && requestedShareId > 0 ? requestedShareId : null
|
||||
const result = await createWorkerCancelRequest({
|
||||
workOrderId: Number(workOrderId),
|
||||
workerId: Number(session.workerId),
|
||||
shareId,
|
||||
reason,
|
||||
now,
|
||||
})
|
||||
|
||||
+2
@@ -7,6 +7,7 @@ export const WORKER_PLATFORM_NOTIFICATION_EVENT_KEYS = [
|
||||
'recharge_requested',
|
||||
'acceptance_submitted',
|
||||
'acceptance_reminded',
|
||||
'order_accepted',
|
||||
'material_required',
|
||||
'cancel_requested',
|
||||
'feedback_submitted',
|
||||
@@ -48,6 +49,7 @@ export function createDefaultWorkerPlatformNotificationConfig(): WorkerPlatformN
|
||||
recharge_requested: createEnabledEventConfig(),
|
||||
acceptance_submitted: createEnabledEventConfig(),
|
||||
acceptance_reminded: createEnabledEventConfig(),
|
||||
order_accepted: createEnabledEventConfig(),
|
||||
material_required: {
|
||||
todoEnabled: false,
|
||||
soundEnabled: false,
|
||||
|
||||
@@ -18,11 +18,11 @@ import {
|
||||
buildWorkOrderWhere,
|
||||
buildWorkerUserWhere,
|
||||
resolveWorkOrderShareCancellationStatus,
|
||||
resolveWorkOrderShareDepositAmount,
|
||||
resolveWorkOrderShareDepositPlan,
|
||||
resolveWorkOrderSharingCancellationPlan,
|
||||
resolveWorkOrderShareJoinMode,
|
||||
resolveWorkOrderShareJoinQuantity,
|
||||
resolveOpenSharingSyncReward,
|
||||
recomputeWorkOrderSharingStatusWithClient,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import { resolveProblemReturnToWorkerStatus } from '../../repositories/worker-platform/work-order-issue-repo.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||
@@ -1473,25 +1473,63 @@ test('个人免押额度与等级免押额度叠加', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('拼单按打手累计份额扣除个人免押额度', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderShareDepositAmount({
|
||||
test('拼单按打手在母单内的累计份额扣除免押额度', () => {
|
||||
// 首次参与 2 份:累计需求 2000,本次冻结 2000
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderShareDepositPlan({
|
||||
requiredDepositAmount: 10_000,
|
||||
totalQuantity: 10,
|
||||
nextQuantity: 5,
|
||||
depositFreeAmount: 3_000,
|
||||
quantityBefore: 0,
|
||||
nextQuantity: 2,
|
||||
depositFreeAmount: 0,
|
||||
}),
|
||||
2_000,
|
||||
{ cumulativeRequired: 2_000, cumulativeDeposit: 2_000, increment: 2_000 },
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderShareDepositAmount({
|
||||
|
||||
// 已冻结 1500 后再追加 1 份:累计需求 2000,只补差额 500,不回退
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderShareDepositPlan({
|
||||
requiredDepositAmount: 10_000,
|
||||
totalQuantity: 10,
|
||||
nextQuantity: 5,
|
||||
depositFreeAmount: 8_000,
|
||||
currentFrozenDeposit: 1_500,
|
||||
quantityBefore: 1,
|
||||
nextQuantity: 1,
|
||||
depositFreeAmount: 0,
|
||||
frozenDepositSoFar: 1_500,
|
||||
}),
|
||||
1_500,
|
||||
{ cumulativeRequired: 2_000, cumulativeDeposit: 2_000, increment: 500 },
|
||||
)
|
||||
})
|
||||
|
||||
test('拼单免押额度跟着母单走,在整个工单内只抵扣一次', () => {
|
||||
const base = {
|
||||
requiredDepositAmount: 10_000,
|
||||
totalQuantity: 10,
|
||||
depositFreeAmount: 5_000,
|
||||
}
|
||||
// 累计 2 份:需求 2000 < 免押 5000,不用冻结
|
||||
assert.equal(
|
||||
resolveWorkOrderShareDepositPlan({ ...base, quantityBefore: 0, nextQuantity: 2 }).increment,
|
||||
0,
|
||||
)
|
||||
// 累计 4 份:需求 4000 仍低于免押额度,继续不冻结(免押额没有被重复消耗)
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderShareDepositPlan({
|
||||
...base,
|
||||
quantityBefore: 2,
|
||||
nextQuantity: 2,
|
||||
frozenDepositSoFar: 0,
|
||||
}),
|
||||
{ cumulativeRequired: 4_000, cumulativeDeposit: 0, increment: 0 },
|
||||
)
|
||||
// 累计 6 份:需求 6000 超出免押 5000,仅对超出的 1000 冻结
|
||||
assert.deepEqual(
|
||||
resolveWorkOrderShareDepositPlan({
|
||||
...base,
|
||||
quantityBefore: 4,
|
||||
nextQuantity: 2,
|
||||
frozenDepositSoFar: 0,
|
||||
}),
|
||||
{ cumulativeRequired: 6_000, cumulativeDeposit: 1_000, increment: 1_000 },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1661,57 +1699,33 @@ test('拼单份数全部承接后不再显示在抢单大厅', () => {
|
||||
assert.equal(isWorkOrderSharingFilled(false, 15, 15), false)
|
||||
})
|
||||
|
||||
test('拼单份额取消后允许原打手重新加入', () => {
|
||||
assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: 'cancelled' }), 'rejoin')
|
||||
assert.equal(
|
||||
resolveWorkOrderShareJoinMode({ existingShareStatus: 'cancelled', takeRemaining: true }),
|
||||
'rejoin',
|
||||
)
|
||||
})
|
||||
|
||||
test('拼单份额未提交时只有立即抢单可以追加份数', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderShareJoinMode({ existingShareStatus: 'joined', takeRemaining: true }),
|
||||
'append',
|
||||
)
|
||||
assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: 'joined' }), 'reject')
|
||||
})
|
||||
|
||||
test('拼单份额已提交或已验收后不再允许追加份数', () => {
|
||||
assert.equal(
|
||||
resolveWorkOrderShareJoinMode({ existingShareStatus: 'submitted', takeRemaining: true }),
|
||||
'reject',
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkOrderShareJoinMode({ existingShareStatus: 'accepted', takeRemaining: true }),
|
||||
'reject',
|
||||
)
|
||||
})
|
||||
|
||||
test('打手首次参与拼单时新建份额', () => {
|
||||
assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: null }), 'create')
|
||||
})
|
||||
|
||||
test('取消的拼单份额重新加入必须复用原行并重置超时与验收状态', async () => {
|
||||
/**
|
||||
* 同一打手可以多次拼单(需求),所以「取消后重新加入」不再靠复用原份额行:
|
||||
* 迁移 109 删掉了「一人一条」唯一键,每次参与都插入新份额,取消的历史份额保留追溯。
|
||||
*/
|
||||
test('拼单份额取消后原打手可以重新参与(一人一条唯一键已移除)', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const migration = await readFile(
|
||||
new URL('../../db/migrations/109_work_order_share_participation.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
migration.includes('DROP CONSTRAINT work_order_shares_work_order_id_worker_id_key'),
|
||||
'必须移除「同一工单同一打手只能一条份额」唯一键,否则取消后再次参与会 23505',
|
||||
)
|
||||
const shareRepoSrc = await readFile(
|
||||
new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
shareRepoSrc.includes('resolveWorkOrderShareJoinMode'),
|
||||
'joinWorkOrderShare 必须按份额状态区分新建、追加、重入与拒绝',
|
||||
!shareRepoSrc.includes('worker_share_locked') && !shareRepoSrc.includes('worker_share_exists'),
|
||||
'再次参与不得因已有(已取消)份额被拒绝,取消的份额只是历史记录',
|
||||
)
|
||||
assert.ok(
|
||||
shareRepoSrc.includes("status = 'joined',") &&
|
||||
shareRepoSrc.includes('created_at = $5') &&
|
||||
shareRepoSrc.includes('submitted_at = NULL') &&
|
||||
shareRepoSrc.includes('accepted_at = NULL'),
|
||||
'复用 cancelled 份额时必须重置 created_at(超时时钟)与验收时间,避免重入后立即被判超时',
|
||||
)
|
||||
assert.ok(
|
||||
shareRepoSrc.includes('const currentFrozenDeposit = revivingCancelledShare'),
|
||||
'取消份额的押金已退还,重新加入时必须按已有押金为 0 重新计算',
|
||||
shareRepoSrc.includes(
|
||||
"FROM work_order_shares\n WHERE work_order_id = $1 AND worker_id = $2 AND status != 'cancelled'",
|
||||
),
|
||||
'累计份数与已冻押金只能统计未取消份额,取消后重入才会从 0 开始',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1862,7 +1876,10 @@ test('打手订单状态筛选使用个人拼单份额状态', () => {
|
||||
|
||||
assert.deepEqual(params, [42, ['in_progress', 'pending_acceptance']])
|
||||
assert.match(joins, /work_order_shares share_source/)
|
||||
assert.match(joins, /worker_share\.worker_id = \$1/)
|
||||
// 份额关联改为按工单取一条代表份额,避免多份拼单把列表/统计行放大。
|
||||
assert.match(joins, /LEFT JOIN LATERAL \(/)
|
||||
assert.match(joins, /wos\.worker_id = \$1/)
|
||||
assert.match(joins, /\) worker_share ON true/)
|
||||
assert.match(whereClause, /WHEN 'joined' THEN 'in_progress'/)
|
||||
assert.match(whereClause, /WHEN 'submitted' THEN 'pending_acceptance'/)
|
||||
assert.match(whereClause, /worker_share\.status/)
|
||||
@@ -3151,22 +3168,22 @@ test('打手端「我的订单」必须走"先取 id 再取详情"的延迟分
|
||||
assert.ok(src.includes('rowsById.get(id)'), 'ANY() 不保证顺序,必须按 id 顺序还原分页结果')
|
||||
})
|
||||
|
||||
test('大厅候选集必须优先保留置顶单,不能被容量上限挤出', async () => {
|
||||
test('大厅候选集必须把置顶单无条件并入,不能被 FIFO 容量上限挤出', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const src = await readFile(
|
||||
new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const start = src.indexOf('function buildHallCandidateIdsSql')
|
||||
const hallSql = src.slice(start, start + 2600)
|
||||
assert.equal(
|
||||
(hallSql.match(/candidate\.pinned_at DESC NULLS LAST/g) || []).length,
|
||||
2,
|
||||
'主分类与子分类的窗口排名都必须优先置顶单',
|
||||
const hallSql = src.slice(start, start + 4200)
|
||||
assert.ok(hallSql.includes('UNION'), '置顶单没有并入候选集:FIFO 窗口外就抢不到/拼不了')
|
||||
assert.ok(
|
||||
hallSql.includes('pinned.pinned_at IS NOT NULL'),
|
||||
'置顶分支必须只取 status=open 且 pinned_at 非空的单',
|
||||
)
|
||||
assert.ok(
|
||||
hallSql.includes('ranked.pinned_at DESC NULLS LAST'),
|
||||
'最终候选集 LIMIT 前必须优先置顶单,否则置顶单会被新单挤出大厅',
|
||||
/LIMIT \$\{limitPlaceholder\}/.test(hallSql),
|
||||
'FIFO 窗口仍要受容量上限约束,置顶单只做额外并入',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3238,6 +3255,41 @@ test('接单排行榜必须有短缓存(单次全表聚合 277MB/742ms,独
|
||||
)
|
||||
})
|
||||
|
||||
test('大厅卡片要带个人份额,拼单成功后立刻失效大厅缓存(按钮显示「再拼一次」)', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const hallSrc = await readFile(new URL('./worker-hall-service.ts', import.meta.url), 'utf8')
|
||||
const listBody = hallSrc.slice(
|
||||
hallSrc.indexOf('export async function listWorkerHallOrders'),
|
||||
hallSrc.indexOf('export async function getWorkerHallCategories'),
|
||||
)
|
||||
assert.ok(listBody.includes('mySharesByOrderId'), '大厅没有按工单收集个人份额')
|
||||
assert.ok(
|
||||
listBody.includes('mapWorkOrderForHall(') && /myShares,/.test(listBody),
|
||||
'大厅响应缺少 myShares(必须经统一映射器输出,避免手写字段漏项)',
|
||||
)
|
||||
const mapperSrc = await readFile(
|
||||
new URL('./work-order-display-mappers.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(mapperSrc.includes('myShares: myShares.map('), '大厅映射器缺少 myShares')
|
||||
assert.ok(
|
||||
hallSrc.includes('evictByPrefix(`[${workerId},`)'),
|
||||
'大厅缓存缺少按打手失效的入口(拼单后的列表会命中 2s 旧缓存)',
|
||||
)
|
||||
|
||||
const actionSrc = await readFile(
|
||||
new URL('./worker-hall-action-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const joinBody = actionSrc.slice(
|
||||
actionSrc.indexOf('export async function joinWorkerSharingOrder'),
|
||||
)
|
||||
assert.ok(
|
||||
joinBody.includes('invalidateWorkerHallListCache([worker.id])'),
|
||||
'拼单成功后必须失效大厅缓存,否则按钮不会变成「再拼一次」',
|
||||
)
|
||||
})
|
||||
|
||||
test('已冻结打手调级或变更资料不应因未传 freezeReason 报错', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const src = await readFile(new URL('./admin-worker-user-service.ts', import.meta.url), 'utf8')
|
||||
@@ -3311,3 +3363,250 @@ test('拼单超时处置链路完整性验证', async () => {
|
||||
'settleOverdueWorkOrderShare 必须将超时份额置为 cancelled 并释放/扣除押金',
|
||||
)
|
||||
})
|
||||
|
||||
type ShareAggregate = {
|
||||
allocated: number
|
||||
joined_count: number
|
||||
submitted_count: number
|
||||
accepted_count: number
|
||||
}
|
||||
|
||||
async function runRecompute(
|
||||
workOrder: Partial<WorkOrderRow>,
|
||||
aggregate: ShareAggregate,
|
||||
): Promise<{ status: string | null; updates: unknown[][]; shareQueries: number }> {
|
||||
const updates: unknown[][] = []
|
||||
let shareQueries = 0
|
||||
const client = {
|
||||
query: async (sql: string, params: unknown[] = []) => {
|
||||
if (sql.includes('UPDATE work_orders')) {
|
||||
updates.push(params)
|
||||
return { rows: [{ ...buildWorkOrderRow(workOrder), status: params[0] }] }
|
||||
}
|
||||
if (sql.includes('FROM work_orders')) {
|
||||
return { rows: [buildWorkOrderRow(workOrder)] }
|
||||
}
|
||||
if (sql.includes('FROM work_order_shares')) {
|
||||
shareQueries += 1
|
||||
return { rows: [aggregate] }
|
||||
}
|
||||
return { rows: [] }
|
||||
},
|
||||
} as unknown as PoolClient
|
||||
|
||||
const updated = await recomputeWorkOrderSharingStatusWithClient(
|
||||
client,
|
||||
1,
|
||||
'2026-10-23T12:00:00.000Z',
|
||||
)
|
||||
return { status: updated ? String(updated.status) : null, updates, shareQueries }
|
||||
}
|
||||
|
||||
test('拼单母单状态由份额集合唯一推导:满额且无待履约份额才转待验收', async () => {
|
||||
const pending = await runRecompute(
|
||||
{ status: 'open', sharing_total_quantity: 10 },
|
||||
{ allocated: 10, joined_count: 0, submitted_count: 2, accepted_count: 0 },
|
||||
)
|
||||
assert.equal(pending.status, 'pending_acceptance')
|
||||
assert.equal(pending.updates.length, 1)
|
||||
|
||||
// 一份超时后另一份仍在履约(joined_count > 0):母单回到 open,不能提前待验收
|
||||
const stillOpen = await runRecompute(
|
||||
{ status: 'pending_acceptance', sharing_total_quantity: 10 },
|
||||
{ allocated: 9, joined_count: 1, submitted_count: 1, accepted_count: 0 },
|
||||
)
|
||||
assert.equal(stillOpen.status, 'open')
|
||||
assert.equal(stillOpen.updates.length, 1)
|
||||
|
||||
// 未满额也必须是 open
|
||||
const partial = await runRecompute(
|
||||
{ status: 'pending_acceptance', sharing_total_quantity: 10 },
|
||||
{ allocated: 4, joined_count: 0, submitted_count: 1, accepted_count: 0 },
|
||||
)
|
||||
assert.equal(partial.status, 'open')
|
||||
})
|
||||
|
||||
test('拼单母单已验收或已取消时 recompute 不回头改状态', async () => {
|
||||
for (const status of ['accepted', 'cancelled']) {
|
||||
const result = await runRecompute(
|
||||
{ status, sharing_total_quantity: 10 },
|
||||
{ allocated: 10, joined_count: 0, submitted_count: 0, accepted_count: 3 },
|
||||
)
|
||||
assert.equal(result.status, status)
|
||||
assert.equal(result.updates.length, 0)
|
||||
assert.equal(result.shareQueries, 0, '终态母单连份额聚合都不该再跑')
|
||||
}
|
||||
})
|
||||
|
||||
test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收敛', async () => {
|
||||
const src = fs.readFileSync(
|
||||
new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const cancelBlock = src.slice(
|
||||
src.indexOf('export async function cancelWorkOrderShare('),
|
||||
src.indexOf('export async function settleOverdueWorkOrderShare('),
|
||||
)
|
||||
assert.ok(cancelBlock.length > 0, 'cancelWorkOrderShare 片段必须存在')
|
||||
assert.ok(
|
||||
!cancelBlock.includes('recomputeWorkOrderSharingStatusWithClient('),
|
||||
'后台单人撤单不得重算母单状态(撤一份会把待验收的母单踢回大厅)',
|
||||
)
|
||||
assert.ok(
|
||||
!/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(cancelBlock),
|
||||
'后台单人撤单不得自己写母单 status',
|
||||
)
|
||||
assert.ok(cancelBlock.includes('toStatus: workOrder.status'), '撤单事件不得记录母单状态迁移')
|
||||
|
||||
const timeoutBlock = src.slice(
|
||||
src.indexOf('export async function settleOverdueWorkOrderShare('),
|
||||
src.indexOf('export async function cancelWorkOrderSharing('),
|
||||
)
|
||||
assert.ok(timeoutBlock.length > 0, 'settleOverdueWorkOrderShare 片段必须存在')
|
||||
assert.ok(
|
||||
timeoutBlock.includes('recomputeWorkOrderSharingStatusWithClient('),
|
||||
'份额超时必须用 recompute 收敛母单状态',
|
||||
)
|
||||
assert.ok(
|
||||
!/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(timeoutBlock),
|
||||
'份额超时不得自己写母单 status(只能由 recompute 推导)',
|
||||
)
|
||||
})
|
||||
|
||||
test('撤份额与单人撤单都把原因和份数写进份额行,列表才能显示「撤 N 份 · 原因」', () => {
|
||||
const settlementSrc = fs.readFileSync(
|
||||
new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const cancelBlock = settlementSrc.slice(
|
||||
settlementSrc.indexOf('export async function cancelWorkOrderShare('),
|
||||
settlementSrc.indexOf('export async function settleOverdueWorkOrderShare('),
|
||||
)
|
||||
assert.ok(cancelBlock.length > 0, 'cancelWorkOrderShare 片段必须存在')
|
||||
assert.ok(cancelBlock.includes('cancel_note = $'), '单人撤单必须把原因写进 cancel_note')
|
||||
assert.ok(
|
||||
cancelBlock.includes("String(input.reason || '').trim()"),
|
||||
'单人撤单写入 cancel_note 的必须是运营填写的自由文本原因',
|
||||
)
|
||||
assert.ok(
|
||||
cancelBlock.includes("cancelled_reason = 'admin'"),
|
||||
'cancelled_reason 仍保留枚举口径,自由文本不得塞进去',
|
||||
)
|
||||
assert.ok(
|
||||
cancelBlock.includes('cancel_quantity = COALESCE(quantity, 0)'),
|
||||
'单人撤单要按整份数量记录撤销份数,列表才能显示「撤 N 份」',
|
||||
)
|
||||
|
||||
const shareSrc = fs.readFileSync(
|
||||
new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const reduceBlock = shareSrc.slice(
|
||||
shareSrc.indexOf('export async function reduceWorkOrderShareQuantity('),
|
||||
shareSrc.indexOf('export async function submitWorkOrderShareAcceptance('),
|
||||
)
|
||||
assert.ok(reduceBlock.length > 0, 'reduceWorkOrderShareQuantity 片段必须存在')
|
||||
assert.ok(reduceBlock.includes('cancel_note = $'), '撤份额必须把原因写进 cancel_note')
|
||||
assert.ok(
|
||||
reduceBlock.includes('String(share.cancel_note'),
|
||||
'后续撤份额没填原因时必须保留上一次填的说明',
|
||||
)
|
||||
assert.ok(
|
||||
reduceBlock.includes('cancel_quantity = $') &&
|
||||
reduceBlock.includes('Number(share.cancel_quantity'),
|
||||
'撤份额必须按「已有撤销份数 + 本次份数」累计写入 cancel_quantity',
|
||||
)
|
||||
|
||||
const mapperSrc = fs.readFileSync(
|
||||
new URL('./work-order-display-mappers.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
mapperSrc.includes('cancelNote: share.cancel_note'),
|
||||
'拼单明细接口必须下发 cancelNote,否则列表显示不出来',
|
||||
)
|
||||
assert.ok(
|
||||
mapperSrc.includes('cancelQuantity: Math.max(0, Number(share.cancel_quantity'),
|
||||
'拼单明细接口必须下发 cancelQuantity,列表才能拼「撤 N 份」前缀',
|
||||
)
|
||||
})
|
||||
|
||||
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 幂等、按参与时间固化截止时间', () => {
|
||||
const src = fs.readFileSync(
|
||||
new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
src.includes('wos.worker_id = $1 AND wos.request_id = $2'),
|
||||
'参与前必须按 (worker_id, request_id) 查既有份额',
|
||||
)
|
||||
assert.ok(src.includes('duplicated: true'), '命中幂等键必须返回 duplicated 标记')
|
||||
assert.ok(
|
||||
src.includes('INSERT INTO work_order_shares (') &&
|
||||
!/UPDATE\s+work_order_shares[\s\S]{0,120}?quantity\s*=\s*quantity\s*\+/i.test(src),
|
||||
'每次参与都必须 INSERT 新份额,不得把份数加到原记录上',
|
||||
)
|
||||
assert.ok(
|
||||
src.includes('timeoutMinutes * 60 * 1000') && src.includes('deadline_at, timeout_minutes'),
|
||||
'deadline 必须按本次参与时间 + 母单当时的 timeout_minutes 固化',
|
||||
)
|
||||
assert.ok(
|
||||
src.includes('quantityBefore') && src.includes('frozenDepositSoFar: frozenBefore'),
|
||||
'押金必须按该打手在本工单的累计份数与已冻结合计计算增量',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -126,6 +126,21 @@ export type RuntimeConfig = {
|
||||
sessionIdleTtlHours: number
|
||||
/** 打手会话从登录起算的绝对上限,滑动续期不能突破。 */
|
||||
sessionAbsoluteTtlHours: number
|
||||
/**
|
||||
* 验收视频(打手端验收弹窗,选填)。
|
||||
*
|
||||
* 只用 `.env` 配置,不提供后台管理界面;前端按这些参数在本机压缩,
|
||||
* 服务端用同一份参数做时长/体积兜底校验。
|
||||
*/
|
||||
acceptanceVideo: {
|
||||
enabled: boolean
|
||||
maxDurationSeconds: number
|
||||
maxSizeMb: number
|
||||
targetHeight: number
|
||||
bitrateKbps: number
|
||||
audioBitrateKbps: number
|
||||
fps: number
|
||||
}
|
||||
}
|
||||
cors: {
|
||||
allowedOrigins: string[]
|
||||
|
||||
Reference in New Issue
Block a user