From 798ea583f80fa1e401f7d7fe296539ad3c8876b9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 21 Aug 2026 15:17:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E6=89=93=E6=89=8B=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=94=AE=E5=90=8E=E4=B8=8E=E8=AE=A2=E5=8D=95=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/repositories/worker-platform/index.ts | 7 + .../repositories/worker-platform/shared.ts | 19 + .../worker-platform/work-category-repo.ts | 84 + .../worker-platform/work-order-event-repo.ts | 82 + .../worker-platform/work-order-repo.ts | 1919 +---------------- .../worker-platform/work-order-share-repo.ts | 478 ++++ .../worker-platform/work-product-rule-repo.ts | 421 ++++ .../worker-cancel-request-repo.ts | 609 ++++++ .../worker-order-feedback-repo.ts | 175 ++ .../worker-platform/worker-order-note-repo.ts | 82 + .../admin-kuaishou-match-service.ts | 455 ++++ .../services/worker-platform/admin-service.ts | 1346 +----------- .../admin-worker-catalog-service.ts | 474 ++++ .../admin-worker-config-service.ts | 37 + .../admin-worker-finance-service.ts | 235 ++ .../worker-platform/after-sales-service.ts | 287 +++ .../src/services/worker-platform/index.ts | 5 + .../sync-work-orders-from-send-code.ts | 2 +- .../worker-platform/worker-service.ts | 65 - .../pages/admin/panels/AfterSalesPanel.tsx | 470 ++++ .../pages/admin/panels/WorkOrdersPanel.tsx | 402 +--- .../pages/worker/WorkerAfterSalesSection.tsx | 178 ++ .../src/pages/worker/WorkerOrdersPage.tsx | 526 +---- .../src/pages/worker/worker-order-utils.tsx | 328 +++ 24 files changed, 4473 insertions(+), 4213 deletions(-) create mode 100644 apps/backend/src/repositories/worker-platform/work-category-repo.ts create mode 100644 apps/backend/src/repositories/worker-platform/work-order-event-repo.ts create mode 100644 apps/backend/src/repositories/worker-platform/work-order-share-repo.ts create mode 100644 apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts create mode 100644 apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts create mode 100644 apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts create mode 100644 apps/backend/src/repositories/worker-platform/worker-order-note-repo.ts create mode 100644 apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts create mode 100644 apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts create mode 100644 apps/backend/src/services/worker-platform/admin-worker-config-service.ts create mode 100644 apps/backend/src/services/worker-platform/admin-worker-finance-service.ts create mode 100644 apps/backend/src/services/worker-platform/after-sales-service.ts create mode 100644 apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx create mode 100644 apps/frontend/src/pages/worker/WorkerAfterSalesSection.tsx create mode 100644 apps/frontend/src/pages/worker/worker-order-utils.tsx diff --git a/apps/backend/src/repositories/worker-platform/index.ts b/apps/backend/src/repositories/worker-platform/index.ts index a4463121..4a91b6ea 100644 --- a/apps/backend/src/repositories/worker-platform/index.ts +++ b/apps/backend/src/repositories/worker-platform/index.ts @@ -2,6 +2,13 @@ export * from './types.js' export * from './shared.js' export * from './worker-repo.js' export * from './work-order-repo.js' +export * from './work-order-event-repo.js' +export * from './work-order-share-repo.js' +export * from './work-category-repo.js' +export * from './work-product-rule-repo.js' +export * from './worker-order-note-repo.js' +export * from './worker-order-feedback-repo.js' +export * from './worker-cancel-request-repo.js' export * from './after-sales-repo.js' export * from './product-match-repo.js' export * from './sms-code-repo.js' diff --git a/apps/backend/src/repositories/worker-platform/shared.ts b/apps/backend/src/repositories/worker-platform/shared.ts index 3eba6b3b..2e5fcec0 100644 --- a/apps/backend/src/repositories/worker-platform/shared.ts +++ b/apps/backend/src/repositories/worker-platform/shared.ts @@ -41,3 +41,22 @@ export function toPositiveInteger(value: unknown, fallback: number): number { if (!Number.isFinite(parsed)) return fallback return Math.max(0, Math.round(parsed)) } + +export const WORK_ORDER_SHARE_SELECT = ` + SELECT + wos.*, + wu.username AS worker_username, + wu.display_name AS worker_display_name + FROM work_order_shares wos + LEFT JOIN worker_users wu ON wu.id = wos.worker_id +` + +/** 校验单个拼单份额能否撤单,并返回母工单应回退到的状态。 */ +export function resolveWorkOrderShareCancellationStatus( + workOrderStatus: string, + shareStatus: string, +): 'open' | null { + if (!['open', 'pending_acceptance'].includes(workOrderStatus)) return null + if (!['joined', 'submitted'].includes(shareStatus)) return null + return 'open' +} diff --git a/apps/backend/src/repositories/worker-platform/work-category-repo.ts b/apps/backend/src/repositories/worker-platform/work-category-repo.ts new file mode 100644 index 00000000..746c74a6 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/work-category-repo.ts @@ -0,0 +1,84 @@ +import { query } from '../../db/client.js' +import type { WorkCategoryRow } from './types.js' + +export async function getWorkCategoryByKey(categoryKey: string): Promise { + const result = await query( + 'SELECT * FROM work_categories WHERE category_key = $1 LIMIT 1', + [String(categoryKey || '').trim()], + ) + return result.rows[0] || null +} + +export async function getWorkCategoryById( + categoryId: number | string, +): Promise { + const result = await query( + 'SELECT * FROM work_categories WHERE id = $1 LIMIT 1', + [Number(categoryId)], + ) + return result.rows[0] || null +} + +export async function listWorkCategories(): Promise { + const result = await query( + `SELECT * FROM work_categories WHERE status = 'active' ORDER BY sort_order ASC, id ASC`, + ) + return result.rows +} + +export async function listAllWorkCategories(): Promise { + const result = await query( + `SELECT * FROM work_categories ORDER BY sort_order ASC, id ASC`, + ) + return result.rows +} + +export async function upsertWorkCategory(input: { + categoryKey: string + name: string + sortOrder: number + status: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO work_categories (category_key, name, sort_order, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (category_key) DO UPDATE + SET name = EXCLUDED.name, sort_order = EXCLUDED.sort_order, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at + RETURNING * + `, + [input.categoryKey, input.name, input.sortOrder, input.status, input.now, input.now], + ) + return result.rows[0] || null +} + +export async function countWorkCategoryUsages(categoryId: number | string): Promise<{ + workOrderCount: number + productRuleCount: number +}> { + const [orders, rules] = await Promise.all([ + query<{ total: number }>( + 'SELECT COUNT(*)::int AS total FROM work_orders WHERE category_id = $1', + [Number(categoryId)], + ), + query<{ total: number }>( + 'SELECT COUNT(*)::int AS total FROM work_product_rules WHERE category_id = $1', + [Number(categoryId)], + ), + ]) + return { + workOrderCount: Number(orders.rows[0]?.total || 0), + productRuleCount: Number(rules.rows[0]?.total || 0), + } +} + +export async function deleteWorkCategory( + categoryId: number | string, +): Promise<{ deleted: boolean }> { + const result = await query<{ id: number }>( + 'DELETE FROM work_categories WHERE id = $1 RETURNING id', + [Number(categoryId)], + ) + return { deleted: Boolean(result.rows[0]) } +} diff --git a/apps/backend/src/repositories/worker-platform/work-order-event-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-event-repo.ts new file mode 100644 index 00000000..10db8c3f --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/work-order-event-repo.ts @@ -0,0 +1,82 @@ +import type { PoolClient } from 'pg' + +import { query } from '../../db/client.js' + +export type WorkOrderEventRow = { + id: number + work_order_id: number + actor_type: string + actor_id: string + event_type: string + from_status: string + to_status: string + payload_json: string | Record + created_at: string +} + +export async function listWorkOrderEventsByOrderId( + workOrderId: number | string, +): Promise { + const result = await query( + ` + SELECT * + FROM work_order_events + WHERE work_order_id = $1 + ORDER BY id ASC + `, + [Number(workOrderId)], + ) + return result.rows +} + +export async function createWorkOrderEvent(input: { + workOrderId: number + actorType: string + actorId: string + eventType: string + fromStatus?: string + toStatus?: string + payloadJson?: string + now: string +}): Promise { + await query( + ` + INSERT INTO work_order_events ( + work_order_id, actor_type, actor_id, event_type, from_status, to_status, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8) + `, + [ + input.workOrderId, + input.actorType, + input.actorId, + input.eventType, + input.fromStatus || '', + input.toStatus || '', + input.payloadJson || '{}', + input.now, + ], + ) +} + +export async function createWorkOrderEventWithClient( + client: PoolClient, + input: Parameters[0], +) { + await client.query( + ` + INSERT INTO work_order_events ( + work_order_id, actor_type, actor_id, event_type, from_status, to_status, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8) + `, + [ + input.workOrderId, + input.actorType, + input.actorId, + input.eventType, + input.fromStatus || '', + input.toStatus || '', + input.payloadJson || '{}', + input.now, + ], + ) +} diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index 879fce3a..3bb4fdb0 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -5,26 +5,24 @@ import { getWorkerWalletWithClient, toJsonString, toPositiveInteger, + WORK_ORDER_SHARE_SELECT, + resolveWorkOrderShareCancellationStatus, } from './shared.js' +import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import type { CreateWorkOrderInput, GrabWorkOrderResult, ListInput, ProblemWorkOrderResolutionAction, - ProductRuleListInput, - WorkCategoryRow, WorkerDepositUnfreezeRow, - WorkerCancelRequestRow, - WorkerOrderFeedbackRow, WorkOrderRow, WorkOrderShareRow, WorkOrderStatistics, - WorkProductRuleRow, } from './types.js' import type { PoolClient } from 'pg' import { maybeUpgradeWorkerLevelWithClient } from './worker-repo.js' -const WORK_ORDER_SELECT = ` +export const WORK_ORDER_SELECT = ` SELECT wo.*, COALESCE(share_summary.active_share_quantity, 0)::int AS active_share_quantity, @@ -44,69 +42,6 @@ const WORK_ORDER_SELECT = ` ) share_summary ON TRUE ` -const WORK_PRODUCT_RULE_SELECT = ` - SELECT - wpr.*, - wc.name AS category_name - FROM work_product_rules wpr - LEFT JOIN work_categories wc ON wc.id = wpr.category_id -` - -const WORK_ORDER_SHARE_SELECT = ` - SELECT - wos.*, - wu.username AS worker_username, - wu.display_name AS worker_display_name - FROM work_order_shares wos - LEFT JOIN worker_users wu ON wu.id = wos.worker_id -` - -const WORKER_CANCEL_REQUEST_SELECT = ` - SELECT - cr.*, - COALESCE(NULLIF(BTRIM(wo.platform_order_id), ''), o.platform_order_id, ft.platform_order_id, '') AS platform_order_id, - COALESCE( - NULLIF(BTRIM(o.shop_name), ''), - NULLIF(BTRIM(o.shop_id), ''), - NULLIF(BTRIM(ft.shop_name), ''), - NULLIF(BTRIM(ft.shop_id), ''), - '' - ) AS shop_name, - wo.product_name, - wu.username AS worker_username, - wu.display_name AS worker_display_name, - wos.quantity AS share_quantity - FROM worker_order_cancel_requests cr - INNER JOIN work_orders wo ON wo.id = cr.work_order_id - LEFT JOIN orders o ON o.id = wo.order_id - LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id - INNER JOIN worker_users wu ON wu.id = cr.worker_id - LEFT JOIN work_order_shares wos ON wos.id = cr.work_order_share_id -` - -const WORKER_ORDER_FEEDBACK_SELECT = ` - SELECT - wf.*, - COALESCE(NULLIF(BTRIM(wo.platform_order_id), ''), wo.work_order_no) AS work_order_no, - wo.product_name, - COALESCE( - NULLIF(BTRIM(o.shop_name), ''), - NULLIF(BTRIM(o.shop_id), ''), - NULLIF(BTRIM(ft.shop_name), ''), - NULLIF(BTRIM(ft.shop_id), ''), - '' - ) AS shop_name, - wu.username AS worker_username, - wu.display_name AS worker_display_name, - wos.quantity AS share_quantity - FROM worker_order_feedbacks wf - INNER JOIN work_orders wo ON wo.id = wf.work_order_id - LEFT JOIN orders o ON o.id = wo.order_id - LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id - INNER JOIN worker_users wu ON wu.id = wf.worker_id - LEFT JOIN work_order_shares wos ON wos.id = wf.work_order_share_id -` - type WorkerOrderOverviewRow = { status: string order_count: number @@ -185,1018 +120,6 @@ function workerOrderStatusExpression(workerShareAlias = 'worker_share') { END` } -export async function listWorkOrderShares( - workOrderId: number | string, -): Promise { - const result = await query( - `${WORK_ORDER_SHARE_SELECT} WHERE wos.work_order_id = $1 ORDER BY wos.id ASC`, - [Number(workOrderId)], - ) - return result.rows -} - -export async function listWorkOrderSharesByOrderIds( - workOrderIds: number[], -): Promise { - if (workOrderIds.length === 0) return [] - const result = await query( - `${WORK_ORDER_SHARE_SELECT} - WHERE wos.work_order_id = ANY($1::bigint[]) - ORDER BY wos.id ASC`, - [workOrderIds], - ) - return result.rows -} - -export async function listWorkerSharesByWorker( - workerId: number | string, -): Promise { - const result = await query( - `${WORK_ORDER_SHARE_SELECT} - WHERE wos.worker_id = $1 AND wos.status != 'cancelled' - ORDER BY wos.id DESC`, - [Number(workerId)], - ) - return result.rows -} - -export async function countWorkOrderPendingSharingSubmissions( - workOrderId: number | string, -): Promise { - const result = await query<{ total: number }>( - ` - SELECT COUNT(*)::int AS total - FROM work_order_shares - WHERE work_order_id = $1 AND status = 'joined' - `, - [Number(workOrderId)], - ) - return Number(result.rows[0]?.total || 0) -} - -export async function getWorkOrderShare( - workOrderId: number | string, - workerId: number | string, -): Promise { - const result = await query( - `${WORK_ORDER_SHARE_SELECT} - WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled' LIMIT 1`, - [Number(workOrderId), Number(workerId)], - ) - return result.rows[0] || null -} - -/** 普通拼单使用指定份数,立即抢单则在事务中承接全部剩余份数。 */ -export function resolveWorkOrderShareJoinQuantity(input: { - quantity: number - remaining: number - takeRemaining?: boolean -}): number { - return input.takeRemaining ? Math.max(0, input.remaining) : input.quantity -} - -/** 校验单个拼单份额能否撤单,并返回母工单应回退到的状态。 */ -export function resolveWorkOrderShareCancellationStatus( - workOrderStatus: string, - shareStatus: string, -): 'open' | null { - if (!['open', 'pending_acceptance'].includes(workOrderStatus)) return null - if (!['joined', 'submitted'].includes(shareStatus)) return null - return 'open' -} - -export async function joinWorkOrderShare(input: { - workOrderId: number - workerId: number - quantity: number - /** 立即抢单时在事务锁内承接全部剩余份数。 */ - takeRemaining?: boolean - maxActiveOrders: number - hallCandidateLimit?: number - visibleAfterIso?: string - now: string -}): Promise<{ - share: WorkOrderShareRow | null - failureReason: - | 'work_order_not_open' - | 'sharing_disabled' - | 'work_order_owner_conflict' - | 'sharing_quantity_full' - | 'worker_active_order_limit' - | 'worker_deposit_insufficient' - | null -}> { - return withTransaction(async (client) => { - await ensureWorkerWalletWithClient(client, input.workerId, input.now) - - const orderResult = await client.query( - ` - SELECT * - FROM work_orders - WHERE id = $1 - FOR UPDATE - `, - [input.workOrderId], - ) - const workOrder = orderResult.rows[0] || null - if (!workOrder || workOrder.status !== 'open') { - return { share: null, failureReason: 'work_order_not_open' } - } - if ( - input.hallCandidateLimit && - !(await isWorkOrderWithinHallCapacityWithClient(client, { - workOrderId: input.workOrderId, - hallCandidateLimit: input.hallCandidateLimit, - visibleAfterIso: input.visibleAfterIso, - })) - ) { - return { share: null, failureReason: 'work_order_not_open' } - } - if (workOrder.sharing_enabled !== true) { - return { share: null, failureReason: 'sharing_disabled' } - } - if (Number(workOrder.assigned_worker_id || 0) === input.workerId) { - return { share: null, failureReason: 'work_order_owner_conflict' } - } - - const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1) - const joinedResult = await client.query<{ total: number }>( - ` - SELECT COALESCE(SUM(quantity), 0)::int AS total - FROM work_order_shares - WHERE work_order_id = $1 AND status != 'cancelled' - `, - [input.workOrderId], - ) - const joinedQuantity = Number(joinedResult.rows[0]?.total || 0) - const remaining = Math.max(0, totalQuantity - joinedQuantity) - const requestedQuantity = resolveWorkOrderShareJoinQuantity({ - quantity: input.quantity, - remaining, - takeRemaining: input.takeRemaining === true, - }) - if (requestedQuantity <= 0 || requestedQuantity > remaining) { - return { share: null, failureReason: 'sharing_quantity_full' } - } - - const existingShareResult = await client.query( - `SELECT * - FROM work_order_shares - WHERE work_order_id = $1 AND worker_id = $2 - FOR UPDATE`, - [input.workOrderId, input.workerId], - ) - const existingShare = existingShareResult.rows[0] || null - if (existingShare && (!input.takeRemaining || existingShare.status !== 'joined')) { - return { share: null, failureReason: 'sharing_quantity_full' } - } - if (!existingShare) { - const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) - if (activeOrderCount >= input.maxActiveOrders) { - return { share: null, failureReason: 'worker_active_order_limit' } - } - } - - const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0) - const nextQuantity = Number(existingShare?.quantity || 0) + requestedQuantity - const shareReward = nextQuantity * unitReward - const nextShareDeposit = Math.round( - (Number(workOrder.required_deposit_amount || 0) * nextQuantity) / totalQuantity, - ) - const depositToFreeze = Math.max( - 0, - nextShareDeposit - Number(existingShare?.share_deposit || 0), - ) - - const wallet = await getWorkerWalletWithClient(client, input.workerId) - const available = Number(wallet?.available_amount || 0) - if (available < depositToFreeze) { - return { share: null, failureReason: 'worker_deposit_insufficient' } - } - - const shareResult = existingShare - ? await client.query<{ id: number }>( - ` - UPDATE work_order_shares - SET - quantity = $1, - unit_reward = $2, - share_reward = $3, - share_deposit = $4, - updated_at = $5 - WHERE id = $6 - RETURNING id - `, - [nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id], - ) - : await client.query<{ id: number }>( - ` - INSERT INTO work_order_shares ( - work_order_id, worker_id, quantity, unit_reward, share_reward, - share_deposit, status, acceptance_json, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $7) - RETURNING id - `, - [ - input.workOrderId, - input.workerId, - requestedQuantity, - unitReward, - shareReward, - nextShareDeposit, - input.now, - ], - ) - - if (depositToFreeze > 0) { - const nextAvailable = available - depositToFreeze - const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + depositToFreeze - await client.query( - ` - UPDATE worker_wallets - SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 - WHERE worker_id = $4 - `, - [nextAvailable, nextFrozen, input.now, input.workerId], - ) - await client.query( - ` - INSERT INTO worker_wallet_ledgers ( - worker_id, ledger_type, amount, balance_after, frozen_after, - related_work_order_id, note, payload_json, created_at - ) VALUES ($1, 'deposit_freeze', $2, $3, $4, $5, $6, $7::jsonb, $8) - `, - [ - input.workerId, - depositToFreeze, - nextAvailable, - nextFrozen, - input.workOrderId, - `拼单冻结押金 ${requestedQuantity} 份`, - JSON.stringify({ workOrderId: input.workOrderId, quantity: requestedQuantity }), - input.now, - ], - ) - } - - await createWorkOrderEventWithClient(client, { - workOrderId: input.workOrderId, - actorType: 'worker', - actorId: String(input.workerId), - eventType: 'sharing_joined', - fromStatus: 'open', - toStatus: 'open', - payloadJson: JSON.stringify({ - workerId: input.workerId, - quantity: requestedQuantity, - takeRemaining: input.takeRemaining === true, - }), - now: input.now, - }) - - const share = await client.query( - `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, - [Number(shareResult.rows[0]?.id || 0)], - ) - return { share: share.rows[0] || null, failureReason: null } - }) -} - -export async function submitWorkOrderShareAcceptance(input: { - workOrderId: number - workerId: number - acceptanceJson: string - now: string -}): Promise<{ - share: WorkOrderShareRow | null - failureReason: 'share_not_found' | 'share_status_invalid' | null -}> { - return withTransaction(async (client) => { - const orderResult = await client.query<{ - status: string - sharing_total_quantity: number - }>( - `SELECT status, sharing_total_quantity - FROM work_orders - WHERE id = $1 - FOR UPDATE`, - [input.workOrderId], - ) - const workOrder = orderResult.rows[0] || null - if (!workOrder || !['open', 'pending_acceptance'].includes(workOrder.status)) { - return { share: null, failureReason: 'share_not_found' } - } - - const lockResult = await client.query<{ id: number }>( - ` - SELECT id - FROM work_order_shares - WHERE work_order_id = $1 AND worker_id = $2 - FOR UPDATE - `, - [input.workOrderId, input.workerId], - ) - const shareId = lockResult.rows[0]?.id || 0 - if (!shareId) { - return { share: null, failureReason: 'share_not_found' } - } - const shareResult = await client.query( - `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, - [shareId], - ) - const share = shareResult.rows[0] || null - if (!share) { - return { share: null, failureReason: 'share_not_found' } - } - if (share.status !== 'joined') { - return { share, failureReason: 'share_status_invalid' } - } - await client.query( - ` - UPDATE work_order_shares - SET status = 'submitted', acceptance_json = $1::jsonb, draft_acceptance_json = '{}'::jsonb, submitted_at = $2, updated_at = $2 - WHERE id = $3 - `, - [input.acceptanceJson, input.now, Number(share.id)], - ) - - const progressResult = await client.query<{ - joined_quantity: number - pending_submission_count: number - }>( - `SELECT - COALESCE(SUM(quantity), 0)::int AS joined_quantity, - COUNT(*) FILTER (WHERE status = 'joined')::int AS pending_submission_count - FROM work_order_shares - WHERE work_order_id = $1 - AND status != 'cancelled'`, - [input.workOrderId], - ) - const progress = progressResult.rows[0] - const allQuantityAllocated = - Number(progress?.joined_quantity || 0) >= - toPositiveInteger(workOrder.sharing_total_quantity, 1) - if (allQuantityAllocated && Number(progress?.pending_submission_count || 0) === 0) { - await client.query( - `UPDATE work_orders - SET - status = 'pending_acceptance', - submitted_at = COALESCE(submitted_at, $1), - updated_at = $1 - WHERE id = $2 - AND status = 'open'`, - [input.now, input.workOrderId], - ) - } - - await createWorkOrderEventWithClient(client, { - workOrderId: input.workOrderId, - actorType: 'worker', - actorId: String(input.workerId), - eventType: 'sharing_acceptance_submitted', - fromStatus: 'joined', - toStatus: 'submitted', - payloadJson: JSON.stringify({ workerId: input.workerId, quantity: share.quantity }), - now: input.now, - }) - const updated = await client.query( - `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, - [Number(share.id)], - ) - return { share: updated.rows[0] || null, failureReason: null } - }) -} - -/** 保存整单验收草稿(暂存图片/说明),不改变工单状态 */ -export async function updateWorkOrderDraftAcceptance(input: { - workOrderId: number - draftJson: string - now: string -}): Promise { - await query( - ` - UPDATE work_orders - SET draft_acceptance_json = $1::jsonb, updated_at = $2 - WHERE id = $3 - `, - [input.draftJson, input.now, Number(input.workOrderId)], - ) - return getWorkOrderById(input.workOrderId) -} - -/** 更新整单已提交的验收资料(保留原 submitted_at,用于提交验收后补充/修改图片) */ -export async function updateWorkOrderAcceptanceRecord(input: { - workOrderId: number - acceptanceJson: string - now: string -}): Promise { - await query( - ` - UPDATE work_orders - SET acceptance_json = $1::jsonb, updated_at = $2 - WHERE id = $3 - `, - [input.acceptanceJson, input.now, Number(input.workOrderId)], - ) - return getWorkOrderById(input.workOrderId) -} - -/** 保存拼单份额的验收草稿(暂存图片/说明),不改变份额状态 */ -export async function updateWorkOrderShareDraftAcceptance(input: { - workOrderId: number - workerId: number - draftJson: string - now: string -}): Promise { - const result = await query( - `${WORK_ORDER_SHARE_SELECT} - WHERE wos.work_order_id = $1 AND wos.worker_id = $2`, - [Number(input.workOrderId), Number(input.workerId)], - ) - const share = result.rows[0] || null - if (!share) return null - await query( - ` - UPDATE work_order_shares - SET draft_acceptance_json = $1::jsonb, updated_at = $2 - WHERE id = $3 - `, - [input.draftJson, input.now, Number(share.id)], - ) - return getWorkOrderShareById(share.id) -} - -/** 更新拼单份额已提交的验收资料(保留原 submitted_at,用于提交验收后补充/修改图片) */ -export async function updateWorkOrderShareAcceptanceRecord(input: { - workOrderId: number - workerId: number - acceptanceJson: string - now: string -}): Promise { - const result = await query( - `${WORK_ORDER_SHARE_SELECT} - WHERE wos.work_order_id = $1 AND wos.worker_id = $2`, - [Number(input.workOrderId), Number(input.workerId)], - ) - const share = result.rows[0] || null - if (!share) return null - await query( - ` - UPDATE work_order_shares - SET acceptance_json = $1::jsonb, updated_at = $2 - WHERE id = $3 - `, - [input.acceptanceJson, input.now, Number(share.id)], - ) - return getWorkOrderShareById(share.id) -} - -export async function listWorkerWorkOrderNotes( - workerId: number | string, - workOrderIds: number[], -): Promise> { - if (workOrderIds.length === 0) return [] - const result = await query<{ work_order_id: number; note: string }>( - ` - SELECT work_order_id, note - FROM worker_work_order_notes - WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) - `, - [Number(workerId), workOrderIds], - ) - return result.rows -} - -export async function upsertWorkerWorkOrderNote(input: { - workOrderId: number - workerId: number - note: string - now: string -}): Promise { - const result = await query<{ note: string }>( - ` - INSERT INTO worker_work_order_notes ( - work_order_id, worker_id, note, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $4) - ON CONFLICT (work_order_id, worker_id) DO UPDATE - SET note = EXCLUDED.note, updated_at = EXCLUDED.updated_at - RETURNING note - `, - [input.workOrderId, input.workerId, input.note, input.now], - ) - return String(result.rows[0]?.note || '') -} - -/** 查询打手已查看过的工单,用于计算“新”标识。 */ -export async function listWorkerWorkOrderViews( - workerId: number | string, - workOrderIds: number[], -): Promise> { - if (workOrderIds.length === 0) return [] - const result = await query<{ work_order_id: number; viewed_at: string }>( - ` - SELECT work_order_id, viewed_at - FROM worker_work_order_views - WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) - `, - [Number(workerId), workOrderIds], - ) - return result.rows -} - -/** 首次查看工单时写入已读记录;重复调用保持原查看时间不变。 */ -export async function markWorkerWorkOrderViewed(input: { - workOrderId: number - workerId: number - now: string -}): Promise { - const inserted = await query<{ viewed_at: string }>( - ` - INSERT INTO worker_work_order_views (work_order_id, worker_id, viewed_at) - VALUES ($1, $2, $3) - ON CONFLICT (work_order_id, worker_id) DO NOTHING - RETURNING viewed_at - `, - [input.workOrderId, input.workerId, input.now], - ) - if (inserted.rows[0]?.viewed_at) return String(inserted.rows[0].viewed_at) - const existing = await query<{ viewed_at: string }>( - ` - SELECT viewed_at - FROM worker_work_order_views - WHERE work_order_id = $1 AND worker_id = $2 - LIMIT 1 - `, - [input.workOrderId, input.workerId], - ) - return String(existing.rows[0]?.viewed_at || input.now) -} - -async function getWorkOrderShareById(shareId: number): Promise { - const result = await query( - `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, - [shareId], - ) - return result.rows[0] || null -} - -export async function getWorkCategoryByKey(categoryKey: string): Promise { - const result = await query( - 'SELECT * FROM work_categories WHERE category_key = $1 LIMIT 1', - [String(categoryKey || '').trim()], - ) - return result.rows[0] || null -} - -export async function getWorkCategoryById( - categoryId: number | string, -): Promise { - const result = await query( - 'SELECT * FROM work_categories WHERE id = $1 LIMIT 1', - [Number(categoryId)], - ) - return result.rows[0] || null -} - -export async function listWorkCategories(): Promise { - const result = await query( - `SELECT * FROM work_categories WHERE status = 'active' ORDER BY sort_order ASC, id ASC`, - ) - return result.rows -} - -export async function listAllWorkCategories(): Promise { - const result = await query( - `SELECT * FROM work_categories ORDER BY sort_order ASC, id ASC`, - ) - return result.rows -} - -export async function upsertWorkCategory(input: { - categoryKey: string - name: string - sortOrder: number - status: string - now: string -}): Promise { - const result = await query( - ` - INSERT INTO work_categories (category_key, name, sort_order, status, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (category_key) DO UPDATE - SET name = EXCLUDED.name, sort_order = EXCLUDED.sort_order, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at - RETURNING * - `, - [input.categoryKey, input.name, input.sortOrder, input.status, input.now, input.now], - ) - return result.rows[0] || null -} - -export async function countWorkCategoryUsages(categoryId: number | string): Promise<{ - workOrderCount: number - productRuleCount: number -}> { - const [orders, rules] = await Promise.all([ - query<{ total: number }>( - 'SELECT COUNT(*)::int AS total FROM work_orders WHERE category_id = $1', - [Number(categoryId)], - ), - query<{ total: number }>( - 'SELECT COUNT(*)::int AS total FROM work_product_rules WHERE category_id = $1', - [Number(categoryId)], - ), - ]) - return { - workOrderCount: Number(orders.rows[0]?.total || 0), - productRuleCount: Number(rules.rows[0]?.total || 0), - } -} - -export async function deleteWorkCategory( - categoryId: number | string, -): Promise<{ deleted: boolean }> { - const result = await query<{ id: number }>( - 'DELETE FROM work_categories WHERE id = $1 RETURNING id', - [Number(categoryId)], - ) - return { deleted: Boolean(result.rows[0]) } -} - -export async function listWorkProductRules({ - enabled = null, - keyword = '', - categoryId = 0, -}: ProductRuleListInput = {}): Promise { - const { whereClause, params } = buildWorkProductRuleWhere({ enabled, keyword, categoryId }) - const result = await query( - `${WORK_PRODUCT_RULE_SELECT} - ${whereClause} - ORDER BY wpr.sort_order ASC, wpr.id DESC`, - params, - ) - return result.rows -} - -export async function listWorkProductRulesPage({ - page = 1, - pageSize = 20, - enabled = null, - keyword = '', - categoryId = 0, -}: ProductRuleListInput): Promise<{ items: WorkProductRuleRow[]; total: number }> { - const { whereClause, params } = buildWorkProductRuleWhere({ enabled, keyword, categoryId }) - const totalResult = await query<{ total: number }>( - ` - SELECT COUNT(*)::int AS total - FROM work_product_rules wpr - ${whereClause} - `, - params, - ) - const offset = (page - 1) * pageSize - const listParams = [...params, pageSize, offset] - const result = await query( - `${WORK_PRODUCT_RULE_SELECT} - ${whereClause} - ORDER BY wpr.sort_order ASC, wpr.id DESC - LIMIT $${listParams.length - 1} OFFSET $${listParams.length}`, - listParams, - ) - return { items: result.rows, total: Number(totalResult.rows[0]?.total || 0) } -} - -export async function countWorkProductRulesByCategory( - input: { - enabled?: boolean | null - keyword?: string - } = {}, -): Promise> { - const { whereClause, params } = buildWorkProductRuleWhere(input) - const result = await query<{ category_id: number | null; total: number }>( - ` - SELECT wpr.category_id, COUNT(*)::int AS total - FROM work_product_rules wpr - ${whereClause} - GROUP BY wpr.category_id - `, - params, - ) - return result.rows.map((row) => ({ - categoryId: row.category_id ? Number(row.category_id) : null, - total: Number(row.total || 0), - })) -} - -export async function getWorkProductRuleByKey(ruleKey: string): Promise { - const result = await query( - `${WORK_PRODUCT_RULE_SELECT} WHERE wpr.rule_key = $1 LIMIT 1`, - [String(ruleKey || '').trim()], - ) - return result.rows[0] || null -} - -export async function getWorkProductRuleById( - ruleId: number | string, -): Promise { - const result = await query( - `${WORK_PRODUCT_RULE_SELECT} WHERE wpr.id = $1 LIMIT 1`, - [Number(ruleId)], - ) - return result.rows[0] || null -} - -/** 查询仍在接单大厅、且明确来源于指定模板的工单。 */ -export async function listOpenWorkOrdersByProductRuleId( - ruleId: number | string, -): Promise { - const result = await query( - `${WORK_ORDER_SELECT} - WHERE wo.product_rule_id = $1 - AND wo.status = 'open' - ORDER BY wo.id ASC`, - [Number(ruleId)], - ) - return result.rows -} - -export type SyncOpenWorkOrderPriceResult = { - updated: boolean - reason: 'updated' | 'price_unchanged' | 'not_open' | 'assigned' | 'sharing_filled' - workOrderId: number - sharingPartial: boolean - workerIds: number[] -} - -/** 拼单改价时保留已加入份额的报酬,只按新价计算剩余份额。 */ -export function resolveOpenSharingSyncReward(input: { - totalQuantity: number - joinedQuantity: number - lockedShareReward: number - nextUnitReward: number -}) { - const totalQuantity = toPositiveInteger(input.totalQuantity, 1) - const joinedQuantity = Math.min(totalQuantity, Math.max(0, Number(input.joinedQuantity || 0))) - return ( - Math.max(0, Number(input.lockedShareReward || 0)) + - (totalQuantity - joinedQuantity) * toPositiveInteger(input.nextUnitReward, 0) - ) -} - -/** - * 同步大厅工单的模板价格。 - * 已加入的拼单份额报酬是合同快照,只更新尚未加入的剩余份额价格。 - */ -export async function syncOpenWorkOrderPriceFromRule(input: { - workOrderId: number - productRuleId: number - rewardAmount: number - sharingUnitReward?: number - actorName: string - now: string -}): Promise { - return withTransaction(async (client) => { - const orderResult = await client.query( - `SELECT * - FROM work_orders - WHERE id = $1 AND product_rule_id = $2 - FOR UPDATE`, - [input.workOrderId, input.productRuleId], - ) - const workOrder = orderResult.rows[0] || null - if (!workOrder || workOrder.status !== 'open') { - return { - updated: false, - reason: 'not_open', - workOrderId: input.workOrderId, - sharingPartial: false, - workerIds: [], - } - } - if (workOrder.assigned_worker_id) { - return { - updated: false, - reason: 'assigned', - workOrderId: input.workOrderId, - sharingPartial: false, - workerIds: [], - } - } - - const sharesResult = await client.query( - `SELECT * - FROM work_order_shares - WHERE work_order_id = $1 - FOR UPDATE`, - [workOrder.id], - ) - const activeShares = sharesResult.rows.filter((share) => share.status !== 'cancelled') - const joinedQuantity = activeShares.reduce( - (sum, share) => sum + Math.max(0, Number(share.quantity || 0)), - 0, - ) - const workerIds = [ - ...new Set( - activeShares.map((share) => Number(share.worker_id)).filter((workerId) => workerId > 0), - ), - ] - const sharingPartial = workOrder.sharing_enabled === true && joinedQuantity > 0 - let nextRewardAmount = toPositiveInteger(input.rewardAmount, 0) - let nextSharingUnitReward = Number(workOrder.sharing_unit_reward || 0) - - if (workOrder.sharing_enabled === true) { - const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1) - if (joinedQuantity >= totalQuantity) { - return { - updated: false, - reason: 'sharing_filled', - workOrderId: Number(workOrder.id), - sharingPartial, - workerIds, - } - } - nextSharingUnitReward = toPositiveInteger(input.sharingUnitReward, 0) - const lockedShareReward = activeShares.reduce( - (sum, share) => sum + Math.max(0, Number(share.share_reward || 0)), - 0, - ) - nextRewardAmount = resolveOpenSharingSyncReward({ - totalQuantity, - joinedQuantity, - lockedShareReward, - nextUnitReward: nextSharingUnitReward, - }) - } - - const unchanged = - Number(workOrder.reward_amount || 0) === nextRewardAmount && - (!workOrder.sharing_enabled || - Number(workOrder.sharing_unit_reward || 0) === nextSharingUnitReward) - if (unchanged) { - return { - updated: false, - reason: 'price_unchanged', - workOrderId: Number(workOrder.id), - sharingPartial, - workerIds, - } - } - - await client.query( - `UPDATE work_orders - SET reward_amount = $1, - sharing_unit_reward = $2, - updated_at = $3 - WHERE id = $4`, - [nextRewardAmount, nextSharingUnitReward, input.now, workOrder.id], - ) - await client.query( - `INSERT INTO work_order_events ( - work_order_id, actor_type, actor_id, event_type, from_status, to_status, payload_json, created_at - ) VALUES ($1, 'admin', $2, 'price_synced_from_rule', $3, $3, $4::jsonb, $5)`, - [ - workOrder.id, - input.actorName, - workOrder.status, - JSON.stringify({ - productRuleId: input.productRuleId, - previousRewardAmount: Number(workOrder.reward_amount || 0), - rewardAmount: nextRewardAmount, - previousSharingUnitReward: Number(workOrder.sharing_unit_reward || 0), - sharingUnitReward: nextSharingUnitReward, - joinedQuantity, - lockedShareReward: activeShares.reduce( - (sum, share) => sum + Math.max(0, Number(share.share_reward || 0)), - 0, - ), - }), - input.now, - ], - ) - return { - updated: true, - reason: 'updated', - workOrderId: Number(workOrder.id), - sharingPartial, - workerIds, - } - }) -} - -export async function deleteWorkProductRule( - ruleId: number | string, -): Promise<{ deleted: boolean }> { - const result = await query<{ id: number }>( - 'DELETE FROM work_product_rules WHERE id = $1 RETURNING id', - [Number(ruleId)], - ) - return { deleted: Boolean(result.rows[0]) } -} - -export async function upsertWorkProductRule(input: { - ruleKey: string - provider: string - platform: string - shopId: string - skuCode: string - productName: string - matchType: string - categoryId: number | null - enabled: boolean - autoCreate: boolean - rewardAmount: number - unitPriceFen?: number - requiredDepositAmount: number - depositThresholdAmount: number - sharingEnabled?: boolean - sharingTotalQuantity?: number - sharingUnitReward?: number - sharingAutoFromOrder?: boolean - timeoutMinutes?: number - timeoutPolicy?: string - requirementJson: string - matchJson?: string - sortOrder: number - now: string -}): Promise { - const result = await query( - ` - INSERT INTO work_product_rules ( - rule_key, provider, platform, shop_id, sku_code, product_name, - match_type, category_id, enabled, auto_create, reward_amount, - unit_price_fen, - required_deposit_amount, deposit_threshold_amount, - sharing_enabled, sharing_total_quantity, sharing_unit_reward, sharing_auto_from_order, - timeout_minutes, timeout_policy, - requirement_json, match_json, - sort_order, created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, $10, $11, - $12, - $13, $14, - $15, $16, $17, $18, - $19, $20, - $21::jsonb, $22::jsonb, - $23, $24, $25 - ) - ON CONFLICT (rule_key) DO UPDATE - SET - provider = EXCLUDED.provider, - platform = EXCLUDED.platform, - shop_id = EXCLUDED.shop_id, - sku_code = EXCLUDED.sku_code, - product_name = EXCLUDED.product_name, - match_type = EXCLUDED.match_type, - category_id = EXCLUDED.category_id, - enabled = EXCLUDED.enabled, - auto_create = EXCLUDED.auto_create, - reward_amount = EXCLUDED.reward_amount, - unit_price_fen = EXCLUDED.unit_price_fen, - required_deposit_amount = EXCLUDED.required_deposit_amount, - deposit_threshold_amount = EXCLUDED.deposit_threshold_amount, - sharing_enabled = EXCLUDED.sharing_enabled, - sharing_total_quantity = EXCLUDED.sharing_total_quantity, - sharing_unit_reward = EXCLUDED.sharing_unit_reward, - sharing_auto_from_order = EXCLUDED.sharing_auto_from_order, - timeout_minutes = EXCLUDED.timeout_minutes, - timeout_policy = EXCLUDED.timeout_policy, - requirement_json = EXCLUDED.requirement_json, - match_json = EXCLUDED.match_json, - sort_order = EXCLUDED.sort_order, - updated_at = EXCLUDED.updated_at - RETURNING * - `, - [ - input.ruleKey, - input.provider, - input.platform, - input.shopId, - input.skuCode, - input.productName, - input.matchType, - input.categoryId, - input.enabled, - input.autoCreate, - input.rewardAmount, - Math.max(0, toPositiveInteger(input.unitPriceFen, 0)), - input.requiredDepositAmount, - input.depositThresholdAmount, - input.sharingEnabled === true, - toPositiveInteger(input.sharingTotalQuantity, 1), - toPositiveInteger(input.sharingUnitReward, 0), - input.sharingAutoFromOrder === true, - toPositiveInteger(input.timeoutMinutes, 0), - String(input.timeoutPolicy || 'reopen').trim(), - input.requirementJson, - input.matchJson || '{}', - input.sortOrder, - input.now, - input.now, - ], - ) - return getWorkProductRuleByKey(result.rows[0]?.rule_key || input.ruleKey) -} - export async function createWorkOrder(input: CreateWorkOrderInput): Promise { const result = await query<{ id: number }>( ` @@ -1969,548 +892,6 @@ export async function cancelWorkerWorkOrder(input: { } /** 打手提交工单问题反馈,不改变工单状态、押金或资金流水。 */ -export async function createWorkerOrderFeedback(input: { - workOrderId: number - workerId: number - content: string - now: string -}): Promise<{ feedback: WorkerOrderFeedbackRow | null; failureReason: 'owner_required' | null }> { - return withTransaction(async (client) => { - const orderResult = await client.query( - 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', - [input.workOrderId], - ) - const order = orderResult.rows[0] || null - if (!order) return { feedback: null, failureReason: 'owner_required' } - - const shareResult = await client.query( - `SELECT * FROM work_order_shares - WHERE work_order_id = $1 AND worker_id = $2 AND status != 'cancelled' - FOR UPDATE`, - [input.workOrderId, input.workerId], - ) - const share = shareResult.rows[0] || null - const isOrderOwner = - Number(order.assigned_worker_id || 0) === input.workerId || - Number(order.last_assigned_worker_id || 0) === input.workerId - if (!share && !isOrderOwner) return { feedback: null, failureReason: 'owner_required' } - - const result = await client.query<{ id: number }>( - `INSERT INTO worker_order_feedbacks ( - work_order_id, work_order_share_id, worker_id, content, status, created_at, updated_at - ) VALUES ($1, $2, $3, $4, 'pending', $5, $5) - RETURNING id`, - [input.workOrderId, share?.id || null, input.workerId, input.content, input.now], - ) - const feedbackId = Number(result.rows[0]?.id || 0) - await createWorkOrderEventWithClient(client, { - workOrderId: input.workOrderId, - actorType: 'worker', - actorId: String(input.workerId), - eventType: 'worker_feedback_submitted', - fromStatus: order.status, - toStatus: order.status, - payloadJson: JSON.stringify({ - feedbackId, - shareId: share?.id || null, - content: input.content, - }), - now: input.now, - }) - return { - feedback: await getWorkerOrderFeedbackByIdWithClient(client, feedbackId), - failureReason: null, - } - }) -} - -export async function listWorkerOrderFeedbacks( - workerId: number | string, -): Promise { - const result = await query( - `${WORKER_ORDER_FEEDBACK_SELECT} - WHERE wf.worker_id = $1 - ORDER BY wf.id DESC - LIMIT 100`, - [Number(workerId)], - ) - return result.rows -} - -export async function listAdminWorkerOrderFeedbacks( - status = 'pending', -): Promise { - const result = await query( - `${WORKER_ORDER_FEEDBACK_SELECT} - WHERE ($1 = '' OR wf.status = $1) - ORDER BY wf.id DESC - LIMIT 200`, - [status], - ) - return result.rows -} - -export async function resolveWorkerOrderFeedback(input: { - feedbackId: number - reply: string - actorName: string - now: string -}): Promise<{ - feedback: WorkerOrderFeedbackRow | null - order: WorkOrderRow | null - failureReason: 'not_found' | 'not_pending' | null -}> { - return withTransaction(async (client) => { - const feedbackResult = await client.query( - 'SELECT * FROM worker_order_feedbacks WHERE id = $1 FOR UPDATE', - [input.feedbackId], - ) - const feedback = feedbackResult.rows[0] || null - if (!feedback) return { feedback: null, order: null, failureReason: 'not_found' } - if (feedback.status !== 'pending') { - return { - feedback: await getWorkerOrderFeedbackByIdWithClient(client, feedback.id), - order: null, - failureReason: 'not_pending', - } - } - const orderResult = await client.query( - 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', - [feedback.work_order_id], - ) - const order = orderResult.rows[0] || null - if (!order) return { feedback: null, order: null, failureReason: 'not_found' } - await client.query( - `UPDATE worker_order_feedbacks - SET status = 'resolved', reply = $1, handled_by = $2, handled_at = $3, updated_at = $3 - WHERE id = $4`, - [input.reply, input.actorName, input.now, feedback.id], - ) - await createWorkOrderEventWithClient(client, { - workOrderId: Number(order.id), - actorType: 'admin', - actorId: input.actorName, - eventType: 'worker_feedback_resolved', - fromStatus: order.status, - toStatus: order.status, - payloadJson: JSON.stringify({ feedbackId: feedback.id, reply: input.reply }), - now: input.now, - }) - return { - feedback: await getWorkerOrderFeedbackByIdWithClient(client, feedback.id), - order: await getWorkOrderByIdWithClient(client, order.id), - failureReason: null, - } - }) -} - -/** 打手提交撤单申请;待审核期间订单与押金均保持原状。 */ -export async function createWorkerCancelRequest(input: { - workOrderId: number - workerId: number - reason: string - now: string -}): Promise<{ - request: WorkerCancelRequestRow | null - dailyUsed: number - failureReason: - | 'work_order_not_cancellable' - | 'work_order_owner_required' - | 'share_not_cancellable' - | 'request_pending' - | 'daily_limit_reached' - | null -}> { - return withTransaction(async (client) => { - const orderResult = await client.query( - 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', - [input.workOrderId], - ) - const workOrder = orderResult.rows[0] || null - if (!workOrder) { - return { request: null, dailyUsed: 0, failureReason: 'work_order_not_cancellable' } - } - - let shareId: number | null = null - if (workOrder.sharing_enabled === true) { - const shareResult = await client.query( - `SELECT * FROM work_order_shares - WHERE work_order_id = $1 AND worker_id = $2 - FOR UPDATE`, - [input.workOrderId, input.workerId], - ) - const share = shareResult.rows[0] || null - if (!share || !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)) { - return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' } - } - shareId = Number(share.id) - } else if ( - workOrder.status !== 'in_progress' || - Number(workOrder.assigned_worker_id || 0) !== input.workerId - ) { - return { - request: null, - dailyUsed: 0, - failureReason: workOrder.assigned_worker_id - ? 'work_order_not_cancellable' - : 'work_order_owner_required', - } - } - - const dailyResult = await client.query<{ total: number }>( - `SELECT COUNT(*)::int AS total - FROM worker_order_cancel_requests - WHERE worker_id = $1 - AND status IN ('pending', 'approved') - AND created_at >= date_trunc('day', $2::timestamptz AT TIME ZONE 'Asia/Shanghai') AT TIME ZONE 'Asia/Shanghai'`, - [input.workerId, input.now], - ) - const dailyUsed = Number(dailyResult.rows[0]?.total || 0) - if (dailyUsed >= 5) { - return { request: null, dailyUsed, failureReason: 'daily_limit_reached' } - } - - const pendingResult = await client.query<{ id: number }>( - `SELECT id FROM worker_order_cancel_requests - WHERE work_order_id = $1 - AND worker_id = $2 - AND COALESCE(work_order_share_id, 0) = $3 - AND status = 'pending' - LIMIT 1`, - [input.workOrderId, input.workerId, shareId || 0], - ) - if (pendingResult.rows[0]) { - return { request: null, dailyUsed, failureReason: 'request_pending' } - } - - const created = await client.query<{ id: number }>( - `INSERT INTO worker_order_cancel_requests ( - work_order_id, work_order_share_id, worker_id, reason, status, created_at, updated_at - ) VALUES ($1, $2, $3, $4, 'pending', $5, $5) - RETURNING id`, - [input.workOrderId, shareId, input.workerId, input.reason, input.now], - ) - const requestId = Number(created.rows[0]?.id || 0) - await createWorkOrderEventWithClient(client, { - workOrderId: input.workOrderId, - actorType: 'worker', - actorId: String(input.workerId), - eventType: 'cancel_request_submitted', - fromStatus: workOrder.status, - toStatus: workOrder.status, - payloadJson: JSON.stringify({ requestId, shareId, reason: input.reason }), - now: input.now, - }) - return { - request: await getWorkerCancelRequestByIdWithClient(client, requestId), - dailyUsed: dailyUsed + 1, - failureReason: null, - } - }) -} - -export async function listWorkerCancelRequests( - workerId: number | string, -): Promise { - const result = await query( - `${WORKER_CANCEL_REQUEST_SELECT} - WHERE cr.worker_id = $1 - ORDER BY cr.id DESC - LIMIT 100`, - [Number(workerId)], - ) - return result.rows -} - -export async function listAdminWorkerCancelRequests( - status = 'pending', -): Promise { - const result = await query( - `${WORKER_CANCEL_REQUEST_SELECT} - WHERE ($1 = '' OR cr.status = $1) - ORDER BY cr.id DESC - LIMIT 200`, - [status], - ) - return result.rows -} - -export async function getWorkerCancelRequestRisk(workerId: number | string, now: string) { - const result = await query<{ daily_used: number; approved_total: number }>( - `SELECT - COUNT(*) FILTER ( - WHERE status IN ('pending', 'approved') - AND created_at >= date_trunc('day', $2::timestamptz AT TIME ZONE 'Asia/Shanghai') AT TIME ZONE 'Asia/Shanghai' - )::int AS daily_used, - COUNT(*) FILTER (WHERE status = 'approved')::int AS approved_total - FROM worker_order_cancel_requests - WHERE worker_id = $1`, - [Number(workerId), now], - ) - const row = result.rows[0] - const approvedTotal = Number(row?.approved_total || 0) - const nextPenaltyAt = Math.ceil((approvedTotal + 1) / 10) * 10 - return { - dailyUsed: Number(row?.daily_used || 0), - dailyLimit: 5, - approvedTotal, - nextPenaltyAt, - remainingToPenalty: Math.max(0, nextPenaltyAt - approvedTotal), - } -} - -/** 客服审核通过时在同一事务内撤单、退押金并执行等级处罚。 */ -export async function reviewWorkerCancelRequest(input: { - requestId: number - approved: boolean - reviewNote: string - actorName: string - now: string -}): Promise<{ - request: WorkerCancelRequestRow | null - order: WorkOrderRow | null - releasedDepositAmount: number - levelChanged: boolean - accountDisabled: boolean - failureReason: 'request_not_found' | 'request_not_pending' | 'target_state_changed' | null -}> { - return withTransaction(async (client) => { - const requestResult = await client.query( - 'SELECT * FROM worker_order_cancel_requests WHERE id = $1 FOR UPDATE', - [input.requestId], - ) - const request = requestResult.rows[0] || null - if (!request) { - return { - request: null, - order: null, - releasedDepositAmount: 0, - levelChanged: false, - accountDisabled: false, - failureReason: 'request_not_found', - } - } - if (request.status !== 'pending') { - return { - request: await getWorkerCancelRequestByIdWithClient(client, request.id), - order: null, - releasedDepositAmount: 0, - levelChanged: false, - accountDisabled: false, - failureReason: 'request_not_pending', - } - } - - const workOrderResult = await client.query( - 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', - [request.work_order_id], - ) - const workOrder = workOrderResult.rows[0] || null - if (!workOrder) { - return { - request: null, - order: null, - releasedDepositAmount: 0, - levelChanged: false, - accountDisabled: false, - failureReason: 'target_state_changed', - } - } - - if (!input.approved) { - await client.query( - `UPDATE worker_order_cancel_requests - SET status = 'rejected', review_note = $1, reviewed_by = $2, reviewed_at = $3, updated_at = $3 - WHERE id = $4`, - [input.reviewNote, input.actorName, input.now, request.id], - ) - await createWorkOrderEventWithClient(client, { - workOrderId: Number(workOrder.id), - actorType: 'admin', - actorId: input.actorName, - eventType: 'cancel_request_rejected', - fromStatus: workOrder.status, - toStatus: workOrder.status, - payloadJson: JSON.stringify({ requestId: request.id, note: input.reviewNote }), - now: input.now, - }) - return { - request: await getWorkerCancelRequestByIdWithClient(client, request.id), - order: await getWorkOrderByIdWithClient(client, workOrder.id), - releasedDepositAmount: 0, - levelChanged: false, - accountDisabled: false, - failureReason: null, - } - } - - let releasedDepositAmount = 0 - if (request.work_order_share_id) { - const shareResult = await client.query( - `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, - [request.work_order_share_id, request.work_order_id], - ) - const share = shareResult.rows[0] || null - if ( - !share || - Number(share.worker_id) !== Number(request.worker_id) || - !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status) - ) { - return { - request: null, - order: null, - releasedDepositAmount: 0, - levelChanged: false, - accountDisabled: false, - failureReason: 'target_state_changed', - } - } - await ensureWorkerWalletWithClient(client, Number(request.worker_id), input.now) - const wallet = await getWorkerWalletWithClient(client, Number(request.worker_id)) - releasedDepositAmount = Math.min( - Number(share.share_deposit || 0), - Number(wallet?.frozen_deposit_amount || 0), - ) - if (releasedDepositAmount > 0) { - const nextAvailable = Number(wallet?.available_amount || 0) + releasedDepositAmount - const nextFrozen = Math.max( - 0, - Number(wallet?.frozen_deposit_amount || 0) - releasedDepositAmount, - ) - await client.query( - `UPDATE worker_wallets - SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 - WHERE worker_id = $4`, - [nextAvailable, nextFrozen, input.now, request.worker_id], - ) - await client.query( - `INSERT INTO worker_wallet_ledgers ( - worker_id, ledger_type, amount, balance_after, frozen_after, - related_work_order_id, note, payload_json, created_at - ) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '撤单申请审核通过退还拼单押金', $6::jsonb, $7)`, - [ - request.worker_id, - releasedDepositAmount, - nextAvailable, - nextFrozen, - request.work_order_id, - JSON.stringify({ requestId: request.id, shareId: share.id, reason: request.reason }), - input.now, - ], - ) - } - await client.query( - `UPDATE work_order_shares SET status = 'cancelled', updated_at = $1 WHERE id = $2`, - [input.now, share.id], - ) - await client.query( - `UPDATE work_orders - SET status = 'open', submitted_at = NULL, published_at = COALESCE(published_at, $1), - hall_queued_at = COALESCE(hall_queued_at, $1), updated_at = $1 - WHERE id = $2`, - [input.now, workOrder.id], - ) - } else { - if ( - workOrder.status !== 'in_progress' || - Number(workOrder.assigned_worker_id || 0) !== Number(request.worker_id) - ) { - return { - request: null, - order: null, - releasedDepositAmount: 0, - levelChanged: false, - accountDisabled: false, - failureReason: 'target_state_changed', - } - } - await ensureWorkerWalletWithClient(client, Number(request.worker_id), input.now) - const wallet = await getWorkerWalletWithClient(client, Number(request.worker_id)) - releasedDepositAmount = Math.min( - await getOutstandingDepositAmountWithClient( - client, - Number(request.worker_id), - workOrder.id, - ), - Number(wallet?.frozen_deposit_amount || 0), - ) - await client.query( - `UPDATE work_orders - SET status = 'open', assigned_worker_id = NULL, assigned_at = NULL, deadline_at = NULL, - published_at = $1, hall_queued_at = $1, updated_at = $1 - WHERE id = $2`, - [input.now, workOrder.id], - ) - if (releasedDepositAmount > 0) { - const nextAvailable = Number(wallet?.available_amount || 0) + releasedDepositAmount - const nextFrozen = Math.max( - 0, - Number(wallet?.frozen_deposit_amount || 0) - releasedDepositAmount, - ) - await client.query( - `UPDATE worker_wallets - SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 - WHERE worker_id = $4`, - [nextAvailable, nextFrozen, input.now, request.worker_id], - ) - await client.query( - `INSERT INTO worker_wallet_ledgers ( - worker_id, ledger_type, amount, balance_after, frozen_after, - related_work_order_id, note, payload_json, created_at - ) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '撤单申请审核通过退还押金', $6::jsonb, $7)`, - [ - request.worker_id, - releasedDepositAmount, - nextAvailable, - nextFrozen, - request.work_order_id, - JSON.stringify({ requestId: request.id, reason: request.reason }), - input.now, - ], - ) - } - } - - const penalty = await applyWorkerCancelRequestPenaltyWithClient(client, { - workerId: Number(request.worker_id), - requestId: Number(request.id), - now: input.now, - }) - await client.query( - `UPDATE worker_order_cancel_requests - SET status = 'approved', review_note = $1, reviewed_by = $2, reviewed_at = $3, - level_before_id = $4, updated_at = $3 - WHERE id = $5`, - [input.reviewNote, input.actorName, input.now, penalty.levelBeforeId, request.id], - ) - const updatedOrder = await getWorkOrderByIdWithClient(client, workOrder.id) - await createWorkOrderEventWithClient(client, { - workOrderId: Number(workOrder.id), - actorType: 'admin', - actorId: input.actorName, - eventType: 'cancel_request_approved', - fromStatus: workOrder.status, - toStatus: updatedOrder?.status || workOrder.status, - payloadJson: JSON.stringify({ - requestId: request.id, - shareId: request.work_order_share_id, - releasedDepositAmount, - levelChanged: penalty.levelChanged, - accountDisabled: penalty.accountDisabled, - note: input.reviewNote, - }), - now: input.now, - }) - return { - request: await getWorkerCancelRequestByIdWithClient(client, request.id), - order: updatedOrder, - releasedDepositAmount, - levelChanged: penalty.levelChanged, - accountDisabled: penalty.accountDisabled, - failureReason: null, - } - }) -} export async function unassignWorkOrder(input: { workOrderId: number @@ -4164,262 +2545,7 @@ export async function countWorkerTimeoutEvents(workerId: number | string): Promi return counts.get(Number(workerId)) || 0 } -export type WorkOrderEventRow = { - id: number - work_order_id: number - actor_type: string - actor_id: string - event_type: string - from_status: string - to_status: string - payload_json: string | Record - created_at: string -} - -export async function listWorkOrderEventsByOrderId( - workOrderId: number | string, -): Promise { - const result = await query( - ` - SELECT * - FROM work_order_events - WHERE work_order_id = $1 - ORDER BY id ASC - `, - [Number(workOrderId)], - ) - return result.rows -} - -export async function createWorkOrderEvent(input: { - workOrderId: number - actorType: string - actorId: string - eventType: string - fromStatus?: string - toStatus?: string - payloadJson?: string - now: string -}): Promise { - await query( - ` - INSERT INTO work_order_events ( - work_order_id, actor_type, actor_id, event_type, from_status, to_status, payload_json, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8) - `, - [ - input.workOrderId, - input.actorType, - input.actorId, - input.eventType, - input.fromStatus || '', - input.toStatus || '', - input.payloadJson || '{}', - input.now, - ], - ) -} - -async function createWorkOrderEventWithClient( - client: PoolClient, - input: Parameters[0], -) { - await client.query( - ` - INSERT INTO work_order_events ( - work_order_id, actor_type, actor_id, event_type, from_status, to_status, payload_json, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8) - `, - [ - input.workOrderId, - input.actorType, - input.actorId, - input.eventType, - input.fromStatus || '', - input.toStatus || '', - input.payloadJson || '{}', - input.now, - ], - ) -} - -async function getWorkerCancelRequestByIdWithClient( - client: PoolClient, - requestId: number | string, -): Promise { - const result = await client.query( - `${WORKER_CANCEL_REQUEST_SELECT} WHERE cr.id = $1 LIMIT 1`, - [Number(requestId)], - ) - return result.rows[0] || null -} - -async function getWorkerOrderFeedbackByIdWithClient( - client: PoolClient, - feedbackId: number | string, -): Promise { - const result = await client.query( - `${WORKER_ORDER_FEEDBACK_SELECT} WHERE wf.id = $1 LIMIT 1`, - [Number(feedbackId)], - ) - return result.rows[0] || null -} - -async function countWorkerAcceptedOrdersWithClient( - client: PoolClient, - workerId: number, -): Promise { - const result = await client.query<{ total: number }>( - `SELECT COUNT(DISTINCT order_key)::int AS total - FROM ( - SELECT COALESCE( - 'order:' || NULLIF(wo.order_id::text, ''), - 'platform_order:' || NULLIF(BTRIM(wo.platform_order_id), ''), - 'work_order:' || wo.id::text - ) AS order_key - FROM work_orders wo - WHERE wo.assigned_worker_id = $1 AND wo.status = 'accepted' - - UNION - - SELECT COALESCE( - 'order:' || NULLIF(wo.order_id::text, ''), - 'platform_order:' || NULLIF(BTRIM(wo.platform_order_id), ''), - 'work_order:' || wo.id::text - ) AS order_key - FROM work_order_shares wos - INNER JOIN work_orders wo ON wo.id = wos.work_order_id - WHERE wos.worker_id = $1 AND wos.status = 'accepted' - ) AS completed_orders`, - [workerId], - ) - return Number(result.rows[0]?.total || 0) -} - -/** 将有效验收次数压到低一级门槛,保留真实订单与资金流水。 */ -async function applyWorkerCancelRequestPenaltyWithClient( - client: PoolClient, - input: { workerId: number; requestId: number; now: string }, -): Promise<{ levelBeforeId: number | null; levelChanged: boolean; accountDisabled: boolean }> { - const workerResult = await client.query<{ - level_id: number | null - level_key: string - level_name: string - sort_order: number - level_penalty_accepted_count: number - }>( - `SELECT wu.level_id, wl.level_key, wl.name AS level_name, wl.sort_order, - wu.level_penalty_accepted_count - FROM worker_users wu - LEFT JOIN worker_levels wl ON wl.id = wu.level_id - WHERE wu.id = $1 - FOR UPDATE OF wu`, - [input.workerId], - ) - const worker = workerResult.rows[0] - const levelBeforeId = worker?.level_id ? Number(worker.level_id) : null - if (!worker || !levelBeforeId) { - return { levelBeforeId, levelChanged: false, accountDisabled: false } - } - - const approvedResult = await client.query<{ total: number }>( - `SELECT COUNT(*)::int AS total - FROM worker_order_cancel_requests - WHERE worker_id = $1 AND status = 'approved'`, - [input.workerId], - ) - const approvedTotal = Number(approvedResult.rows[0]?.total || 0) + 1 - const isVip1 = String(worker.level_key || '').toLowerCase() === 'vip1' - - if (isVip1) { - const vip1ApprovedResult = await client.query<{ total: number }>( - `SELECT COUNT(*)::int AS total - FROM worker_order_cancel_requests - WHERE worker_id = $1 AND status = 'approved' AND level_before_id = $2`, - [input.workerId, levelBeforeId], - ) - const vip1ApprovedTotal = Number(vip1ApprovedResult.rows[0]?.total || 0) + 1 - if (vip1ApprovedTotal >= 5) { - await client.query( - `UPDATE worker_users - SET status = 'disabled', session_version = session_version + 1, updated_at = $1 - WHERE id = $2`, - [input.now, input.workerId], - ) - await client.query( - `UPDATE worker_sessions - SET status = 'revoked', revoked_at = $1, updated_at = $1 - WHERE worker_id = $2 AND status = 'active'`, - [input.now, input.workerId], - ) - await client.query( - `INSERT INTO worker_level_logs ( - worker_id, from_level_id, to_level_id, reason, payload_json, created_at - ) VALUES ($1, $2, $2, 'cancel_request_ban', $3::jsonb, $4)`, - [ - input.workerId, - levelBeforeId, - JSON.stringify({ requestId: input.requestId, vip1ApprovedTotal, approvedTotal }), - input.now, - ], - ) - return { levelBeforeId, levelChanged: false, accountDisabled: true } - } - return { levelBeforeId, levelChanged: false, accountDisabled: false } - } - - if (approvedTotal % 10 !== 0) { - return { levelBeforeId, levelChanged: false, accountDisabled: false } - } - - const lowerLevelResult = await client.query<{ - id: number - level_key: string - threshold: number - }>( - `SELECT id, level_key, COALESCE((permission_json->>'upgradeThreshold')::int, 0) AS threshold - FROM worker_levels - WHERE status = 'active' AND sort_order < $1 - ORDER BY sort_order DESC, id DESC - LIMIT 1`, - [Number(worker.sort_order || 0)], - ) - const lowerLevel = lowerLevelResult.rows[0] - if (!lowerLevel) { - return { levelBeforeId, levelChanged: false, accountDisabled: false } - } - const actualAcceptedCount = await countWorkerAcceptedOrdersWithClient(client, input.workerId) - const nextPenaltyAcceptedCount = Math.max( - Number(worker.level_penalty_accepted_count || 0), - actualAcceptedCount - Math.max(0, Number(lowerLevel.threshold || 0)), - ) - await client.query( - `UPDATE worker_users - SET level_id = $1, level_penalty_accepted_count = $2, updated_at = $3 - WHERE id = $4`, - [lowerLevel.id, nextPenaltyAcceptedCount, input.now, input.workerId], - ) - await client.query( - `INSERT INTO worker_level_logs ( - worker_id, from_level_id, to_level_id, reason, payload_json, created_at - ) VALUES ($1, $2, $3, 'cancel_request_penalty', $4::jsonb, $5)`, - [ - input.workerId, - levelBeforeId, - lowerLevel.id, - JSON.stringify({ - requestId: input.requestId, - approvedTotal, - actualAcceptedCount, - penaltyAcceptedCount: nextPenaltyAcceptedCount, - }), - input.now, - ], - ) - return { levelBeforeId, levelChanged: true, accountDisabled: false } -} - -async function getOutstandingDepositAmountWithClient( +export async function getOutstandingDepositAmountWithClient( client: PoolClient, workerId: number, workOrderId: number, @@ -4443,7 +2569,7 @@ async function getOutstandingDepositAmountWithClient( return resolveOutstandingDepositAmount(result.rows[0]) } -async function countWorkerActiveOrdersWithClient( +export async function countWorkerActiveOrdersWithClient( client: PoolClient, workerId: number, ): Promise { @@ -4486,7 +2612,7 @@ export function resolveOutstandingDepositAmount( ) } -async function getWorkOrderByIdWithClient( +export async function getWorkOrderByIdWithClient( client: PoolClient, workOrderId: number | string, ): Promise { @@ -4701,7 +2827,7 @@ function buildHallCandidateIdsSql(limitPlaceholder: string) { } /** 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 */ -async function isWorkOrderWithinHallCapacityWithClient( +export async function isWorkOrderWithinHallCapacityWithClient( client: PoolClient, input: { workOrderId: number; hallCandidateLimit: number; visibleAfterIso?: string | undefined }, ) { @@ -4723,32 +2849,3 @@ async function isWorkOrderWithinHallCapacityWithClient( ) return result.rows[0]?.visible === true } - -function buildWorkProductRuleWhere({ - enabled = null, - keyword = '', - categoryId = 0, -}: ProductRuleListInput) { - const filters: string[] = [] - const params: unknown[] = [] - if (enabled !== null && enabled !== undefined) { - params.push(Boolean(enabled)) - filters.push(`wpr.enabled = $${params.length}`) - } - if (keyword) { - params.push(`%${keyword}%`) - filters.push(`( - wpr.rule_key ILIKE $${params.length} - OR wpr.sku_code ILIKE $${params.length} - OR wpr.product_name ILIKE $${params.length} - )`) - } - if (categoryId) { - params.push(categoryId) - filters.push(`wpr.category_id = $${params.length}`) - } - return { - whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', - params, - } -} diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts new file mode 100644 index 00000000..596727d3 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -0,0 +1,478 @@ +import { query, withTransaction } from '../../db/client.js' +import { + ensureWorkerWalletWithClient, + getWorkerWalletWithClient, + toPositiveInteger, + WORK_ORDER_SHARE_SELECT, +} from './shared.js' +import { createWorkOrderEventWithClient } from './work-order-event-repo.js' +import { + countWorkerActiveOrdersWithClient, + getWorkOrderById, + isWorkOrderWithinHallCapacityWithClient, +} from './work-order-repo.js' +import type { WorkOrderRow, WorkOrderShareRow } from './types.js' + +async function getWorkOrderShareById(shareId: number): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [shareId], + ) + return result.rows[0] || null +} + +export async function listWorkOrderShares( + workOrderId: number | string, +): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.work_order_id = $1 ORDER BY wos.id ASC`, + [Number(workOrderId)], + ) + return result.rows +} + +export async function listWorkOrderSharesByOrderIds( + workOrderIds: number[], +): Promise { + if (workOrderIds.length === 0) return [] + const result = await query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.work_order_id = ANY($1::bigint[]) + ORDER BY wos.id ASC`, + [workOrderIds], + ) + return result.rows +} + +export async function listWorkerSharesByWorker( + workerId: number | string, +): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.worker_id = $1 AND wos.status != 'cancelled' + ORDER BY wos.id DESC`, + [Number(workerId)], + ) + return result.rows +} + +export async function countWorkOrderPendingSharingSubmissions( + workOrderId: number | string, +): Promise { + const result = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_order_shares + WHERE work_order_id = $1 AND status = 'joined' + `, + [Number(workOrderId)], + ) + return Number(result.rows[0]?.total || 0) +} + +export async function getWorkOrderShare( + workOrderId: number | string, + workerId: number | string, +): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled' LIMIT 1`, + [Number(workOrderId), Number(workerId)], + ) + return result.rows[0] || null +} + +/** 普通拼单使用指定份数,立即抢单则在事务中承接全部剩余份数。 */ +export function resolveWorkOrderShareJoinQuantity(input: { + quantity: number + remaining: number + takeRemaining?: boolean +}): number { + return input.takeRemaining ? Math.max(0, input.remaining) : input.quantity +} + +export async function joinWorkOrderShare(input: { + workOrderId: number + workerId: number + quantity: number + /** 立即抢单时在事务锁内承接全部剩余份数。 */ + takeRemaining?: boolean + maxActiveOrders: number + hallCandidateLimit?: number + visibleAfterIso?: string + now: string +}): Promise<{ + share: WorkOrderShareRow | null + failureReason: + | 'work_order_not_open' + | 'sharing_disabled' + | 'work_order_owner_conflict' + | 'sharing_quantity_full' + | 'worker_active_order_limit' + | 'worker_deposit_insufficient' + | null +}> { + return withTransaction(async (client) => { + await ensureWorkerWalletWithClient(client, input.workerId, input.now) + + const orderResult = await client.query( + ` + SELECT * + FROM work_orders + WHERE id = $1 + FOR UPDATE + `, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder || workOrder.status !== 'open') { + return { share: null, failureReason: 'work_order_not_open' } + } + if ( + input.hallCandidateLimit && + !(await isWorkOrderWithinHallCapacityWithClient(client, { + workOrderId: input.workOrderId, + hallCandidateLimit: input.hallCandidateLimit, + visibleAfterIso: input.visibleAfterIso, + })) + ) { + return { share: null, failureReason: 'work_order_not_open' } + } + if (workOrder.sharing_enabled !== true) { + return { share: null, failureReason: 'sharing_disabled' } + } + if (Number(workOrder.assigned_worker_id || 0) === input.workerId) { + return { share: null, failureReason: 'work_order_owner_conflict' } + } + + const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1) + const joinedResult = await client.query<{ total: number }>( + ` + SELECT COALESCE(SUM(quantity), 0)::int AS total + FROM work_order_shares + WHERE work_order_id = $1 AND status != 'cancelled' + `, + [input.workOrderId], + ) + const joinedQuantity = Number(joinedResult.rows[0]?.total || 0) + const remaining = Math.max(0, totalQuantity - joinedQuantity) + const requestedQuantity = resolveWorkOrderShareJoinQuantity({ + quantity: input.quantity, + remaining, + takeRemaining: input.takeRemaining === true, + }) + if (requestedQuantity <= 0 || requestedQuantity > remaining) { + return { share: null, failureReason: 'sharing_quantity_full' } + } + + const existingShareResult = await client.query( + `SELECT * + FROM work_order_shares + WHERE work_order_id = $1 AND worker_id = $2 + FOR UPDATE`, + [input.workOrderId, input.workerId], + ) + const existingShare = existingShareResult.rows[0] || null + if (existingShare && (!input.takeRemaining || existingShare.status !== 'joined')) { + return { share: null, failureReason: 'sharing_quantity_full' } + } + if (!existingShare) { + const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) + if (activeOrderCount >= input.maxActiveOrders) { + return { share: null, failureReason: 'worker_active_order_limit' } + } + } + + const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0) + const nextQuantity = Number(existingShare?.quantity || 0) + requestedQuantity + const shareReward = nextQuantity * unitReward + const nextShareDeposit = Math.round( + (Number(workOrder.required_deposit_amount || 0) * nextQuantity) / totalQuantity, + ) + const depositToFreeze = Math.max( + 0, + nextShareDeposit - Number(existingShare?.share_deposit || 0), + ) + + const wallet = await getWorkerWalletWithClient(client, input.workerId) + const available = Number(wallet?.available_amount || 0) + if (available < depositToFreeze) { + return { share: null, failureReason: 'worker_deposit_insufficient' } + } + + const shareResult = existingShare + ? await client.query<{ id: number }>( + ` + UPDATE work_order_shares + SET + quantity = $1, + unit_reward = $2, + share_reward = $3, + share_deposit = $4, + updated_at = $5 + WHERE id = $6 + RETURNING id + `, + [nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id], + ) + : await client.query<{ id: number }>( + ` + INSERT INTO work_order_shares ( + work_order_id, worker_id, quantity, unit_reward, share_reward, + share_deposit, status, acceptance_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $7) + RETURNING id + `, + [ + input.workOrderId, + input.workerId, + requestedQuantity, + unitReward, + shareReward, + nextShareDeposit, + input.now, + ], + ) + + if (depositToFreeze > 0) { + const nextAvailable = available - depositToFreeze + const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + depositToFreeze + await client.query( + ` + UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4 + `, + [nextAvailable, nextFrozen, input.now, input.workerId], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'deposit_freeze', $2, $3, $4, $5, $6, $7::jsonb, $8) + `, + [ + input.workerId, + depositToFreeze, + nextAvailable, + nextFrozen, + input.workOrderId, + `拼单冻结押金 ${requestedQuantity} 份`, + JSON.stringify({ workOrderId: input.workOrderId, quantity: requestedQuantity }), + input.now, + ], + ) + } + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'worker', + actorId: String(input.workerId), + eventType: 'sharing_joined', + fromStatus: 'open', + toStatus: 'open', + payloadJson: JSON.stringify({ + workerId: input.workerId, + quantity: requestedQuantity, + takeRemaining: input.takeRemaining === true, + }), + now: input.now, + }) + + const share = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [Number(shareResult.rows[0]?.id || 0)], + ) + return { share: share.rows[0] || null, failureReason: null } + }) +} + +export async function submitWorkOrderShareAcceptance(input: { + workOrderId: number + workerId: number + acceptanceJson: string + now: string +}): Promise<{ + share: WorkOrderShareRow | null + failureReason: 'share_not_found' | 'share_status_invalid' | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query<{ + status: string + sharing_total_quantity: number + }>( + `SELECT status, sharing_total_quantity + FROM work_orders + WHERE id = $1 + FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder || !['open', 'pending_acceptance'].includes(workOrder.status)) { + return { share: null, failureReason: 'share_not_found' } + } + + const lockResult = await client.query<{ id: number }>( + ` + SELECT id + FROM work_order_shares + WHERE work_order_id = $1 AND worker_id = $2 + FOR UPDATE + `, + [input.workOrderId, input.workerId], + ) + const shareId = lockResult.rows[0]?.id || 0 + if (!shareId) { + return { share: null, failureReason: 'share_not_found' } + } + const shareResult = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [shareId], + ) + const share = shareResult.rows[0] || null + if (!share) { + return { share: null, failureReason: 'share_not_found' } + } + if (share.status !== 'joined') { + return { share, failureReason: 'share_status_invalid' } + } + await client.query( + ` + UPDATE work_order_shares + SET status = 'submitted', acceptance_json = $1::jsonb, draft_acceptance_json = '{}'::jsonb, submitted_at = $2, updated_at = $2 + WHERE id = $3 + `, + [input.acceptanceJson, input.now, Number(share.id)], + ) + + const progressResult = await client.query<{ + joined_quantity: number + pending_submission_count: number + }>( + `SELECT + COALESCE(SUM(quantity), 0)::int AS joined_quantity, + COUNT(*) FILTER (WHERE status = 'joined')::int AS pending_submission_count + FROM work_order_shares + WHERE work_order_id = $1 + AND status != 'cancelled'`, + [input.workOrderId], + ) + const progress = progressResult.rows[0] + const allQuantityAllocated = + Number(progress?.joined_quantity || 0) >= + toPositiveInteger(workOrder.sharing_total_quantity, 1) + if (allQuantityAllocated && Number(progress?.pending_submission_count || 0) === 0) { + await client.query( + `UPDATE work_orders + SET + status = 'pending_acceptance', + submitted_at = COALESCE(submitted_at, $1), + updated_at = $1 + WHERE id = $2 + AND status = 'open'`, + [input.now, input.workOrderId], + ) + } + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'worker', + actorId: String(input.workerId), + eventType: 'sharing_acceptance_submitted', + fromStatus: 'joined', + toStatus: 'submitted', + payloadJson: JSON.stringify({ workerId: input.workerId, quantity: share.quantity }), + now: input.now, + }) + const updated = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [Number(share.id)], + ) + return { share: updated.rows[0] || null, failureReason: null } + }) +} + +/** 保存整单验收草稿(暂存图片/说明),不改变工单状态 */ +export async function updateWorkOrderDraftAcceptance(input: { + workOrderId: number + draftJson: string + now: string +}): Promise { + await query( + ` + UPDATE work_orders + SET draft_acceptance_json = $1::jsonb, updated_at = $2 + WHERE id = $3 + `, + [input.draftJson, input.now, Number(input.workOrderId)], + ) + return getWorkOrderById(input.workOrderId) +} + +/** 更新整单已提交的验收资料(保留原 submitted_at,用于提交验收后补充/修改图片) */ +export async function updateWorkOrderAcceptanceRecord(input: { + workOrderId: number + acceptanceJson: string + now: string +}): Promise { + await query( + ` + UPDATE work_orders + SET acceptance_json = $1::jsonb, updated_at = $2 + WHERE id = $3 + `, + [input.acceptanceJson, input.now, Number(input.workOrderId)], + ) + return getWorkOrderById(input.workOrderId) +} + +/** 保存拼单份额的验收草稿(暂存图片/说明),不改变份额状态 */ +export async function updateWorkOrderShareDraftAcceptance(input: { + workOrderId: number + workerId: number + draftJson: string + now: string +}): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.work_order_id = $1 AND wos.worker_id = $2`, + [Number(input.workOrderId), Number(input.workerId)], + ) + const share = result.rows[0] || null + if (!share) return null + await query( + ` + UPDATE work_order_shares + SET draft_acceptance_json = $1::jsonb, updated_at = $2 + WHERE id = $3 + `, + [input.draftJson, input.now, Number(share.id)], + ) + return getWorkOrderShareById(share.id) +} + +/** 更新拼单份额已提交的验收资料(保留原 submitted_at,用于提交验收后补充/修改图片) */ +export async function updateWorkOrderShareAcceptanceRecord(input: { + workOrderId: number + workerId: number + acceptanceJson: string + now: string +}): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.work_order_id = $1 AND wos.worker_id = $2`, + [Number(input.workOrderId), Number(input.workerId)], + ) + const share = result.rows[0] || null + if (!share) return null + await query( + ` + UPDATE work_order_shares + SET acceptance_json = $1::jsonb, updated_at = $2 + WHERE id = $3 + `, + [input.acceptanceJson, input.now, Number(share.id)], + ) + return getWorkOrderShareById(share.id) +} diff --git a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts new file mode 100644 index 00000000..244deb04 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts @@ -0,0 +1,421 @@ +import { query, withTransaction } from '../../db/client.js' +import { toPositiveInteger } from './shared.js' +import type { + ProductRuleListInput, + WorkOrderRow, + WorkOrderShareRow, + WorkProductRuleRow, +} from './types.js' +import { WORK_ORDER_SELECT } from './work-order-repo.js' + +const WORK_PRODUCT_RULE_SELECT = ` + SELECT + wpr.*, + wc.name AS category_name + FROM work_product_rules wpr + LEFT JOIN work_categories wc ON wc.id = wpr.category_id +` + +function buildWorkProductRuleWhere({ + enabled = null, + keyword = '', + categoryId = 0, +}: ProductRuleListInput) { + const filters: string[] = [] + const params: unknown[] = [] + if (enabled !== null && enabled !== undefined) { + params.push(Boolean(enabled)) + filters.push(`wpr.enabled = $${params.length}`) + } + if (keyword) { + params.push(`%${keyword}%`) + filters.push(`( + wpr.rule_key ILIKE $${params.length} + OR wpr.sku_code ILIKE $${params.length} + OR wpr.product_name ILIKE $${params.length} + )`) + } + if (categoryId) { + params.push(categoryId) + filters.push(`wpr.category_id = $${params.length}`) + } + return { + whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', + params, + } +} + +export async function listWorkProductRules({ + enabled = null, + keyword = '', + categoryId = 0, +}: ProductRuleListInput = {}): Promise { + const { whereClause, params } = buildWorkProductRuleWhere({ enabled, keyword, categoryId }) + const result = await query( + `${WORK_PRODUCT_RULE_SELECT} + ${whereClause} + ORDER BY wpr.sort_order ASC, wpr.id DESC`, + params, + ) + return result.rows +} + +export async function listWorkProductRulesPage({ + page = 1, + pageSize = 20, + enabled = null, + keyword = '', + categoryId = 0, +}: ProductRuleListInput): Promise<{ items: WorkProductRuleRow[]; total: number }> { + const { whereClause, params } = buildWorkProductRuleWhere({ enabled, keyword, categoryId }) + const totalResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_product_rules wpr + ${whereClause} + `, + params, + ) + const offset = (page - 1) * pageSize + const listParams = [...params, pageSize, offset] + const result = await query( + `${WORK_PRODUCT_RULE_SELECT} + ${whereClause} + ORDER BY wpr.sort_order ASC, wpr.id DESC + LIMIT $${listParams.length - 1} OFFSET $${listParams.length}`, + listParams, + ) + return { items: result.rows, total: Number(totalResult.rows[0]?.total || 0) } +} + +export async function countWorkProductRulesByCategory( + input: { + enabled?: boolean | null + keyword?: string + } = {}, +): Promise> { + const { whereClause, params } = buildWorkProductRuleWhere(input) + const result = await query<{ category_id: number | null; total: number }>( + ` + SELECT wpr.category_id, COUNT(*)::int AS total + FROM work_product_rules wpr + ${whereClause} + GROUP BY wpr.category_id + `, + params, + ) + return result.rows.map((row) => ({ + categoryId: row.category_id ? Number(row.category_id) : null, + total: Number(row.total || 0), + })) +} + +export async function getWorkProductRuleByKey(ruleKey: string): Promise { + const result = await query( + `${WORK_PRODUCT_RULE_SELECT} WHERE wpr.rule_key = $1 LIMIT 1`, + [String(ruleKey || '').trim()], + ) + return result.rows[0] || null +} + +export async function getWorkProductRuleById( + ruleId: number | string, +): Promise { + const result = await query( + `${WORK_PRODUCT_RULE_SELECT} WHERE wpr.id = $1 LIMIT 1`, + [Number(ruleId)], + ) + return result.rows[0] || null +} + +/** 查询仍在接单大厅、且明确来源于指定模板的工单。 */ +export async function listOpenWorkOrdersByProductRuleId( + ruleId: number | string, +): Promise { + const result = await query( + `${WORK_ORDER_SELECT} + WHERE wo.product_rule_id = $1 + AND wo.status = 'open' + ORDER BY wo.id ASC`, + [Number(ruleId)], + ) + return result.rows +} + +export type SyncOpenWorkOrderPriceResult = { + updated: boolean + reason: 'updated' | 'price_unchanged' | 'not_open' | 'assigned' | 'sharing_filled' + workOrderId: number + sharingPartial: boolean + workerIds: number[] +} + +/** 拼单改价时保留已加入份额的报酬,只按新价计算剩余份额。 */ +export function resolveOpenSharingSyncReward(input: { + totalQuantity: number + joinedQuantity: number + lockedShareReward: number + nextUnitReward: number +}) { + const totalQuantity = toPositiveInteger(input.totalQuantity, 1) + const joinedQuantity = Math.min(totalQuantity, Math.max(0, Number(input.joinedQuantity || 0))) + return ( + Math.max(0, Number(input.lockedShareReward || 0)) + + (totalQuantity - joinedQuantity) * toPositiveInteger(input.nextUnitReward, 0) + ) +} + +/** + * 同步大厅工单的模板价格。 + * 已加入的拼单份额报酬是合同快照,只更新尚未加入的剩余份额价格。 + */ +export async function syncOpenWorkOrderPriceFromRule(input: { + workOrderId: number + productRuleId: number + rewardAmount: number + sharingUnitReward?: number + actorName: string + now: string +}): Promise { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * + FROM work_orders + WHERE id = $1 AND product_rule_id = $2 + FOR UPDATE`, + [input.workOrderId, input.productRuleId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder || workOrder.status !== 'open') { + return { + updated: false, + reason: 'not_open', + workOrderId: input.workOrderId, + sharingPartial: false, + workerIds: [], + } + } + if (workOrder.assigned_worker_id) { + return { + updated: false, + reason: 'assigned', + workOrderId: input.workOrderId, + sharingPartial: false, + workerIds: [], + } + } + + const sharesResult = await client.query( + `SELECT * + FROM work_order_shares + WHERE work_order_id = $1 + FOR UPDATE`, + [workOrder.id], + ) + const activeShares = sharesResult.rows.filter((share) => share.status !== 'cancelled') + const joinedQuantity = activeShares.reduce( + (sum, share) => sum + Math.max(0, Number(share.quantity || 0)), + 0, + ) + const workerIds = [ + ...new Set( + activeShares.map((share) => Number(share.worker_id)).filter((workerId) => workerId > 0), + ), + ] + const sharingPartial = workOrder.sharing_enabled === true && joinedQuantity > 0 + let nextRewardAmount = toPositiveInteger(input.rewardAmount, 0) + let nextSharingUnitReward = Number(workOrder.sharing_unit_reward || 0) + + if (workOrder.sharing_enabled === true) { + const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1) + if (joinedQuantity >= totalQuantity) { + return { + updated: false, + reason: 'sharing_filled', + workOrderId: Number(workOrder.id), + sharingPartial, + workerIds, + } + } + nextSharingUnitReward = toPositiveInteger(input.sharingUnitReward, 0) + const lockedShareReward = activeShares.reduce( + (sum, share) => sum + Math.max(0, Number(share.share_reward || 0)), + 0, + ) + nextRewardAmount = resolveOpenSharingSyncReward({ + totalQuantity, + joinedQuantity, + lockedShareReward, + nextUnitReward: nextSharingUnitReward, + }) + } + + const unchanged = + Number(workOrder.reward_amount || 0) === nextRewardAmount && + (!workOrder.sharing_enabled || + Number(workOrder.sharing_unit_reward || 0) === nextSharingUnitReward) + if (unchanged) { + return { + updated: false, + reason: 'price_unchanged', + workOrderId: Number(workOrder.id), + sharingPartial, + workerIds, + } + } + + await client.query( + `UPDATE work_orders + SET reward_amount = $1, + sharing_unit_reward = $2, + updated_at = $3 + WHERE id = $4`, + [nextRewardAmount, nextSharingUnitReward, input.now, workOrder.id], + ) + await client.query( + `INSERT INTO work_order_events ( + work_order_id, actor_type, actor_id, event_type, from_status, to_status, payload_json, created_at + ) VALUES ($1, 'admin', $2, 'price_synced_from_rule', $3, $3, $4::jsonb, $5)`, + [ + workOrder.id, + input.actorName, + workOrder.status, + JSON.stringify({ + productRuleId: input.productRuleId, + previousRewardAmount: Number(workOrder.reward_amount || 0), + rewardAmount: nextRewardAmount, + previousSharingUnitReward: Number(workOrder.sharing_unit_reward || 0), + sharingUnitReward: nextSharingUnitReward, + joinedQuantity, + lockedShareReward: activeShares.reduce( + (sum, share) => sum + Math.max(0, Number(share.share_reward || 0)), + 0, + ), + }), + input.now, + ], + ) + return { + updated: true, + reason: 'updated', + workOrderId: Number(workOrder.id), + sharingPartial, + workerIds, + } + }) +} + +export async function deleteWorkProductRule( + ruleId: number | string, +): Promise<{ deleted: boolean }> { + const result = await query<{ id: number }>( + 'DELETE FROM work_product_rules WHERE id = $1 RETURNING id', + [Number(ruleId)], + ) + return { deleted: Boolean(result.rows[0]) } +} + +export async function upsertWorkProductRule(input: { + ruleKey: string + provider: string + platform: string + shopId: string + skuCode: string + productName: string + matchType: string + categoryId: number | null + enabled: boolean + autoCreate: boolean + rewardAmount: number + unitPriceFen?: number + requiredDepositAmount: number + depositThresholdAmount: number + sharingEnabled?: boolean + sharingTotalQuantity?: number + sharingUnitReward?: number + sharingAutoFromOrder?: boolean + timeoutMinutes?: number + timeoutPolicy?: string + requirementJson: string + matchJson?: string + sortOrder: number + now: string +}): Promise { + const result = await query( + ` + INSERT INTO work_product_rules ( + rule_key, provider, platform, shop_id, sku_code, product_name, + match_type, category_id, enabled, auto_create, reward_amount, + unit_price_fen, + required_deposit_amount, deposit_threshold_amount, + sharing_enabled, sharing_total_quantity, sharing_unit_reward, sharing_auto_from_order, + timeout_minutes, timeout_policy, + requirement_json, match_json, + sort_order, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, + $12, + $13, $14, + $15, $16, $17, $18, + $19, $20, + $21::jsonb, $22::jsonb, + $23, $24, $25 + ) + ON CONFLICT (rule_key) DO UPDATE + SET + provider = EXCLUDED.provider, + platform = EXCLUDED.platform, + shop_id = EXCLUDED.shop_id, + sku_code = EXCLUDED.sku_code, + product_name = EXCLUDED.product_name, + match_type = EXCLUDED.match_type, + category_id = EXCLUDED.category_id, + enabled = EXCLUDED.enabled, + auto_create = EXCLUDED.auto_create, + reward_amount = EXCLUDED.reward_amount, + unit_price_fen = EXCLUDED.unit_price_fen, + required_deposit_amount = EXCLUDED.required_deposit_amount, + deposit_threshold_amount = EXCLUDED.deposit_threshold_amount, + sharing_enabled = EXCLUDED.sharing_enabled, + sharing_total_quantity = EXCLUDED.sharing_total_quantity, + sharing_unit_reward = EXCLUDED.sharing_unit_reward, + sharing_auto_from_order = EXCLUDED.sharing_auto_from_order, + timeout_minutes = EXCLUDED.timeout_minutes, + timeout_policy = EXCLUDED.timeout_policy, + requirement_json = EXCLUDED.requirement_json, + match_json = EXCLUDED.match_json, + sort_order = EXCLUDED.sort_order, + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + input.ruleKey, + input.provider, + input.platform, + input.shopId, + input.skuCode, + input.productName, + input.matchType, + input.categoryId, + input.enabled, + input.autoCreate, + input.rewardAmount, + Math.max(0, toPositiveInteger(input.unitPriceFen, 0)), + input.requiredDepositAmount, + input.depositThresholdAmount, + input.sharingEnabled === true, + toPositiveInteger(input.sharingTotalQuantity, 1), + toPositiveInteger(input.sharingUnitReward, 0), + input.sharingAutoFromOrder === true, + toPositiveInteger(input.timeoutMinutes, 0), + String(input.timeoutPolicy || 'reopen').trim(), + input.requirementJson, + input.matchJson || '{}', + input.sortOrder, + input.now, + input.now, + ], + ) + return getWorkProductRuleByKey(result.rows[0]?.rule_key || input.ruleKey) +} diff --git a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts new file mode 100644 index 00000000..e7c9acb2 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts @@ -0,0 +1,609 @@ +import type { PoolClient } from 'pg' + +import { query, withTransaction } from '../../db/client.js' +import { + ensureWorkerWalletWithClient, + getWorkerWalletWithClient, + resolveWorkOrderShareCancellationStatus, +} from './shared.js' +import { createWorkOrderEventWithClient } from './work-order-event-repo.js' +import { + getOutstandingDepositAmountWithClient, + getWorkOrderByIdWithClient, +} from './work-order-repo.js' +import type { WorkerCancelRequestRow, WorkOrderRow, WorkOrderShareRow } from './types.js' + +const WORKER_CANCEL_REQUEST_SELECT = ` + SELECT + cr.*, + COALESCE(NULLIF(BTRIM(wo.platform_order_id), ''), o.platform_order_id, ft.platform_order_id, '') AS platform_order_id, + COALESCE( + NULLIF(BTRIM(o.shop_name), ''), + NULLIF(BTRIM(o.shop_id), ''), + NULLIF(BTRIM(ft.shop_name), ''), + NULLIF(BTRIM(ft.shop_id), ''), + '' + ) AS shop_name, + wo.product_name, + wu.username AS worker_username, + wu.display_name AS worker_display_name, + wos.quantity AS share_quantity + FROM worker_order_cancel_requests cr + INNER JOIN work_orders wo ON wo.id = cr.work_order_id + LEFT JOIN orders o ON o.id = wo.order_id + LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id + INNER JOIN worker_users wu ON wu.id = cr.worker_id + LEFT JOIN work_order_shares wos ON wos.id = cr.work_order_share_id +` + +async function getWorkerCancelRequestByIdWithClient( + client: PoolClient, + requestId: number | string, +): Promise { + const result = await client.query( + `${WORKER_CANCEL_REQUEST_SELECT} WHERE cr.id = $1 LIMIT 1`, + [Number(requestId)], + ) + return result.rows[0] || null +} + +async function countWorkerAcceptedOrdersWithClient( + client: PoolClient, + workerId: number, +): Promise { + const result = await client.query<{ total: number }>( + `SELECT COUNT(DISTINCT order_key)::int AS total + FROM ( + SELECT COALESCE( + 'order:' || NULLIF(wo.order_id::text, ''), + 'platform_order:' || NULLIF(BTRIM(wo.platform_order_id), ''), + 'work_order:' || wo.id::text + ) AS order_key + FROM work_orders wo + WHERE wo.assigned_worker_id = $1 AND wo.status = 'accepted' + + UNION + + SELECT COALESCE( + 'order:' || NULLIF(wo.order_id::text, ''), + 'platform_order:' || NULLIF(BTRIM(wo.platform_order_id), ''), + 'work_order:' || wo.id::text + ) AS order_key + FROM work_order_shares wos + INNER JOIN work_orders wo ON wo.id = wos.work_order_id + WHERE wos.worker_id = $1 AND wos.status = 'accepted' + ) accepted_orders`, + [workerId], + ) + return Number(result.rows[0]?.total || 0) +} + +async function applyWorkerCancelRequestPenaltyWithClient( + client: PoolClient, + input: { workerId: number; requestId: number; now: string }, +): Promise<{ levelBeforeId: number | null; levelChanged: boolean; accountDisabled: boolean }> { + const workerResult = await client.query<{ + level_id: number | null + level_key: string + level_name: string + sort_order: number + level_penalty_accepted_count: number + }>( + `SELECT wu.level_id, wl.level_key, wl.name AS level_name, wl.sort_order, + wu.level_penalty_accepted_count + FROM worker_users wu + LEFT JOIN worker_levels wl ON wl.id = wu.level_id + WHERE wu.id = $1 + FOR UPDATE OF wu`, + [input.workerId], + ) + const worker = workerResult.rows[0] + const levelBeforeId = worker?.level_id ? Number(worker.level_id) : null + if (!worker || !levelBeforeId) { + return { levelBeforeId, levelChanged: false, accountDisabled: false } + } + + const approvedResult = await client.query<{ total: number }>( + `SELECT COUNT(*)::int AS total + FROM worker_order_cancel_requests + WHERE worker_id = $1 AND status = 'approved'`, + [input.workerId], + ) + const approvedTotal = Number(approvedResult.rows[0]?.total || 0) + 1 + const isVip1 = String(worker.level_key || '').toLowerCase() === 'vip1' + + if (isVip1) { + const vip1ApprovedResult = await client.query<{ total: number }>( + `SELECT COUNT(*)::int AS total + FROM worker_order_cancel_requests + WHERE worker_id = $1 AND status = 'approved' AND level_before_id = $2`, + [input.workerId, levelBeforeId], + ) + const vip1ApprovedTotal = Number(vip1ApprovedResult.rows[0]?.total || 0) + 1 + if (vip1ApprovedTotal >= 5) { + await client.query( + `UPDATE worker_users + SET status = 'disabled', session_version = session_version + 1, updated_at = $1 + WHERE id = $2`, + [input.now, input.workerId], + ) + await client.query( + `UPDATE worker_sessions + SET status = 'revoked', revoked_at = $1, updated_at = $1 + WHERE worker_id = $2 AND status = 'active'`, + [input.now, input.workerId], + ) + await client.query( + `INSERT INTO worker_level_logs ( + worker_id, from_level_id, to_level_id, reason, payload_json, created_at + ) VALUES ($1, $2, $2, 'cancel_request_ban', $3::jsonb, $4)`, + [ + input.workerId, + levelBeforeId, + JSON.stringify({ requestId: input.requestId, vip1ApprovedTotal, approvedTotal }), + input.now, + ], + ) + return { levelBeforeId, levelChanged: false, accountDisabled: true } + } + return { levelBeforeId, levelChanged: false, accountDisabled: false } + } + + if (approvedTotal % 10 !== 0) { + return { levelBeforeId, levelChanged: false, accountDisabled: false } + } + + const lowerLevelResult = await client.query<{ + id: number + level_key: string + threshold: number + }>( + `SELECT id, level_key, COALESCE((permission_json->>'upgradeThreshold')::int, 0) AS threshold + FROM worker_levels + WHERE status = 'active' AND sort_order < $1 + ORDER BY sort_order DESC, id DESC + LIMIT 1`, + [Number(worker.sort_order || 0)], + ) + const lowerLevel = lowerLevelResult.rows[0] + if (!lowerLevel) { + return { levelBeforeId, levelChanged: false, accountDisabled: false } + } + const actualAcceptedCount = await countWorkerAcceptedOrdersWithClient(client, input.workerId) + const nextPenaltyAcceptedCount = Math.max( + Number(worker.level_penalty_accepted_count || 0), + actualAcceptedCount - Math.max(0, Number(lowerLevel.threshold || 0)), + ) + await client.query( + `UPDATE worker_users + SET level_id = $1, level_penalty_accepted_count = $2, updated_at = $3 + WHERE id = $4`, + [lowerLevel.id, nextPenaltyAcceptedCount, input.now, input.workerId], + ) + await client.query( + `INSERT INTO worker_level_logs ( + worker_id, from_level_id, to_level_id, reason, payload_json, created_at + ) VALUES ($1, $2, $3, 'cancel_request_penalty', $4::jsonb, $5)`, + [ + input.workerId, + levelBeforeId, + lowerLevel.id, + JSON.stringify({ + requestId: input.requestId, + approvedTotal, + actualAcceptedCount, + penaltyAcceptedCount: nextPenaltyAcceptedCount, + }), + input.now, + ], + ) + return { levelBeforeId, levelChanged: true, accountDisabled: false } +} + +/** 打手提交撤单申请;待审核期间订单与押金均保持原状。 */ +export async function createWorkerCancelRequest(input: { + workOrderId: number + workerId: number + reason: string + now: string +}): Promise<{ + request: WorkerCancelRequestRow | null + dailyUsed: number + failureReason: + | 'work_order_not_cancellable' + | 'work_order_owner_required' + | 'share_not_cancellable' + | 'request_pending' + | 'daily_limit_reached' + | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder) { + return { request: null, dailyUsed: 0, failureReason: 'work_order_not_cancellable' } + } + + let shareId: number | null = null + if (workOrder.sharing_enabled === true) { + const shareResult = await client.query( + `SELECT * FROM work_order_shares + WHERE work_order_id = $1 AND worker_id = $2 + FOR UPDATE`, + [input.workOrderId, input.workerId], + ) + const share = shareResult.rows[0] || null + if (!share || !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)) { + return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' } + } + shareId = Number(share.id) + } else if ( + workOrder.status !== 'in_progress' || + Number(workOrder.assigned_worker_id || 0) !== input.workerId + ) { + return { + request: null, + dailyUsed: 0, + failureReason: workOrder.assigned_worker_id + ? 'work_order_not_cancellable' + : 'work_order_owner_required', + } + } + + const dailyResult = await client.query<{ total: number }>( + `SELECT COUNT(*)::int AS total + FROM worker_order_cancel_requests + WHERE worker_id = $1 + AND status IN ('pending', 'approved') + AND created_at >= date_trunc('day', $2::timestamptz AT TIME ZONE 'Asia/Shanghai') AT TIME ZONE 'Asia/Shanghai'`, + [input.workerId, input.now], + ) + const dailyUsed = Number(dailyResult.rows[0]?.total || 0) + if (dailyUsed >= 5) { + return { request: null, dailyUsed, failureReason: 'daily_limit_reached' } + } + + const pendingResult = await client.query<{ id: number }>( + `SELECT id FROM worker_order_cancel_requests + WHERE work_order_id = $1 + AND worker_id = $2 + AND COALESCE(work_order_share_id, 0) = $3 + AND status = 'pending' + LIMIT 1`, + [input.workOrderId, input.workerId, shareId || 0], + ) + if (pendingResult.rows[0]) { + return { request: null, dailyUsed, failureReason: 'request_pending' } + } + + const created = await client.query<{ id: number }>( + `INSERT INTO worker_order_cancel_requests ( + work_order_id, work_order_share_id, worker_id, reason, status, created_at, updated_at + ) VALUES ($1, $2, $3, $4, 'pending', $5, $5) + RETURNING id`, + [input.workOrderId, shareId, input.workerId, input.reason, input.now], + ) + const requestId = Number(created.rows[0]?.id || 0) + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'worker', + actorId: String(input.workerId), + eventType: 'cancel_request_submitted', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ requestId, shareId, reason: input.reason }), + now: input.now, + }) + return { + request: await getWorkerCancelRequestByIdWithClient(client, requestId), + dailyUsed: dailyUsed + 1, + failureReason: null, + } + }) +} + +export async function listWorkerCancelRequests( + workerId: number | string, +): Promise { + const result = await query( + `${WORKER_CANCEL_REQUEST_SELECT} + WHERE cr.worker_id = $1 + ORDER BY cr.id DESC + LIMIT 100`, + [Number(workerId)], + ) + return result.rows +} + +export async function listAdminWorkerCancelRequests( + status = 'pending', +): Promise { + const result = await query( + `${WORKER_CANCEL_REQUEST_SELECT} + WHERE ($1 = '' OR cr.status = $1) + ORDER BY cr.id DESC + LIMIT 200`, + [status], + ) + return result.rows +} + +export async function getWorkerCancelRequestRisk(workerId: number | string, now: string) { + const result = await query<{ daily_used: number; approved_total: number }>( + `SELECT + COUNT(*) FILTER ( + WHERE status IN ('pending', 'approved') + AND created_at >= date_trunc('day', $2::timestamptz AT TIME ZONE 'Asia/Shanghai') AT TIME ZONE 'Asia/Shanghai' + )::int AS daily_used, + COUNT(*) FILTER (WHERE status = 'approved')::int AS approved_total + FROM worker_order_cancel_requests + WHERE worker_id = $1`, + [Number(workerId), now], + ) + const row = result.rows[0] + const approvedTotal = Number(row?.approved_total || 0) + const nextPenaltyAt = Math.ceil((approvedTotal + 1) / 10) * 10 + return { + dailyUsed: Number(row?.daily_used || 0), + dailyLimit: 5, + approvedTotal, + nextPenaltyAt, + remainingToPenalty: Math.max(0, nextPenaltyAt - approvedTotal), + } +} + +/** 客服审核通过时在同一事务内撤单、退押金并执行等级处罚。 */ +export async function reviewWorkerCancelRequest(input: { + requestId: number + approved: boolean + reviewNote: string + actorName: string + now: string +}): Promise<{ + request: WorkerCancelRequestRow | null + order: WorkOrderRow | null + releasedDepositAmount: number + levelChanged: boolean + accountDisabled: boolean + failureReason: 'request_not_found' | 'request_not_pending' | 'target_state_changed' | null +}> { + return withTransaction(async (client) => { + const requestResult = await client.query( + 'SELECT * FROM worker_order_cancel_requests WHERE id = $1 FOR UPDATE', + [input.requestId], + ) + const request = requestResult.rows[0] || null + if (!request) { + return { + request: null, + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'request_not_found', + } + } + if (request.status !== 'pending') { + return { + request: await getWorkerCancelRequestByIdWithClient(client, request.id), + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'request_not_pending', + } + } + + const workOrderResult = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [request.work_order_id], + ) + const workOrder = workOrderResult.rows[0] || null + if (!workOrder) { + return { + request: null, + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'target_state_changed', + } + } + + if (!input.approved) { + await client.query( + `UPDATE worker_order_cancel_requests + SET status = 'rejected', review_note = $1, reviewed_by = $2, reviewed_at = $3, updated_at = $3 + WHERE id = $4`, + [input.reviewNote, input.actorName, input.now, request.id], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: Number(workOrder.id), + actorType: 'admin', + actorId: input.actorName, + eventType: 'cancel_request_rejected', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ requestId: request.id, note: input.reviewNote }), + now: input.now, + }) + return { + request: await getWorkerCancelRequestByIdWithClient(client, request.id), + order: await getWorkOrderByIdWithClient(client, workOrder.id), + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: null, + } + } + + let releasedDepositAmount = 0 + if (request.work_order_share_id) { + const shareResult = await client.query( + `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, + [request.work_order_share_id, request.work_order_id], + ) + const share = shareResult.rows[0] || null + if ( + !share || + Number(share.worker_id) !== Number(request.worker_id) || + !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status) + ) { + return { + request: null, + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'target_state_changed', + } + } + await ensureWorkerWalletWithClient(client, Number(request.worker_id), input.now) + const wallet = await getWorkerWalletWithClient(client, Number(request.worker_id)) + releasedDepositAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (releasedDepositAmount > 0) { + const nextAvailable = Number(wallet?.available_amount || 0) + releasedDepositAmount + const nextFrozen = Math.max( + 0, + Number(wallet?.frozen_deposit_amount || 0) - releasedDepositAmount, + ) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozen, input.now, request.worker_id], + ) + await client.query( + `INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '撤单申请审核通过退还拼单押金', $6::jsonb, $7)`, + [ + request.worker_id, + releasedDepositAmount, + nextAvailable, + nextFrozen, + request.work_order_id, + JSON.stringify({ requestId: request.id, shareId: share.id, reason: request.reason }), + input.now, + ], + ) + } + await client.query( + `UPDATE work_order_shares SET status = 'cancelled', updated_at = $1 WHERE id = $2`, + [input.now, share.id], + ) + await client.query( + `UPDATE work_orders + SET status = 'open', submitted_at = NULL, published_at = COALESCE(published_at, $1), + hall_queued_at = COALESCE(hall_queued_at, $1), updated_at = $1 + WHERE id = $2`, + [input.now, workOrder.id], + ) + } else { + if ( + workOrder.status !== 'in_progress' || + Number(workOrder.assigned_worker_id || 0) !== Number(request.worker_id) + ) { + return { + request: null, + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'target_state_changed', + } + } + await ensureWorkerWalletWithClient(client, Number(request.worker_id), input.now) + const wallet = await getWorkerWalletWithClient(client, Number(request.worker_id)) + releasedDepositAmount = Math.min( + await getOutstandingDepositAmountWithClient( + client, + Number(request.worker_id), + workOrder.id, + ), + Number(wallet?.frozen_deposit_amount || 0), + ) + await client.query( + `UPDATE work_orders + SET status = 'open', assigned_worker_id = NULL, assigned_at = NULL, deadline_at = NULL, + published_at = $1, hall_queued_at = $1, updated_at = $1 + WHERE id = $2`, + [input.now, workOrder.id], + ) + if (releasedDepositAmount > 0) { + const nextAvailable = Number(wallet?.available_amount || 0) + releasedDepositAmount + const nextFrozen = Math.max( + 0, + Number(wallet?.frozen_deposit_amount || 0) - releasedDepositAmount, + ) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozen, input.now, request.worker_id], + ) + await client.query( + `INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '撤单申请审核通过退还押金', $6::jsonb, $7)`, + [ + request.worker_id, + releasedDepositAmount, + nextAvailable, + nextFrozen, + request.work_order_id, + JSON.stringify({ requestId: request.id, reason: request.reason }), + input.now, + ], + ) + } + } + + const penalty = await applyWorkerCancelRequestPenaltyWithClient(client, { + workerId: Number(request.worker_id), + requestId: Number(request.id), + now: input.now, + }) + await client.query( + `UPDATE worker_order_cancel_requests + SET status = 'approved', review_note = $1, reviewed_by = $2, reviewed_at = $3, + level_before_id = $4, updated_at = $3 + WHERE id = $5`, + [input.reviewNote, input.actorName, input.now, penalty.levelBeforeId, request.id], + ) + const updatedOrder = await getWorkOrderByIdWithClient(client, workOrder.id) + await createWorkOrderEventWithClient(client, { + workOrderId: Number(workOrder.id), + actorType: 'admin', + actorId: input.actorName, + eventType: 'cancel_request_approved', + fromStatus: workOrder.status, + toStatus: updatedOrder?.status || workOrder.status, + payloadJson: JSON.stringify({ + requestId: request.id, + shareId: request.work_order_share_id, + releasedDepositAmount, + levelChanged: penalty.levelChanged, + accountDisabled: penalty.accountDisabled, + note: input.reviewNote, + }), + now: input.now, + }) + return { + request: await getWorkerCancelRequestByIdWithClient(client, request.id), + order: updatedOrder, + releasedDepositAmount, + levelChanged: penalty.levelChanged, + accountDisabled: penalty.accountDisabled, + failureReason: null, + } + }) +} diff --git a/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts b/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts new file mode 100644 index 00000000..e21f1ec4 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts @@ -0,0 +1,175 @@ +import type { PoolClient } from 'pg' + +import { query, withTransaction } from '../../db/client.js' +import { createWorkOrderEventWithClient } from './work-order-event-repo.js' +import { getWorkOrderByIdWithClient } from './work-order-repo.js' +import type { WorkerOrderFeedbackRow, WorkOrderRow, WorkOrderShareRow } from './types.js' + +const WORKER_ORDER_FEEDBACK_SELECT = ` + SELECT + wf.*, + COALESCE(NULLIF(BTRIM(wo.platform_order_id), ''), wo.work_order_no) AS work_order_no, + wo.product_name, + COALESCE( + NULLIF(BTRIM(o.shop_name), ''), + NULLIF(BTRIM(o.shop_id), ''), + NULLIF(BTRIM(ft.shop_name), ''), + NULLIF(BTRIM(ft.shop_id), ''), + '' + ) AS shop_name, + wu.username AS worker_username, + wu.display_name AS worker_display_name, + wos.quantity AS share_quantity + FROM worker_order_feedbacks wf + INNER JOIN work_orders wo ON wo.id = wf.work_order_id + LEFT JOIN orders o ON o.id = wo.order_id + LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id + INNER JOIN worker_users wu ON wu.id = wf.worker_id + LEFT JOIN work_order_shares wos ON wos.id = wf.work_order_share_id +` + +async function getWorkerOrderFeedbackByIdWithClient( + client: PoolClient, + feedbackId: number | string, +): Promise { + const result = await client.query( + `${WORKER_ORDER_FEEDBACK_SELECT} WHERE wf.id = $1 LIMIT 1`, + [Number(feedbackId)], + ) + return result.rows[0] || null +} + +export async function createWorkerOrderFeedback(input: { + workOrderId: number + workerId: number + content: string + now: string +}): Promise<{ feedback: WorkerOrderFeedbackRow | null; failureReason: 'owner_required' | null }> { + return withTransaction(async (client) => { + const orderResult = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [input.workOrderId], + ) + const order = orderResult.rows[0] || null + if (!order) return { feedback: null, failureReason: 'owner_required' } + + const shareResult = await client.query( + `SELECT * FROM work_order_shares + WHERE work_order_id = $1 AND worker_id = $2 AND status != 'cancelled' + FOR UPDATE`, + [input.workOrderId, input.workerId], + ) + const share = shareResult.rows[0] || null + const isOrderOwner = + Number(order.assigned_worker_id || 0) === input.workerId || + Number(order.last_assigned_worker_id || 0) === input.workerId + if (!share && !isOrderOwner) return { feedback: null, failureReason: 'owner_required' } + + const result = await client.query<{ id: number }>( + `INSERT INTO worker_order_feedbacks ( + work_order_id, work_order_share_id, worker_id, content, status, created_at, updated_at + ) VALUES ($1, $2, $3, $4, 'pending', $5, $5) + RETURNING id`, + [input.workOrderId, share?.id || null, input.workerId, input.content, input.now], + ) + const feedbackId = Number(result.rows[0]?.id || 0) + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'worker', + actorId: String(input.workerId), + eventType: 'worker_feedback_submitted', + fromStatus: order.status, + toStatus: order.status, + payloadJson: JSON.stringify({ + feedbackId, + shareId: share?.id || null, + content: input.content, + }), + now: input.now, + }) + return { + feedback: await getWorkerOrderFeedbackByIdWithClient(client, feedbackId), + failureReason: null, + } + }) +} + +export async function listWorkerOrderFeedbacks( + workerId: number | string, +): Promise { + const result = await query( + `${WORKER_ORDER_FEEDBACK_SELECT} + WHERE wf.worker_id = $1 + ORDER BY wf.id DESC + LIMIT 100`, + [Number(workerId)], + ) + return result.rows +} + +export async function listAdminWorkerOrderFeedbacks( + status = 'pending', +): Promise { + const result = await query( + `${WORKER_ORDER_FEEDBACK_SELECT} + WHERE ($1 = '' OR wf.status = $1) + ORDER BY wf.id DESC + LIMIT 200`, + [status], + ) + return result.rows +} + +export async function resolveWorkerOrderFeedback(input: { + feedbackId: number + reply: string + actorName: string + now: string +}): Promise<{ + feedback: WorkerOrderFeedbackRow | null + order: WorkOrderRow | null + failureReason: 'not_found' | 'not_pending' | null +}> { + return withTransaction(async (client) => { + const feedbackResult = await client.query( + 'SELECT * FROM worker_order_feedbacks WHERE id = $1 FOR UPDATE', + [input.feedbackId], + ) + const feedback = feedbackResult.rows[0] || null + if (!feedback) return { feedback: null, order: null, failureReason: 'not_found' } + if (feedback.status !== 'pending') { + return { + feedback: await getWorkerOrderFeedbackByIdWithClient(client, feedback.id), + order: null, + failureReason: 'not_pending', + } + } + const orderResult = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [feedback.work_order_id], + ) + const order = orderResult.rows[0] || null + if (!order) return { feedback: null, order: null, failureReason: 'not_found' } + await client.query( + `UPDATE worker_order_feedbacks + SET status = 'resolved', reply = $1, handled_by = $2, handled_at = $3, updated_at = $3 + WHERE id = $4`, + [input.reply, input.actorName, input.now, feedback.id], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: Number(order.id), + actorType: 'admin', + actorId: input.actorName, + eventType: 'worker_feedback_resolved', + fromStatus: order.status, + toStatus: order.status, + payloadJson: JSON.stringify({ feedbackId: feedback.id, reply: input.reply }), + now: input.now, + }) + return { + feedback: await getWorkerOrderFeedbackByIdWithClient(client, feedback.id), + order: await getWorkOrderByIdWithClient(client, order.id), + failureReason: null, + } + }) +} diff --git a/apps/backend/src/repositories/worker-platform/worker-order-note-repo.ts b/apps/backend/src/repositories/worker-platform/worker-order-note-repo.ts new file mode 100644 index 00000000..3ea85f99 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/worker-order-note-repo.ts @@ -0,0 +1,82 @@ +import { query } from '../../db/client.js' + +export async function listWorkerWorkOrderNotes( + workerId: number | string, + workOrderIds: number[], +): Promise> { + if (workOrderIds.length === 0) return [] + const result = await query<{ work_order_id: number; note: string }>( + ` + SELECT work_order_id, note + FROM worker_work_order_notes + WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) + `, + [Number(workerId), workOrderIds], + ) + return result.rows +} + +export async function upsertWorkerWorkOrderNote(input: { + workOrderId: number + workerId: number + note: string + now: string +}): Promise { + const result = await query<{ note: string }>( + ` + INSERT INTO worker_work_order_notes ( + work_order_id, worker_id, note, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $4) + ON CONFLICT (work_order_id, worker_id) DO UPDATE + SET note = EXCLUDED.note, updated_at = EXCLUDED.updated_at + RETURNING note + `, + [input.workOrderId, input.workerId, input.note, input.now], + ) + return String(result.rows[0]?.note || '') +} + +/** 查询打手已查看过的工单,用于计算"新"标识。 */ +export async function listWorkerWorkOrderViews( + workerId: number | string, + workOrderIds: number[], +): Promise> { + if (workOrderIds.length === 0) return [] + const result = await query<{ work_order_id: number; viewed_at: string }>( + ` + SELECT work_order_id, viewed_at + FROM worker_work_order_views + WHERE worker_id = $1 AND work_order_id = ANY($2::bigint[]) + `, + [Number(workerId), workOrderIds], + ) + return result.rows +} + +/** 首次查看工单时写入已读记录;重复调用保持原查看时间不变。 */ +export async function markWorkerWorkOrderViewed(input: { + workOrderId: number + workerId: number + now: string +}): Promise { + const inserted = await query<{ viewed_at: string }>( + ` + INSERT INTO worker_work_order_views (work_order_id, worker_id, viewed_at) + VALUES ($1, $2, $3) + ON CONFLICT (work_order_id, worker_id) DO NOTHING + RETURNING viewed_at + `, + [input.workOrderId, input.workerId, input.now], + ) + if (inserted.rows[0]?.viewed_at) return String(inserted.rows[0].viewed_at) + const existing = await query<{ viewed_at: string }>( + ` + SELECT viewed_at + FROM worker_work_order_views + WHERE work_order_id = $1 AND worker_id = $2 + LIMIT 1 + `, + [input.workOrderId, input.workerId], + ) + return String(existing.rows[0]?.viewed_at || input.now) +} diff --git a/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts b/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts new file mode 100644 index 00000000..523cb7c7 --- /dev/null +++ b/apps/backend/src/services/worker-platform/admin-kuaishou-match-service.ts @@ -0,0 +1,455 @@ +import { listKuaishouIndustryVoucherWorkOrderSyncSources } from '../../repositories/kuaishou-industry-voucher-repo.js' +import { + deleteWorkProductRuleMapping, + listWorkProductMatchLogs, + listWorkProductRuleMappings, + listWorkProductRuleMappingsPage, + upsertWorkProductRuleMapping, +} from '../../repositories/worker-platform/product-match-repo.js' +import { listWorkProductRules } from '../../repositories/worker-platform/work-product-rule-repo.js' +import type { + WorkProductMatchLogRow, + WorkProductRuleMappingRow, +} from '../../repositories/worker-platform/types.js' +import type { JsonObject } from '../../types/json.js' +import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' +import { + mapWorkProductRule, + normalizeBoolean, + normalizeOptionalId, + normalizePositiveInteger, + resolveKuaishouWorkProductMatchContext, + resolveMatchingProductRuleDecision, +} from './mappers.js' +import { reprocessKuaishouSendCodeWorkOrders } from './sync-work-orders-from-send-code.js' +import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js' + +export async function reprocessAdminKuaishouSendCodeWorkOrders(payload: JsonObject = {}) { + const limit = Math.min(500, normalizePositiveInteger(payload.limit, 100)) + return reprocessKuaishouSendCodeWorkOrders(limit) +} + +export async function listAdminWorkProductRuleMappings(query: JsonObject = {}) { + const ruleId = normalizeOptionalId(query.ruleId ?? query.rule_id) || 0 + const hasPagination = query.page !== undefined || query.pageSize !== undefined || query.keyword + if (hasPagination) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const keyword = String(query.keyword || '').trim() + const result = await listWorkProductRuleMappingsPage({ + page, + pageSize, + keyword, + ruleId, + }) + return { + items: result.items.map(mapWorkProductRuleMapping), + pagination: { page, pageSize, total: result.total }, + } + } + const items = await listWorkProductRuleMappings({ ruleId }) + return { items: items.map(mapWorkProductRuleMapping) } +} + +export async function saveAdminWorkProductRuleMapping(payload: JsonObject = {}) { + const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id) + const sellerIds = normalizeKuaishouMatchIds( + payload.sellerIds ?? payload.seller_ids ?? payload.sellerId ?? payload.seller_id, + ) + const relItemIds = normalizeKuaishouMatchIdList(payload.relItemId ?? payload.rel_item_id) + const relItemId = relItemIds.join(',') + const itemTitle = String(payload.itemTitle ?? payload.item_title ?? '').trim() + const rawMappingType = String(payload.mappingType ?? payload.mapping_type ?? '').trim() + const mappingType = + rawMappingType === 'sku_series' + ? 'sku_series' + : rawMappingType === 'sku_exact' || rawMappingType === 'sku_override' + ? 'sku_exact' + : 'product_default' + const relSkuId = + mappingType === 'sku_exact' + ? normalizeKuaishouMatchId(payload.relSkuId ?? payload.rel_sku_id) + : '' + const skuNick = + mappingType === 'product_default' + ? '' + : String(payload.skuNick ?? payload.sku_nick ?? '').trim() + const skuMatchMode = + mappingType === 'sku_series' && + String(payload.skuMatchMode ?? payload.sku_match_mode ?? '').trim() === 'exact' + ? 'exact' + : 'fuzzy' + const requiresProductScope = mappingType === 'product_default' + if ( + !ruleId || + sellerIds.length === 0 || + (requiresProductScope && relItemIds.length === 0 && !itemTitle) + ) { + throw createHttpError( + '请选择接单模板,并填写至少一个店铺;商品默认规则还需填写大标题或关联商品 ID', + { + statusCode: 400, + errorCode: 'work_product_mapping_identity_required', + }, + ) + } + if ( + mappingType === 'product_default' && + sellerIds.length > 1 && + (!itemTitle || relItemIds.length > 0) + ) { + throw createHttpError('多店共享规则请填写快手大标题,并留空关联商品 ID', { + statusCode: 400, + errorCode: 'work_product_mapping_multi_shop_scope_invalid', + }) + } + if (mappingType === 'sku_exact' && !relSkuId && !skuNick) { + throw createHttpError('SKU 精确规则需要关联 SKU ID 或具体 SKU 名称', { + statusCode: 400, + errorCode: 'work_product_mapping_sku_required', + }) + } + if (mappingType === 'sku_series' && !skuNick) { + throw createHttpError('SKU 数量系列需要填写系列名称', { + statusCode: 400, + errorCode: 'work_product_mapping_series_required', + }) + } + const rule = (await listWorkProductRules()).find((item) => Number(item.id) === ruleId) + if (!rule) { + throw createHttpError('接单模板不存在', { + statusCode: 404, + errorCode: 'work_product_mapping_rule_not_found', + }) + } + const mappingId = normalizeOptionalId(payload.mappingId ?? payload.mapping_id) + if (normalizeBoolean(payload.enabled, true)) { + await assertNoConflictingWorkProductRuleMapping({ + mappingId, + sellerIds, + relItemId, + itemTitle, + relSkuId, + skuNick, + mappingType, + }) + } + const mapping = await upsertWorkProductRuleMapping({ + mappingId, + ruleId, + sellerIds, + relItemId, + itemTitle, + relSkuId, + skuNick, + skuMatchMode, + mappingType, + enabled: normalizeBoolean(payload.enabled, true), + now: nowIso(), + }) + if (!mapping) { + throw createHttpError('商品映射保存失败', { + statusCode: 500, + errorCode: 'work_product_mapping_save_failed', + }) + } + return { + mapping: mapWorkProductRuleMapping({ + ...mapping, + rule_key: rule.rule_key, + product_name: rule.product_name, + }), + } +} + +export async function deleteAdminWorkProductRuleMapping(mappingId: number | string) { + const normalizedMappingId = normalizeOptionalId(mappingId) + if (!normalizedMappingId) { + throw createHttpError('商品映射 ID 不正确', { + statusCode: 400, + errorCode: 'work_product_mapping_id_invalid', + }) + } + return deleteWorkProductRuleMapping(normalizedMappingId) +} + +export async function listAdminKuaishouMatchSources(payload: JsonObject = {}) { + const limit = Math.min(500, normalizePositiveInteger(payload.limit, 100)) + const sources = await listKuaishouIndustryVoucherWorkOrderSyncSources(limit) + const grouped = new Map() + for (const source of sources) { + const rawPayload = safeParseJson(source.raw_payload_json) + const body = resolveKuaishouMatchPayload(rawPayload) + const ext = safeParseJson(body.ext) + const sellerId = normalizeKuaishouMatchId(body.sellerId) + const relItemId = normalizeKuaishouMatchId(ext.relItemId) + const itemTitle = String(body.itemTitle || '').trim() + const relSkuId = normalizeKuaishouMatchId(ext.relSkuId) + const skuNick = String(ext.skuNick || '').trim() + if (!sellerId || (!relItemId && !itemTitle)) continue + const key = [ + sellerId, + relItemId, + normalizeMatchTextForAdmin(itemTitle), + relSkuId, + normalizeMatchTextForAdmin(skuNick), + ].join('|') + const current = grouped.get(key) + grouped.set(key, { + sellerId, + relItemId, + itemTitle, + relSkuId, + skuNick, + sampleOid: String(body.oid || source.oid || '').trim(), + lastSeenAt: source.updated_at, + seenCount: Number(current?.seenCount || 0) + 1, + rawPayload, + }) + } + return { + items: Array.from(grouped.values()).sort((left, right) => + String(right.lastSeenAt).localeCompare(String(left.lastSeenAt)), + ), + } +} + +export async function testAdminKuaishouProductMatch(payload: JsonObject = {}) { + const body = resolveKuaishouMatchPayload(payload.rawPayload ?? payload.raw_payload ?? payload) + const ext = safeParseJson(body.ext) + const order = { + provider: '91kaquan', + platform: 'kuaishou', + shop_id: String(body.sellerId || '').trim(), + } as OrderRow + const item = { + id: 0, + order_id: 0, + sku_code: String(body.itemId || '').trim(), + sku_name: String(ext.skuNick || '').trim(), + quantity: Math.max(1, Number(body.num) || 1), + spec_json: {}, + item_snapshot_json: { + kuaishouSendCode: { + sellerId: String(body.sellerId || '').trim(), + itemId: String(body.itemId || '').trim(), + itemTitle: String(body.itemTitle || '').trim(), + skuId: String(body.skuId || '').trim(), + relItemId: normalizeKuaishouMatchId(ext.relItemId), + relSkuId: normalizeKuaishouMatchId(ext.relSkuId), + skuNick: String(ext.skuNick || '').trim(), + }, + }, + } as OrderItemRow + const [rules, mappings] = await Promise.all([ + listWorkProductRules({ enabled: true }), + listWorkProductRuleMappings({ enabled: true }), + ]) + const decision = resolveMatchingProductRuleDecision(order, item, rules, mappings, { + legacyFallbackEnabled: getWorkerProductMatchConfig().legacyFallbackEnabled, + }) + const context = resolveKuaishouWorkProductMatchContext(item) + return { + context, + status: decision.reason, + mappingId: decision.mappingId, + rule: decision.rule ? mapWorkProductRule(decision.rule) : null, + candidates: decision.candidates, + } +} + +export async function listAdminWorkProductMatchLogs(payload: JsonObject = {}) { + const limit = Math.min(500, normalizePositiveInteger(payload.limit, 100)) + return { items: (await listWorkProductMatchLogs(limit)).map(mapWorkProductMatchLog) } +} + +function mapWorkProductRuleMapping(mapping: WorkProductRuleMappingRow) { + const sellerIds = parseKuaishouMappingSellerIds(mapping.seller_ids_json) + return { + mappingId: Number(mapping.id), + ruleId: Number(mapping.rule_id), + ruleKey: String(mapping.rule_key || ''), + productName: String(mapping.product_name || ''), + sellerIds, + sellerId: sellerIds[0] || '', + relItemId: String(mapping.rel_item_id || ''), + itemTitle: String(mapping.item_title || ''), + relSkuId: String(mapping.rel_sku_id || ''), + skuNick: String(mapping.sku_nick || ''), + skuMatchMode: mapping.sku_match_mode === 'exact' ? 'exact' : 'fuzzy', + mappingType: + mapping.mapping_type === 'sku_series' + ? 'sku_series' + : mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override' + ? 'sku_exact' + : 'product_default', + enabled: mapping.enabled === true, + createdAt: mapping.created_at, + updatedAt: mapping.updated_at, + } +} + +function mapWorkProductMatchLog(log: WorkProductMatchLogRow) { + return { + logId: Number(log.id), + orderId: log.order_id ? Number(log.order_id) : null, + orderItemId: log.order_item_id ? Number(log.order_item_id) : null, + source: String(log.source || ''), + sellerId: String(log.seller_id || ''), + relItemId: String(log.rel_item_id || ''), + itemTitle: String(log.item_title || ''), + relSkuId: String(log.rel_sku_id || ''), + skuNick: String(log.sku_nick || ''), + status: String(log.match_status || ''), + ruleId: log.rule_id ? Number(log.rule_id) : null, + mappingId: log.mapping_id ? Number(log.mapping_id) : null, + ruleKey: String(log.rule_key || ''), + productName: String(log.product_name || ''), + candidates: parseJsonArray(log.candidates_json), + rawPayload: safeParseJson(log.raw_payload_json), + createdAt: log.created_at, + } +} + +function resolveKuaishouMatchPayload(value: unknown): JsonObject { + const raw = safeParseJson(value) + const body = safeParseJson(raw.body) + return Object.keys(body).length > 0 ? body : raw +} + +function normalizeKuaishouMatchId(value: unknown) { + const normalized = String(value || '').trim() + return normalized === '0' ? '' : normalized +} + +function normalizeKuaishouMatchIds(value: unknown) { + const values = Array.isArray(value) + ? value + : String(value ?? '') + .split(/[,,\n]/) + .map((item) => item.trim()) + return Array.from(new Set(values.map(normalizeKuaishouMatchId).filter(Boolean))) +} + +function normalizeKuaishouMatchIdList(value: unknown) { + const values = Array.isArray(value) ? value : [value] + return normalizeKuaishouMatchIds( + values.flatMap((item) => String(item ?? '').split(/[,,;;\s\n\r]+/)), + ) +} + +function parseKuaishouMappingSellerIds(value: unknown) { + if (Array.isArray(value)) return normalizeKuaishouMatchIds(value) + try { + return normalizeKuaishouMatchIds(JSON.parse(String(value || '[]'))) + } catch { + return normalizeKuaishouMatchIds(value) + } +} + +function normalizeMatchTextForAdmin(value: unknown) { + return String(value || '') + .normalize('NFKC') + .toLowerCase() + .replace(/[\s\u3000]+/g, '') + .trim() +} + +function assertNoConflictingWorkProductRuleMapping(input: { + mappingId: number | null + sellerIds: string[] + relItemId: string + itemTitle: string + relSkuId: string + skuNick: string + mappingType: 'product_default' | 'sku_exact' | 'sku_series' +}) { + return listWorkProductRuleMappings({ enabled: true }).then((mappings) => { + const conflict = mappings.find((mapping) => { + if (input.mappingId && Number(mapping.id) === input.mappingId) return false + const existingType = + mapping.mapping_type === 'sku_series' + ? 'sku_series' + : mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override' + ? 'sku_exact' + : 'product_default' + if (existingType !== input.mappingType) return false + const existingSellerIds = parseKuaishouMappingSellerIds(mapping.seller_ids_json) + if (!existingSellerIds.some((sellerId) => input.sellerIds.includes(sellerId))) return false + + const inputItemIds = normalizeKuaishouMatchIdList(input.relItemId) + const existingItemIds = normalizeKuaishouMatchIdList(mapping.rel_item_id) + if ( + !mappingProductScopesOverlap( + inputItemIds, + input.itemTitle, + existingItemIds, + mapping.item_title, + ) + ) { + return false + } + + if (input.mappingType === 'product_default') return true + if (input.mappingType === 'sku_series') { + // 精确系列是模糊系列的子集,两者并存仍可能产生同分冲突,因此统一拦截。 + return ( + normalizeMatchTextForAdmin(input.skuNick) === normalizeMatchTextForAdmin(mapping.sku_nick) + ) + } + + const inputSkuId = normalizeKuaishouMatchId(input.relSkuId) + const existingSkuId = normalizeKuaishouMatchId(mapping.rel_sku_id) + const skuIdConflict = Boolean(inputSkuId && existingSkuId && inputSkuId === existingSkuId) + const skuNameConflict = + Boolean(input.skuNick && mapping.sku_nick) && + normalizeMatchTextForAdmin(input.skuNick) === normalizeMatchTextForAdmin(mapping.sku_nick) + return skuIdConflict || skuNameConflict + }) + + if (conflict) { + throw createHttpError('商品映射与已有启用映射冲突,请停用或调整其中一条', { + statusCode: 409, + errorCode: 'work_product_mapping_conflict', + context: { + mappingId: Number(conflict.id), + ruleKey: String(conflict.rule_key || ''), + }, + }) + } + }) +} + +function mappingProductScopesOverlap( + leftItemIds: string[], + leftTitle: string, + rightItemIds: string[], + rightTitle: string, +) { + if (leftItemIds.length > 0 && rightItemIds.length > 0) { + return leftItemIds.some((itemId) => rightItemIds.includes(itemId)) + } + // 只有一侧指定商品 ID 时,另一侧可能是全商品范围,按可能重叠处理并拦截。 + if (leftItemIds.length > 0 || rightItemIds.length > 0) return true + const normalizedLeftTitle = normalizeMatchTextForAdmin(leftTitle) + const normalizedRightTitle = normalizeMatchTextForAdmin(rightTitle) + if (normalizedLeftTitle || normalizedRightTitle) { + return Boolean( + normalizedLeftTitle && normalizedRightTitle && normalizedLeftTitle === normalizedRightTitle, + ) + } + return true +} + +function parseJsonArray(value: unknown): unknown[] { + if (Array.isArray(value)) return value + try { + const parsed = JSON.parse(String(value || '[]')) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index f92048dc..ef942f11 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -1,61 +1,33 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' -import { listKuaishouIndustryVoucherWorkOrderSyncSources } from '../../repositories/kuaishou-industry-voucher-repo.js' import { acceptWorkOrderAndSettle, acceptWorkOrderShareAndSettle, - addWorkerWalletCredit, assignWorkOrderToWorker, cancelAssignedWorkOrder, cancelWorkOrderShare, returnAssignedWorkOrderToHall, unassignWorkOrder, countWorkerActiveOrders, - countWorkCategoryUsages, - countWorkerLevelUsages, - countWorkProductRulesByCategory, countWorkOrderPendingSharingSubmissions, countTimeoutEventsByWorkerIds, createWorkProductMatchLog, createWorkOrder, - createAfterSalesCase, createWorkOrderEvent, deductPendingDepositUnfreeze, - deleteWorkCategory, - deleteWorkProductRuleMapping, - deleteWorkProductRule, deleteWorkOrder, - deleteWorkerLevel, - getWorkerFinanceRequestById, - getWorkCategoryById, getWorkerLevelById, - getWorkerLevelByKey, getWorkerUserById, getWorkOrderStatistics, getWorkOrderByOrderItemId, - getAfterSalesCaseById, - getWorkProductRuleById, - listAllWorkCategories, - listWorkerFinanceRequests, listWorkOrders, - listAfterSalesCaseEvents, - listAfterSalesCases, listWorkOrderEventsByOrderId, - listOpenWorkOrdersByProductRuleId, listWorkProductRules, listWorkProductRuleMappings, - listWorkProductRuleMappingsPage, - listWorkProductMatchLogs, - listWorkProductRulesPage, - listWorkerLevels, listWorkerUsers, - listWorkerWithdrawalAccounts, listWorkOrderShares, listWorkOrderSharesByOrderIds, sumPendingUnfreezeByOrderIds, - syncOpenWorkOrderPriceFromRule, resolveProblemWorkOrder, - resolveAfterSalesCase, - reviewWorkerFinanceRequest, reopenCancelledWorkOrder, listAdminWorkerCancelRequests as listWorkerCancelRequestsForAdmin, listAdminWorkerOrderFeedbacks as listWorkerOrderFeedbacksForAdmin, @@ -66,16 +38,7 @@ import { updateWorkerUser, updateWorkerPassword, revokeAllWorkerSessions, - upsertWorkerWithdrawalAccount, - upsertWorkCategory, - upsertWorkProductRule, - upsertWorkProductRuleMapping, - upsertWorkerLevel, type WorkOrderRow, - type WorkProductMatchLogRow, - type WorkProductRuleMappingRow, - type WorkProductRuleRow, - type WorkerWithdrawalAccountRow, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' @@ -83,27 +46,14 @@ import { createHttpError } from '../../utils/http.js' import { randomId } from '../../utils/random.js' import { nowIso } from '../../utils/time.js' import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' -import { getWorkerFinanceConfig, saveWorkerFinanceConfig } from './worker-finance-config-service.js' -import { - getWorkerPlatformNotificationConfig, - saveWorkerPlatformNotificationConfig, -} from './worker-platform-notification-config-service.js' -import { - getWorkerProductMatchConfig, - saveWorkerProductMatchConfig, -} from './worker-product-match-config-service.js' -import { - getWorkerAnnouncementConfig, - saveWorkerAnnouncementConfig, -} from './worker-announcement-config-service.js' +import { getWorkerFinanceConfig } from './worker-finance-config-service.js' +import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js' import { getWorkerHallConfig, saveWorkerHallConfig } from './worker-hall-config-service.js' -import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' import { createWorkOrderMaterialAdminNotification, resolveAdminNotificationEntity, } from '../admin/admin-notification-service.js' import { - publishWorkerFinanceRealtimeChange, publishWorkerHallRealtimeRefresh, publishWorkerWalletRealtimeChange, publishWorkOrderRealtimeChange, @@ -114,33 +64,17 @@ import { } from './publish-voucher-service.js' import { - DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, - DEFAULT_LEVEL_KEY, - DEFAULT_LEVEL_NAME, - mapFinanceRequest, - mapWallet, - mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, - mapWorkProductRule, - mapWorkerLevel, mapWorkerUser, mapWorkerCancelRequest, - mapWorkerAfterSalesCase, mapWorkerOrderFeedback, - normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, - normalizeEnabledStatus, - normalizeFinanceRequestStatus, - normalizeFinanceRequestType, normalizeInteger, - normalizeMatchType, - normalizeWorkProductRuleMatch, normalizeOptionalId, normalizePositiveInteger, - normalizeProofFiles, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, @@ -148,13 +82,11 @@ import { normalizeSessionVersion, hashWorkerPassword, validateWorkerPassword, - normalizeSlugKey, normalizeStatuses, normalizeSubmittedFields, normalizeUploadedFiles, normalizeWorkerType, normalizeWorkerFreezeReason, - normalizeWithdrawChannel, resolveFreezeDepositAmount, mapWorkOrderEvents, resolveMatchingProductRuleDecision, @@ -172,847 +104,13 @@ import { getRequiredWorker, normalizeWorkOrderTimeoutPolicy, } from './worker-service.js' -import { reprocessKuaishouSendCodeWorkOrders } from './sync-work-orders-from-send-code.js' +import { resolveSharingQuantity } from './admin-worker-catalog-service.js' import { getKuaishouIndustrySourceConfig, listKuaishouIndustryShopConfigs, type KuaishouIndustryShopConfig, } from '../platforms/kuaishou-industry/source-config-service.js' -export async function listAdminWorkerLevels() { - await ensureWorkerPlatformDefaults() - return { items: (await listWorkerLevels()).map(mapWorkerLevel) } -} - -export async function listAdminWorkCategories() { - await ensureWorkerPlatformDefaults() - return { items: (await listAllWorkCategories()).map(mapWorkCategory) } -} - -export async function saveAdminWorkCategory(payload: JsonObject = {}) { - const now = nowIso() - const categoryKey = normalizeSlugKey( - payload.categoryKey || payload.category_key || payload.name, - DEFAULT_CATEGORY_KEY, - ) - const name = String(payload.name || '').trim() || '默认分类' - const category = await upsertWorkCategory({ - categoryKey, - name, - sortOrder: normalizeInteger(payload.sortOrder, 100), - status: normalizeEnabledStatus(payload.status), - now, - }) - return { category: mapWorkCategory(category) } -} - -export async function deleteAdminWorkCategory(categoryId: number | string) { - const category = await getWorkCategoryById(Number(categoryId)) - if (!category) { - throw createHttpError('分类不存在', { - statusCode: 404, - errorCode: 'work_category_not_found', - }) - } - const usages = await countWorkCategoryUsages(Number(categoryId)) - if (usages.workOrderCount > 0 || usages.productRuleCount > 0) { - throw createHttpError( - `该分类已被 ${usages.workOrderCount} 个工单和 ${usages.productRuleCount} 条物品规则使用,无法删除,请先停用`, - { - statusCode: 409, - errorCode: 'work_category_in_use', - }, - ) - } - const { deleted } = await deleteWorkCategory(Number(categoryId)) - return { deleted } -} - -export async function saveAdminWorkerLevel(payload: JsonObject = {}) { - const now = nowIso() - const levelKey = String(payload.levelKey || payload.level_key || '').trim() || DEFAULT_LEVEL_KEY - const existingLevel = await getWorkerLevelByKey(levelKey) - const existingPermissions = safeParseJson(existingLevel?.permission_json) - const name = String(payload.name || '').trim() || DEFAULT_LEVEL_NAME - const permissions = { - depositFreeAmount: normalizeAmountFen( - payload.depositFreeAmount ?? payload.depositFreeAmountYuan, - 0, - ), - maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 1), - upgradeThreshold: normalizeInteger(payload.upgradeThreshold, 0), - autoAcceptWithoutEvidence: normalizeBoolean( - payload.autoAcceptWithoutEvidence, - normalizeBoolean(existingPermissions.autoAcceptWithoutEvidence, levelKey === 'vip5'), - ), - visibleDelaySeconds: normalizeInteger(payload.visibleDelaySeconds, -1), - } - const level = await upsertWorkerLevel({ - levelKey, - name, - sortOrder: normalizeInteger(payload.sortOrder, Number(existingLevel?.sort_order || 100)), - status: String(payload.status || 'active').trim() === 'disabled' ? 'disabled' : 'active', - permissionJson: JSON.stringify(permissions), - now, - }) - return { level: mapWorkerLevel(level) } -} - -export async function deleteAdminWorkerLevel(levelId: number | string) { - const level = await getWorkerLevelById(Number(levelId)) - if (!level) { - throw createHttpError('等级不存在', { - statusCode: 404, - errorCode: 'worker_level_not_found', - }) - } - if (level.level_key === DEFAULT_LEVEL_KEY) { - throw createHttpError('默认等级(VIP1)不可删除', { - statusCode: 409, - errorCode: 'worker_level_default_protected', - }) - } - const usageCount = await countWorkerLevelUsages(Number(levelId)) - if (usageCount > 0) { - throw createHttpError(`该等级正在被 ${usageCount} 名打手使用,请先调整打手等级后再删除`, { - statusCode: 409, - errorCode: 'worker_level_in_use', - }) - } - const { deleted } = await deleteWorkerLevel(Number(levelId)) - return { deleted } -} - -export async function listAdminWorkProductRules(query: JsonObject = {}) { - await ensureWorkerPlatformDefaults() - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const enabledValue = String(query.enabled ?? '').trim() - const enabled = enabledValue - ? ['true', '1', 'enabled', 'active'].includes(enabledValue.toLowerCase()) - : null - const keyword = String(query.keyword || '').trim() - const categoryId = normalizeOptionalId(query.categoryId ?? query.category_id) || 0 - const [{ items, total }, categoryRows] = await Promise.all([ - listWorkProductRulesPage({ - page, - pageSize, - enabled, - keyword, - categoryId, - }), - countWorkProductRulesByCategory({ enabled, keyword }), - ]) - return { - items: items.map(mapWorkProductRule), - pagination: { page, pageSize, total }, - categoryCounts: categoryRows.map((row) => ({ - categoryId: row.categoryId, - total: row.total, - })), - } -} - -export async function reprocessAdminKuaishouSendCodeWorkOrders(payload: JsonObject = {}) { - const limit = Math.min(500, normalizePositiveInteger(payload.limit, 100)) - return reprocessKuaishouSendCodeWorkOrders(limit) -} - -export async function listAdminWorkProductRuleMappings(query: JsonObject = {}) { - const ruleId = normalizeOptionalId(query.ruleId ?? query.rule_id) || 0 - const hasPagination = query.page !== undefined || query.pageSize !== undefined || query.keyword - if (hasPagination) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const keyword = String(query.keyword || '').trim() - const result = await listWorkProductRuleMappingsPage({ - page, - pageSize, - keyword, - ruleId, - }) - return { - items: result.items.map(mapWorkProductRuleMapping), - pagination: { page, pageSize, total: result.total }, - } - } - const items = await listWorkProductRuleMappings({ ruleId }) - return { items: items.map(mapWorkProductRuleMapping) } -} - -export async function saveAdminWorkProductRuleMapping(payload: JsonObject = {}) { - const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id) - const sellerIds = normalizeKuaishouMatchIds( - payload.sellerIds ?? payload.seller_ids ?? payload.sellerId ?? payload.seller_id, - ) - const relItemIds = normalizeKuaishouMatchIdList(payload.relItemId ?? payload.rel_item_id) - const relItemId = relItemIds.join(',') - const itemTitle = String(payload.itemTitle ?? payload.item_title ?? '').trim() - const rawMappingType = String(payload.mappingType ?? payload.mapping_type ?? '').trim() - const mappingType = - rawMappingType === 'sku_series' - ? 'sku_series' - : rawMappingType === 'sku_exact' || rawMappingType === 'sku_override' - ? 'sku_exact' - : 'product_default' - const relSkuId = - mappingType === 'sku_exact' - ? normalizeKuaishouMatchId(payload.relSkuId ?? payload.rel_sku_id) - : '' - const skuNick = - mappingType === 'product_default' - ? '' - : String(payload.skuNick ?? payload.sku_nick ?? '').trim() - const skuMatchMode = - mappingType === 'sku_series' && - String(payload.skuMatchMode ?? payload.sku_match_mode ?? '').trim() === 'exact' - ? 'exact' - : 'fuzzy' - const requiresProductScope = mappingType === 'product_default' - if ( - !ruleId || - sellerIds.length === 0 || - (requiresProductScope && relItemIds.length === 0 && !itemTitle) - ) { - throw createHttpError( - '请选择接单模板,并填写至少一个店铺;商品默认规则还需填写大标题或关联商品 ID', - { - statusCode: 400, - errorCode: 'work_product_mapping_identity_required', - }, - ) - } - if ( - mappingType === 'product_default' && - sellerIds.length > 1 && - (!itemTitle || relItemIds.length > 0) - ) { - throw createHttpError('多店共享规则请填写快手大标题,并留空关联商品 ID', { - statusCode: 400, - errorCode: 'work_product_mapping_multi_shop_scope_invalid', - }) - } - if (mappingType === 'sku_exact' && !relSkuId && !skuNick) { - throw createHttpError('SKU 精确规则需要关联 SKU ID 或具体 SKU 名称', { - statusCode: 400, - errorCode: 'work_product_mapping_sku_required', - }) - } - if (mappingType === 'sku_series' && !skuNick) { - throw createHttpError('SKU 数量系列需要填写系列名称', { - statusCode: 400, - errorCode: 'work_product_mapping_series_required', - }) - } - const rule = (await listWorkProductRules()).find((item) => Number(item.id) === ruleId) - if (!rule) { - throw createHttpError('接单模板不存在', { - statusCode: 404, - errorCode: 'work_product_mapping_rule_not_found', - }) - } - const mappingId = normalizeOptionalId(payload.mappingId ?? payload.mapping_id) - if (normalizeBoolean(payload.enabled, true)) { - await assertNoConflictingWorkProductRuleMapping({ - mappingId, - sellerIds, - relItemId, - itemTitle, - relSkuId, - skuNick, - mappingType, - }) - } - const mapping = await upsertWorkProductRuleMapping({ - mappingId, - ruleId, - sellerIds, - relItemId, - itemTitle, - relSkuId, - skuNick, - skuMatchMode, - mappingType, - enabled: normalizeBoolean(payload.enabled, true), - now: nowIso(), - }) - if (!mapping) { - throw createHttpError('商品映射保存失败', { - statusCode: 500, - errorCode: 'work_product_mapping_save_failed', - }) - } - return { - mapping: mapWorkProductRuleMapping({ - ...mapping, - rule_key: rule.rule_key, - product_name: rule.product_name, - }), - } -} - -export async function deleteAdminWorkProductRuleMapping(mappingId: number | string) { - const normalizedMappingId = normalizeOptionalId(mappingId) - if (!normalizedMappingId) { - throw createHttpError('商品映射 ID 不正确', { - statusCode: 400, - errorCode: 'work_product_mapping_id_invalid', - }) - } - return deleteWorkProductRuleMapping(normalizedMappingId) -} - -export async function listAdminKuaishouMatchSources(payload: JsonObject = {}) { - const limit = Math.min(500, normalizePositiveInteger(payload.limit, 100)) - const sources = await listKuaishouIndustryVoucherWorkOrderSyncSources(limit) - const grouped = new Map() - for (const source of sources) { - const rawPayload = safeParseJson(source.raw_payload_json) - const body = resolveKuaishouMatchPayload(rawPayload) - const ext = safeParseJson(body.ext) - const sellerId = normalizeKuaishouMatchId(body.sellerId) - const relItemId = normalizeKuaishouMatchId(ext.relItemId) - const itemTitle = String(body.itemTitle || '').trim() - const relSkuId = normalizeKuaishouMatchId(ext.relSkuId) - const skuNick = String(ext.skuNick || '').trim() - if (!sellerId || (!relItemId && !itemTitle)) continue - const key = [ - sellerId, - relItemId, - normalizeMatchTextForAdmin(itemTitle), - relSkuId, - normalizeMatchTextForAdmin(skuNick), - ].join('|') - const current = grouped.get(key) - grouped.set(key, { - sellerId, - relItemId, - itemTitle, - relSkuId, - skuNick, - sampleOid: String(body.oid || source.oid || '').trim(), - lastSeenAt: source.updated_at, - seenCount: Number(current?.seenCount || 0) + 1, - rawPayload, - }) - } - return { - items: Array.from(grouped.values()).sort((left, right) => - String(right.lastSeenAt).localeCompare(String(left.lastSeenAt)), - ), - } -} - -export async function testAdminKuaishouProductMatch(payload: JsonObject = {}) { - const body = resolveKuaishouMatchPayload(payload.rawPayload ?? payload.raw_payload ?? payload) - const ext = safeParseJson(body.ext) - const order = { - provider: '91kaquan', - platform: 'kuaishou', - shop_id: String(body.sellerId || '').trim(), - } as OrderRow - const item = { - id: 0, - order_id: 0, - sku_code: String(body.itemId || '').trim(), - sku_name: String(ext.skuNick || '').trim(), - quantity: Math.max(1, Number(body.num) || 1), - spec_json: {}, - item_snapshot_json: { - kuaishouSendCode: { - sellerId: String(body.sellerId || '').trim(), - itemId: String(body.itemId || '').trim(), - itemTitle: String(body.itemTitle || '').trim(), - skuId: String(body.skuId || '').trim(), - relItemId: normalizeKuaishouMatchId(ext.relItemId), - relSkuId: normalizeKuaishouMatchId(ext.relSkuId), - skuNick: String(ext.skuNick || '').trim(), - }, - }, - } as OrderItemRow - const [rules, mappings] = await Promise.all([ - listWorkProductRules({ enabled: true }), - listWorkProductRuleMappings({ enabled: true }), - ]) - const decision = resolveMatchingProductRuleDecision(order, item, rules, mappings, { - legacyFallbackEnabled: getWorkerProductMatchConfig().legacyFallbackEnabled, - }) - const context = resolveKuaishouWorkProductMatchContext(item) - return { - context, - status: decision.reason, - mappingId: decision.mappingId, - rule: decision.rule ? mapWorkProductRule(decision.rule) : null, - candidates: decision.candidates, - } -} - -export async function listAdminWorkProductMatchLogs(payload: JsonObject = {}) { - const limit = Math.min(500, normalizePositiveInteger(payload.limit, 100)) - return { items: (await listWorkProductMatchLogs(limit)).map(mapWorkProductMatchLog) } -} - -function mapWorkProductRuleMapping(mapping: WorkProductRuleMappingRow) { - const sellerIds = parseKuaishouMappingSellerIds(mapping.seller_ids_json) - return { - mappingId: Number(mapping.id), - ruleId: Number(mapping.rule_id), - ruleKey: String(mapping.rule_key || ''), - productName: String(mapping.product_name || ''), - sellerIds, - sellerId: sellerIds[0] || '', - relItemId: String(mapping.rel_item_id || ''), - itemTitle: String(mapping.item_title || ''), - relSkuId: String(mapping.rel_sku_id || ''), - skuNick: String(mapping.sku_nick || ''), - skuMatchMode: mapping.sku_match_mode === 'exact' ? 'exact' : 'fuzzy', - mappingType: - mapping.mapping_type === 'sku_series' - ? 'sku_series' - : mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override' - ? 'sku_exact' - : 'product_default', - enabled: mapping.enabled === true, - createdAt: mapping.created_at, - updatedAt: mapping.updated_at, - } -} - -function mapWorkProductMatchLog(log: WorkProductMatchLogRow) { - return { - logId: Number(log.id), - orderId: log.order_id ? Number(log.order_id) : null, - orderItemId: log.order_item_id ? Number(log.order_item_id) : null, - source: String(log.source || ''), - sellerId: String(log.seller_id || ''), - relItemId: String(log.rel_item_id || ''), - itemTitle: String(log.item_title || ''), - relSkuId: String(log.rel_sku_id || ''), - skuNick: String(log.sku_nick || ''), - status: String(log.match_status || ''), - ruleId: log.rule_id ? Number(log.rule_id) : null, - mappingId: log.mapping_id ? Number(log.mapping_id) : null, - ruleKey: String(log.rule_key || ''), - productName: String(log.product_name || ''), - candidates: parseJsonArray(log.candidates_json), - rawPayload: safeParseJson(log.raw_payload_json), - createdAt: log.created_at, - } -} - -function resolveKuaishouMatchPayload(value: unknown): JsonObject { - const raw = safeParseJson(value) - const body = safeParseJson(raw.body) - return Object.keys(body).length > 0 ? body : raw -} - -function normalizeKuaishouMatchId(value: unknown) { - const normalized = String(value || '').trim() - return normalized === '0' ? '' : normalized -} - -function hasOwnPayload(payload: JsonObject, key: string) { - return Object.prototype.hasOwnProperty.call(payload, key) -} - -function normalizeKuaishouMatchIds(value: unknown) { - const values = Array.isArray(value) - ? value - : String(value ?? '') - .split(/[,,\n]/) - .map((item) => item.trim()) - return Array.from(new Set(values.map(normalizeKuaishouMatchId).filter(Boolean))) -} - -function normalizeKuaishouMatchIdList(value: unknown) { - const values = Array.isArray(value) ? value : [value] - return normalizeKuaishouMatchIds( - values.flatMap((item) => String(item ?? '').split(/[,,;;\s\n\r]+/)), - ) -} - -function parseKuaishouMappingSellerIds(value: unknown) { - if (Array.isArray(value)) return normalizeKuaishouMatchIds(value) - try { - return normalizeKuaishouMatchIds(JSON.parse(String(value || '[]'))) - } catch { - return normalizeKuaishouMatchIds(value) - } -} - -function normalizeMatchTextForAdmin(value: unknown) { - return String(value || '') - .normalize('NFKC') - .toLowerCase() - .replace(/[\s\u3000]+/g, '') - .trim() -} - -function assertNoConflictingWorkProductRuleMapping(input: { - mappingId: number | null - sellerIds: string[] - relItemId: string - itemTitle: string - relSkuId: string - skuNick: string - mappingType: 'product_default' | 'sku_exact' | 'sku_series' -}) { - return listWorkProductRuleMappings({ enabled: true }).then((mappings) => { - const conflict = mappings.find((mapping) => { - if (input.mappingId && Number(mapping.id) === input.mappingId) return false - const existingType = - mapping.mapping_type === 'sku_series' - ? 'sku_series' - : mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override' - ? 'sku_exact' - : 'product_default' - if (existingType !== input.mappingType) return false - const existingSellerIds = parseKuaishouMappingSellerIds(mapping.seller_ids_json) - if (!existingSellerIds.some((sellerId) => input.sellerIds.includes(sellerId))) return false - - const inputItemIds = normalizeKuaishouMatchIdList(input.relItemId) - const existingItemIds = normalizeKuaishouMatchIdList(mapping.rel_item_id) - if ( - !mappingProductScopesOverlap( - inputItemIds, - input.itemTitle, - existingItemIds, - mapping.item_title, - ) - ) { - return false - } - - if (input.mappingType === 'product_default') return true - if (input.mappingType === 'sku_series') { - // 精确系列是模糊系列的子集,两者并存仍可能产生同分冲突,因此统一拦截。 - return ( - normalizeMatchTextForAdmin(input.skuNick) === normalizeMatchTextForAdmin(mapping.sku_nick) - ) - } - - const inputSkuId = normalizeKuaishouMatchId(input.relSkuId) - const existingSkuId = normalizeKuaishouMatchId(mapping.rel_sku_id) - const skuIdConflict = Boolean(inputSkuId && existingSkuId && inputSkuId === existingSkuId) - const skuNameConflict = - Boolean(input.skuNick && mapping.sku_nick) && - normalizeMatchTextForAdmin(input.skuNick) === normalizeMatchTextForAdmin(mapping.sku_nick) - return skuIdConflict || skuNameConflict - }) - - if (conflict) { - throw createHttpError('商品映射与已有启用映射冲突,请停用或调整其中一条', { - statusCode: 409, - errorCode: 'work_product_mapping_conflict', - context: { - mappingId: Number(conflict.id), - ruleKey: String(conflict.rule_key || ''), - }, - }) - } - }) -} - -function mappingProductScopesOverlap( - leftItemIds: string[], - leftTitle: string, - rightItemIds: string[], - rightTitle: string, -) { - if (leftItemIds.length > 0 && rightItemIds.length > 0) { - return leftItemIds.some((itemId) => rightItemIds.includes(itemId)) - } - // 只有一侧指定商品 ID 时,另一侧可能是全商品范围,按可能重叠处理并拦截。 - if (leftItemIds.length > 0 || rightItemIds.length > 0) return true - const normalizedLeftTitle = normalizeMatchTextForAdmin(leftTitle) - const normalizedRightTitle = normalizeMatchTextForAdmin(rightTitle) - if (normalizedLeftTitle || normalizedRightTitle) { - return Boolean( - normalizedLeftTitle && normalizedRightTitle && normalizedLeftTitle === normalizedRightTitle, - ) - } - return true -} - -function parseJsonArray(value: unknown): unknown[] { - if (Array.isArray(value)) return value - try { - const parsed = JSON.parse(String(value || '[]')) - return Array.isArray(parsed) ? parsed : [] - } catch { - return [] - } -} - -export async function saveAdminWorkProductRule(payload: JsonObject = {}) { - const defaults = await ensureWorkerPlatformDefaults() - const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id) - const existingRule = ruleId ? await getWorkProductRuleById(ruleId) : null - if (ruleId && !existingRule) { - throw createHttpError('接单模板不存在', { - statusCode: 404, - errorCode: 'work_product_rule_not_found', - }) - } - const hasLegacyMatchPayload = [ - 'match', - 'sellerId', - 'seller_id', - 'itemId', - 'item_id', - 'relItemId', - 'rel_item_id', - 'skuId', - 'sku_id', - 'relSkuId', - 'rel_sku_id', - 'itemTitle', - 'item_title', - 'skuNick', - 'sku_nick', - 'itemTitleMatchType', - 'item_title_match_type', - 'skuNickMatchType', - 'sku_nick_match_type', - ].some((key) => Object.prototype.hasOwnProperty.call(payload, key)) - const match = hasLegacyMatchPayload - ? normalizeWorkProductRuleMatch(payload) - : safeParseJson(existingRule?.match_json) - const productName = String( - payload.productName || payload.product_name || payload.skuName || '', - ).trim() - const skuCode = String(payload.skuCode || payload.sku_code || '').trim() - const matchedProductName = String(match.itemTitle || match.skuNick || '').trim() - if (!productName && !skuCode && !matchedProductName) { - throw createHttpError('请填写商品名或 SKU', { - statusCode: 400, - errorCode: 'work_product_rule_target_required', - }) - } - - const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0) - const unitPriceFen = - payload.unitPrice === undefined && payload.unitPriceYuan === undefined - ? 0 - : normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0) - const sharingEnabled = normalizeBoolean(payload.sharingEnabled ?? payload.sharing_enabled, false) - const hasManualSharingConfig = - hasConfiguredSharingValue(payload.sharingTotalQuantity ?? payload.sharing_total_quantity) || - hasConfiguredSharingValue(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan) || - hasConfiguredSharingValue(payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan) - const sharingAutoFromOrder = - sharingEnabled && - unitPriceFen > 0 && - (normalizeBoolean(payload.sharingAutoFromOrder ?? payload.sharing_auto_from_order, false) || - !hasManualSharingConfig) - const hasManualSharingQuantity = hasConfiguredSharingValue( - payload.sharingTotalQuantity ?? payload.sharing_total_quantity, - ) - const hasManualSharingUnitReward = hasConfiguredSharingValue( - payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, - ) - if ( - sharingEnabled && - unitPriceFen > 0 && - !sharingAutoFromOrder && - hasManualSharingQuantity !== hasManualSharingUnitReward - ) { - throw createHttpError('按件计价的拼单覆盖值请同时填写拼单数量和拼单单价', { - statusCode: 400, - errorCode: 'work_product_rule_sharing_override_incomplete', - }) - } - const sharingUnitReward = normalizeAmountFen( - payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, - 0, - ) - const sharingTotalAmount = normalizeAmountFen( - payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan, - 0, - ) - const sharingTotalQuantity = resolveSharingQuantity( - sharingTotalAmount, - normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1), - sharingUnitReward, - ) - const resolvedSharingTotalAmount = - sharingTotalAmount > 0 ? sharingTotalAmount : sharingUnitReward * sharingTotalQuantity - if (sharingEnabled && !sharingAutoFromOrder) { - if (resolvedSharingTotalAmount <= 0) { - throw createHttpError('启用拼单时请填写拼单总价或单价', { - statusCode: 400, - errorCode: 'work_product_rule_sharing_amount_invalid', - }) - } - } - const finalRewardAmount = sharingEnabled - ? sharingAutoFromOrder - ? 0 - : resolvedSharingTotalAmount - : rewardAmount - const fields = normalizeRequirementFieldsFromPayload(payload) - const ruleKey = existingRule?.rule_key || randomId('rule-') - const rule = await upsertWorkProductRule({ - ruleKey, - provider: hasOwnPayload(payload, 'provider') - ? String(payload.provider || '').trim() - : String(existingRule?.provider || '').trim(), - platform: hasOwnPayload(payload, 'platform') - ? String(payload.platform || '').trim() - : String(existingRule?.platform || '').trim(), - shopId: - hasOwnPayload(payload, 'shopId') || hasOwnPayload(payload, 'shop_id') - ? String(payload.shopId || payload.shop_id || '').trim() - : String(existingRule?.shop_id || '').trim(), - skuCode: - hasOwnPayload(payload, 'skuCode') || hasOwnPayload(payload, 'sku_code') - ? skuCode - : String(existingRule?.sku_code || '').trim(), - productName: productName || matchedProductName, - matchType: - hasOwnPayload(payload, 'matchType') || hasOwnPayload(payload, 'match_type') - ? normalizeMatchType(payload.matchType || payload.match_type) - : normalizeMatchType(existingRule?.match_type), - categoryId: - normalizeOptionalId(payload.categoryId || payload.category_id) || - defaults.category?.id || - null, - enabled: normalizeBoolean(payload.enabled, true), - autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, true), - rewardAmount: finalRewardAmount, - unitPriceFen, - // 商品规则不配置押金;工单创建后由运营按需手工填写。 - requiredDepositAmount: 0, - depositThresholdAmount: 0, - sharingEnabled, - sharingTotalQuantity, - sharingUnitReward, - sharingAutoFromOrder, - timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)), - timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy), - requirementJson: JSON.stringify({ fields }), - matchJson: JSON.stringify(match), - sortOrder: normalizeInteger(payload.sortOrder, 100), - now: nowIso(), - }) - return { rule: mapWorkProductRule(rule) } -} - -/** 根据工单创建时的规格,计算当前模板可同步的价格。 */ -export function resolveWorkOrderTemplateSyncPrice(input: { - rule: WorkProductRuleRow - workOrder: WorkOrderRow -}): { rewardAmount: number; sharingUnitReward?: number } | null { - const material = safeParseJson(input.workOrder.material_json) - const source = safeParseJson(material.source) - const sourceSkuQuantity = Number(source.skuQuantity || 0) - const skuQuantity = - Number.isSafeInteger(sourceSkuQuantity) && sourceSkuQuantity > 0 - ? sourceSkuQuantity - : resolveSkuNameQuantity(input.workOrder.product_name) - const pricing = resolveWorkOrderRulePricing({ - skuQuantity, - unitPriceFen: Number(input.rule.unit_price_fen || 0), - fixedRewardAmount: Number(input.rule.reward_amount || 0), - sharingEnabled: input.rule.sharing_enabled === true, - sharingAutoFromOrder: input.rule.sharing_auto_from_order === true, - sharingTotalQuantity: Number(input.rule.sharing_total_quantity || 1), - sharingUnitReward: Number(input.rule.sharing_unit_reward || 0), - }) - - if (input.workOrder.sharing_enabled === true) { - if (input.rule.sharing_enabled !== true || pricing.sharingUnitReward <= 0) { - return null - } - return { - rewardAmount: pricing.rewardAmount, - sharingUnitReward: pricing.sharingUnitReward, - } - } - return pricing.rewardAmount > 0 ? { rewardAmount: pricing.rewardAmount } : null -} - -/** 将指定模板的当前价格同步到仍在接单大厅的来源工单。 */ -export async function syncAdminWorkProductRulePrice(ruleId: number | string, actorName = '') { - const normalizedRuleId = normalizeOptionalId(ruleId) - const rule = normalizedRuleId ? await getWorkProductRuleById(normalizedRuleId) : null - if (!rule) { - throw createHttpError('接单模板不存在', { - statusCode: 404, - errorCode: 'work_product_rule_not_found', - }) - } - - const candidates = await listOpenWorkOrdersByProductRuleId(rule.id) - const result = { - candidateCount: candidates.length, - updatedCount: 0, - updatedNormalCount: 0, - updatedPartialSharingCount: 0, - skippedInvalidPricingCount: 0, - skippedUnchangedCount: 0, - skippedUnavailableCount: 0, - } - const now = nowIso() - - for (const workOrder of candidates) { - const price = resolveWorkOrderTemplateSyncPrice({ rule, workOrder }) - if (!price) { - result.skippedInvalidPricingCount += 1 - continue - } - const synced = await syncOpenWorkOrderPriceFromRule({ - workOrderId: Number(workOrder.id), - productRuleId: Number(rule.id), - rewardAmount: price.rewardAmount, - ...(price.sharingUnitReward === undefined - ? {} - : { sharingUnitReward: price.sharingUnitReward }), - actorName, - now, - }) - if (!synced.updated) { - if (synced.reason === 'price_unchanged') { - result.skippedUnchangedCount += 1 - } else { - result.skippedUnavailableCount += 1 - } - continue - } - result.updatedCount += 1 - if (synced.sharingPartial) { - result.updatedPartialSharingCount += 1 - } else { - result.updatedNormalCount += 1 - } - publishWorkOrderRealtimeChange({ - workOrderId: synced.workOrderId, - workerIds: synced.workerIds, - hallChanged: true, - }) - } - - return { rule: mapWorkProductRule(rule), ...result } -} - -export async function deleteAdminWorkProductRule(ruleId: number | string) { - const normalizedRuleId = normalizeOptionalId(ruleId) - if (!normalizedRuleId) { - throw createHttpError('物品规则 ID 不正确', { - statusCode: 400, - errorCode: 'work_product_rule_id_invalid', - }) - } - const { deleted } = await deleteWorkProductRule(normalizedRuleId) - if (!deleted) { - throw createHttpError('物品规则不存在或已删除', { - statusCode: 404, - errorCode: 'work_product_rule_not_found', - }) - } - return { deleted } -} - export async function listAdminWorkerUsers(query: JsonObject = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) @@ -1231,232 +329,6 @@ function resolveAssignedDeadlineAt(timeoutMinutes: number, now: string): string return new Date(new Date(now).getTime() + timeoutMinutes * 60 * 1000).toISOString() } -export async function creditAdminWorkerWallet(workerId: number | string, payload: JsonObject = {}) { - await getRequiredWorker(workerId) - const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0) - if (amount <= 0) { - throw createHttpError('充值金额必须大于 0', { - statusCode: 400, - errorCode: 'worker_credit_amount_invalid', - }) - } - const wallet = await addWorkerWalletCredit({ - workerId: Number(workerId), - amount, - note: String(payload.note || '后台人工充值').trim(), - payloadJson: JSON.stringify({ source: 'admin_manual_credit' }), - now: nowIso(), - }) - publishWorkerWalletRealtimeChange(Number(workerId)) - return { wallet: mapWallet(wallet) } -} - -export async function listAdminWorkerWithdrawalAccounts(workerId: number | string) { - const worker = await getRequiredWorker(workerId) - const items = await listWorkerWithdrawalAccounts(worker.id) - return { - worker: mapWorkerUser(worker), - items: items.map(mapAdminWorkerWithdrawalAccount), - } -} - -export async function saveAdminWorkerWithdrawalAccount( - workerId: number | string, - accountChannelValue: unknown, - payload: JsonObject = {}, -) { - const worker = await getRequiredWorker(workerId) - const accountChannel = normalizeWithdrawChannel(accountChannelValue) - const currentAccounts = await listWorkerWithdrawalAccounts(worker.id) - const current = currentAccounts.find((item) => item.account_channel === accountChannel) - const accountName = String(payload.accountName || payload.realName || '').trim() - const accountNo = - accountChannel === 'alipay' - ? String(payload.accountNo || payload.account || current?.account_no || '').trim() - : '' - const uploadedAlipayQrCode = normalizeProofFiles( - payload.alipayQrCodeImages || payload.alipayQrCodes, - )[0] - const alipayQrCodeImage = - accountChannel === 'alipay' - ? uploadedAlipayQrCode || safeParseJson(current?.alipay_qr_code_json) - : {} - const uploadedWechatQrCode = normalizeProofFiles( - payload.wechatQrCodeImages || payload.wechatQrCodes, - )[0] - const wechatQrCodeImage = - accountChannel === 'wechat' - ? uploadedWechatQrCode || safeParseJson(current?.wechat_qr_code_json) - : {} - - if (!accountName) { - throw createHttpError('请填写收款人姓名', { - statusCode: 400, - errorCode: 'admin_worker_withdraw_account_name_required', - }) - } - if (accountChannel === 'alipay' && !current && !accountNo) { - throw createHttpError('请填写支付宝账号', { - statusCode: 400, - errorCode: 'admin_worker_withdraw_alipay_account_required', - }) - } - if (accountChannel === 'alipay' && !String(alipayQrCodeImage.url || '').trim()) { - throw createHttpError('请上传支付宝收款二维码', { - statusCode: 400, - errorCode: 'admin_worker_withdraw_alipay_qr_required', - }) - } - if (accountChannel === 'wechat' && !String(wechatQrCodeImage.url || '').trim()) { - throw createHttpError('请上传微信收款二维码', { - statusCode: 400, - errorCode: 'admin_worker_withdraw_wechat_qr_required', - }) - } - - const saved = await upsertWorkerWithdrawalAccount({ - workerId: worker.id, - accountChannel, - accountName, - accountNo, - alipayQrCodeJson: JSON.stringify(alipayQrCodeImage), - wechatQrCodeJson: JSON.stringify(wechatQrCodeImage), - now: nowIso(), - }) - if (!saved) { - throw createHttpError('保存打手提现信息失败', { - statusCode: 500, - errorCode: 'admin_worker_withdraw_account_save_failed', - }) - } - return { account: mapAdminWorkerWithdrawalAccount(saved) } -} - -export async function getAdminWorkerFinanceConfig() { - return getWorkerFinanceConfig() -} - -export async function saveAdminWorkerFinanceConfig(payload: JsonObject = {}) { - const config = await saveWorkerFinanceConfig(payload) - return config -} - -export async function getAdminWorkerPlatformNotificationConfig() { - return getWorkerPlatformNotificationConfig() -} - -export async function saveAdminWorkerPlatformNotificationConfig(payload: JsonObject = {}) { - return saveWorkerPlatformNotificationConfig(payload) -} - -export async function getAdminWorkerProductMatchConfig() { - return getWorkerProductMatchConfig() -} - -export async function getAdminWorkerAnnouncementConfig() { - return getWorkerAnnouncementConfig() -} - -export async function saveAdminWorkerAnnouncementConfig(payload: JsonObject = {}) { - return saveWorkerAnnouncementConfig(payload) -} - -export async function saveAdminWorkerProductMatchConfig(payload: JsonObject = {}) { - return saveWorkerProductMatchConfig(payload) -} - -export async function listAdminWorkerFinanceRequests(query: JsonObject = {}) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const requestId = normalizeOptionalId(query.requestId ?? query.request_id) || 0 - const status = normalizeFinanceRequestStatus(query.status) - const requestType = normalizeFinanceRequestType(query.requestType) - const keyword = String(query.keyword || '').trim() - const { items, total } = await listWorkerFinanceRequests({ - page, - pageSize, - requestId, - status, - requestType, - keyword, - }) - return { - items: items.map(mapFinanceRequest), - pagination: { page, pageSize, total }, - } -} - -export async function reviewAdminWorkerFinanceRequest( - requestId: number | string, - payload: JsonObject = {}, -) { - const current = await getWorkerFinanceRequestById(requestId) - if (!current) { - throw createHttpError('资金申请不存在', { - statusCode: 404, - errorCode: 'worker_finance_request_not_found', - }) - } - - const status = normalizeAdminFinanceReviewStatus(payload.status) - const reviewedNote = String(payload.reviewedNote || payload.note || '').trim() - const reviewed = await reviewWorkerFinanceRequest({ - requestId: Number(current.id), - status, - reviewedNote, - now: nowIso(), - }) - - if (reviewed.failureReason === 'request_not_pending') { - throw createHttpError('该申请已处理,请刷新后重试', { - statusCode: 409, - errorCode: 'worker_finance_request_reviewed', - }) - } - - if (reviewed.failureReason === 'withdraw_insufficient') { - throw createHttpError('打手余额不足,暂时无法通过该提现申请', { - statusCode: 409, - errorCode: 'worker_finance_request_withdraw_insufficient', - }) - } - - if (!reviewed.request) { - throw createHttpError('资金申请不存在', { - statusCode: 404, - errorCode: 'worker_finance_request_not_found', - }) - } - - await resolveAdminNotificationEntity( - 'worker_finance_request', - Number(reviewed.request.id), - 'finance_request_reviewed', - ) - publishWorkerFinanceRealtimeChange({ - requestId: Number(reviewed.request.id), - workerId: Number(reviewed.request.worker_id), - walletChanged: reviewed.request.request_type === 'withdraw' && status === 'approved', - }) - - return { - request: mapFinanceRequest(reviewed.request), - } -} - -function mapAdminWorkerWithdrawalAccount(account: WorkerWithdrawalAccountRow) { - const alipayQrCodeImage = refreshUploadedFileUrls(safeParseJson(account.alipay_qr_code_json)) - const wechatQrCodeImage = refreshUploadedFileUrls(safeParseJson(account.wechat_qr_code_json)) - return { - accountChannel: account.account_channel, - accountName: account.account_name, - accountNo: account.account_channel === 'alipay' ? account.account_no : '', - alipayQrCodeImage: String(alipayQrCodeImage.url || '').trim() ? alipayQrCodeImage : null, - wechatQrCodeImage: String(wechatQrCodeImage.url || '').trim() ? wechatQrCodeImage : null, - createdAt: account.created_at, - } -} - export async function getAdminWorkOrderEvents(workOrderId: number | string) { await getRequiredWorkOrder(workOrderId) const events = await listWorkOrderEventsByOrderId(workOrderId) @@ -1705,203 +577,6 @@ export async function deductAdminWorkOrderPendingDeposit( return { deductedAmount: result.deductedAmount } } -export async function listAdminAfterSalesCases(query: JsonObject = {}) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const workOrderId = normalizeOptionalId(query.workOrderId ?? query.work_order_id) - const result = await listAfterSalesCases({ - page, - pageSize, - status: String(query.status || '').trim(), - keyword: String(query.keyword || '').trim(), - ...(workOrderId ? { workOrderId } : {}), - }) - return { - items: result.items.map(mapWorkerAfterSalesCase), - pagination: { page, pageSize, total: result.total }, - } -} - -export async function getAdminAfterSalesCaseEvents(caseId: number | string) { - const afterSalesCase = await getAfterSalesCaseById(caseId) - if (!afterSalesCase) { - throw createHttpError('售后问题单不存在', { - statusCode: 404, - errorCode: 'after_sales_case_not_found', - }) - } - return { - items: (await listAfterSalesCaseEvents(afterSalesCase.id)).map((event) => ({ - eventId: Number(event.id), - actorType: event.actor_type, - actorId: event.actor_id, - eventType: event.event_type, - payload: safeParseJson(event.payload_json), - createdAt: event.created_at, - })), - } -} - -export async function createAdminAfterSalesCase( - workOrderId: number | string, - payload: JsonObject = {}, - actorName = '', -) { - const complaintNote = String(payload.complaintNote || payload.note || '').trim() - if (!complaintNote) { - throw createHttpError('请填写用户投诉说明', { - statusCode: 400, - errorCode: 'after_sales_case_complaint_required', - }) - } - if (complaintNote.length > 2_000) { - throw createHttpError('用户投诉说明不能超过 2000 个字符', { - statusCode: 400, - errorCode: 'after_sales_case_complaint_too_long', - }) - } - const problemType = String(payload.problemType || 'fake_evidence').trim() || 'fake_evidence' - if (problemType.length > 64) { - throw createHttpError('问题类型不正确', { - statusCode: 400, - errorCode: 'after_sales_case_problem_type_invalid', - }) - } - const shareId = normalizeOptionalId(payload.shareId ?? payload.workOrderShareId) - const now = nowIso() - const result = await createAfterSalesCase({ - caseNo: randomId('AS'), - workOrderId: Number(workOrderId), - workOrderShareId: shareId || null, - problemType, - complaintNote, - complaintFilesJson: JSON.stringify(normalizeProofFiles(payload.files || payload.proofFiles)), - createdBy: actorName, - now, - }) - if (result.failureReason === 'work_order_not_accepted') { - throw createHttpError('只有已验收的工单可以发起售后问题单', { - statusCode: 409, - errorCode: 'after_sales_case_work_order_status_invalid', - }) - } - if (result.failureReason === 'work_order_share_not_accepted') { - throw createHttpError('该拼单份额尚未验收,不能发起售后问题单', { - statusCode: 409, - errorCode: 'after_sales_case_share_status_invalid', - }) - } - if (result.failureReason === 'worker_missing') { - throw createHttpError('请指定已验收的责任打手或拼单份额', { - statusCode: 409, - errorCode: 'after_sales_case_worker_required', - }) - } - if (result.failureReason === 'active_case_exists' || !result.case) { - throw createHttpError('该责任打手已有处理中售后问题单', { - statusCode: 409, - errorCode: 'after_sales_case_active_exists', - }) - } - await createWorkOrderEvent({ - workOrderId: Number(result.case.work_order_id), - actorType: 'admin', - actorId: actorName, - eventType: 'after_sales_case_created', - fromStatus: WORK_ORDER_STATUS.ACCEPTED, - toStatus: WORK_ORDER_STATUS.ACCEPTED, - payloadJson: JSON.stringify({ - caseId: Number(result.case.id), - caseNo: result.case.case_no, - workerId: Number(result.case.worker_id), - shareId: result.case.work_order_share_id ? Number(result.case.work_order_share_id) : null, - problemType, - }), - now, - }) - publishWorkOrderRealtimeChange({ - workOrderId: Number(result.case.work_order_id), - workerIds: [Number(result.case.worker_id)], - }) - return { case: mapWorkerAfterSalesCase(result.case) } -} - -export async function resolveAdminAfterSalesCase( - caseId: number | string, - payload: JsonObject = {}, - actorName = '', -) { - const action = String(payload.action || '').trim() - if (!['not_upheld', 'remedied', 'upheld'].includes(action)) { - throw createHttpError('售后处置方式不正确', { - statusCode: 400, - errorCode: 'after_sales_case_resolution_action_invalid', - }) - } - const resolutionNote = String(payload.resolutionNote || payload.note || '').trim() - if (!resolutionNote) { - throw createHttpError('请填写售后处置备注', { - statusCode: 400, - errorCode: 'after_sales_case_resolution_note_required', - }) - } - if (resolutionNote.length > 2_000) { - throw createHttpError('售后处置备注不能超过 2000 个字符', { - statusCode: 400, - errorCode: 'after_sales_case_resolution_note_too_long', - }) - } - const recoveryAmount = normalizeAmountFen(payload.recoveryAmount ?? payload.amount, 0) - if (action === 'upheld' && recoveryAmount <= 0) { - throw createHttpError('问题成立时请填写大于 0 的追缴金额', { - statusCode: 400, - errorCode: 'after_sales_case_recovery_amount_required', - }) - } - const result = await resolveAfterSalesCase({ - caseId: Number(caseId), - action: action as 'not_upheld' | 'remedied' | 'upheld', - resolutionNote, - recoveryAmount: action === 'upheld' ? recoveryAmount : 0, - resolvedBy: actorName, - now: nowIso(), - }) - if (result.failureReason === 'not_found') { - throw createHttpError('售后问题单不存在', { - statusCode: 404, - errorCode: 'after_sales_case_not_found', - }) - } - if (result.failureReason === 'not_resolvable' || !result.case) { - throw createHttpError('该售后问题单已处置,请刷新后重试', { - statusCode: 409, - errorCode: 'after_sales_case_not_resolvable', - }) - } - await createWorkOrderEvent({ - workOrderId: Number(result.case.work_order_id), - actorType: 'admin', - actorId: actorName, - eventType: 'after_sales_case_resolved', - fromStatus: WORK_ORDER_STATUS.ACCEPTED, - toStatus: WORK_ORDER_STATUS.ACCEPTED, - payloadJson: JSON.stringify({ - caseId: Number(result.case.id), - action, - recoveryAmount: Number(result.case.recovery_amount || 0), - debtAmount: Number(result.case.debt_amount || 0), - }), - now: nowIso(), - }) - const workerId = Number(result.case.worker_id) - publishWorkOrderRealtimeChange({ - workOrderId: Number(result.case.work_order_id), - workerIds: workerId > 0 ? [workerId] : [], - }) - if (workerId > 0) publishWorkerWalletRealtimeChange(workerId) - return { case: mapWorkerAfterSalesCase(result.case) } -} - export async function getAdminWorkOrderSharing(workOrderId: number | string) { const workOrder = await getRequiredWorkOrder(workOrderId) const shares = await listWorkOrderShares(workOrder.id) @@ -1974,21 +649,6 @@ export async function updateAdminWorkOrderSharing( return { order: mapWorkOrderAdmin(updated || workOrder) } } -function resolveSharingQuantity( - totalAmount: number, - fallbackQuantity: number, - unitReward: number, -): number { - if (totalAmount > 0 && unitReward > 0) { - return Math.max(1, Math.round(totalAmount / unitReward)) - } - return Math.max(1, fallbackQuantity) -} - -function hasConfiguredSharingValue(value: unknown) { - return value !== undefined && value !== null && String(value).trim() !== '' -} - export async function submitAdminWorkOrderMaterial( workOrderId: number | string, payload: JsonObject = {}, diff --git a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts new file mode 100644 index 00000000..c04a3446 --- /dev/null +++ b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts @@ -0,0 +1,474 @@ +import { + countWorkCategoryUsages, + deleteWorkCategory, + getWorkCategoryById, + listAllWorkCategories, + upsertWorkCategory, +} from '../../repositories/worker-platform/work-category-repo.js' +import { + countWorkerLevelUsages, + deleteWorkerLevel, + getWorkerLevelById, + getWorkerLevelByKey, + listWorkerLevels, + upsertWorkerLevel, +} from '../../repositories/worker-platform/worker-repo.js' +import { + countWorkProductRulesByCategory, + deleteWorkProductRule, + getWorkProductRuleById, + listOpenWorkOrdersByProductRuleId, + listWorkProductRulesPage, + syncOpenWorkOrderPriceFromRule, + upsertWorkProductRule, +} from '../../repositories/worker-platform/work-product-rule-repo.js' +import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker-platform/types.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { randomId } from '../../utils/random.js' +import { nowIso } from '../../utils/time.js' +import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' +import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' +import { + DEFAULT_CATEGORY_KEY, + DEFAULT_LEVEL_KEY, + DEFAULT_LEVEL_NAME, + mapWorkCategory, + mapWorkProductRule, + mapWorkerLevel, + normalizeAmountFen, + normalizeBoolean, + normalizeEnabledStatus, + normalizeInteger, + normalizeMatchType, + normalizeOptionalId, + normalizePositiveInteger, + normalizeRequirementFieldsFromPayload, + normalizeSlugKey, + normalizeWorkProductRuleMatch, + resolveSkuNameQuantity, + resolveWorkOrderRulePricing, +} from './mappers.js' +import { ensureWorkerPlatformDefaults, normalizeWorkOrderTimeoutPolicy } from './worker-service.js' + +export async function listAdminWorkerLevels() { + await ensureWorkerPlatformDefaults() + return { items: (await listWorkerLevels()).map(mapWorkerLevel) } +} + +export async function listAdminWorkCategories() { + await ensureWorkerPlatformDefaults() + return { items: (await listAllWorkCategories()).map(mapWorkCategory) } +} + +export async function saveAdminWorkCategory(payload: JsonObject = {}) { + const now = nowIso() + const categoryKey = normalizeSlugKey( + payload.categoryKey || payload.category_key || payload.name, + DEFAULT_CATEGORY_KEY, + ) + const name = String(payload.name || '').trim() || '默认分类' + const category = await upsertWorkCategory({ + categoryKey, + name, + sortOrder: normalizeInteger(payload.sortOrder, 100), + status: normalizeEnabledStatus(payload.status), + now, + }) + return { category: mapWorkCategory(category) } +} + +export async function deleteAdminWorkCategory(categoryId: number | string) { + const category = await getWorkCategoryById(Number(categoryId)) + if (!category) { + throw createHttpError('分类不存在', { + statusCode: 404, + errorCode: 'work_category_not_found', + }) + } + const usages = await countWorkCategoryUsages(Number(categoryId)) + if (usages.workOrderCount > 0 || usages.productRuleCount > 0) { + throw createHttpError( + `该分类已被 ${usages.workOrderCount} 个工单和 ${usages.productRuleCount} 条物品规则使用,无法删除,请先停用`, + { + statusCode: 409, + errorCode: 'work_category_in_use', + }, + ) + } + const { deleted } = await deleteWorkCategory(Number(categoryId)) + return { deleted } +} + +export async function saveAdminWorkerLevel(payload: JsonObject = {}) { + const now = nowIso() + const levelKey = String(payload.levelKey || payload.level_key || '').trim() || DEFAULT_LEVEL_KEY + const existingLevel = await getWorkerLevelByKey(levelKey) + const existingPermissions = safeParseJson(existingLevel?.permission_json) + const name = String(payload.name || '').trim() || DEFAULT_LEVEL_NAME + const permissions = { + depositFreeAmount: normalizeAmountFen( + payload.depositFreeAmount ?? payload.depositFreeAmountYuan, + 0, + ), + maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 1), + upgradeThreshold: normalizeInteger(payload.upgradeThreshold, 0), + autoAcceptWithoutEvidence: normalizeBoolean( + payload.autoAcceptWithoutEvidence, + normalizeBoolean(existingPermissions.autoAcceptWithoutEvidence, levelKey === 'vip5'), + ), + visibleDelaySeconds: normalizeInteger(payload.visibleDelaySeconds, -1), + } + const level = await upsertWorkerLevel({ + levelKey, + name, + sortOrder: normalizeInteger(payload.sortOrder, Number(existingLevel?.sort_order || 100)), + status: String(payload.status || 'active').trim() === 'disabled' ? 'disabled' : 'active', + permissionJson: JSON.stringify(permissions), + now, + }) + return { level: mapWorkerLevel(level) } +} + +export async function deleteAdminWorkerLevel(levelId: number | string) { + const level = await getWorkerLevelById(Number(levelId)) + if (!level) { + throw createHttpError('等级不存在', { + statusCode: 404, + errorCode: 'worker_level_not_found', + }) + } + if (level.level_key === DEFAULT_LEVEL_KEY) { + throw createHttpError('默认等级(VIP1)不可删除', { + statusCode: 409, + errorCode: 'worker_level_default_protected', + }) + } + const usageCount = await countWorkerLevelUsages(Number(levelId)) + if (usageCount > 0) { + throw createHttpError(`该等级正在被 ${usageCount} 名打手使用,请先调整打手等级后再删除`, { + statusCode: 409, + errorCode: 'worker_level_in_use', + }) + } + const { deleted } = await deleteWorkerLevel(Number(levelId)) + return { deleted } +} + +export async function listAdminWorkProductRules(query: JsonObject = {}) { + await ensureWorkerPlatformDefaults() + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const enabledValue = String(query.enabled ?? '').trim() + const enabled = enabledValue + ? ['true', '1', 'enabled', 'active'].includes(enabledValue.toLowerCase()) + : null + const keyword = String(query.keyword || '').trim() + const categoryId = normalizeOptionalId(query.categoryId ?? query.category_id) || 0 + const [{ items, total }, categoryRows] = await Promise.all([ + listWorkProductRulesPage({ + page, + pageSize, + enabled, + keyword, + categoryId, + }), + countWorkProductRulesByCategory({ enabled, keyword }), + ]) + return { + items: items.map(mapWorkProductRule), + pagination: { page, pageSize, total }, + categoryCounts: categoryRows.map((row) => ({ + categoryId: row.categoryId, + total: row.total, + })), + } +} + +export function resolveSharingQuantity( + totalAmount: number, + fallbackQuantity: number, + unitReward: number, +): number { + if (totalAmount > 0 && unitReward > 0) { + return Math.max(1, Math.round(totalAmount / unitReward)) + } + return Math.max(1, fallbackQuantity) +} + +function hasConfiguredSharingValue(value: unknown) { + return value !== undefined && value !== null && String(value).trim() !== '' +} + +function hasOwnPayload(payload: JsonObject, key: string) { + return Object.prototype.hasOwnProperty.call(payload, key) +} + +export async function saveAdminWorkProductRule(payload: JsonObject = {}) { + const defaults = await ensureWorkerPlatformDefaults() + const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id) + const existingRule = ruleId ? await getWorkProductRuleById(ruleId) : null + if (ruleId && !existingRule) { + throw createHttpError('接单模板不存在', { + statusCode: 404, + errorCode: 'work_product_rule_not_found', + }) + } + const hasLegacyMatchPayload = [ + 'match', + 'sellerId', + 'seller_id', + 'itemId', + 'item_id', + 'relItemId', + 'rel_item_id', + 'skuId', + 'sku_id', + 'relSkuId', + 'rel_sku_id', + 'itemTitle', + 'item_title', + 'skuNick', + 'sku_nick', + 'itemTitleMatchType', + 'item_title_match_type', + 'skuNickMatchType', + 'sku_nick_match_type', + ].some((key) => Object.prototype.hasOwnProperty.call(payload, key)) + const match = hasLegacyMatchPayload + ? normalizeWorkProductRuleMatch(payload) + : safeParseJson(existingRule?.match_json) + const productName = String( + payload.productName || payload.product_name || payload.skuName || '', + ).trim() + const skuCode = String(payload.skuCode || payload.sku_code || '').trim() + const matchedProductName = String(match.itemTitle || match.skuNick || '').trim() + if (!productName && !skuCode && !matchedProductName) { + throw createHttpError('请填写商品名或 SKU', { + statusCode: 400, + errorCode: 'work_product_rule_target_required', + }) + } + + const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0) + const unitPriceFen = + payload.unitPrice === undefined && payload.unitPriceYuan === undefined + ? 0 + : normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0) + const sharingEnabled = normalizeBoolean(payload.sharingEnabled ?? payload.sharing_enabled, false) + const hasManualSharingConfig = + hasConfiguredSharingValue(payload.sharingTotalQuantity ?? payload.sharing_total_quantity) || + hasConfiguredSharingValue(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan) || + hasConfiguredSharingValue(payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan) + const sharingAutoFromOrder = + sharingEnabled && + unitPriceFen > 0 && + (normalizeBoolean(payload.sharingAutoFromOrder ?? payload.sharing_auto_from_order, false) || + !hasManualSharingConfig) + const hasManualSharingQuantity = hasConfiguredSharingValue( + payload.sharingTotalQuantity ?? payload.sharing_total_quantity, + ) + const hasManualSharingUnitReward = hasConfiguredSharingValue( + payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, + ) + if ( + sharingEnabled && + unitPriceFen > 0 && + !sharingAutoFromOrder && + hasManualSharingQuantity !== hasManualSharingUnitReward + ) { + throw createHttpError('按件计价的拼单覆盖值请同时填写拼单数量和拼单单价', { + statusCode: 400, + errorCode: 'work_product_rule_sharing_override_incomplete', + }) + } + const sharingUnitReward = normalizeAmountFen( + payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, + 0, + ) + const sharingTotalAmount = normalizeAmountFen( + payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan, + 0, + ) + const sharingTotalQuantity = resolveSharingQuantity( + sharingTotalAmount, + normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1), + sharingUnitReward, + ) + const resolvedSharingTotalAmount = + sharingTotalAmount > 0 ? sharingTotalAmount : sharingUnitReward * sharingTotalQuantity + if (sharingEnabled && !sharingAutoFromOrder) { + if (resolvedSharingTotalAmount <= 0) { + throw createHttpError('启用拼单时请填写拼单总价或单价', { + statusCode: 400, + errorCode: 'work_product_rule_sharing_amount_invalid', + }) + } + } + const finalRewardAmount = sharingEnabled + ? sharingAutoFromOrder + ? 0 + : resolvedSharingTotalAmount + : rewardAmount + const fields = normalizeRequirementFieldsFromPayload(payload) + const ruleKey = existingRule?.rule_key || randomId('rule-') + const rule = await upsertWorkProductRule({ + ruleKey, + provider: hasOwnPayload(payload, 'provider') + ? String(payload.provider || '').trim() + : String(existingRule?.provider || '').trim(), + platform: hasOwnPayload(payload, 'platform') + ? String(payload.platform || '').trim() + : String(existingRule?.platform || '').trim(), + shopId: + hasOwnPayload(payload, 'shopId') || hasOwnPayload(payload, 'shop_id') + ? String(payload.shopId || payload.shop_id || '').trim() + : String(existingRule?.shop_id || '').trim(), + skuCode: + hasOwnPayload(payload, 'skuCode') || hasOwnPayload(payload, 'sku_code') + ? skuCode + : String(existingRule?.sku_code || '').trim(), + productName: productName || matchedProductName, + matchType: + hasOwnPayload(payload, 'matchType') || hasOwnPayload(payload, 'match_type') + ? normalizeMatchType(payload.matchType || payload.match_type) + : normalizeMatchType(existingRule?.match_type), + categoryId: + normalizeOptionalId(payload.categoryId || payload.category_id) || + defaults.category?.id || + null, + enabled: normalizeBoolean(payload.enabled, true), + autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, true), + rewardAmount: finalRewardAmount, + unitPriceFen, + // 商品规则不配置押金;工单创建后由运营按需手工填写。 + requiredDepositAmount: 0, + depositThresholdAmount: 0, + sharingEnabled, + sharingTotalQuantity, + sharingUnitReward, + sharingAutoFromOrder, + timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)), + timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy), + requirementJson: JSON.stringify({ fields }), + matchJson: JSON.stringify(match), + sortOrder: normalizeInteger(payload.sortOrder, 100), + now: nowIso(), + }) + return { rule: mapWorkProductRule(rule) } +} + +/** 根据工单创建时的规格,计算当前模板可同步的价格。 */ +export function resolveWorkOrderTemplateSyncPrice(input: { + rule: WorkProductRuleRow + workOrder: WorkOrderRow +}): { rewardAmount: number; sharingUnitReward?: number } | null { + const material = safeParseJson(input.workOrder.material_json) + const source = safeParseJson(material.source) + const sourceSkuQuantity = Number(source.skuQuantity || 0) + const skuQuantity = + Number.isSafeInteger(sourceSkuQuantity) && sourceSkuQuantity > 0 + ? sourceSkuQuantity + : resolveSkuNameQuantity(input.workOrder.product_name) + const pricing = resolveWorkOrderRulePricing({ + skuQuantity, + unitPriceFen: Number(input.rule.unit_price_fen || 0), + fixedRewardAmount: Number(input.rule.reward_amount || 0), + sharingEnabled: input.rule.sharing_enabled === true, + sharingAutoFromOrder: input.rule.sharing_auto_from_order === true, + sharingTotalQuantity: Number(input.rule.sharing_total_quantity || 1), + sharingUnitReward: Number(input.rule.sharing_unit_reward || 0), + }) + + if (input.workOrder.sharing_enabled === true) { + if (input.rule.sharing_enabled !== true || pricing.sharingUnitReward <= 0) { + return null + } + return { + rewardAmount: pricing.rewardAmount, + sharingUnitReward: pricing.sharingUnitReward, + } + } + return pricing.rewardAmount > 0 ? { rewardAmount: pricing.rewardAmount } : null +} + +/** 将指定模板的当前价格同步到仍在接单大厅的来源工单。 */ +export async function syncAdminWorkProductRulePrice(ruleId: number | string, actorName = '') { + const normalizedRuleId = normalizeOptionalId(ruleId) + const rule = normalizedRuleId ? await getWorkProductRuleById(normalizedRuleId) : null + if (!rule) { + throw createHttpError('接单模板不存在', { + statusCode: 404, + errorCode: 'work_product_rule_not_found', + }) + } + + const candidates = await listOpenWorkOrdersByProductRuleId(rule.id) + const result = { + candidateCount: candidates.length, + updatedCount: 0, + updatedNormalCount: 0, + updatedPartialSharingCount: 0, + skippedInvalidPricingCount: 0, + skippedUnchangedCount: 0, + skippedUnavailableCount: 0, + } + const now = nowIso() + + for (const workOrder of candidates) { + const price = resolveWorkOrderTemplateSyncPrice({ rule, workOrder }) + if (!price) { + result.skippedInvalidPricingCount += 1 + continue + } + const synced = await syncOpenWorkOrderPriceFromRule({ + workOrderId: Number(workOrder.id), + productRuleId: Number(rule.id), + rewardAmount: price.rewardAmount, + ...(price.sharingUnitReward === undefined + ? {} + : { sharingUnitReward: price.sharingUnitReward }), + actorName, + now, + }) + if (!synced.updated) { + if (synced.reason === 'price_unchanged') { + result.skippedUnchangedCount += 1 + } else { + result.skippedUnavailableCount += 1 + } + continue + } + result.updatedCount += 1 + if (synced.sharingPartial) { + result.updatedPartialSharingCount += 1 + } else { + result.updatedNormalCount += 1 + } + publishWorkOrderRealtimeChange({ + workOrderId: synced.workOrderId, + workerIds: synced.workerIds, + hallChanged: true, + }) + } + + return { rule: mapWorkProductRule(rule), ...result } +} + +export async function deleteAdminWorkProductRule(ruleId: number | string) { + const normalizedRuleId = normalizeOptionalId(ruleId) + if (!normalizedRuleId) { + throw createHttpError('物品规则 ID 不正确', { + statusCode: 400, + errorCode: 'work_product_rule_id_invalid', + }) + } + const { deleted } = await deleteWorkProductRule(normalizedRuleId) + if (!deleted) { + throw createHttpError('物品规则不存在或已删除', { + statusCode: 404, + errorCode: 'work_product_rule_not_found', + }) + } + return { deleted } +} diff --git a/apps/backend/src/services/worker-platform/admin-worker-config-service.ts b/apps/backend/src/services/worker-platform/admin-worker-config-service.ts new file mode 100644 index 00000000..7048a389 --- /dev/null +++ b/apps/backend/src/services/worker-platform/admin-worker-config-service.ts @@ -0,0 +1,37 @@ +import type { JsonObject } from '../../types/json.js' +import { + getWorkerAnnouncementConfig, + saveWorkerAnnouncementConfig, +} from './worker-announcement-config-service.js' +import { + getWorkerPlatformNotificationConfig, + saveWorkerPlatformNotificationConfig, +} from './worker-platform-notification-config-service.js' +import { + getWorkerProductMatchConfig, + saveWorkerProductMatchConfig, +} from './worker-product-match-config-service.js' + +export async function getAdminWorkerPlatformNotificationConfig() { + return getWorkerPlatformNotificationConfig() +} + +export async function saveAdminWorkerPlatformNotificationConfig(payload: JsonObject = {}) { + return saveWorkerPlatformNotificationConfig(payload) +} + +export async function getAdminWorkerProductMatchConfig() { + return getWorkerProductMatchConfig() +} + +export async function getAdminWorkerAnnouncementConfig() { + return getWorkerAnnouncementConfig() +} + +export async function saveAdminWorkerAnnouncementConfig(payload: JsonObject = {}) { + return saveWorkerAnnouncementConfig(payload) +} + +export async function saveAdminWorkerProductMatchConfig(payload: JsonObject = {}) { + return saveWorkerProductMatchConfig(payload) +} diff --git a/apps/backend/src/services/worker-platform/admin-worker-finance-service.ts b/apps/backend/src/services/worker-platform/admin-worker-finance-service.ts new file mode 100644 index 00000000..2115028b --- /dev/null +++ b/apps/backend/src/services/worker-platform/admin-worker-finance-service.ts @@ -0,0 +1,235 @@ +import { + addWorkerWalletCredit, + getWorkerFinanceRequestById, + listWorkerFinanceRequests, + listWorkerWithdrawalAccounts, + reviewWorkerFinanceRequest, + upsertWorkerWithdrawalAccount, +} from '../../repositories/worker-platform/worker-repo.js' +import type { WorkerWithdrawalAccountRow } from '../../repositories/worker-platform/types.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' +import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js' +import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js' +import { + publishWorkerFinanceRealtimeChange, + publishWorkerWalletRealtimeChange, +} from '../realtime/realtime-event-service.js' +import { + mapFinanceRequest, + mapWallet, + mapWorkerUser, + normalizeAdminFinanceReviewStatus, + normalizeAmountFen, + normalizeFinanceRequestStatus, + normalizeFinanceRequestType, + normalizeOptionalId, + normalizeProofFiles, + normalizeWithdrawChannel, +} from './mappers.js' +import { getRequiredWorker } from './worker-service.js' +import { getWorkerFinanceConfig, saveWorkerFinanceConfig } from './worker-finance-config-service.js' + +function mapAdminWorkerWithdrawalAccount(account: WorkerWithdrawalAccountRow) { + const alipayQrCodeImage = refreshUploadedFileUrls(safeParseJson(account.alipay_qr_code_json)) + const wechatQrCodeImage = refreshUploadedFileUrls(safeParseJson(account.wechat_qr_code_json)) + return { + accountChannel: account.account_channel, + accountName: account.account_name, + accountNo: account.account_channel === 'alipay' ? account.account_no : '', + alipayQrCodeImage: String(alipayQrCodeImage.url || '').trim() ? alipayQrCodeImage : null, + wechatQrCodeImage: String(wechatQrCodeImage.url || '').trim() ? wechatQrCodeImage : null, + createdAt: account.created_at, + } +} + +export async function creditAdminWorkerWallet(workerId: number | string, payload: JsonObject = {}) { + await getRequiredWorker(workerId) + const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0) + if (amount <= 0) { + throw createHttpError('充值金额必须大于 0', { + statusCode: 400, + errorCode: 'worker_credit_amount_invalid', + }) + } + const wallet = await addWorkerWalletCredit({ + workerId: Number(workerId), + amount, + note: String(payload.note || '后台人工充值').trim(), + payloadJson: JSON.stringify({ source: 'admin_manual_credit' }), + now: nowIso(), + }) + publishWorkerWalletRealtimeChange(Number(workerId)) + return { wallet: mapWallet(wallet) } +} + +export async function listAdminWorkerWithdrawalAccounts(workerId: number | string) { + const worker = await getRequiredWorker(workerId) + const items = await listWorkerWithdrawalAccounts(worker.id) + return { + worker: mapWorkerUser(worker), + items: items.map(mapAdminWorkerWithdrawalAccount), + } +} + +export async function saveAdminWorkerWithdrawalAccount( + workerId: number | string, + accountChannelValue: unknown, + payload: JsonObject = {}, +) { + const worker = await getRequiredWorker(workerId) + const accountChannel = normalizeWithdrawChannel(accountChannelValue) + const currentAccounts = await listWorkerWithdrawalAccounts(worker.id) + const current = currentAccounts.find((item) => item.account_channel === accountChannel) + const accountName = String(payload.accountName || payload.realName || '').trim() + const accountNo = + accountChannel === 'alipay' + ? String(payload.accountNo || payload.account || current?.account_no || '').trim() + : '' + const uploadedAlipayQrCode = normalizeProofFiles( + payload.alipayQrCodeImages || payload.alipayQrCodes, + )[0] + const alipayQrCodeImage = + accountChannel === 'alipay' + ? uploadedAlipayQrCode || safeParseJson(current?.alipay_qr_code_json) + : {} + const uploadedWechatQrCode = normalizeProofFiles( + payload.wechatQrCodeImages || payload.wechatQrCodes, + )[0] + const wechatQrCodeImage = + accountChannel === 'wechat' + ? uploadedWechatQrCode || safeParseJson(current?.wechat_qr_code_json) + : {} + + if (!accountName) { + throw createHttpError('请填写收款人姓名', { + statusCode: 400, + errorCode: 'admin_worker_withdraw_account_name_required', + }) + } + if (accountChannel === 'alipay' && !current && !accountNo) { + throw createHttpError('请填写支付宝账号', { + statusCode: 400, + errorCode: 'admin_worker_withdraw_alipay_account_required', + }) + } + if (accountChannel === 'alipay' && !String(alipayQrCodeImage.url || '').trim()) { + throw createHttpError('请上传支付宝收款二维码', { + statusCode: 400, + errorCode: 'admin_worker_withdraw_alipay_qr_required', + }) + } + if (accountChannel === 'wechat' && !String(wechatQrCodeImage.url || '').trim()) { + throw createHttpError('请上传微信收款二维码', { + statusCode: 400, + errorCode: 'admin_worker_withdraw_wechat_qr_required', + }) + } + + const saved = await upsertWorkerWithdrawalAccount({ + workerId: worker.id, + accountChannel, + accountName, + accountNo, + alipayQrCodeJson: JSON.stringify(alipayQrCodeImage), + wechatQrCodeJson: JSON.stringify(wechatQrCodeImage), + now: nowIso(), + }) + if (!saved) { + throw createHttpError('保存打手提现信息失败', { + statusCode: 500, + errorCode: 'admin_worker_withdraw_account_save_failed', + }) + } + return { account: mapAdminWorkerWithdrawalAccount(saved) } +} + +export async function getAdminWorkerFinanceConfig() { + return getWorkerFinanceConfig() +} + +export async function saveAdminWorkerFinanceConfig(payload: JsonObject = {}) { + const config = await saveWorkerFinanceConfig(payload) + return config +} + +export async function listAdminWorkerFinanceRequests(query: JsonObject = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const requestId = normalizeOptionalId(query.requestId ?? query.request_id) || 0 + const status = normalizeFinanceRequestStatus(query.status) + const requestType = normalizeFinanceRequestType(query.requestType) + const keyword = String(query.keyword || '').trim() + const { items, total } = await listWorkerFinanceRequests({ + page, + pageSize, + requestId, + status, + requestType, + keyword, + }) + return { + items: items.map(mapFinanceRequest), + pagination: { page, pageSize, total }, + } +} + +export async function reviewAdminWorkerFinanceRequest( + requestId: number | string, + payload: JsonObject = {}, +) { + const current = await getWorkerFinanceRequestById(requestId) + if (!current) { + throw createHttpError('资金申请不存在', { + statusCode: 404, + errorCode: 'worker_finance_request_not_found', + }) + } + + const status = normalizeAdminFinanceReviewStatus(payload.status) + const reviewedNote = String(payload.reviewedNote || payload.note || '').trim() + const reviewed = await reviewWorkerFinanceRequest({ + requestId: Number(current.id), + status, + reviewedNote, + now: nowIso(), + }) + + if (reviewed.failureReason === 'request_not_pending') { + throw createHttpError('该申请已处理,请刷新后重试', { + statusCode: 409, + errorCode: 'worker_finance_request_reviewed', + }) + } + + if (reviewed.failureReason === 'withdraw_insufficient') { + throw createHttpError('打手余额不足,暂时无法通过该提现申请', { + statusCode: 409, + errorCode: 'worker_finance_request_withdraw_insufficient', + }) + } + + if (!reviewed.request) { + throw createHttpError('资金申请不存在', { + statusCode: 404, + errorCode: 'worker_finance_request_not_found', + }) + } + + await resolveAdminNotificationEntity( + 'worker_finance_request', + Number(reviewed.request.id), + 'finance_request_reviewed', + ) + publishWorkerFinanceRealtimeChange({ + requestId: Number(reviewed.request.id), + workerId: Number(reviewed.request.worker_id), + walletChanged: reviewed.request.request_type === 'withdraw' && status === 'approved', + }) + + return { + request: mapFinanceRequest(reviewed.request), + } +} diff --git a/apps/backend/src/services/worker-platform/after-sales-service.ts b/apps/backend/src/services/worker-platform/after-sales-service.ts new file mode 100644 index 00000000..442c5f50 --- /dev/null +++ b/apps/backend/src/services/worker-platform/after-sales-service.ts @@ -0,0 +1,287 @@ +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + createAfterSalesCase, + getAfterSalesCaseById, + listAfterSalesCaseEvents, + listAfterSalesCases, + listWorkerAfterSalesCases, + resolveAfterSalesCase, + submitWorkerAfterSalesResponse, +} from '../../repositories/worker-platform/after-sales-repo.js' +import { createWorkOrderEvent } from '../../repositories/worker-platform/work-order-event-repo.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { randomId } from '../../utils/random.js' +import { nowIso } from '../../utils/time.js' +import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' +import { + publishWorkerWalletRealtimeChange, + publishWorkOrderRealtimeChange, +} from '../realtime/realtime-event-service.js' +import { + mapWorkerAfterSalesCase, + normalizeAmountFen, + normalizeOptionalId, + normalizeProofFiles, + normalizeUploadedFiles, +} from './mappers.js' +import { requireActiveWorkerSession, type WorkerSession } from './worker-service.js' + +export async function listAdminAfterSalesCases(query: JsonObject = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const workOrderId = normalizeOptionalId(query.workOrderId ?? query.work_order_id) + const result = await listAfterSalesCases({ + page, + pageSize, + status: String(query.status || '').trim(), + keyword: String(query.keyword || '').trim(), + ...(workOrderId ? { workOrderId } : {}), + }) + return { + items: result.items.map(mapWorkerAfterSalesCase), + pagination: { page, pageSize, total: result.total }, + } +} + +export async function getAdminAfterSalesCaseEvents(caseId: number | string) { + const afterSalesCase = await getAfterSalesCaseById(caseId) + if (!afterSalesCase) { + throw createHttpError('售后问题单不存在', { + statusCode: 404, + errorCode: 'after_sales_case_not_found', + }) + } + return { + items: (await listAfterSalesCaseEvents(afterSalesCase.id)).map((event) => ({ + eventId: Number(event.id), + actorType: event.actor_type, + actorId: event.actor_id, + eventType: event.event_type, + payload: safeParseJson(event.payload_json), + createdAt: event.created_at, + })), + } +} + +export async function createAdminAfterSalesCase( + workOrderId: number | string, + payload: JsonObject = {}, + actorName = '', +) { + const complaintNote = String(payload.complaintNote || payload.note || '').trim() + if (!complaintNote) { + throw createHttpError('请填写用户投诉说明', { + statusCode: 400, + errorCode: 'after_sales_case_complaint_required', + }) + } + if (complaintNote.length > 2_000) { + throw createHttpError('用户投诉说明不能超过 2000 个字符', { + statusCode: 400, + errorCode: 'after_sales_case_complaint_too_long', + }) + } + const problemType = String(payload.problemType || 'fake_evidence').trim() || 'fake_evidence' + if (problemType.length > 64) { + throw createHttpError('问题类型不正确', { + statusCode: 400, + errorCode: 'after_sales_case_problem_type_invalid', + }) + } + const shareId = normalizeOptionalId(payload.shareId ?? payload.workOrderShareId) + const now = nowIso() + const result = await createAfterSalesCase({ + caseNo: randomId('AS'), + workOrderId: Number(workOrderId), + workOrderShareId: shareId || null, + problemType, + complaintNote, + complaintFilesJson: JSON.stringify(normalizeProofFiles(payload.files || payload.proofFiles)), + createdBy: actorName, + now, + }) + if (result.failureReason === 'work_order_not_accepted') { + throw createHttpError('只有已验收的工单可以发起售后问题单', { + statusCode: 409, + errorCode: 'after_sales_case_work_order_status_invalid', + }) + } + if (result.failureReason === 'work_order_share_not_accepted') { + throw createHttpError('该拼单份额尚未验收,不能发起售后问题单', { + statusCode: 409, + errorCode: 'after_sales_case_share_status_invalid', + }) + } + if (result.failureReason === 'worker_missing') { + throw createHttpError('请指定已验收的责任打手或拼单份额', { + statusCode: 409, + errorCode: 'after_sales_case_worker_required', + }) + } + if (result.failureReason === 'active_case_exists' || !result.case) { + throw createHttpError('该责任打手已有处理中售后问题单', { + statusCode: 409, + errorCode: 'after_sales_case_active_exists', + }) + } + await createWorkOrderEvent({ + workOrderId: Number(result.case.work_order_id), + actorType: 'admin', + actorId: actorName, + eventType: 'after_sales_case_created', + fromStatus: WORK_ORDER_STATUS.ACCEPTED, + toStatus: WORK_ORDER_STATUS.ACCEPTED, + payloadJson: JSON.stringify({ + caseId: Number(result.case.id), + caseNo: result.case.case_no, + workerId: Number(result.case.worker_id), + shareId: result.case.work_order_share_id ? Number(result.case.work_order_share_id) : null, + problemType, + }), + now, + }) + publishWorkOrderRealtimeChange({ + workOrderId: Number(result.case.work_order_id), + workerIds: [Number(result.case.worker_id)], + }) + return { case: mapWorkerAfterSalesCase(result.case) } +} + +export async function resolveAdminAfterSalesCase( + caseId: number | string, + payload: JsonObject = {}, + actorName = '', +) { + const action = String(payload.action || '').trim() + if (!['not_upheld', 'remedied', 'upheld'].includes(action)) { + throw createHttpError('售后处置方式不正确', { + statusCode: 400, + errorCode: 'after_sales_case_resolution_action_invalid', + }) + } + const resolutionNote = String(payload.resolutionNote || payload.note || '').trim() + if (!resolutionNote) { + throw createHttpError('请填写售后处置备注', { + statusCode: 400, + errorCode: 'after_sales_case_resolution_note_required', + }) + } + if (resolutionNote.length > 2_000) { + throw createHttpError('售后处置备注不能超过 2000 个字符', { + statusCode: 400, + errorCode: 'after_sales_case_resolution_note_too_long', + }) + } + const recoveryAmount = normalizeAmountFen(payload.recoveryAmount ?? payload.amount, 0) + if (action === 'upheld' && recoveryAmount <= 0) { + throw createHttpError('问题成立时请填写大于 0 的追缴金额', { + statusCode: 400, + errorCode: 'after_sales_case_recovery_amount_required', + }) + } + const result = await resolveAfterSalesCase({ + caseId: Number(caseId), + action: action as 'not_upheld' | 'remedied' | 'upheld', + resolutionNote, + recoveryAmount: action === 'upheld' ? recoveryAmount : 0, + resolvedBy: actorName, + now: nowIso(), + }) + if (result.failureReason === 'not_found') { + throw createHttpError('售后问题单不存在', { + statusCode: 404, + errorCode: 'after_sales_case_not_found', + }) + } + if (result.failureReason === 'not_resolvable' || !result.case) { + throw createHttpError('该售后问题单已处置,请刷新后重试', { + statusCode: 409, + errorCode: 'after_sales_case_not_resolvable', + }) + } + await createWorkOrderEvent({ + workOrderId: Number(result.case.work_order_id), + actorType: 'admin', + actorId: actorName, + eventType: 'after_sales_case_resolved', + fromStatus: WORK_ORDER_STATUS.ACCEPTED, + toStatus: WORK_ORDER_STATUS.ACCEPTED, + payloadJson: JSON.stringify({ + caseId: Number(result.case.id), + action, + recoveryAmount: Number(result.case.recovery_amount || 0), + debtAmount: Number(result.case.debt_amount || 0), + }), + now: nowIso(), + }) + const workerId = Number(result.case.worker_id) + publishWorkOrderRealtimeChange({ + workOrderId: Number(result.case.work_order_id), + workerIds: workerId > 0 ? [workerId] : [], + }) + if (workerId > 0) publishWorkerWalletRealtimeChange(workerId) + return { case: mapWorkerAfterSalesCase(result.case) } +} + +export async function getWorkerAfterSalesCaseSummary(session: WorkerSession) { + requireActiveWorkerSession(session) + return { items: (await listWorkerAfterSalesCases(session.workerId)).map(mapWorkerAfterSalesCase) } +} + +export async function submitWorkerAfterSalesCaseResponse( + caseId: number | string, + payload: JsonObject = {}, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const response = String(payload.response || payload.note || '').trim() + const files = normalizeUploadedFiles(payload.files || payload.proofFiles) + if (!response && files.length === 0) { + throw createHttpError('请填写说明或上传补救材料', { + statusCode: 400, + errorCode: 'after_sales_case_response_required', + }) + } + if (response.length > 2_000) { + throw createHttpError('售后说明不能超过 2000 个字符', { + statusCode: 400, + errorCode: 'after_sales_case_response_too_long', + }) + } + const now = nowIso() + const result = await submitWorkerAfterSalesResponse({ + caseId: Number(caseId), + workerId: Number(session.workerId), + response, + filesJson: JSON.stringify(files), + now, + }) + if (result.failureReason === 'not_found') { + throw createHttpError('售后问题单不存在或不属于当前账号', { + statusCode: 404, + errorCode: 'after_sales_case_not_found', + }) + } + if (result.failureReason === 'not_pending' || !result.case) { + throw createHttpError('该售后问题单已提交说明或已处置', { + statusCode: 409, + errorCode: 'after_sales_case_response_status_invalid', + }) + } + await createWorkOrderEvent({ + workOrderId: Number(result.case.work_order_id), + actorType: 'worker', + actorId: String(session.workerId), + eventType: 'after_sales_case_responded', + fromStatus: WORK_ORDER_STATUS.ACCEPTED, + toStatus: WORK_ORDER_STATUS.ACCEPTED, + payloadJson: JSON.stringify({ caseId: Number(result.case.id), fileCount: files.length }), + now, + }) + publishWorkOrderRealtimeChange({ + workOrderId: Number(result.case.work_order_id), + workerIds: [Number(session.workerId)], + }) + return { case: mapWorkerAfterSalesCase(result.case) } +} diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index fcd678cb..720a00e8 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -1,6 +1,11 @@ export * from './mappers.js' export * from './worker-service.js' +export * from './after-sales-service.js' export * from './admin-service.js' +export * from './admin-worker-finance-service.js' +export * from './admin-worker-catalog-service.js' +export * from './admin-kuaishou-match-service.js' +export * from './admin-worker-config-service.js' export * from './worker-product-match-config-service.js' export * from './worker-announcement-config-service.js' export * from './worker-hall-config-service.js' diff --git a/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts index f6bcd733..28037191 100644 --- a/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts +++ b/apps/backend/src/services/worker-platform/sync-work-orders-from-send-code.ts @@ -33,7 +33,7 @@ import { updateWorkOrder, } from '../../repositories/worker-platform/work-order-repo.js' import { syncWorkerOrdersForSourceOrder } from './admin-service.js' -import { createWorkOrderEvent } from '../../repositories/worker-platform/work-order-repo.js' +import { createWorkOrderEvent } from '../../repositories/worker-platform/work-order-event-repo.js' import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../open-91/config.js' import { findKuaishouIndustryShopConfig, diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 7cc91b79..2c84c6ae 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -8,7 +8,6 @@ import { countWorkerAcceptedOrders, createWorkerCancelRequest, createWorkerOrderFeedback, - listWorkerAfterSalesCases, countWorkerActiveOrders, countWorkerTimeoutEvents, countWorkerWithdrawRequestsOnDay, @@ -65,7 +64,6 @@ import { releaseDepositUnfreeze, settleOverdueWorkOrder, submitWorkOrderShareAcceptance, - submitWorkerAfterSalesResponse, updateWorkOrder, updateWorkOrderAcceptanceRecord, updateWorkOrderDraftAcceptance, @@ -124,7 +122,6 @@ import { mapWorkOrderEvents, mapWorkOrderShare, mapWorkerCancelRequest, - mapWorkerAfterSalesCase, mapWorkerOrderFeedback, mapWorkerUser, normalizeAmountFen, @@ -1858,68 +1855,6 @@ export async function getWorkerOrderFeedbackSummary(session: WorkerSession) { return { items: (await listWorkerOrderFeedbacks(session.workerId)).map(mapWorkerOrderFeedback) } } -export async function getWorkerAfterSalesCaseSummary(session: WorkerSession) { - requireActiveWorkerSession(session) - return { items: (await listWorkerAfterSalesCases(session.workerId)).map(mapWorkerAfterSalesCase) } -} - -export async function submitWorkerAfterSalesCaseResponse( - caseId: number | string, - payload: JsonObject = {}, - session: WorkerSession, -) { - requireActiveWorkerSession(session) - const response = String(payload.response || payload.note || '').trim() - const files = normalizeUploadedFiles(payload.files || payload.proofFiles) - if (!response && files.length === 0) { - throw createHttpError('请填写说明或上传补救材料', { - statusCode: 400, - errorCode: 'after_sales_case_response_required', - }) - } - if (response.length > 2_000) { - throw createHttpError('售后说明不能超过 2000 个字符', { - statusCode: 400, - errorCode: 'after_sales_case_response_too_long', - }) - } - const now = nowIso() - const result = await submitWorkerAfterSalesResponse({ - caseId: Number(caseId), - workerId: Number(session.workerId), - response, - filesJson: JSON.stringify(files), - now, - }) - if (result.failureReason === 'not_found') { - throw createHttpError('售后问题单不存在或不属于当前账号', { - statusCode: 404, - errorCode: 'after_sales_case_not_found', - }) - } - if (result.failureReason === 'not_pending' || !result.case) { - throw createHttpError('该售后问题单已提交说明或已处置', { - statusCode: 409, - errorCode: 'after_sales_case_response_status_invalid', - }) - } - await createWorkOrderEvent({ - workOrderId: Number(result.case.work_order_id), - actorType: 'worker', - actorId: String(session.workerId), - eventType: 'after_sales_case_responded', - fromStatus: WORK_ORDER_STATUS.ACCEPTED, - toStatus: WORK_ORDER_STATUS.ACCEPTED, - payloadJson: JSON.stringify({ caseId: Number(result.case.id), fileCount: files.length }), - now, - }) - publishWorkOrderRealtimeChange({ - workOrderId: Number(result.case.work_order_id), - workerIds: [Number(session.workerId)], - }) - return { case: mapWorkerAfterSalesCase(result.case) } -} - export async function submitWorkerOrderFeedback( workOrderId: number | string, payload: JsonObject = {}, diff --git a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx new file mode 100644 index 00000000..13486555 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx @@ -0,0 +1,470 @@ +import { ReloadOutlined } from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + App, + Alert, + Button, + Card, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Table, + Tag, + Typography, +} from 'antd' +import { forwardRef, useImperativeHandle, useState } from 'react' + +import ImageUpload from '@/components/files/ImageUpload' +import ImagePreviewList from '@/components/files/ImagePreviewList' +import { + createAdminAfterSalesCase, + fetchAdminAfterSalesCases, + fetchAdminWorkOrderSharing, + resolveAdminAfterSalesCase, +} from '@/services/admin' +import type { UploadedFile, WorkerAfterSalesCase, WorkOrder } from '@/types/worker-platform' +import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' +import { asRecord, formatMoney } from './shared' + +type AfterSalesCreateFormValues = { + shareId?: number + problemType?: string + complaintNote?: string +} + +type AfterSalesResolutionFormValues = { + action?: 'not_upheld' | 'remedied' | 'upheld' + resolutionNote?: string + recoveryAmount?: number +} + +export type AfterSalesPanelHandle = { + openCreateModal: (row: WorkOrder) => void +} + +const AfterSalesPanel = forwardRef(function AfterSalesPanel(_, ref) { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [afterSalesOrder, setAfterSalesOrder] = useState(null) + const [afterSalesCase, setAfterSalesCase] = useState(null) + const [afterSalesFiles, setAfterSalesFiles] = useState([]) + const [afterSalesPage, setAfterSalesPage] = useState(1) + const [afterSalesPageSize, setAfterSalesPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [afterSalesCreateForm] = Form.useForm() + const [afterSalesResolutionForm] = Form.useForm() + + const afterSalesSharingQuery = useQuery({ + queryKey: ['admin-worker-platform-after-sales-sharing', afterSalesOrder?.workOrderId], + queryFn: () => fetchAdminWorkOrderSharing(Number(afterSalesOrder?.workOrderId)), + enabled: Boolean(afterSalesOrder?.sharing?.enabled), + }) + const afterSalesCasesQuery = useQuery({ + queryKey: ['admin-worker-platform-after-sales-cases', afterSalesPage, afterSalesPageSize], + queryFn: () => + fetchAdminAfterSalesCases({ page: afterSalesPage, pageSize: afterSalesPageSize }), + }) + + useImperativeHandle(ref, () => ({ + openCreateModal: (row: WorkOrder) => { + setAfterSalesOrder(row) + setAfterSalesFiles([]) + afterSalesCreateForm.setFieldsValue({ + shareId: undefined, + problemType: 'fake_evidence', + complaintNote: '', + }) + }, + })) + + async function runAction( + action: () => Promise, + successMessage: string | ((result: T) => string), + ) { + try { + const result = await action() + message.success( + typeof successMessage === 'function' ? successMessage(result) : successMessage, + ) + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-after-sales-cases'], + }) + return true + } catch (error) { + message.error(error instanceof Error ? error.message : '操作失败') + return false + } + } + + async function submitAfterSalesCreate(values: AfterSalesCreateFormValues) { + if (!afterSalesOrder) return + const succeeded = await runAction( + () => + createAdminAfterSalesCase(afterSalesOrder.workOrderId, { + shareId: values.shareId || null, + problemType: values.problemType || 'fake_evidence', + complaintNote: String(values.complaintNote || '').trim(), + files: afterSalesFiles, + }), + '售后问题单已创建,已通知责任打手说明', + ) + if (!succeeded) return + setAfterSalesOrder(null) + setAfterSalesFiles([]) + afterSalesCreateForm.resetFields() + } + + function openAfterSalesResolutionModal(item: WorkerAfterSalesCase) { + setAfterSalesCase(item) + afterSalesResolutionForm.setFieldsValue({ + action: 'upheld', + resolutionNote: '', + recoveryAmount: Number(item.recoveryAmount || 0) / 100, + }) + } + + async function submitAfterSalesResolution(values: AfterSalesResolutionFormValues) { + const action = values.action + if (!afterSalesCase || !action) return + const succeeded = await runAction( + () => + resolveAdminAfterSalesCase(afterSalesCase.caseId, { + action, + resolutionNote: String(values.resolutionNote || '').trim(), + recoveryAmount: action === 'upheld' ? Number(values.recoveryAmount || 0) : 0, + }), + '售后问题单已处置', + ) + if (!succeeded) return + setAfterSalesCase(null) + afterSalesResolutionForm.resetFields() + } + + return ( + <> + } + loading={afterSalesCasesQuery.isFetching} + onClick={() => afterSalesCasesQuery.refetch()} + > + 刷新 + + } + bordered={false} + > + + rowKey="caseId" + loading={afterSalesCasesQuery.isLoading} + dataSource={afterSalesCasesQuery.data?.data.items || []} + pagination={buildAdminTablePagination({ + current: afterSalesCasesQuery.data?.data.pagination?.page || afterSalesPage, + pageSize: afterSalesCasesQuery.data?.data.pagination?.pageSize || afterSalesPageSize, + total: afterSalesCasesQuery.data?.data.pagination?.total || 0, + onChange: (nextPage, nextPageSize) => { + setAfterSalesPage(nextPage) + setAfterSalesPageSize(nextPageSize) + }, + })} + scroll={{ x: 1160 }} + columns={[ + { + title: '售后单', + width: 190, + render: (_, item) => ( +
+ {item.caseNo} + {item.workOrderNo || '-'} + + {formatAdminDateTime(item.createdAt)} + +
+ ), + }, + { + title: '责任打手', + width: 150, + render: (_, item) => ( +
+ {item.workerName || `打手#${item.workerId}`} + {item.shareQuantity ? ( + 拼单 {item.shareQuantity} 份 + ) : null} +
+ ), + }, + { + title: '投诉与证据', + width: 260, + render: (_, item) => ( +
+ {formatAfterSalesProblemType(item.problemType)} + + {item.complaintNote || '-'} + + {item.complaintFiles.length > 0 ? ( + + ) : null} + {getAfterSalesSnapshotFiles(item).length > 0 ? ( + + + 固化验收图 + + + + ) : null} +
+ ), + }, + { + title: '打手说明', + width: 250, + render: (_, item) => ( +
+ + {item.workerResponse || '暂未提交'} + + {item.workerResponseFiles.length > 0 ? ( + + ) : null} + {item.respondedAt ? ( + + {formatAdminDateTime(item.respondedAt)} + + ) : null} +
+ ), + }, + { + title: '处置与追缴', + width: 240, + render: (_, item) => ( +
+ + {formatAfterSalesStatus(item.status)} + + {item.resolutionAction ? ( + + {formatAfterSalesResolution(item.resolutionAction)} ·{' '} + {item.resolutionNote || '-'} + + ) : null} + {item.resolutionAction === 'upheld' ? ( + 0 ? 'danger' : undefined}> + 追缴 {formatMoney(item.recoveryAmount)},待还 {formatMoney(item.debtAmount)} + + ) : null} +
+ ), + }, + { + title: '操作', + width: 120, + render: (_, item) => + ['awaiting_worker_response', 'under_review'].includes(item.status) ? ( + + ) : ( + 已完成 + ), + }, + ]} + /> +
+ + { + setAfterSalesOrder(null) + setAfterSalesFiles([]) + afterSalesCreateForm.resetFields() + }} + onOk={() => afterSalesCreateForm.submit()} + destroyOnHidden + > + {afterSalesOrder ? ( +
+ + {(afterSalesSharingQuery.data?.data.shares || []).some( + (share) => share.status === 'accepted', + ) ? ( + + + + + + + + + + + ) : null} +
+ + { + setAfterSalesCase(null) + afterSalesResolutionForm.resetFields() + }} + onOk={() => afterSalesResolutionForm.submit()} + destroyOnHidden + > + {afterSalesCase ? ( +
+ + share.status === 'accepted') - .map((share) => ({ - value: share.shareId, - label: `${share.worker?.displayName || share.worker?.username || `打手#${share.workerId}`} · ${share.quantity} 份`, - }))} - /> - - ) : null} - - - - previous.action !== current.action} - > - {({ getFieldValue }) => - getFieldValue('action') === 'upheld' ? ( - - - - ) : null - } - - - - -
- ) : null} -
- asRecord(value)), - ...imageUrls.map((url) => asRecord({ url: String(url || '') })), - ] - .map((file) => { - const url = String(file.url || '').trim() - if (!url) return null - return { - objectKey: String(file.objectKey || file.object_key || ''), - url, - thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || url), - mediumUrl: String(file.mediumUrl || file.medium_url || url), - filename: String(file.filename || '验收图片'), - contentType: String(file.contentType || ''), - size: Number(file.size || 0), - } satisfies UploadedFile - }) - .filter((file): file is UploadedFile => Boolean(file)) -} - function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise }) { const { message } = App.useApp() const [loading, setLoading] = useState(false) diff --git a/apps/frontend/src/pages/worker/WorkerAfterSalesSection.tsx b/apps/frontend/src/pages/worker/WorkerAfterSalesSection.tsx new file mode 100644 index 00000000..fadc804b --- /dev/null +++ b/apps/frontend/src/pages/worker/WorkerAfterSalesSection.tsx @@ -0,0 +1,178 @@ +import { ReloadOutlined } from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { App, Alert, Button, Card, Form, Input, Modal, Space, Tag, Typography } from 'antd' +import { useState } from 'react' + +import ImagePreviewList from '@/components/files/ImagePreviewList' +import ImageUpload from '@/components/files/ImageUpload' +import { fetchWorkerAfterSalesCases, submitWorkerAfterSalesCaseResponse } from '@/services/worker' +import type { UploadedFile, WorkerAfterSalesCase } from '@/types/worker-platform' + +type AfterSalesResponseFormValues = { + response?: string +} + +function formatMoney(value: number | undefined) { + return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}` +} + +function formatWorkerAfterSalesStatus(status: string) { + if (status === 'awaiting_worker_response') return '待说明' + if (status === 'under_review') return '处理中' + if (status === 'recovery_pending') return '追缴中' + return '已关闭' +} + +function resolveWorkerAfterSalesStatusColor(status: string) { + if (status === 'awaiting_worker_response') return 'orange' + if (status === 'under_review') return 'blue' + if (status === 'recovery_pending') return 'red' + return 'green' +} + +export default function WorkerAfterSalesSection({ isMobile }: { isMobile: boolean }) { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [afterSalesCase, setAfterSalesCase] = useState(null) + const [afterSalesResponseFiles, setAfterSalesResponseFiles] = useState([]) + const [afterSalesResponseForm] = Form.useForm() + + const afterSalesCasesQuery = useQuery({ + queryKey: ['worker-after-sales-cases'], + queryFn: fetchWorkerAfterSalesCases, + retry: false, + }) + const afterSalesCases = afterSalesCasesQuery.data?.data.items || [] + + function openAfterSalesResponseModal(item: WorkerAfterSalesCase) { + setAfterSalesCase(item) + setAfterSalesResponseFiles([]) + afterSalesResponseForm.resetFields() + } + + async function submitAfterSalesResponse(values: AfterSalesResponseFormValues) { + if (!afterSalesCase) return + try { + await submitWorkerAfterSalesCaseResponse(afterSalesCase.caseId, { + response: String(values.response || '').trim(), + files: afterSalesResponseFiles, + }) + message.success('售后说明已提交,等待客服处理') + await queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] }) + setAfterSalesCase(null) + setAfterSalesResponseFiles([]) + afterSalesResponseForm.resetFields() + } catch (error) { + message.error(error instanceof Error ? error.message : '提交失败') + } + } + + if (afterSalesCases.length === 0) return null + + return ( + <> + } + loading={afterSalesCasesQuery.isFetching} + onClick={() => afterSalesCasesQuery.refetch()} + > + 刷新 + + } + > + + {afterSalesCases.map((item) => ( +
+ + + {formatWorkerAfterSalesStatus(item.status)} + + + {item.productName || item.workOrderNo || item.caseNo} + + {item.caseNo} + + + {item.complaintNote || '-'} + + {item.complaintFiles.length > 0 ? ( + + ) : null} + {item.status === 'awaiting_worker_response' ? ( + + ) : item.status === 'under_review' ? ( + 已提交说明,等待客服处理 + ) : item.status === 'recovery_pending' ? ( + + 待追缴 {formatMoney(item.debtAmount)},后续验收报酬将自动抵扣 + + ) : item.resolutionNote ? ( + 处理结果:{item.resolutionNote} + ) : null} +
+ ))} +
+
+ + { + setAfterSalesCase(null) + setAfterSalesResponseFiles([]) + afterSalesResponseForm.resetFields() + }} + onOk={() => afterSalesResponseForm.submit()} + okText="提交说明" + destroyOnHidden + > + {afterSalesCase ? ( + <> + + + {afterSalesCase.complaintNote} + +
+ + + + + + +
+ + ) : null} +
+ + ) +} diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index cc08b14e..6e1f0659 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -10,7 +10,6 @@ import { import { useQuery, useQueryClient } from '@tanstack/react-query' import { App, - Alert, Button, Card, Descriptions, @@ -37,7 +36,6 @@ import ImagePreviewList from '@/components/files/ImagePreviewList' import ImageUpload from '@/components/files/ImageUpload' import { fetchWorkerCancelRequests, - fetchWorkerAfterSalesCases, fetchWorkerOrderFeedbacks, fetchWorkerMyOrderOverview, fetchWorkerMyOrders, @@ -49,20 +47,41 @@ import { supplementWorkerAcceptedOrderEvidence, submitWorkerAcceptance, submitWorkerCancelRequest, - submitWorkerAfterSalesCaseResponse, submitWorkerOrderFeedback, } from '@/services/worker' -import type { - CollectField, - UploadedFile, - WorkOrder, - WorkerAcceptance, - WorkerAfterSalesCase, -} from '@/types/worker-platform' +import type { UploadedFile, WorkOrder } from '@/types/worker-platform' import { formatDateTime } from '@/utils/date-time' -import { getImageIdentity } from '@/utils/image-identity' import { useIsMobile } from '@/utils/use-is-mobile' import { buildWorkerOrderQuery } from '@/utils/worker-order-query' +import WorkerAfterSalesSection from './WorkerAfterSalesSection' +import { + asRecord, + canAutoAcceptOrder, + canEditAcceptanceImages, + canRemindAcceptance, + canRequestCancel, + canSubmitAcceptance, + canSubmitFeedback, + canSupplementAcceptedEvidence, + formatMoney, + formatStatus, + getAcceptanceSubmittedAt, + getCopyOrderInfoItems, + getDisplayOrderNo, + getMaterialDetailItems, + getMaterialScreenshots, + getSettlementLabel, + getStatusHint, + getUniqueAcceptanceFiles, + getWorkerAcceptance, + getWorkerDraft, + hasSubmittedAcceptance, + isVipEvidencePending, + patchWorkOrderReadState, + renderDetailFieldValues, + resolveStatusColor, + toPngBlob, +} from './worker-order-utils' const STATUS_OPTIONS = [ { value: 'in_progress', label: '代练中' }, @@ -91,10 +110,6 @@ type FeedbackFormValues = { content?: string } -type AfterSalesResponseFormValues = { - response?: string -} - type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default' const PRESET_NOTE_TAGS: Array<{ label: string; theme: NoteTheme }> = [ @@ -188,13 +203,6 @@ function resolveNoteTheme(note?: string): NoteTheme { return 'default' } -type DetailFieldItem = { - key: string - label: string - required: boolean - value: string -} - export default function WorkerOrdersPage() { const isMobile = useIsMobile() const { message } = App.useApp() @@ -209,14 +217,11 @@ export default function WorkerOrdersPage() { const [noteOrder, setNoteOrder] = useState(null) const [cancelRequestOrder, setCancelRequestOrder] = useState(null) const [feedbackOrder, setFeedbackOrder] = useState(null) - const [afterSalesCase, setAfterSalesCase] = useState(null) const [acceptanceFiles, setAcceptanceFiles] = useState([]) - const [afterSalesResponseFiles, setAfterSalesResponseFiles] = useState([]) const [form] = Form.useForm() const [noteForm] = Form.useForm() const [cancelRequestForm] = Form.useForm() const [feedbackForm] = Form.useForm() - const [afterSalesResponseForm] = Form.useForm() const ordersQuery = useQuery({ queryKey: ['worker-my-orders', status, keyword, page, pageSize], @@ -251,15 +256,9 @@ export default function WorkerOrdersPage() { queryFn: fetchWorkerOrderFeedbacks, retry: false, }) - const afterSalesCasesQuery = useQuery({ - queryKey: ['worker-after-sales-cases'], - queryFn: fetchWorkerAfterSalesCases, - retry: false, - }) const overviewByStatus = new Map( (overviewQuery.data?.data.items || []).map((item) => [item.status, item]), ) - const afterSalesCases = afterSalesCasesQuery.data?.data.items || [] async function refreshAll() { await Promise.all([ @@ -267,7 +266,6 @@ export default function WorkerOrdersPage() { queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }), queryClient.invalidateQueries({ queryKey: ['worker-cancel-requests'] }), queryClient.invalidateQueries({ queryKey: ['worker-order-feedbacks'] }), - queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] }), ]) } @@ -395,29 +393,6 @@ export default function WorkerOrdersPage() { } } - function openAfterSalesResponseModal(item: WorkerAfterSalesCase) { - setAfterSalesCase(item) - setAfterSalesResponseFiles([]) - afterSalesResponseForm.resetFields() - } - - async function submitAfterSalesResponse(values: AfterSalesResponseFormValues) { - if (!afterSalesCase) return - try { - await submitWorkerAfterSalesCaseResponse(afterSalesCase.caseId, { - response: String(values.response || '').trim(), - files: afterSalesResponseFiles, - }) - message.success('售后说明已提交,等待客服处理') - setAfterSalesCase(null) - setAfterSalesResponseFiles([]) - afterSalesResponseForm.resetFields() - await refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '提交售后说明失败') - } - } - async function saveOrderNote(values: WorkerNoteFormValues) { if (!noteOrder) return try { @@ -768,65 +743,7 @@ export default function WorkerOrdersPage() { return ( <> - {afterSalesCases.length > 0 ? ( - } - loading={afterSalesCasesQuery.isFetching} - onClick={() => afterSalesCasesQuery.refetch()} - > - 刷新 - - } - > - - {afterSalesCases.map((item) => ( -
- - - {formatWorkerAfterSalesStatus(item.status)} - - - {item.productName || item.workOrderNo || item.caseNo} - - {item.caseNo} - - - {item.complaintNote || '-'} - - {item.complaintFiles.length > 0 ? ( - - ) : null} - {item.status === 'awaiting_worker_response' ? ( - - ) : item.status === 'under_review' ? ( - 已提交说明,等待客服处理 - ) : item.status === 'recovery_pending' ? ( - - 待追缴 {formatMoney(item.debtAmount)},后续验收报酬将自动抵扣 - - ) : item.resolutionNote ? ( - - 处理结果:{item.resolutionNote} - - ) : null} -
- ))} -
-
- ) : null} + {isMobile ? (
{/* 顶部水平滑动状态切换胶囊条 */} @@ -1506,52 +1423,6 @@ export default function WorkerOrdersPage() { - { - setAfterSalesCase(null) - setAfterSalesResponseFiles([]) - afterSalesResponseForm.resetFields() - }} - onOk={() => afterSalesResponseForm.submit()} - okText="提交说明" - destroyOnHidden - > - {afterSalesCase ? ( - <> - - - {afterSalesCase.complaintNote} - -
- - - - - - -
- - ) : null} -
) } - -function patchWorkOrderReadState(current: unknown, workOrderId: number) { - if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { - return current - } - return { - ...current, - data: { - ...current.data, - items: current.data.items.map((item) => - isRecord(item) && Number(item.workOrderId) === workOrderId - ? { ...item, isUnread: false } - : item, - ), - }, - } -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)) -} - -function canSubmitAcceptance(order: WorkOrder) { - if (order.myShare) { - return order.myShare.status === 'joined' && order.status !== 'accepted' - } - return ['in_progress', 'problem'].includes(order.status) -} - -function canRemindAcceptance(order: WorkOrder) { - return !order.myShare && order.status === 'pending_acceptance' -} - -function canRequestCancel(order: WorkOrder) { - if (order.myShare) { - return ( - ['joined', 'submitted'].includes(order.myShare.status) && - ['open', 'pending_acceptance'].includes(order.status) - ) - } - return order.status === 'in_progress' -} - -function canSubmitFeedback(order: WorkOrder) { - if (order.myShare) return order.myShare.status !== 'cancelled' - return Boolean(order.assignedAt || order.worker) -} - -function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutEvidence: boolean) { - if (!workerCanAutoAcceptWithoutEvidence) return false - if (order.myShare) return order.myShare.status === 'joined' && order.status !== 'accepted' - return order.sharing?.enabled !== true && ['in_progress', 'problem'].includes(order.status) -} - -function canSupplementAcceptedEvidence(order: WorkOrder) { - return order.status === 'accepted' -} - -function isVipEvidencePending(order: WorkOrder) { - const acceptance = getWorkerAcceptance(order) - return acceptance?.reviewMode === 'vip_auto' && acceptance?.evidenceStatus === 'pending' -} - -/** 是否可编辑验收图片:未提交时可暂存,已提交(待验收)时可补充/修改 */ -function canEditAcceptanceImages(order: WorkOrder) { - if (order.myShare) { - return ( - ['joined', 'submitted'].includes(order.myShare.status) || canSupplementAcceptedEvidence(order) - ) - } - return ( - ['in_progress', 'problem', 'pending_acceptance'].includes(order.status) || - canSupplementAcceptedEvidence(order) - ) -} - -/** 是否已提交验收(整单 pending_acceptance / 拼单份额 submitted) */ -function hasSubmittedAcceptance(order: WorkOrder) { - if (order.myShare) { - return order.myShare.status === 'submitted' - } - return order.status === 'pending_acceptance' || order.status === 'accepted' -} - -function getDisplayOrderNo(order: Pick | null | undefined): string { - return String(order?.platformOrderId || '').trim() || '-' -} - -function getRequirementFields(order: WorkOrder | null): CollectField[] { - if (!order) return [] - const rawFields = Array.isArray(order.requirement?.fields) ? order.requirement.fields : [] - return rawFields - .map((item) => { - const source = asRecord(item) - const key = String(source.key || '').trim() - if (!key) return null - return { - key, - label: String(source.label || key).trim(), - required: source.required !== false, - } - }) - .filter((item): item is CollectField => Boolean(item)) -} - -function getMaterialFields(order: WorkOrder): Record { - const material = asRecord(order.material) - const collect = asRecord(material.collect) - const fields = asRecord(collect.fields || material.fields) - return Object.fromEntries( - Object.entries(fields).map(([key, value]) => [key, String(value || '')]), - ) -} - -/** 把任意图片 Blob 转成 PNG(Chrome 剪贴板写入只支持 image/png) */ -async function toPngBlob(source: Blob): Promise { - const url = URL.createObjectURL(source) - try { - const image = await loadImageElement(url) - const canvas = document.createElement('canvas') - canvas.width = image.naturalWidth - canvas.height = image.naturalHeight - const context = canvas.getContext('2d') - if (!context) return source - context.drawImage(image, 0, 0) - const png = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')) - return png || source - } finally { - URL.revokeObjectURL(url) - } -} - -function loadImageElement(src: string): Promise { - return new Promise((resolve, reject) => { - const image = new Image() - image.onload = () => resolve(image) - image.onerror = () => reject(new Error('图片解码失败')) - image.src = src - }) -} - -function getMaterialScreenshots(order: WorkOrder): UploadedFile[] { - const material = asRecord(order.material) - const collect = asRecord(material.collect) - const screenshots = Array.isArray(collect.screenshots) ? collect.screenshots : [] - return screenshots - .map((item) => { - const file = asRecord(item) - const url = String(file.url || '').trim() - if (!url) return null - return { - objectKey: String(file.objectKey || file.object_key || '').trim(), - filename: String(file.originalFilename || file.filename || '').trim(), - url, - mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(), - thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(), - } as UploadedFile - }) - .filter((item): item is UploadedFile => Boolean(item)) -} - -function getMaterialDetailItems(order: WorkOrder): DetailFieldItem[] { - const values = getMaterialFields(order) - const requiredFields = getRequirementFields(order) - const usedKeys = new Set() - const items = requiredFields.map((field) => { - usedKeys.add(field.key) - return { - key: field.key, - label: field.label, - required: field.required, - value: String(values[field.key] || ''), - } - }) - - Object.entries(values).forEach(([key, value]) => { - if (usedKeys.has(key)) return - items.push({ - key, - label: key, - required: false, - value: String(value || ''), - }) - }) - - return items -} - -function getCopyOrderInfoItems(order: WorkOrder): Array> { - const productName = String(order.productName || '').trim() - const materialItems = getMaterialDetailItems(order).filter((item) => - String(item.value || '').trim(), - ) - - return [...(productName ? [{ label: '物品', value: productName }] : []), ...materialItems] -} - -function renderDetailFieldValues(items: DetailFieldItem[], emptyText: string) { - if (items.length === 0) { - return {emptyText} - } - - return ( - - {items.map((item) => ( - - {item.label} - {item.required ? 必填 : null} - - } - > - - {String(item.value || '').trim() || '-'} - - - ))} - - ) -} - -/** 打手自己的验收资料(拼单时取份额自己的) */ -function getWorkerAcceptance(order: WorkOrder | null | undefined): WorkerAcceptance | undefined { - if (!order) return undefined - return order.myShare ? order.myShare.acceptance : order.acceptance -} - -/** 打手自己的暂存草稿(拼单时取份额自己的) */ -function getWorkerDraft(order: WorkOrder | null | undefined): WorkerAcceptance | undefined { - if (!order) return undefined - return order.myShare?.draftAcceptance || order.draftAcceptance -} - -function getAcceptanceFilesFromSource(source?: WorkerAcceptance | null): UploadedFile[] { - if (!source || !Array.isArray(source.files)) return [] - return source.files -} - -/** 合并草稿兼容字段,并按固定图片地址去重,避免同一图片重复回填。 */ -function getUniqueAcceptanceFiles(source?: WorkerAcceptance | null): UploadedFile[] { - if (!source) return [] - const files = [ - ...getAcceptanceFilesFromSource(source), - ...(Array.isArray(source.imageUrls) ? source.imageUrls : []).map( - (url): UploadedFile => ({ - objectKey: '', - url: String(url || ''), - thumbnailUrl: String(url || ''), - mediumUrl: String(url || ''), - filename: '图片', - contentType: '', - size: 0, - }), - ), - ] - const identities = new Set() - return files.filter((file) => { - const identity = getImageIdentity(file) - if (!identity || identities.has(identity)) return false - identities.add(identity) - return true - }) -} - -function getAcceptanceSubmittedAt(order: WorkOrder): string | null { - if (order.myShare) return order.myShare.submittedAt - const acceptanceSubmittedAt = - typeof order.acceptance?.submittedAt === 'string' ? order.acceptance.submittedAt : null - return order.submittedAt || acceptanceSubmittedAt || null -} - -function asRecord(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : {} -} - -function formatMoney(value: number | undefined) { - return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}` -} - -function getSettlementLabel(status: string) { - if (status === 'accepted' || status === 'vip_evidence_pending') return '已结算' - if (!status) return '结算合计' - return '预计' -} - -function formatWorkerAfterSalesStatus(status: string) { - if (status === 'awaiting_worker_response') return '待说明' - if (status === 'under_review') return '处理中' - if (status === 'recovery_pending') return '追缴中' - return '已关闭' -} - -function resolveWorkerAfterSalesStatusColor(status: string) { - if (status === 'awaiting_worker_response') return 'orange' - if (status === 'under_review') return 'blue' - if (status === 'recovery_pending') return 'red' - return 'green' -} - -function formatStatus(status: string) { - const labels: Record = { - open: '已超时', - in_progress: '代练中', - pending_acceptance: '待验收', - problem: '问题单', - accepted: '已验收', - cancelled: '已取消', - } - return labels[status] || status -} - -function getStatusHint(status: string) { - const hints: Record = { - open: '任务已超时被系统判定失败,如需继续可前往大厅重新抢单。', - in_progress: '订单进行中,完成后请及时提交验收资料。', - pending_acceptance: '已提交验收,等待后台审核。', - problem: '后台已标记问题,请根据备注调整后重新提交。', - accepted: '订单已完成验收,可以在个人中心查看结算记录。', - cancelled: '订单已取消,本次流程已结束。', - } - return hints[status] || '请关注当前订单状态变化。' -} - -function resolveStatusColor(status: string) { - if (status === 'accepted') return 'green' - if (status === 'cancelled') return 'default' - if (status === 'problem') return 'red' - if (status === 'pending_acceptance') return 'gold' - if (status === 'open') return 'red' - return 'blue' -} diff --git a/apps/frontend/src/pages/worker/worker-order-utils.tsx b/apps/frontend/src/pages/worker/worker-order-utils.tsx new file mode 100644 index 00000000..376de53d --- /dev/null +++ b/apps/frontend/src/pages/worker/worker-order-utils.tsx @@ -0,0 +1,328 @@ +import { Descriptions, Space, Tag, Typography } from 'antd' + +import type { + CollectField, + UploadedFile, + WorkerAcceptance, + WorkOrder, +} from '@/types/worker-platform' +import { getImageIdentity } from '@/utils/image-identity' + +type DetailFieldItem = { + key: string + label: string + required: boolean + value: string +} + +export function patchWorkOrderReadState(current: unknown, workOrderId: number) { + if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { + return current + } + return { + ...current, + data: { + ...current.data, + items: current.data.items.map((item) => + isRecord(item) && Number(item.workOrderId) === workOrderId + ? { ...item, isUnread: false } + : item, + ), + }, + } +} + +export function canSubmitAcceptance(order: WorkOrder) { + if (order.myShare) { + return order.myShare.status === 'joined' && order.status !== 'accepted' + } + return ['in_progress', 'problem'].includes(order.status) +} + +export function canRemindAcceptance(order: WorkOrder) { + return !order.myShare && order.status === 'pending_acceptance' +} + +export function canRequestCancel(order: WorkOrder) { + if (order.myShare) { + return ( + ['joined', 'submitted'].includes(order.myShare.status) && + ['open', 'pending_acceptance'].includes(order.status) + ) + } + return order.status === 'in_progress' +} + +export function canSubmitFeedback(order: WorkOrder) { + if (order.myShare) return order.myShare.status !== 'cancelled' + return Boolean(order.assignedAt || order.worker) +} + +export function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutEvidence: boolean) { + if (!workerCanAutoAcceptWithoutEvidence) return false + if (order.myShare) return order.myShare.status === 'joined' && order.status !== 'accepted' + return order.sharing?.enabled !== true && ['in_progress', 'problem'].includes(order.status) +} + +export function canSupplementAcceptedEvidence(order: WorkOrder) { + return order.status === 'accepted' +} + +export function isVipEvidencePending(order: WorkOrder) { + const acceptance = getWorkerAcceptance(order) + return acceptance?.reviewMode === 'vip_auto' && acceptance?.evidenceStatus === 'pending' +} + +/** 是否可编辑验收图片:未提交时可暂存,已提交时可补充或修改。 */ +export function canEditAcceptanceImages(order: WorkOrder) { + if (order.myShare) { + return ( + ['joined', 'submitted'].includes(order.myShare.status) || canSupplementAcceptedEvidence(order) + ) + } + return ( + ['in_progress', 'problem', 'pending_acceptance'].includes(order.status) || + canSupplementAcceptedEvidence(order) + ) +} + +/** 是否已提交验收:拼单依据份额状态,整单依据工单状态。 */ +export function hasSubmittedAcceptance(order: WorkOrder) { + if (order.myShare) { + return order.myShare.status === 'submitted' + } + return order.status === 'pending_acceptance' || order.status === 'accepted' +} + +export function getDisplayOrderNo( + order: Pick | null | undefined, +): string { + return String(order?.platformOrderId || '').trim() || '-' +} + +export function getRequirementFields(order: WorkOrder | null): CollectField[] { + if (!order) return [] + const rawFields = Array.isArray(order.requirement?.fields) ? order.requirement.fields : [] + return rawFields + .map((item) => { + const source = asRecord(item) + const key = String(source.key || '').trim() + if (!key) return null + return { + key, + label: String(source.label || key).trim(), + required: source.required !== false, + } + }) + .filter((item): item is CollectField => Boolean(item)) +} + +export function getMaterialFields(order: WorkOrder): Record { + const material = asRecord(order.material) + const collect = asRecord(material.collect) + const fields = asRecord(collect.fields || material.fields) + return Object.fromEntries( + Object.entries(fields).map(([key, value]) => [key, String(value || '')]), + ) +} + +/** 把任意图片 Blob 转成 PNG,兼容 Chrome 剪贴板 API。 */ +export async function toPngBlob(source: Blob): Promise { + const url = URL.createObjectURL(source) + try { + const image = await loadImageElement(url) + const canvas = document.createElement('canvas') + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext('2d') + if (!context) return source + const png = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')) + return png || source + } finally { + URL.revokeObjectURL(url) + } +} + +export function getMaterialScreenshots(order: WorkOrder): UploadedFile[] { + const material = asRecord(order.material) + const collect = asRecord(material.collect) + const screenshots = Array.isArray(collect.screenshots) ? collect.screenshots : [] + return screenshots + .map((item) => { + const file = asRecord(item) + const url = String(file.url || '').trim() + if (!url) return null + return { + objectKey: String(file.objectKey || file.object_key || '').trim(), + filename: String(file.originalFilename || file.filename || '').trim(), + url, + mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(), + thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(), + } as UploadedFile + }) + .filter((item): item is UploadedFile => Boolean(item)) +} + +export function getMaterialDetailItems(order: WorkOrder): DetailFieldItem[] { + const values = getMaterialFields(order) + const requiredFields = getRequirementFields(order) + const usedKeys = new Set() + const items = requiredFields.map((field) => { + usedKeys.add(field.key) + return { + key: field.key, + label: field.label, + required: field.required, + value: String(values[field.key] || ''), + } + }) + + Object.entries(values).forEach(([key, value]) => { + if (usedKeys.has(key)) return + items.push({ key, label: key, required: false, value: String(value || '') }) + }) + return items +} + +export function getCopyOrderInfoItems( + order: WorkOrder, +): Array> { + const productName = String(order.productName || '').trim() + const materialItems = getMaterialDetailItems(order).filter((item) => + String(item.value || '').trim(), + ) + return [...(productName ? [{ label: '物品', value: productName }] : []), ...materialItems] +} + +export function renderDetailFieldValues(items: DetailFieldItem[], emptyText: string) { + if (items.length === 0) { + return {emptyText} + } + return ( + + {items.map((item) => ( + + {item.label} + {item.required ? 必填 : null} + + } + > + + {String(item.value || '').trim() || '-'} + + + ))} + + ) +} + +/** 打手自己的验收资料,拼单时只读取其责任份额。 */ +export function getWorkerAcceptance( + order: WorkOrder | null | undefined, +): WorkerAcceptance | undefined { + if (!order) return undefined + return order.myShare ? order.myShare.acceptance : order.acceptance +} + +/** 打手自己的暂存草稿,拼单时只读取其责任份额。 */ +export function getWorkerDraft(order: WorkOrder | null | undefined): WorkerAcceptance | undefined { + if (!order) return undefined + return order.myShare?.draftAcceptance || order.draftAcceptance +} + +export function getUniqueAcceptanceFiles(source?: WorkerAcceptance | null): UploadedFile[] { + if (!source) return [] + const files = [ + ...(Array.isArray(source.files) ? source.files : []), + ...(Array.isArray(source.imageUrls) ? source.imageUrls : []).map( + (url): UploadedFile => ({ + objectKey: '', + url: String(url || ''), + thumbnailUrl: String(url || ''), + mediumUrl: String(url || ''), + filename: '图片', + contentType: '', + size: 0, + }), + ), + ] + const identities = new Set() + return files.filter((file) => { + const identity = getImageIdentity(file) + if (!identity || identities.has(identity)) return false + identities.add(identity) + return true + }) +} + +export function getAcceptanceSubmittedAt(order: WorkOrder): string | null { + if (order.myShare) return order.myShare.submittedAt + const acceptanceSubmittedAt = + typeof order.acceptance?.submittedAt === 'string' ? order.acceptance.submittedAt : null + return order.submittedAt || acceptanceSubmittedAt || null +} + +export function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +export function formatMoney(value: number | undefined) { + return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}` +} + +export function getSettlementLabel(status: string) { + if (status === 'accepted' || status === 'vip_evidence_pending') return '已结算' + if (!status) return '结算合计' + return '预计' +} + +export function formatStatus(status: string) { + const labels: Record = { + open: '已超时', + in_progress: '代练中', + pending_acceptance: '待验收', + problem: '问题单', + accepted: '已验收', + cancelled: '已取消', + } + return labels[status] || status +} + +export function getStatusHint(status: string) { + const hints: Record = { + open: '任务已超时被系统判定失败,如需继续可前往大厅重新抢单。', + in_progress: '订单进行中,完成后请及时提交验收资料。', + pending_acceptance: '已提交验收,等待后台审核。', + problem: '后台已标记问题,请根据备注调整后重新提交。', + accepted: '订单已完成验收,可以在个人中心查看结算记录。', + cancelled: '订单已取消,本次流程已结束。', + } + return hints[status] || '请关注当前订单状态变化。' +} + +export function resolveStatusColor(status: string) { + if (status === 'accepted') return 'green' + if (status === 'cancelled') return 'default' + if (status === 'problem') return 'red' + if (status === 'pending_acceptance') return 'gold' + if (status === 'open') return 'red' + return 'blue' +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + +function loadImageElement(src: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = () => reject(new Error('图片解码失败')) + image.src = src + }) +}