diff --git a/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts index 915e9576..49493d97 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts @@ -83,8 +83,15 @@ export function hasInternalConsignmentFulfillment(workOrder: { return true } -/** 开始好友赠送冷却:内部寄售跳过打手资料,直接进入等 N 天。 */ -export async function startWorkOrderGiftCountdownWithClient( +/** + * 内部寄售现货履约落地:占库成功,但**不**算已加好友。 + * + * 把工单停在「待加好友」阶段,由客服线下通知买家/加好友后**手动**点确认, + * 才进入等 N 天赠送冷却(见 confirmWorkOrderFriendAdded)。落库复用 + * acceptance_mode=friend_gift 的初始阶段 material_required:内部现货单本来就没有打手资料, + * 该阶段在这里只表示"待加好友",前端对内部现货单展示为「待加好友」。 + */ +export async function parkWorkOrderWaitingFriendAddWithClient( client: PoolClient, workOrder: WorkOrderRow, input: { @@ -93,14 +100,15 @@ export async function startWorkOrderGiftCountdownWithClient( eventType?: string | undefined materialJson?: unknown extraPayload?: Record - forceFriendGift?: boolean }, ): Promise { if (GIFT_COUNTDOWN_BLOCKED_STATUSES.has(workOrder.status)) return null + // 冷却时长按商品规则落到工单上:两者都为 0 时补默认 72 小时, + // 否则客服确认后会得到 0 秒冷却(gift_available_at 立刻到点)。 let cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours) const cooldownMinutes = normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes) - if (input.forceFriendGift && cooldownHours === 0 && cooldownMinutes === 0) { + if (cooldownHours === 0 && cooldownMinutes === 0) { cooldownHours = DEFAULT_GIFT_COOLDOWN_HOURS } const nextStatus = ['problem'].includes(workOrder.status) ? workOrder.status : 'in_progress' @@ -118,8 +126,72 @@ export async function startWorkOrderGiftCountdownWithClient( UPDATE work_orders SET status = $1, - acceptance_mode = CASE WHEN $7 THEN 'friend_gift' ELSE acceptance_mode END, - gift_cooldown_hours = CASE WHEN $7 THEN $3 ELSE gift_cooldown_hours END, + acceptance_mode = 'friend_gift', + gift_cooldown_hours = $3, + gift_phase = 'material_required', + friend_added_at = NULL, + gift_available_at = NULL, + deadline_at = NULL, + accepted_at = CASE WHEN $4 THEN NULL ELSE accepted_at END, + material_json = COALESCE($5::jsonb, material_json), + updated_at = $2::timestamptz + WHERE id = $6 + `, + [nextStatus, input.now, cooldownHours, clearAcceptedAt, materialJson, workOrder.id], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: Number(workOrder.id), + actorType: 'admin', + actorId: input.actorName || '', + eventType: input.eventType || 'consignment_waiting_friend_add', + fromStatus: workOrder.status, + toStatus: nextStatus, + payloadJson: JSON.stringify({ + cooldownHours, + cooldownMinutes, + giftPhase: WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED, + internalConsignment: hasInternalConsignmentFulfillment({ + ...workOrder, + material_json: materialForInternalCheck, + }), + ...(input.extraPayload || {}), + }), + now: input.now, + }) + return getWorkOrderByIdWithClient(client, workOrder.id) +} + +/** 开始好友赠送冷却:客服确认加好友后进入等 N 天。 */ +export async function startWorkOrderGiftCountdownWithClient( + client: PoolClient, + workOrder: WorkOrderRow, + input: { + now: string + actorName?: string | undefined + eventType?: string | undefined + materialJson?: unknown + extraPayload?: Record + }, +): Promise { + if (GIFT_COUNTDOWN_BLOCKED_STATUSES.has(workOrder.status)) return null + + const cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours) + const cooldownMinutes = normalizeWorkOrderGiftCooldownMinutes(workOrder.gift_cooldown_minutes) + const nextStatus = ['problem'].includes(workOrder.status) ? workOrder.status : 'in_progress' + const clearAcceptedAt = ['accepted', 'completed'].includes(workOrder.status) + const materialJson = + input.materialJson === undefined + ? null + : typeof input.materialJson === 'string' + ? input.materialJson + : JSON.stringify(input.materialJson) + const materialForInternalCheck = + input.materialJson === undefined ? workOrder.material_json : input.materialJson + await client.query( + ` + UPDATE work_orders + SET + status = $1, gift_phase = 'friend_countdown', friend_added_at = $2::timestamptz, gift_available_at = $2::timestamptz + ($3::int * INTERVAL '1 hour') + ($4::int * INTERVAL '1 minute'), @@ -129,7 +201,7 @@ export async function startWorkOrderGiftCountdownWithClient( ELSE NULL END, accepted_at = CASE WHEN $5 THEN NULL ELSE accepted_at END, - material_json = COALESCE($8::jsonb, material_json), + material_json = COALESCE($7::jsonb, material_json), updated_at = $2::timestamptz WHERE id = $6 `, @@ -140,7 +212,6 @@ export async function startWorkOrderGiftCountdownWithClient( cooldownMinutes, clearAcceptedAt, workOrder.id, - Boolean(input.forceFriendGift), materialJson, ], ) @@ -159,7 +230,10 @@ export async function startWorkOrderGiftCountdownWithClient( ...workOrder, material_json: materialForInternalCheck, }), - skippedWorkerMaterial: Boolean(input.forceFriendGift), + skippedWorkerMaterial: hasInternalConsignmentFulfillment({ + ...workOrder, + material_json: materialForInternalCheck, + }), giftPhase: WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN, ...(input.extraPayload || {}), }), diff --git a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts index 54bd1ce9..509209c0 100644 --- a/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts @@ -20,7 +20,7 @@ import { createWorkOrderEvent, createWorkOrderEventWithClient, getWorkOrderById, - startWorkOrderGiftCountdownWithClient, + parkWorkOrderWaitingFriendAddWithClient, updateWorkOrder, type WorkOrderRow, } from '../../repositories/worker-platform/index.js' @@ -198,7 +198,8 @@ export async function fulfillWorkOrderWithConsignment(input: { const now = nowIso() - // 开启原子事务:占库 + 工单进入赠送冷静期。押金留到验收再释放。 + // 开启原子事务:占库 + 工单停在「待加好友」。押金留到验收再释放, + // 客服线下通知加好友并手动确认后(confirm-friend)才进入赠送冷却。 const { updatedOrder, updatedInventory } = await withTransaction(async (client: PoolClient) => { const lockedOrderRes = await client.query( 'SELECT * FROM work_orders WHERE id = $1 AND status = $2 FOR UPDATE', @@ -265,12 +266,11 @@ export async function fulfillWorkOrderWithConsignment(input: { }, } - const wo = await startWorkOrderGiftCountdownWithClient(client, lockedOrder, { + const wo = await parkWorkOrderWaitingFriendAddWithClient(client, lockedOrder, { now, actorName: input.actorName, eventType: 'consignment_fulfilled', materialJson: updatedMaterial, - forceFriendGift: true, extraPayload: { inventoryId: inv.id, inventoryNo: inv.inventory_no, diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 31471d8e..d8802ea2 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -24,6 +24,7 @@ import { resolveOpenSharingSyncReward, } 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' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import { assertWithdrawChannelAllowedForAmount, @@ -69,6 +70,7 @@ import { } from '../../repositories/worker-platform/consignment-repo.js' import { ADMIN_PERMISSION_KEYS } from '../admin/admin-rbac-service.js' import { updateAdminConsignmentInventory } from './consignment-admin-service.js' +import { parkWorkOrderWaitingFriendAddWithClient } from '../../repositories/worker-platform/work-order-gift-repo.js' import { assertConsignmentRevocableWorkOrderStatus, resolveRevocableConsignmentInventoryId, @@ -2048,3 +2050,86 @@ test('编辑接口仍然拒绝直接改物品状态(回归)', async () => { (error as { errorCode?: string }).errorCode === 'consignment_status_update_forbidden', ) }) + +test('内部现货履约停在「待加好友」:不写 friend_added_at,也不进冷却', async () => { + const calls: Array<{ sql: string; values?: unknown[] }> = [] + const client = { + query: async (sql: string, values?: unknown[]) => { + calls.push({ sql, values }) + return { rows: [] } + }, + } as unknown as PoolClient + + await parkWorkOrderWaitingFriendAddWithClient( + client, + { + id: 100154, + status: 'pending_material', + acceptance_mode: 'standard', + gift_cooldown_hours: 0, + gift_cooldown_minutes: 0, + assigned_worker_id: null, + material_json: { consignment: { inventoryId: 1, fulfillmentType: 'internal_stock' } }, + } as unknown as WorkOrderRow, + { now: '2026-09-16T12:00:00.000Z', actorName: '客服A' }, + ) + + const update = calls.find((call) => call.sql.includes('UPDATE work_orders')) + assert.ok(update, '停阶段必须更新工单') + const sql = update.sql + assert.ok(sql.includes("gift_phase = 'material_required'"), '停留阶段应为待加好友阶段') + assert.ok(sql.includes('friend_added_at = NULL'), '不能自动确认加好友') + assert.ok(sql.includes('gift_available_at = NULL')) + assert.ok(sql.includes('deadline_at = NULL'), '没有打手在跑,不应背任务时限') + assert.ok(sql.includes("acceptance_mode = 'friend_gift'")) + assert.ok(!sql.includes('friend_added_at = $2'), '不能把当前时间写成已加好友') + + // 冷却时长两者都为 0 时补默认 72 小时,否则客服确认后会得到 0 秒冷却 + assert.equal(update.values?.[2], 72) + + const event = calls.find((call) => call.sql.includes('INSERT INTO work_order_events')) + assert.ok( + String(event?.values?.join(' ')).includes('"giftPhase":"material_required"'), + '事件要记录停留在待加好友阶段', + ) +}) + +test('停在待加好友时保留商品规则已配的冷却时长', async () => { + const calls: Array<{ sql: string; values?: unknown[] }> = [] + const client = { + query: async (sql: string, values?: unknown[]) => { + calls.push({ sql, values }) + return { rows: [] } + }, + } as unknown as PoolClient + + await parkWorkOrderWaitingFriendAddWithClient( + client, + { + id: 100155, + status: 'pending_material', + acceptance_mode: 'friend_gift', + gift_cooldown_hours: 48, + gift_cooldown_minutes: 0, + material_json: {}, + } as unknown as WorkOrderRow, + { now: '2026-09-16T12:00:00.000Z' }, + ) + + const update = calls.find((call) => call.sql.includes('UPDATE work_orders')) + assert.equal(update?.values?.[2], 48, '规则里配了 48 小时就不能被默认值覆盖') +}) + +test('内部现货履约不再自动跳过加好友直接进冷却', async () => { + const readFile = await import('node:fs/promises') + const src = await readFile.readFile( + new URL('./consignment-fulfillment-service.ts', import.meta.url), + 'utf8', + ) + + assert.ok( + src.includes('parkWorkOrderWaitingFriendAddWithClient('), + '履约成功后必须停在待加好友,等客服确认', + ) + assert.ok(!src.includes('forceFriendGift'), '不能再有"自动确认加好友"的强制进冷却开关') +}) diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 00f2ff8f..9d5310e4 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -85,7 +85,7 @@ import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-pla import { AdminRangePicker } from '@/components/admin/AdminDatePicker' import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' -import { hasAdminPermission, hasAdminRole } from '@/utils/admin-auth' +import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' import { DeadlineCountdown, @@ -122,6 +122,12 @@ import { shouldSkipPublishConfirm, } from './work-order-panel-utils' +/** + * 客服备注快捷项。点击后往备注里追加一行「[标签] 操作人 时间」, + * 保留手动补充的空间,同时让"什么时候通知的、谁通知的"自动留痕。 + */ +const ADMIN_NOTE_QUICK_TAGS = ['已通知加好友'] as const + const FILTERABLE_WORK_ORDER_STATUSES = [ 'pending_material', 'unassigned', @@ -994,6 +1000,12 @@ export default function WorkOrdersPanel() { noteForm.setFieldsValue({ note: row.adminNote || '' }) } + function appendNoteQuickTag(tag: string) { + const current = String(noteForm.getFieldValue('note') || '') + const stamp = `[${tag}] ${getAdminUsername() || '客服'} ${formatAdminDateTime(new Date().toISOString())}` + noteForm.setFieldValue('note', current.trim() ? `${current.trimEnd()}\n${stamp}` : stamp) + } + async function submitNote(values: { note?: string }) { if (!noteOrder) return const succeeded = await runAction( @@ -1106,14 +1118,17 @@ export default function WorkOrdersPanel() { const cooldownLabel = cooldownMinutes > 0 ? `${cooldownMinutes} 分钟` : `${cooldownHours} 小时` const internal = isInternalConsignmentOrder(row) modal.confirm({ - title: internal ? '跳过打手资料,开始赠送冷却?' : '确认已添加打手为游戏好友?', + title: internal ? '确认已加好友,开始赠送冷却?' : '确认已添加打手为游戏好友?', content: (
{internal ? ( <> - 内部寄售履约没有打手交资料。确认后直接开始 {cooldownLabel} - 赠送冷却,冷却结束后再用库存账号给买家赠送并验收。 + 内部寄售履约单没有打手资料。订单会一直停在「待加好友」,请先用库存账号添加买家为游戏好友; + 通知进展可以记在订单备注里(备注支持一键填写「已通知加好友」)。 + + + 确认后开始 {cooldownLabel} 赠送冷却,冷却结束后再用库存账号赠送并验收。 ) : ( @@ -1129,13 +1144,13 @@ export default function WorkOrdersPanel() { )}
), - okText: internal ? '开始冷却' : '已加好友,开始冷却', + okText: internal ? '确认加好友,开始冷却' : '已加好友,开始冷却', cancelText: '取消', onOk: () => runAction( () => confirmAdminWorkOrderFriend(row.workOrderId), internal - ? `已跳过打手资料,${cooldownLabel} 冷却开始` + ? `已确认加好友,${cooldownLabel} 冷却开始` : `已确认加好友,${cooldownLabel} 冷却开始`, ), }) @@ -1326,7 +1341,7 @@ export default function WorkOrdersPanel() { (!['pending_material', 'unassigned', 'open'].includes(row.rawStatus || row.status) || isInternalConsignmentOrder(row)) ? ( - {formatGiftPhase(row.gift.phase)} + {formatGiftPhase(row.gift.phase, isInternalConsignmentOrder(row))} ) : null} @@ -1546,9 +1561,7 @@ export default function WorkOrdersPanel() { icon={} onClick={() => confirmFriendAdded(row)} > - {isInternalConsignmentOrder(row) && row.gift?.phase === 'material_required' - ? '开始赠送冷却' - : '确认加好友'} + 确认加好友 ) : null} {row.capabilities?.canCompleteGiftCooldown && canManageProblem ? ( @@ -1954,7 +1967,10 @@ export default function WorkOrdersPanel() { {formatAcceptanceMode(detailOrder.acceptanceMode)} - {formatGiftPhase(detailOrder.gift.phase)} + {formatGiftPhase( + detailOrder.gift.phase, + isInternalConsignmentOrder(detailOrder), + )} {detailOrder.capabilities?.canConfirmFriend && canManageProblem ? ( ) : null} {detailOrder.capabilities?.canCompleteGiftCooldown && canManageProblem ? ( @@ -2137,6 +2150,14 @@ export default function WorkOrdersPanel() { placeholder="请输入本单客服备注" /> + + 快捷填写: + {ADMIN_NOTE_QUICK_TAGS.map((tag) => ( + + ))} + ) : null} diff --git a/apps/frontend/src/pages/admin/panels/shared.tsx b/apps/frontend/src/pages/admin/panels/shared.tsx index 95b8026d..1a5a4c27 100644 --- a/apps/frontend/src/pages/admin/panels/shared.tsx +++ b/apps/frontend/src/pages/admin/panels/shared.tsx @@ -249,25 +249,8 @@ export function formatAcceptanceMode(mode: string) { return '标准验收' } -export function formatGiftPhase(phase: string) { - const labels: Record = { - material_required: '待打手提交资料', - material_submitted: '待确认加好友', - friend_countdown: '赠送冷却中', - gift_ready: '可赠送', - } - return labels[phase] || phase -} - -export function resolveGiftPhaseColor(phase: string) { - const colors: Record = { - material_required: 'orange', - material_submitted: 'geekblue', - friend_countdown: 'gold', - gift_ready: 'green', - } - return colors[phase] || 'default' -} +// 阶段文案与配色在 utils 里实现,便于单测覆盖「内部现货单显示待加好友」这条口径。 +export { formatGiftPhase, resolveGiftPhaseColor } from '@/utils/gift-phase' export function resolveSharingShareStatusColor(status: string) { if (status === 'accepted') return 'green' diff --git a/apps/frontend/src/utils/gift-phase.ts b/apps/frontend/src/utils/gift-phase.ts new file mode 100644 index 00000000..d1e39ce7 --- /dev/null +++ b/apps/frontend/src/utils/gift-phase.ts @@ -0,0 +1,28 @@ +/** + * 好友赠送阶段文案与配色。 + * + * 注意 material_required 在不同履约方式下含义不同: + * - 打手履约单:等打手提交好友资料,显示「待打手提交资料」; + * - 内部现货履约单:没有打手资料这一步,占库成功后停在这里等客服线下通知加好友并手动确认, + * 所以要显示「待加好友」。 + */ +export function formatGiftPhase(phase: string, internalConsignment = false) { + if (internalConsignment && phase === 'material_required') return '待加好友' + const labels: Record = { + material_required: '待打手提交资料', + material_submitted: '待确认加好友', + friend_countdown: '赠送冷却中', + gift_ready: '可赠送', + } + return labels[phase] || phase +} + +export function resolveGiftPhaseColor(phase: string) { + const colors: Record = { + material_required: 'orange', + material_submitted: 'geekblue', + friend_countdown: 'gold', + gift_ready: 'green', + } + return colors[phase] || 'default' +} diff --git a/apps/frontend/tests/gift-phase.test.ts b/apps/frontend/tests/gift-phase.test.ts new file mode 100644 index 00000000..dbe53713 --- /dev/null +++ b/apps/frontend/tests/gift-phase.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { formatGiftPhase, resolveGiftPhaseColor } from '../src/utils/gift-phase.ts' + +test('内部现货履约单停在 material_required 时显示待加好友', () => { + assert.equal(formatGiftPhase('material_required', true), '待加好友') +}) + +test('打手履约单的 material_required 仍是待打手提交资料', () => { + assert.equal(formatGiftPhase('material_required'), '待打手提交资料') + assert.equal(formatGiftPhase('material_required', false), '待打手提交资料') +}) + +test('其余阶段文案不受履约方式影响', () => { + assert.equal(formatGiftPhase('material_submitted', true), '待确认加好友') + assert.equal(formatGiftPhase('friend_countdown', true), '赠送冷却中') + assert.equal(formatGiftPhase('gift_ready', true), '可赠送') + assert.equal(formatGiftPhase('unknown_phase', true), 'unknown_phase') +}) + +test('阶段配色保持稳定', () => { + assert.equal(resolveGiftPhaseColor('material_required'), 'orange') + assert.equal(resolveGiftPhaseColor('friend_countdown'), 'gold') + assert.equal(resolveGiftPhaseColor('unknown_phase'), 'default') +})