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:
yml2213
2026-10-26 12:10:53 +08:00
94 changed files with 8991 additions and 744 deletions
@@ -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,
})
@@ -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'),
'押金必须按该打手在本工单的累计份数与已冻结合计计算增量',
)
})