新增好友赠送验收模式支持打手资料提交加好友冷却与模板配置
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
-- 好友赠送验收模式:打手接单后提交本人资料 → 后台确认加好友 → 冷却 N 小时后才能赠送并提交验收。
|
||||
-- 发布、核销、大厅、抢单、押金、结算链路保持不变,仅验收前置流程不同。
|
||||
|
||||
ALTER TABLE work_orders
|
||||
ADD COLUMN IF NOT EXISTS acceptance_mode TEXT NOT NULL DEFAULT 'standard',
|
||||
ADD COLUMN IF NOT EXISTS gift_phase TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS booster_material_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS friend_added_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS gift_available_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS gift_cooldown_hours INTEGER NOT NULL DEFAULT 72;
|
||||
|
||||
COMMENT ON COLUMN work_orders.acceptance_mode IS '验收模式:standard 标准验收 / friend_gift 好友赠送(建单时从接单模板拷贝)';
|
||||
COMMENT ON COLUMN work_orders.gift_phase IS '好友赠送阶段:material_required 待打手提交资料 / material_submitted 待后台确认好友 / friend_countdown 冷却中;gift_ready 为 gift_available_at 到点后的实时计算值,不落库';
|
||||
COMMENT ON COLUMN work_orders.booster_material_json IS '打手本人资料:昵称/ID/区服/系统 + 主页截图';
|
||||
COMMENT ON COLUMN work_orders.friend_added_at IS '后台确认加好友时间(冷却起点)';
|
||||
COMMENT ON COLUMN work_orders.gift_available_at IS '可赠送时间 = 确认好友时间 + 冷却小时数';
|
||||
COMMENT ON COLUMN work_orders.gift_cooldown_hours IS '好友赠送冷却小时数,建单时从模板拷贝';
|
||||
|
||||
-- 后台按“可赠送”筛选待办时走该部分索引,避免全表扫描。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_gift_available
|
||||
ON work_orders (gift_available_at)
|
||||
WHERE acceptance_mode = 'friend_gift' AND status = 'in_progress';
|
||||
|
||||
ALTER TABLE work_product_rules
|
||||
ADD COLUMN IF NOT EXISTS acceptance_mode TEXT NOT NULL DEFAULT 'standard',
|
||||
ADD COLUMN IF NOT EXISTS gift_cooldown_hours INTEGER NOT NULL DEFAULT 72;
|
||||
|
||||
COMMENT ON COLUMN work_product_rules.acceptance_mode IS '验收模式:standard 标准验收 / friend_gift 好友赠送;启用 friend_gift 时不允许拼单';
|
||||
COMMENT ON COLUMN work_product_rules.gift_cooldown_hours IS '好友赠送冷却小时数(默认 72 即 3 天),建单时拷贝到工单';
|
||||
@@ -0,0 +1,63 @@
|
||||
export const WORK_ORDER_ACCEPTANCE_MODE = {
|
||||
STANDARD: 'standard',
|
||||
FRIEND_GIFT: 'friend_gift',
|
||||
} as const
|
||||
|
||||
export type WorkOrderAcceptanceMode =
|
||||
| (typeof WORK_ORDER_ACCEPTANCE_MODE)[keyof typeof WORK_ORDER_ACCEPTANCE_MODE]
|
||||
| (string & {})
|
||||
|
||||
export const DEFAULT_GIFT_COOLDOWN_HOURS = 72
|
||||
export const MAX_GIFT_COOLDOWN_HOURS = 24 * 30
|
||||
|
||||
/** 好友赠送模式的落库阶段;gift_ready 由 gift_available_at 到点后实时计算,不落库。 */
|
||||
export const WORK_ORDER_GIFT_PHASE = {
|
||||
MATERIAL_REQUIRED: 'material_required',
|
||||
MATERIAL_SUBMITTED: 'material_submitted',
|
||||
FRIEND_COUNTDOWN: 'friend_countdown',
|
||||
GIFT_READY: 'gift_ready',
|
||||
} as const
|
||||
|
||||
export type WorkOrderGiftPhase =
|
||||
| (typeof WORK_ORDER_GIFT_PHASE)[keyof typeof WORK_ORDER_GIFT_PHASE]
|
||||
| (string & {})
|
||||
|
||||
export function normalizeWorkOrderAcceptanceMode(value: unknown): WorkOrderAcceptanceMode {
|
||||
const mode = String(value || '').trim()
|
||||
return mode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
? WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
: WORK_ORDER_ACCEPTANCE_MODE.STANDARD
|
||||
}
|
||||
|
||||
export function isFriendGiftAcceptanceMode(value: unknown): boolean {
|
||||
return normalizeWorkOrderAcceptanceMode(value) === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
}
|
||||
|
||||
/** 冷却小时数收敛到 [0, 30 天],默认 3 天。 */
|
||||
export function normalizeWorkOrderGiftCooldownHours(value: unknown): number {
|
||||
const hours = Number(value)
|
||||
if (!Number.isFinite(hours) || hours <= 0) return DEFAULT_GIFT_COOLDOWN_HOURS
|
||||
return Math.min(MAX_GIFT_COOLDOWN_HOURS, Math.max(1, Math.round(hours)))
|
||||
}
|
||||
|
||||
export type WorkOrderGiftSource = {
|
||||
acceptance_mode?: string | null
|
||||
gift_phase?: string | null
|
||||
gift_available_at?: string | null
|
||||
}
|
||||
|
||||
export function isWorkOrderGiftAvailable(source: WorkOrderGiftSource): boolean {
|
||||
if (!isFriendGiftAcceptanceMode(source.acceptance_mode)) return false
|
||||
const availableAt = Date.parse(String(source.gift_available_at || ''))
|
||||
return Number.isFinite(availableAt) && availableAt <= Date.now()
|
||||
}
|
||||
|
||||
/** 返回当前赠送阶段;冷却到点后返回实时计算的 gift_ready。 */
|
||||
export function resolveWorkOrderGiftPhase(source: WorkOrderGiftSource): string {
|
||||
if (!isFriendGiftAcceptanceMode(source.acceptance_mode)) return ''
|
||||
const phase = String(source.gift_phase || '').trim()
|
||||
if (phase === WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN && isWorkOrderGiftAvailable(source)) {
|
||||
return WORK_ORDER_GIFT_PHASE.GIFT_READY
|
||||
}
|
||||
return phase
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export * from './work-order-management-repo.js'
|
||||
export * from './work-order-grab-repo.js'
|
||||
export * from './work-order-cancel-repo.js'
|
||||
export * from './work-order-issue-repo.js'
|
||||
export * from './work-order-gift-repo.js'
|
||||
export * from './work-order-deposit-query-repo.js'
|
||||
export * from './work-order-deposit-repo.js'
|
||||
export * from './work-order-event-repo.js'
|
||||
|
||||
@@ -262,6 +262,8 @@ export type WorkProductRuleRow = {
|
||||
sharing_auto_from_order: boolean
|
||||
timeout_minutes: number
|
||||
timeout_policy: string
|
||||
acceptance_mode: string
|
||||
gift_cooldown_hours: number
|
||||
requirement_json: string | Record<string, unknown>
|
||||
match_json: string | Record<string, unknown>
|
||||
sort_order: number
|
||||
@@ -360,6 +362,15 @@ export type WorkOrderRow = {
|
||||
deadline_at: string | null
|
||||
timeout_minutes: number
|
||||
timeout_policy: string
|
||||
/** 验收模式:standard / friend_gift;friend_gift 建单时从接单模板拷贝。 */
|
||||
acceptance_mode: string
|
||||
/** 好友赠送阶段:material_required / material_submitted / friend_countdown;gift_ready 实时计算。 */
|
||||
gift_phase: string
|
||||
/** 打手本人资料(昵称/ID/区服/系统 + 主页截图),仅 friend_gift 模式使用。 */
|
||||
booster_material_json: string | Record<string, unknown>
|
||||
friend_added_at: string | null
|
||||
gift_available_at: string | null
|
||||
gift_cooldown_hours: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
/** 拼单聚合字段,由工单查询统一计算。 */
|
||||
@@ -411,6 +422,10 @@ export type ListInput = {
|
||||
excludeFilledSharing?: boolean
|
||||
/** VIP 免审核后仍在补图期限内的订单;与状态筛选取并集 */
|
||||
vipEvidencePending?: boolean
|
||||
/** 验收模式筛选:standard / friend_gift。 */
|
||||
acceptanceMode?: string
|
||||
/** 好友赠送阶段筛选:material_required / material_submitted / friend_countdown / gift_ready。 */
|
||||
giftPhase?: string
|
||||
/** 置顶订单优先展示(抢单大厅使用) */
|
||||
pinnedFirst?: boolean
|
||||
sort?: 'id_desc' | 'worker_claimed_at_desc'
|
||||
@@ -444,6 +459,8 @@ export type WorkOrderCapabilities = {
|
||||
canCancel: boolean
|
||||
canResolveProblem: boolean
|
||||
canDeduct: boolean
|
||||
canConfirmFriend: boolean
|
||||
canResetGiftFlow: boolean
|
||||
}
|
||||
|
||||
export type ProductRuleListInput = {
|
||||
@@ -510,6 +527,8 @@ export type CreateWorkOrderInput = {
|
||||
sharingUnitReward?: number
|
||||
timeoutMinutes?: number
|
||||
timeoutPolicy?: string
|
||||
acceptanceMode?: string
|
||||
giftCooldownHours?: number
|
||||
materialJson: string
|
||||
requirementJson: string
|
||||
now: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './share
|
||||
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 { RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js'
|
||||
import type { WorkOrderRow } from './types.js'
|
||||
|
||||
export async function cancelWorkerWorkOrder(input: {
|
||||
@@ -47,6 +48,7 @@ export async function cancelWorkerWorkOrder(input: {
|
||||
assigned_at = NULL,
|
||||
deadline_at = NULL,
|
||||
hall_queued_at = $1,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
@@ -137,6 +139,7 @@ export async function unassignWorkOrder(input: {
|
||||
assigned_worker_id = NULL,
|
||||
assigned_at = NULL,
|
||||
deadline_at = NULL,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
@@ -228,6 +231,7 @@ export async function returnAssignedWorkOrderToHall(input: {
|
||||
deadline_at = NULL,
|
||||
published_at = $1,
|
||||
hall_queued_at = $1,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
@@ -424,6 +428,7 @@ export async function reopenCancelledWorkOrder(input: {
|
||||
problem_note = '',
|
||||
published_at = NULL,
|
||||
pinned_at = NULL,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { createWorkOrderEventWithClient } from './work-order-event-repo.js'
|
||||
import { getWorkOrderByIdWithClient } from './work-order-query-repo.js'
|
||||
import type { WorkOrderRow } from './types.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
WORK_ORDER_ACCEPTANCE_MODE,
|
||||
WORK_ORDER_GIFT_PHASE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
|
||||
/**
|
||||
* 工单脱离打手(回大厅、取消指派、恢复未分配等)时重置好友赠送进度,
|
||||
* 下一个打手重新走“提交资料 → 确认好友 → 冷却”流程。
|
||||
* 标准模式工单这些字段本就为空,重置无副作用。
|
||||
*/
|
||||
export const RESET_GIFT_FLOW_SQL = `
|
||||
gift_phase = CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END,
|
||||
booster_material_json = '{}'::jsonb,
|
||||
friend_added_at = NULL,
|
||||
gift_available_at = NULL
|
||||
`
|
||||
|
||||
/** 打手提交本人资料(昵称/ID/区服/系统 + 主页截图),进入待确认好友阶段。 */
|
||||
export async function submitWorkOrderGiftMaterial(input: {
|
||||
workOrderId: number
|
||||
workerId: number
|
||||
boosterMaterialJson: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
failureReason:
|
||||
| 'work_order_not_found'
|
||||
| 'work_order_mode_invalid'
|
||||
| 'work_order_owner_required'
|
||||
| 'work_order_status_invalid'
|
||||
| 'work_order_gift_phase_invalid'
|
||||
| null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const currentResult = await client.query<WorkOrderRow>(
|
||||
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = currentResult.rows[0] || null
|
||||
if (!workOrder) return { order: null, failureReason: 'work_order_not_found' }
|
||||
if (
|
||||
normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode) !==
|
||||
WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
) {
|
||||
return { order: null, failureReason: 'work_order_mode_invalid' }
|
||||
}
|
||||
if (Number(workOrder.assigned_worker_id || 0) !== input.workerId) {
|
||||
return { order: null, failureReason: 'work_order_owner_required' }
|
||||
}
|
||||
if (!['in_progress', 'problem'].includes(workOrder.status)) {
|
||||
return { order: null, failureReason: 'work_order_status_invalid' }
|
||||
}
|
||||
if (workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED) {
|
||||
return { order: null, failureReason: 'work_order_gift_phase_invalid' }
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
gift_phase = 'material_submitted',
|
||||
booster_material_json = $1::jsonb,
|
||||
friend_added_at = NULL,
|
||||
gift_available_at = NULL,
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
[input.boosterMaterialJson, input.now, input.workOrderId],
|
||||
)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'worker',
|
||||
actorId: String(input.workerId),
|
||||
eventType: 'gift_material_submitted',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: input.boosterMaterialJson,
|
||||
now: input.now,
|
||||
})
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 后台确认已加好友:记录冷却起点并顺延任务时限到冷却结束后再起算。 */
|
||||
export async function confirmWorkOrderFriendAdded(input: {
|
||||
workOrderId: number
|
||||
actorName?: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
failureReason:
|
||||
| 'work_order_not_found'
|
||||
| 'work_order_mode_invalid'
|
||||
| 'work_order_gift_phase_invalid'
|
||||
| null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const currentResult = await client.query<WorkOrderRow>(
|
||||
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = currentResult.rows[0] || null
|
||||
if (!workOrder) return { order: null, failureReason: 'work_order_not_found' }
|
||||
if (
|
||||
normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode) !==
|
||||
WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
) {
|
||||
return { order: null, failureReason: 'work_order_mode_invalid' }
|
||||
}
|
||||
if (workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED) {
|
||||
return { order: null, failureReason: 'work_order_gift_phase_invalid' }
|
||||
}
|
||||
|
||||
const cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours)
|
||||
// 参数必须显式转型:未知类型参数与 interval 做运算时 PostgreSQL 无法唯一解析操作符。
|
||||
await client.query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
gift_phase = 'friend_countdown',
|
||||
friend_added_at = $1::timestamptz,
|
||||
gift_available_at = $1::timestamptz + ($2::int * INTERVAL '1 hour'),
|
||||
deadline_at = CASE
|
||||
WHEN timeout_minutes > 0
|
||||
THEN $1::timestamptz + ($2::int * INTERVAL '1 hour') + (timeout_minutes * INTERVAL '1 minute')
|
||||
ELSE NULL
|
||||
END,
|
||||
updated_at = $1::timestamptz
|
||||
WHERE id = $3
|
||||
`,
|
||||
[input.now, cooldownHours, input.workOrderId],
|
||||
)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'friend_confirmed',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
cooldownHours,
|
||||
friendAddedAt: input.now,
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 后台重置打手资料(资料有误或误确认好友):清空好友状态并让打手重新提交。 */
|
||||
export async function resetWorkOrderGiftMaterial(input: {
|
||||
workOrderId: number
|
||||
note?: string
|
||||
actorName?: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
order: WorkOrderRow | null
|
||||
failureReason:
|
||||
| 'work_order_not_found'
|
||||
| 'work_order_mode_invalid'
|
||||
| 'work_order_status_invalid'
|
||||
| 'work_order_gift_phase_invalid'
|
||||
| null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const currentResult = await client.query<WorkOrderRow>(
|
||||
'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE',
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = currentResult.rows[0] || null
|
||||
if (!workOrder) return { order: null, failureReason: 'work_order_not_found' }
|
||||
if (
|
||||
normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode) !==
|
||||
WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
) {
|
||||
return { order: null, failureReason: 'work_order_mode_invalid' }
|
||||
}
|
||||
if (!['in_progress', 'problem'].includes(workOrder.status)) {
|
||||
return { order: null, failureReason: 'work_order_status_invalid' }
|
||||
}
|
||||
if (
|
||||
workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED &&
|
||||
workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN
|
||||
) {
|
||||
return { order: null, failureReason: 'work_order_gift_phase_invalid' }
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
gift_phase = 'material_required',
|
||||
booster_material_json = '{}'::jsonb,
|
||||
friend_added_at = NULL,
|
||||
gift_available_at = NULL,
|
||||
deadline_at = CASE
|
||||
WHEN timeout_minutes > 0
|
||||
THEN $1::timestamptz + (timeout_minutes * INTERVAL '1 minute')
|
||||
ELSE NULL
|
||||
END,
|
||||
updated_at = $1::timestamptz
|
||||
WHERE id = $2
|
||||
`,
|
||||
[input.now, input.workOrderId],
|
||||
)
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: input.actorName || '',
|
||||
eventType: 'gift_material_reset',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
previousPhase: workOrder.gift_phase,
|
||||
note: String(input.note || '').trim(),
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -60,6 +60,10 @@ export async function grabWorkOrder(input: {
|
||||
last_assigned_worker_id = $1,
|
||||
assigned_at = $2,
|
||||
deadline_at = $3,
|
||||
gift_phase = CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END,
|
||||
booster_material_json = '{}'::jsonb,
|
||||
friend_added_at = NULL,
|
||||
gift_available_at = NULL,
|
||||
updated_at = $2
|
||||
WHERE id = $4
|
||||
AND status = 'open'
|
||||
@@ -180,6 +184,10 @@ export async function assignWorkOrderToWorker(input: {
|
||||
last_assigned_worker_id = $1,
|
||||
assigned_at = $2,
|
||||
deadline_at = $3,
|
||||
gift_phase = CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END,
|
||||
booster_material_json = '{}'::jsonb,
|
||||
friend_added_at = NULL,
|
||||
gift_available_at = NULL,
|
||||
updated_at = $2
|
||||
WHERE id = $4
|
||||
`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './share
|
||||
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 { RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js'
|
||||
import type { ProblemWorkOrderResolutionAction, WorkOrderRow } from './types.js'
|
||||
|
||||
export async function resolveProblemWorkOrder(input: {
|
||||
@@ -100,11 +101,34 @@ export async function resolveProblemWorkOrder(input: {
|
||||
submitted_at = CASE WHEN $3 THEN NULL ELSE submitted_at END,
|
||||
problem_note = CASE WHEN $2 THEN '' ELSE problem_note END,
|
||||
deadline_at = CASE
|
||||
WHEN $1 = 'in_progress' AND timeout_minutes > 0
|
||||
THEN NOW() + (timeout_minutes * INTERVAL '1 minute')
|
||||
WHEN $1 = 'in_progress' AND timeout_minutes > 0 THEN
|
||||
CASE
|
||||
WHEN acceptance_mode = 'friend_gift'
|
||||
AND gift_available_at IS NOT NULL
|
||||
AND gift_available_at > NOW()
|
||||
THEN gift_available_at + (timeout_minutes * INTERVAL '1 minute')
|
||||
ELSE NOW() + (timeout_minutes * INTERVAL '1 minute')
|
||||
END
|
||||
WHEN $1 = 'open' THEN NULL
|
||||
ELSE deadline_at
|
||||
END,
|
||||
gift_phase = CASE
|
||||
WHEN $1 = 'open' OR $1 = 'cancelled'
|
||||
THEN CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END
|
||||
ELSE gift_phase
|
||||
END,
|
||||
booster_material_json = CASE
|
||||
WHEN $1 = 'open' OR $1 = 'cancelled' THEN '{}'::jsonb
|
||||
ELSE booster_material_json
|
||||
END,
|
||||
friend_added_at = CASE
|
||||
WHEN $1 = 'open' OR $1 = 'cancelled' THEN NULL
|
||||
ELSE friend_added_at
|
||||
END,
|
||||
gift_available_at = CASE
|
||||
WHEN $1 = 'open' OR $1 = 'cancelled' THEN NULL
|
||||
ELSE gift_available_at
|
||||
END,
|
||||
updated_at = $4
|
||||
WHERE id = $5
|
||||
`,
|
||||
@@ -237,6 +261,7 @@ export async function settleOverdueWorkOrder(input: {
|
||||
problem_note = CASE WHEN $1 = 'open' THEN '' ELSE problem_note END,
|
||||
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,
|
||||
${RESET_GIFT_FLOW_SQL},
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { toJsonString, toPositiveInteger } from './shared.js'
|
||||
import { getWorkOrderById } from './work-order-query-repo.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
WORK_ORDER_ACCEPTANCE_MODE,
|
||||
WORK_ORDER_GIFT_PHASE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import type { CreateWorkOrderInput, WorkOrderRow } from './types.js'
|
||||
|
||||
export async function createWorkOrder(input: CreateWorkOrderInput): Promise<WorkOrderRow | null> {
|
||||
const acceptanceMode = normalizeWorkOrderAcceptanceMode(input.acceptanceMode)
|
||||
const isFriendGift = acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
const result = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO work_orders (
|
||||
@@ -11,10 +19,13 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
|
||||
product_name, category_id, status, reward_amount, required_deposit_amount,
|
||||
deposit_threshold_amount, sharing_enabled, sharing_total_quantity,
|
||||
sharing_unit_reward, timeout_minutes, timeout_policy,
|
||||
acceptance_mode, gift_phase, gift_cooldown_hours,
|
||||
material_json, requirement_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16, $17, $18::jsonb, $19::jsonb, $20, $21
|
||||
$12, $13, $14, $15, $16, $17,
|
||||
$18, $19, $20,
|
||||
$21::jsonb, $22::jsonb, $23, $24
|
||||
)
|
||||
RETURNING id
|
||||
`,
|
||||
@@ -36,6 +47,9 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
|
||||
toPositiveInteger(input.sharingUnitReward, 0),
|
||||
toPositiveInteger(input.timeoutMinutes, 0),
|
||||
String(input.timeoutPolicy || 'reopen').trim() || 'reopen',
|
||||
acceptanceMode,
|
||||
isFriendGift ? WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED : '',
|
||||
normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours),
|
||||
input.materialJson,
|
||||
input.requirementJson,
|
||||
input.now,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import type { ListInput, WorkOrderRow, WorkOrderStatistics } from './types.js'
|
||||
import type { PoolClient } from 'pg'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
WORK_ORDER_GIFT_PHASE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
export const WORK_ORDER_SELECT = `
|
||||
SELECT
|
||||
wo.*,
|
||||
@@ -167,6 +171,8 @@ export async function listWorkOrders({
|
||||
visibleAfterIso = '',
|
||||
excludeFilledSharing = false,
|
||||
vipEvidencePending = false,
|
||||
acceptanceMode = '',
|
||||
giftPhase = '',
|
||||
useOperationalStatus = false,
|
||||
pinnedFirst = false,
|
||||
sort = 'id_desc',
|
||||
@@ -186,6 +192,8 @@ export async function listWorkOrders({
|
||||
visibleAfterIso,
|
||||
excludeFilledSharing,
|
||||
vipEvidencePending,
|
||||
acceptanceMode,
|
||||
giftPhase,
|
||||
useOperationalStatus,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
@@ -382,7 +390,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
|
||||
}))
|
||||
}
|
||||
|
||||
/** 查询已超过接单时限、仍待处置的工单。 */
|
||||
/** 查询已超过接单时限、仍待处置的工单。好友赠送单在“待确认好友”阶段等待后台操作,不计打手超时。 */
|
||||
export async function listOverdueWorkOrders({
|
||||
limit = 50,
|
||||
workerId = 0,
|
||||
@@ -399,6 +407,7 @@ export async function listOverdueWorkOrders({
|
||||
WHERE wo.status = 'in_progress'
|
||||
AND wo.deadline_at IS NOT NULL
|
||||
AND wo.deadline_at < NOW()
|
||||
AND NOT (wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = 'material_submitted')
|
||||
${workerClause}
|
||||
ORDER BY wo.deadline_at ASC
|
||||
LIMIT $${params.length}`,
|
||||
@@ -522,6 +531,8 @@ export function buildWorkOrderWhere({
|
||||
visibleAfterIso = '',
|
||||
excludeFilledSharing = false,
|
||||
vipEvidencePending = false,
|
||||
acceptanceMode = '',
|
||||
giftPhase = '',
|
||||
useOperationalStatus = false,
|
||||
createdFrom = '',
|
||||
createdTo = '',
|
||||
@@ -685,6 +696,25 @@ export function buildWorkOrderWhere({
|
||||
params.push(categoryId)
|
||||
filters.push(`wo.category_id = $${params.length}`)
|
||||
}
|
||||
if (acceptanceMode) {
|
||||
params.push(normalizeWorkOrderAcceptanceMode(acceptanceMode))
|
||||
filters.push(`wo.acceptance_mode = $${params.length}`)
|
||||
}
|
||||
if (giftPhase) {
|
||||
const normalizedGiftPhase = String(giftPhase).trim()
|
||||
if (normalizedGiftPhase === WORK_ORDER_GIFT_PHASE.GIFT_READY) {
|
||||
// gift_ready 是冷却到点后的实时阶段,由时间条件计算而不是落库状态。
|
||||
filters.push(`(
|
||||
wo.acceptance_mode = 'friend_gift'
|
||||
AND wo.gift_phase = 'friend_countdown'
|
||||
AND wo.gift_available_at IS NOT NULL
|
||||
AND wo.gift_available_at <= NOW()
|
||||
)`)
|
||||
} else if (Object.values(WORK_ORDER_GIFT_PHASE).includes(normalizedGiftPhase as never)) {
|
||||
params.push(normalizedGiftPhase)
|
||||
filters.push(`wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = $${params.length}`)
|
||||
}
|
||||
}
|
||||
if (hallCandidateLimit > 0) {
|
||||
params.push(hallCandidateLimit)
|
||||
filters.push(`wo.id IN (${buildHallCandidateIdsSql(`$${params.length}`)})`)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { toPositiveInteger } from './shared.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import type {
|
||||
ProductRuleListInput,
|
||||
WorkOrderRow,
|
||||
@@ -336,6 +340,8 @@ export async function upsertWorkProductRule(input: {
|
||||
sharingAutoFromOrder?: boolean
|
||||
timeoutMinutes?: number
|
||||
timeoutPolicy?: string
|
||||
acceptanceMode?: string
|
||||
giftCooldownHours?: number
|
||||
requirementJson: string
|
||||
matchJson?: string
|
||||
sortOrder: number
|
||||
@@ -350,6 +356,7 @@ export async function upsertWorkProductRule(input: {
|
||||
required_deposit_amount, deposit_threshold_amount,
|
||||
sharing_enabled, sharing_total_quantity, sharing_unit_reward, sharing_auto_from_order,
|
||||
timeout_minutes, timeout_policy,
|
||||
acceptance_mode, gift_cooldown_hours,
|
||||
requirement_json, match_json,
|
||||
sort_order, created_at, updated_at
|
||||
) VALUES (
|
||||
@@ -359,8 +366,9 @@ export async function upsertWorkProductRule(input: {
|
||||
$13, $14,
|
||||
$15, $16, $17, $18,
|
||||
$19, $20,
|
||||
$21::jsonb, $22::jsonb,
|
||||
$23, $24, $25
|
||||
$21, $22,
|
||||
$23::jsonb, $24::jsonb,
|
||||
$25, $26, $27
|
||||
)
|
||||
ON CONFLICT (rule_key) DO UPDATE
|
||||
SET
|
||||
@@ -383,6 +391,8 @@ export async function upsertWorkProductRule(input: {
|
||||
sharing_auto_from_order = EXCLUDED.sharing_auto_from_order,
|
||||
timeout_minutes = EXCLUDED.timeout_minutes,
|
||||
timeout_policy = EXCLUDED.timeout_policy,
|
||||
acceptance_mode = EXCLUDED.acceptance_mode,
|
||||
gift_cooldown_hours = EXCLUDED.gift_cooldown_hours,
|
||||
requirement_json = EXCLUDED.requirement_json,
|
||||
match_json = EXCLUDED.match_json,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
@@ -410,6 +420,8 @@ export async function upsertWorkProductRule(input: {
|
||||
input.sharingAutoFromOrder === true,
|
||||
toPositiveInteger(input.timeoutMinutes, 0),
|
||||
String(input.timeoutPolicy || 'reopen').trim(),
|
||||
normalizeWorkOrderAcceptanceMode(input.acceptanceMode),
|
||||
normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours),
|
||||
input.requirementJson,
|
||||
input.matchJson || '{}',
|
||||
input.sortOrder,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
acceptAdminWorkOrders,
|
||||
assignAdminWorkOrderToWorker,
|
||||
cancelAdminWorkOrder,
|
||||
confirmAdminWorkOrderFriendAdded,
|
||||
createAdminMockWorkOrder,
|
||||
deleteAdminWorkOrder,
|
||||
getAdminWorkOrderEvents,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
publishAdminWorkOrder,
|
||||
reopenAdminCancelledWorkOrder,
|
||||
reprocessAdminKuaishouSendCodeWorkOrders,
|
||||
resetAdminWorkOrderGiftMaterial,
|
||||
submitAdminWorkOrderMaterial,
|
||||
unassignAdminWorkOrder,
|
||||
unpublishAdminWorkOrder,
|
||||
@@ -292,6 +294,53 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/confirm-friend',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
confirmAdminWorkOrderFriendAdded(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '已确认加好友,赠送冷却开始',
|
||||
errorMessage: '确认加好友失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/confirm-friend]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_friend_confirmed',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/reset-gift-material',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
resetAdminWorkOrderGiftMaterial(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '已重置打手资料,等待打手重新提交',
|
||||
errorMessage: '重置打手资料失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/reset-gift-material]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_gift_material_reset',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/orders/:workOrderId/sharing',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
submitWorkerCancelRequest,
|
||||
submitWorkerOrderAcceptance,
|
||||
submitWorkerOrderFeedback,
|
||||
submitWorkerOrderGiftMaterial,
|
||||
supplementWorkerAcceptedOrderEvidence,
|
||||
} from '../../services/worker-platform/index.js'
|
||||
import { getRequiredWorkerSession, requireActiveWorker } from './session.js'
|
||||
@@ -201,6 +202,24 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/orders/:workOrderId/gift-material',
|
||||
requireActiveWorker,
|
||||
createRouteHandler(
|
||||
(req) =>
|
||||
submitWorkerOrderGiftMaterial(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
getRequiredWorkerSession(req),
|
||||
),
|
||||
{
|
||||
successMessage: '好友赠送资料已提交,等待后台确认加好友',
|
||||
errorMessage: '提交好友赠送资料失败',
|
||||
scope: '[worker/orders/:workOrderId/gift-material]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/orders/:workOrderId/remind-acceptance',
|
||||
requireActiveWorker,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import { createWorkOrder, createWorkOrderEvent } from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
@@ -78,6 +82,8 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
||||
depositThresholdAmount,
|
||||
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
|
||||
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
|
||||
acceptanceMode: normalizeWorkOrderAcceptanceMode(payload.acceptanceMode),
|
||||
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(payload.giftCooldownHours),
|
||||
materialJson: JSON.stringify(material),
|
||||
requirementJson: JSON.stringify({ fields }),
|
||||
now,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
confirmWorkOrderFriendAdded,
|
||||
resetWorkOrderGiftMaterial,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js'
|
||||
import { mapWorkOrderAdmin } from './mappers.js'
|
||||
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
|
||||
/** 后台核对打手资料并添加游戏好友后确认,开始 N 小时赠送冷却。 */
|
||||
export async function confirmAdminWorkOrderFriendAdded(
|
||||
workOrderId: number | string,
|
||||
actorName = '',
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const { order, failureReason } = await confirmWorkOrderFriendAdded({
|
||||
workOrderId: Number(workOrder.id),
|
||||
actorName,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (failureReason === 'work_order_gift_phase_invalid') {
|
||||
throw createHttpError('当前阶段不能确认加好友,请刷新后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_friend_confirm_phase_invalid',
|
||||
})
|
||||
}
|
||||
if (!order) {
|
||||
throw createHttpError('确认加好友失败,订单可能已变化', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_friend_confirm_failed',
|
||||
})
|
||||
}
|
||||
const workerId = Number(order.assigned_worker_id || 0)
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(order.id),
|
||||
workerIds: workerId > 0 ? [workerId] : [],
|
||||
})
|
||||
return { order: mapWorkOrderAdmin(order) }
|
||||
}
|
||||
|
||||
/** 打手资料有误或误确认好友时,重置资料让打手重新提交(冷却与好友时间清零)。 */
|
||||
export async function resetAdminWorkOrderGiftMaterial(
|
||||
workOrderId: number | string,
|
||||
payload: { note?: unknown } = {},
|
||||
actorName = '',
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const note = String(payload.note || '').trim()
|
||||
const { order, failureReason } = await resetWorkOrderGiftMaterial({
|
||||
workOrderId: Number(workOrder.id),
|
||||
note,
|
||||
actorName,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (failureReason === 'work_order_gift_phase_invalid') {
|
||||
throw createHttpError('当前阶段没有可重置的打手资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_reset_phase_invalid',
|
||||
})
|
||||
}
|
||||
if (failureReason === 'work_order_status_invalid') {
|
||||
throw createHttpError('当前订单状态不能重置资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_reset_status_invalid',
|
||||
})
|
||||
}
|
||||
if (!order) {
|
||||
throw createHttpError('重置打手资料失败,订单可能已变化', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_reset_failed',
|
||||
})
|
||||
}
|
||||
const workerId = Number(order.assigned_worker_id || 0)
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(order.id),
|
||||
workerIds: workerId > 0 ? [workerId] : [],
|
||||
})
|
||||
return { order: mapWorkOrderAdmin(order) }
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
const vipEvidencePending = normalizeBoolean(query.vipEvidencePending, false)
|
||||
const createdFrom = normalizeDateString(query.createdFrom)
|
||||
const createdTo = normalizeDateString(query.createdTo)
|
||||
const acceptanceMode = String(query.acceptanceMode || '').trim()
|
||||
const giftPhase = String(query.giftPhase || '').trim()
|
||||
const filters = {
|
||||
statuses,
|
||||
workOrderId,
|
||||
@@ -45,6 +47,8 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
workerSharingId: workerId,
|
||||
workerLevelIds,
|
||||
vipEvidencePending,
|
||||
...(acceptanceMode ? { acceptanceMode } : {}),
|
||||
...(giftPhase ? { giftPhase } : {}),
|
||||
useOperationalStatus: true,
|
||||
...(createdFrom ? { createdFrom } : {}),
|
||||
...(createdTo ? { createdTo } : {}),
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
upsertWorkProductRule,
|
||||
} from '../../repositories/worker-platform/work-product-rule-repo.js'
|
||||
import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker-platform/types.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
WORK_ORDER_ACCEPTANCE_MODE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { randomId } from '../../utils/random.js'
|
||||
@@ -257,6 +262,17 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
? 0
|
||||
: normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0)
|
||||
const sharingEnabled = normalizeBoolean(payload.sharingEnabled ?? payload.sharing_enabled, false)
|
||||
const acceptanceMode = normalizeWorkOrderAcceptanceMode(
|
||||
hasOwnPayload(payload, 'acceptanceMode') || hasOwnPayload(payload, 'acceptance_mode')
|
||||
? payload.acceptanceMode || payload.acceptance_mode
|
||||
: existingRule?.acceptance_mode,
|
||||
)
|
||||
if (acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT && sharingEnabled) {
|
||||
throw createHttpError('好友赠送验收模式不支持拼单,请先关闭拼单', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_rule_gift_sharing_conflict',
|
||||
})
|
||||
}
|
||||
const hasManualSharingConfig =
|
||||
hasConfiguredSharingValue(payload.sharingTotalQuantity ?? payload.sharing_total_quantity) ||
|
||||
hasConfiguredSharingValue(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan) ||
|
||||
@@ -351,6 +367,13 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
sharingAutoFromOrder,
|
||||
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
|
||||
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
|
||||
acceptanceMode,
|
||||
giftCooldownHours:
|
||||
hasOwnPayload(payload, 'giftCooldownHours') || hasOwnPayload(payload, 'gift_cooldown_hours')
|
||||
? normalizeWorkOrderGiftCooldownHours(
|
||||
payload.giftCooldownHours ?? payload.gift_cooldown_hours,
|
||||
)
|
||||
: normalizeWorkOrderGiftCooldownHours(existingRule?.gift_cooldown_hours),
|
||||
requirementJson: JSON.stringify({ fields }),
|
||||
matchJson: JSON.stringify(match),
|
||||
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||
|
||||
@@ -15,6 +15,7 @@ export * from './worker-order-timeout-service.js'
|
||||
export * from './worker-order-realtime-service.js'
|
||||
export * from './worker-collect-service.js'
|
||||
export * from './worker-acceptance-service.js'
|
||||
export * from './worker-gift-material-service.js'
|
||||
export * from './worker-my-order-service.js'
|
||||
export * from './worker-hall-action-service.js'
|
||||
export * from './worker-platform-defaults.js'
|
||||
@@ -33,6 +34,7 @@ export * from './admin-worker-cancel-service.js'
|
||||
export * from './admin-work-order-sharing-service.js'
|
||||
export * from './admin-work-order-realtime-service.js'
|
||||
export * from './admin-work-order-material-service.js'
|
||||
export * from './admin-work-order-gift-service.js'
|
||||
export * from './admin-mock-work-order-service.js'
|
||||
export * from './admin-work-order-management-service.js'
|
||||
export * from './admin-worker-platform-summary-service.js'
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
isFriendGiftAcceptanceMode,
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import {
|
||||
createWorkProductMatchLog,
|
||||
createWorkOrder,
|
||||
@@ -95,10 +100,9 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
|
||||
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
|
||||
})
|
||||
const sharingEnabled = resolveWorkOrderSharingEnabled(
|
||||
rule.sharing_enabled === true,
|
||||
sharingTotalQuantity,
|
||||
)
|
||||
const sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode)
|
||||
? false
|
||||
: resolveWorkOrderSharingEnabled(rule.sharing_enabled === true, sharingTotalQuantity)
|
||||
const now = nowIso()
|
||||
const materialComplete = fields.every((field) => !field.required)
|
||||
const workOrder = await createWorkOrder({
|
||||
@@ -119,6 +123,8 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
sharingUnitReward,
|
||||
timeoutMinutes: Number(rule.timeout_minutes || 0),
|
||||
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
|
||||
acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode),
|
||||
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours),
|
||||
materialJson: JSON.stringify({
|
||||
source: {
|
||||
orderId: Number(order.id),
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
isFriendGiftAcceptanceMode,
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
resolveWorkOrderGiftPhase,
|
||||
WORK_ORDER_ACCEPTANCE_MODE,
|
||||
WORK_ORDER_GIFT_PHASE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import {
|
||||
type WorkCategoryRow,
|
||||
type WorkOrderCapabilities,
|
||||
@@ -49,6 +57,20 @@ export function mapWorkOrderMaterial(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取好友赠送模式的打手本人资料;主页截图访问链接需要读取时重签。 */
|
||||
export function mapWorkOrderBoosterMaterial(value: unknown) {
|
||||
const material = safeParseJson(value)
|
||||
return {
|
||||
fields:
|
||||
material.fields && typeof material.fields === 'object' && !Array.isArray(material.fields)
|
||||
? (material.fields as Record<string, string>)
|
||||
: {},
|
||||
screenshots: normalizeUploadedFiles(material.screenshots).map(refreshUploadedFileUrls),
|
||||
note: String(material.note || ''),
|
||||
submittedAt: String(material.submittedAt || ''),
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一计算后台使用的工单业务状态,避免拼单订单继续直接暴露母工单 open。 */
|
||||
export function resolveWorkOrderOperationalStatus(workOrder: WorkOrderRow): string {
|
||||
const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0))
|
||||
@@ -100,6 +122,9 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
|
||||
const sharingFilled =
|
||||
workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity
|
||||
const status = resolveWorkOrderOperationalStatus(workOrder)
|
||||
const acceptanceMode = normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode)
|
||||
const isFriendGift = acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT
|
||||
const giftPhase = String(workOrder.gift_phase || '').trim()
|
||||
const hallAvailable =
|
||||
workOrder.status === WORK_ORDER_STATUS.OPEN &&
|
||||
(!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity)
|
||||
@@ -142,6 +167,18 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
|
||||
canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id),
|
||||
canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM,
|
||||
canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED,
|
||||
canConfirmFriend:
|
||||
isFriendGift &&
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
||||
giftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED,
|
||||
canResetGiftFlow:
|
||||
isFriendGift &&
|
||||
[WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(
|
||||
workOrder.status as never,
|
||||
) &&
|
||||
[WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED, WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN].includes(
|
||||
giftPhase as never,
|
||||
),
|
||||
}
|
||||
return {
|
||||
workOrderId: Number(workOrder.id),
|
||||
@@ -155,6 +192,19 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha
|
||||
status,
|
||||
rawStatus: workOrder.status,
|
||||
hallAvailable,
|
||||
acceptanceMode,
|
||||
...(isFriendGift
|
||||
? {
|
||||
gift: {
|
||||
phase: resolveWorkOrderGiftPhase(workOrder),
|
||||
rawPhase: giftPhase,
|
||||
cooldownHours: normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours),
|
||||
friendAddedAt: workOrder.friend_added_at,
|
||||
giftAvailableAt: workOrder.gift_available_at,
|
||||
boosterMaterial: mapWorkOrderBoosterMaterial(workOrder.booster_material_json),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
sharingProgress: {
|
||||
joinedQuantity: activeShareQuantity,
|
||||
pendingSubmissionCount: pendingShareCount,
|
||||
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
type WorkProductRuleMappingRow,
|
||||
type WorkProductRuleRow,
|
||||
} from '../../repositories/worker-platform/index.js'
|
||||
import {
|
||||
normalizeWorkOrderAcceptanceMode,
|
||||
normalizeWorkOrderGiftCooldownHours,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import { safeParseJson } from '../admin/admin-query-utils.js'
|
||||
@@ -45,6 +49,8 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined)
|
||||
},
|
||||
timeoutMinutes: Number(rule.timeout_minutes || 0),
|
||||
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
|
||||
acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode),
|
||||
giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours),
|
||||
requirement: {
|
||||
fields: normalizeRequirementFields(requirement.fields),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
isFriendGiftAcceptanceMode,
|
||||
isWorkOrderGiftAvailable,
|
||||
WORK_ORDER_GIFT_PHASE,
|
||||
} from '../../domain/work-order-acceptance-mode.js'
|
||||
import {
|
||||
acceptWorkOrderAndSettle,
|
||||
acceptWorkOrderShareAndSettle,
|
||||
@@ -61,13 +66,20 @@ export async function submitWorkerOrderAcceptance(
|
||||
if (sharingShare) {
|
||||
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
|
||||
}
|
||||
const canAutoAcceptWithoutEvidence = canWorkerAutoAcceptWholeOrder({
|
||||
worker,
|
||||
hasPersonalSharingShare: false,
|
||||
})
|
||||
const canAutoAcceptWithoutEvidence = isFriendGiftAcceptanceMode(workOrder.acceptance_mode)
|
||||
? false
|
||||
: canWorkerAutoAcceptWholeOrder({
|
||||
worker,
|
||||
hasPersonalSharingShare: false,
|
||||
})
|
||||
const now = nowIso()
|
||||
// 好友赠送单在“待确认好友”阶段是等后台操作,等待期不计打手超时。
|
||||
const awaitingFriendConfirm =
|
||||
isFriendGiftAcceptanceMode(workOrder.acceptance_mode) &&
|
||||
workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED
|
||||
if (
|
||||
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
|
||||
!awaitingFriendConfirm &&
|
||||
workOrder.deadline_at &&
|
||||
new Date(workOrder.deadline_at).getTime() <= Date.now()
|
||||
) {
|
||||
@@ -95,6 +107,18 @@ export async function submitWorkerOrderAcceptance(
|
||||
errorCode: 'work_order_submit_status_invalid',
|
||||
})
|
||||
}
|
||||
// 好友赠送模式:必须等后台确认加好友且冷却结束后,才能提交赠送凭证。
|
||||
if (isFriendGiftAcceptanceMode(workOrder.acceptance_mode)) {
|
||||
if (
|
||||
workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN ||
|
||||
!isWorkOrderGiftAvailable(workOrder)
|
||||
) {
|
||||
throw createHttpError('好友赠送冷却未结束,暂不能提交验收', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_cooldown_active',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { files, imageUrls, note } = resolveAcceptanceImages(
|
||||
payload,
|
||||
@@ -141,7 +165,11 @@ export async function submitWorkerOrderAcceptance(
|
||||
}),
|
||||
now,
|
||||
})
|
||||
if (worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence) {
|
||||
// 好友赠送模式必须由后台人工验收赠送凭证,内部打手与 VIP 免审核直通均不适用。
|
||||
if (
|
||||
!isFriendGiftAcceptanceMode(workOrder.acceptance_mode) &&
|
||||
(worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence)
|
||||
) {
|
||||
const { order: acceptedOrder, failureReason } = await acceptWorkOrderAndSettle({
|
||||
workOrderId: workOrder.id,
|
||||
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { isFriendGiftAcceptanceMode } from '../../domain/work-order-acceptance-mode.js'
|
||||
import { submitWorkOrderGiftMaterial } from '../../repositories/worker-platform/index.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import {
|
||||
mapWorkOrderForWorker,
|
||||
normalizeUploadedFiles,
|
||||
resolveWorkerPermissions,
|
||||
} from './mappers.js'
|
||||
import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js'
|
||||
import {
|
||||
getRequiredWorkOrder,
|
||||
getRequiredWorker,
|
||||
requireActiveWorkerSession,
|
||||
type WorkerSession,
|
||||
} from './worker-session-context-service.js'
|
||||
|
||||
/** 打手本人资料的固定字段:昵称/ID/区服必填,系统选填(安卓/苹果)。 */
|
||||
const BOOSTER_MATERIAL_REQUIRED_FIELDS = [
|
||||
{ key: 'gameNickname', label: '游戏昵称' },
|
||||
{ key: 'gameId', label: '游戏ID' },
|
||||
{ key: 'serverZone', label: '区服' },
|
||||
] as const
|
||||
|
||||
/** 好友赠送模式:打手接单后提交本人游戏资料与主页截图,等待后台核对并添加好友。 */
|
||||
export async function submitWorkerOrderGiftMaterial(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
session: WorkerSession,
|
||||
) {
|
||||
requireActiveWorkerSession(session)
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
if (!isFriendGiftAcceptanceMode(workOrder.acceptance_mode)) {
|
||||
throw createHttpError('该订单不需要提交好友赠送资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_mode_invalid',
|
||||
})
|
||||
}
|
||||
if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) {
|
||||
throw createHttpError('只能操作自己的订单', {
|
||||
statusCode: 403,
|
||||
errorCode: 'work_order_owner_required',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const fieldsPayload =
|
||||
payload.fields && typeof payload.fields === 'object' && !Array.isArray(payload.fields)
|
||||
? (payload.fields as Record<string, unknown>)
|
||||
: {}
|
||||
const fields: Record<string, string> = {}
|
||||
for (const field of BOOSTER_MATERIAL_REQUIRED_FIELDS) {
|
||||
const value = String(fieldsPayload[field.key] ?? '').trim()
|
||||
if (!value) {
|
||||
throw createHttpError(`请填写${field.label}`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_gift_material_field_required',
|
||||
})
|
||||
}
|
||||
fields[field.key] = value
|
||||
}
|
||||
const system = String(fieldsPayload.system ?? '').trim()
|
||||
if (system) fields.system = system
|
||||
|
||||
const note = String(payload.note || '').trim()
|
||||
if (note.length > 200) {
|
||||
throw createHttpError('备注不能超过 200 个字符', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_gift_material_note_too_long',
|
||||
})
|
||||
}
|
||||
const screenshots = normalizeUploadedFiles(payload.screenshots)
|
||||
if (screenshots.length === 0) {
|
||||
throw createHttpError('请上传主页截图', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_gift_material_screenshot_required',
|
||||
})
|
||||
}
|
||||
|
||||
const { order, failureReason } = await submitWorkOrderGiftMaterial({
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(session.workerId),
|
||||
boosterMaterialJson: JSON.stringify({
|
||||
fields,
|
||||
screenshots,
|
||||
note,
|
||||
submittedAt: now,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
if (failureReason === 'work_order_gift_phase_invalid') {
|
||||
throw createHttpError('当前阶段不能提交资料,请刷新后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_phase_invalid',
|
||||
})
|
||||
}
|
||||
if (failureReason === 'work_order_status_invalid') {
|
||||
throw createHttpError('当前订单状态不能提交资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_status_invalid',
|
||||
})
|
||||
}
|
||||
if (failureReason === 'work_order_owner_required') {
|
||||
throw createHttpError('只能操作自己的订单', {
|
||||
statusCode: 403,
|
||||
errorCode: 'work_order_owner_required',
|
||||
})
|
||||
}
|
||||
if (!order) {
|
||||
throw createHttpError('提交好友赠送资料失败,请稍后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_gift_material_submit_failed',
|
||||
})
|
||||
}
|
||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||
workerIds: [Number(session.workerId)],
|
||||
})
|
||||
return {
|
||||
order: mapWorkOrderForWorker(order, resolveWorkerPermissions(worker)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user