diff --git a/apps/backend/src/repositories/worker-platform-repo.test.ts b/apps/backend/src/repositories/worker-platform-repo.test.ts index 3fdd999f..17669e2f 100644 --- a/apps/backend/src/repositories/worker-platform-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform-repo.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { resolveOutstandingDepositAmount } from './worker-platform-repo.js' +import { resolveOutstandingDepositAmount } from './worker-platform/index.js' test('resolveOutstandingDepositAmount deducts released and deducted history', () => { assert.equal( diff --git a/apps/backend/src/repositories/worker-platform/index.ts b/apps/backend/src/repositories/worker-platform/index.ts new file mode 100644 index 00000000..abcea49d --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/index.ts @@ -0,0 +1,4 @@ +export * from './types.js' +export * from './shared.js' +export * from './worker-repo.js' +export * from './work-order-repo.js' diff --git a/apps/backend/src/repositories/worker-platform/shared.ts b/apps/backend/src/repositories/worker-platform/shared.ts new file mode 100644 index 00000000..dbd26e5f --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/shared.ts @@ -0,0 +1,44 @@ +import type { PoolClient } from 'pg' + +import type { WorkerWalletRow } from './types.js' + + +export async function ensureWorkerWalletWithClient( + client: PoolClient, + workerId: number, + now: string, +): Promise { + if (!workerId) return + await client.query( + ` + INSERT INTO worker_wallets ( + worker_id, available_amount, frozen_deposit_amount, + total_credited_amount, total_settled_amount, created_at, updated_at + ) VALUES ($1, 0, 0, 0, 0, $2, $3) + ON CONFLICT (worker_id) DO NOTHING + `, + [workerId, now, now], + ) +} + +export async function getWorkerWalletWithClient( + client: PoolClient, + workerId: number, +): Promise { + const result = await client.query( + 'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1 FOR UPDATE', + [workerId], + ) + return result.rows[0] || null +} + +export function toJsonString(value: unknown): string { + if (typeof value === 'string') return value + return JSON.stringify(value ?? {}) +} + +export function toPositiveInteger(value: unknown, fallback: number): number { + const parsed = Number(value ?? '') + if (!Number.isFinite(parsed)) return fallback + return Math.max(0, Math.round(parsed)) +} diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts new file mode 100644 index 00000000..45004683 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -0,0 +1,246 @@ + + +export type WorkerLevelRow = { + id: number + level_key: string + name: string + sort_order: number + status: string + permission_json: string | Record + created_at: string + updated_at: string +} + +export type WorkerUserRow = { + id: number + username: string + password_hash: string + display_name: string + phone: string + level_id: number | null + status: string + review_note: string + session_version: number + created_at: string + updated_at: string + reviewed_at: string | null + inviter_id: number | null + invite_code: string + inviter_username?: string + inviter_display_name?: string + level_key?: string + level_name?: string + level_permission_json?: string | Record + available_amount?: number + frozen_deposit_amount?: number + total_credited_amount?: number + total_settled_amount?: number +} + +export type WorkerWalletRow = { + worker_id: number + available_amount: number + frozen_deposit_amount: number + total_credited_amount: number + total_settled_amount: number + created_at: string + updated_at: string +} + +export type WorkerWalletLedgerRow = { + id: number + worker_id: number + ledger_type: string + amount: number + balance_after: number + frozen_after: number + related_work_order_id: number | null + audit_status: string + note: string + payload_json: string | Record + created_at: string +} + +export type WorkerFinanceRequestRow = { + id: number + worker_id: number + request_type: string + status: string + amount: number + account_channel: string + account_name: string + account_no: string + note: string + reviewed_note: string + payload_json: string | Record + created_at: string + updated_at: string + reviewed_at: string | null + worker_username?: string + worker_display_name?: string + worker_phone?: string +} + +export type WorkCategoryRow = { + id: number + category_key: string + name: string + sort_order: number + status: string + created_at: string + updated_at: string +} + +export type WorkProductRuleRow = { + id: number + rule_key: string + provider: string + platform: string + shop_id: string + sku_code: string + product_name: string + match_type: string + category_id: number | null + enabled: boolean + auto_create: boolean + reward_amount: number + required_deposit_amount: number + deposit_threshold_amount: number + sharing_enabled: boolean + sharing_total_quantity: number + sharing_unit_reward: number + requirement_json: string | Record + sort_order: number + created_at: string + updated_at: string + category_name?: string +} + +export type WorkOrderShareRow = { + id: number + work_order_id: number + worker_id: number + quantity: number + unit_reward: number + share_reward: number + share_deposit: number + status: string + acceptance_json: string | Record + submitted_at: string | null + accepted_at: string | null + created_at: string + updated_at: string + worker_username?: string + worker_display_name?: string +} + +export type WorkOrderRow = { + id: number + work_order_no: string + order_id: number | null + order_item_id: number | null + task_id: number | null + platform_order_id: string + product_name: string + category_id: number | null + status: string + reward_amount: number + required_deposit_amount: number + deposit_threshold_amount: number + assigned_worker_id: number | null + material_json: string | Record + requirement_json: string | Record + acceptance_json: string | Record + problem_note: string + sharing_enabled: boolean + sharing_total_quantity: number + sharing_unit_reward: number + published_at: string | null + assigned_at: string | null + submitted_at: string | null + accepted_at: string | null + created_at: string + updated_at: string + category_name?: string + worker_username?: string + worker_display_name?: string +} + +export type ProblemWorkOrderResolutionAction = + | 'return_to_worker' + | 'reopen' + | 'cancel_release' + | 'cancel_deduct' + +export type GrabWorkOrderFailureReason = + | 'worker_active_order_limit' + | 'worker_deposit_insufficient' + | 'work_order_not_open' + +export type GrabWorkOrderResult = { + order: WorkOrderRow | null + failureReason: GrabWorkOrderFailureReason | null +} + +export type ListInput = { + page?: number + pageSize?: number + status?: string + keyword?: string + workerId?: number + categoryId?: number + workerSharingId?: number +} + +export type ProductRuleListInput = { + enabled?: boolean | null + keyword?: string +} + +export type WalletLedgerListInput = { + page?: number + pageSize?: number + workerId?: number + ledgerType?: string +} + +export type FinanceRequestListInput = { + page?: number + pageSize?: number + workerId?: number + status?: string + requestType?: string + keyword?: string +} + +export type CreateWorkerInput = { + username: string + passwordHash: string + displayName: string + phone: string + levelId: number | null + status: string + inviterId?: number | null + inviteCode: string + now: string +} + +export type CreateWorkOrderInput = { + workOrderNo: string + orderId?: number | null + orderItemId?: number | null + taskId?: number | null + platformOrderId: string + productName: string + categoryId?: number | null + status: string + rewardAmount: number + requiredDepositAmount: number + depositThresholdAmount: number + sharingEnabled?: boolean + sharingTotalQuantity?: number + sharingUnitReward?: number + materialJson: string + requirementJson: string + now: string +} diff --git a/apps/backend/src/repositories/worker-platform-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts similarity index 59% rename from apps/backend/src/repositories/worker-platform-repo.ts rename to apps/backend/src/repositories/worker-platform/work-order-repo.ts index c64b2b35..e2bd8fd4 100644 --- a/apps/backend/src/repositories/worker-platform-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -1,269 +1,9 @@ +import { query, withTransaction } from '../../db/client.js' +import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, toJsonString, toPositiveInteger } from './shared.js' +import type { CreateWorkOrderInput, GrabWorkOrderResult, ListInput, ProblemWorkOrderResolutionAction, ProductRuleListInput, WorkCategoryRow, WorkOrderRow, WorkOrderShareRow, WorkProductRuleRow } from './types.js' import type { PoolClient } from 'pg' +import { maybeUpgradeWorkerLevelWithClient } from './worker-repo.js' -import { query, withTransaction } from '../db/client.js' - -export type WorkerLevelRow = { - id: number - level_key: string - name: string - sort_order: number - status: string - permission_json: string | Record - created_at: string - updated_at: string -} - -export type WorkerUserRow = { - id: number - username: string - password_hash: string - display_name: string - phone: string - level_id: number | null - status: string - review_note: string - session_version: number - created_at: string - updated_at: string - reviewed_at: string | null - inviter_id: number | null - invite_code: string - inviter_username?: string - inviter_display_name?: string - level_key?: string - level_name?: string - level_permission_json?: string | Record - available_amount?: number - frozen_deposit_amount?: number - total_credited_amount?: number - total_settled_amount?: number -} - -export type WorkerWalletRow = { - worker_id: number - available_amount: number - frozen_deposit_amount: number - total_credited_amount: number - total_settled_amount: number - created_at: string - updated_at: string -} - -export type WorkerWalletLedgerRow = { - id: number - worker_id: number - ledger_type: string - amount: number - balance_after: number - frozen_after: number - related_work_order_id: number | null - audit_status: string - note: string - payload_json: string | Record - created_at: string -} - -export type WorkerFinanceRequestRow = { - id: number - worker_id: number - request_type: string - status: string - amount: number - account_channel: string - account_name: string - account_no: string - note: string - reviewed_note: string - payload_json: string | Record - created_at: string - updated_at: string - reviewed_at: string | null - worker_username?: string - worker_display_name?: string - worker_phone?: string -} - -export type WorkCategoryRow = { - id: number - category_key: string - name: string - sort_order: number - status: string - created_at: string - updated_at: string -} - -export type WorkProductRuleRow = { - id: number - rule_key: string - provider: string - platform: string - shop_id: string - sku_code: string - product_name: string - match_type: string - category_id: number | null - enabled: boolean - auto_create: boolean - reward_amount: number - required_deposit_amount: number - deposit_threshold_amount: number - sharing_enabled: boolean - sharing_total_quantity: number - sharing_unit_reward: number - requirement_json: string | Record - sort_order: number - created_at: string - updated_at: string - category_name?: string -} - -export type WorkOrderShareRow = { - id: number - work_order_id: number - worker_id: number - quantity: number - unit_reward: number - share_reward: number - share_deposit: number - status: string - acceptance_json: string | Record - submitted_at: string | null - accepted_at: string | null - created_at: string - updated_at: string - worker_username?: string - worker_display_name?: string -} - -export type WorkOrderRow = { - id: number - work_order_no: string - order_id: number | null - order_item_id: number | null - task_id: number | null - platform_order_id: string - product_name: string - category_id: number | null - status: string - reward_amount: number - required_deposit_amount: number - deposit_threshold_amount: number - assigned_worker_id: number | null - material_json: string | Record - requirement_json: string | Record - acceptance_json: string | Record - problem_note: string - sharing_enabled: boolean - sharing_total_quantity: number - sharing_unit_reward: number - published_at: string | null - assigned_at: string | null - submitted_at: string | null - accepted_at: string | null - created_at: string - updated_at: string - category_name?: string - worker_username?: string - worker_display_name?: string -} - -export type ProblemWorkOrderResolutionAction = - | 'return_to_worker' - | 'reopen' - | 'cancel_release' - | 'cancel_deduct' - -export type GrabWorkOrderFailureReason = - | 'worker_active_order_limit' - | 'worker_deposit_insufficient' - | 'work_order_not_open' - -export type GrabWorkOrderResult = { - order: WorkOrderRow | null - failureReason: GrabWorkOrderFailureReason | null -} - -type ListInput = { - page?: number - pageSize?: number - status?: string - keyword?: string - workerId?: number - categoryId?: number - workerSharingId?: number -} - -type ProductRuleListInput = { - enabled?: boolean | null - keyword?: string -} - -type WalletLedgerListInput = { - page?: number - pageSize?: number - workerId?: number - ledgerType?: string -} - -type FinanceRequestListInput = { - page?: number - pageSize?: number - workerId?: number - status?: string - requestType?: string - keyword?: string -} - -type CreateWorkerInput = { - username: string - passwordHash: string - displayName: string - phone: string - levelId: number | null - status: string - inviterId?: number | null - inviteCode: string - now: string -} - -type CreateWorkOrderInput = { - workOrderNo: string - orderId?: number | null - orderItemId?: number | null - taskId?: number | null - platformOrderId: string - productName: string - categoryId?: number | null - status: string - rewardAmount: number - requiredDepositAmount: number - depositThresholdAmount: number - sharingEnabled?: boolean - sharingTotalQuantity?: number - sharingUnitReward?: number - materialJson: string - requirementJson: string - now: string -} - -const WORKER_USER_SELECT = ` - SELECT - wu.*, - wl.level_key, - wl.name AS level_name, - wl.permission_json AS level_permission_json, - inv.username AS inviter_username, - inv.display_name AS inviter_display_name, - ww.available_amount, - ww.frozen_deposit_amount, - ww.total_credited_amount, - ww.total_settled_amount - FROM worker_users wu - LEFT JOIN worker_levels wl ON wl.id = wu.level_id - LEFT JOIN worker_users inv ON inv.id = wu.inviter_id - LEFT JOIN worker_wallets ww ON ww.worker_id = wu.id -` const WORK_ORDER_SELECT = ` SELECT @@ -284,616 +24,6 @@ const WORK_PRODUCT_RULE_SELECT = ` LEFT JOIN work_categories wc ON wc.id = wpr.category_id ` -const WORKER_FINANCE_REQUEST_SELECT = ` - SELECT - wfr.*, - wu.username AS worker_username, - wu.display_name AS worker_display_name, - wu.phone AS worker_phone - FROM worker_finance_requests wfr - LEFT JOIN worker_users wu ON wu.id = wfr.worker_id -` - -export async function getWorkerLevelByKey(levelKey: string): Promise { - const result = await query( - 'SELECT * FROM worker_levels WHERE level_key = $1 LIMIT 1', - [String(levelKey || '').trim()], - ) - return result.rows[0] || null -} - -export async function getWorkerLevelById(levelId: number | string): Promise { - const result = await query('SELECT * FROM worker_levels WHERE id = $1 LIMIT 1', [ - Number(levelId), - ]) - return result.rows[0] || null -} - -export async function listWorkerLevels(): Promise { - const result = await query( - 'SELECT * FROM worker_levels ORDER BY sort_order ASC, id ASC', - ) - return result.rows -} - -export async function upsertWorkerLevel(input: { - levelKey: string - name: string - sortOrder: number - status: string - permissionJson: string - now: string -}): Promise { - const result = await query( - ` - INSERT INTO worker_levels ( - level_key, name, sort_order, status, permission_json, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7) - ON CONFLICT (level_key) DO UPDATE - SET - name = EXCLUDED.name, - sort_order = EXCLUDED.sort_order, - status = EXCLUDED.status, - permission_json = EXCLUDED.permission_json, - updated_at = EXCLUDED.updated_at - RETURNING * - `, - [ - input.levelKey, - input.name, - input.sortOrder, - input.status, - input.permissionJson, - input.now, - input.now, - ], - ) - return result.rows[0] || null -} - -export async function countWorkerLevelUsages(levelId: number | string): Promise { - const result = await query<{ total: number }>( - 'SELECT COUNT(*)::int AS total FROM worker_users WHERE level_id = $1', - [Number(levelId)], - ) - return Number(result.rows[0]?.total || 0) -} - -export async function deleteWorkerLevel(levelId: number | string): Promise<{ deleted: boolean }> { - const result = await query<{ id: number }>( - 'DELETE FROM worker_levels WHERE id = $1 RETURNING id', - [Number(levelId)], - ) - return { deleted: Boolean(result.rows[0]) } -} - -export async function getWorkerUserByUsername(username: string): Promise { - const result = await query( - `${WORKER_USER_SELECT} WHERE wu.username = $1 LIMIT 1`, - [ - String(username || '') - .trim() - .toLowerCase(), - ], - ) - return result.rows[0] || null -} - -export async function getWorkerUserByInviteCode( - inviteCode: string, -): Promise { - const result = await query( - `${WORKER_USER_SELECT} WHERE wu.invite_code = $1 LIMIT 1`, - [String(inviteCode || '').trim().toUpperCase()], - ) - return result.rows[0] || null -} - -export async function getWorkerUserById(workerId: number | string): Promise { - const result = await query(`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, [ - Number(workerId), - ]) - return result.rows[0] || null -} - -export async function createWorkerUser(input: CreateWorkerInput): Promise { - return withTransaction(async (client) => { - const result = await client.query<{ id: number }>( - ` - INSERT INTO worker_users ( - username, password_hash, display_name, phone, level_id, status, - inviter_id, invite_code, - created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING id - `, - [ - input.username, - input.passwordHash, - input.displayName, - input.phone, - input.levelId, - input.status, - input.inviterId || null, - input.inviteCode, - input.now, - input.now, - ], - ) - const workerId = Number(result.rows[0]?.id || 0) - await ensureWorkerWalletWithClient(client, workerId, input.now) - return getWorkerUserByIdWithClient(client, workerId) - }) -} - -export async function updateWorkerUser( - workerId: number | string, - patch: Partial< - Pick< - WorkerUserRow, - 'status' | 'review_note' | 'level_id' | 'session_version' | 'updated_at' | 'reviewed_at' - > - >, -): Promise { - const current = await getWorkerUserById(workerId) - if (!current) return null - const next = { ...current, ...patch } - - await query( - ` - UPDATE worker_users - SET - status = $1, - review_note = $2, - level_id = $3, - session_version = $4, - reviewed_at = $5, - updated_at = $6 - WHERE id = $7 - `, - [ - next.status, - next.review_note || '', - next.level_id || null, - Number(next.session_version || 1), - next.reviewed_at || null, - next.updated_at, - Number(workerId), - ], - ) - return getWorkerUserById(workerId) -} - -export async function updateWorkerPassword(input: { - workerId: number - passwordHash: string - sessionVersion: number - now: string -}): Promise { - const result = await query<{ id: number }>( - ` - UPDATE worker_users - SET - password_hash = $1, - session_version = $2, - updated_at = $3 - WHERE id = $4 - RETURNING id - `, - [input.passwordHash, input.sessionVersion, input.now, input.workerId], - ) - if (!result.rows[0]) { - return null - } - return getWorkerUserById(input.workerId) -} - -export async function incrementWorkerSessionVersion( - workerId: number | string, - now: string, -): Promise { - const result = await query<{ id: number }>( - ` - UPDATE worker_users - SET session_version = session_version + 1, updated_at = $2 - WHERE id = $1 - RETURNING id - `, - [Number(workerId), now], - ) - if (!result.rows[0]) { - return null - } - return getWorkerUserById(workerId) -} - -export async function listWorkerUsers({ - page = 1, - pageSize = 20, - status = '', - keyword = '', -}: ListInput = {}): Promise<{ items: WorkerUserRow[]; total: number }> { - const { whereClause, params } = buildWorkerUserWhere({ status, keyword }) - const totalResult = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM worker_users wu ${whereClause}`, - params, - ) - - const offset = (page - 1) * pageSize - params.push(pageSize, offset) - const itemsResult = await query( - `${WORKER_USER_SELECT} - ${whereClause} - ORDER BY wu.id DESC - LIMIT $${params.length - 1} OFFSET $${params.length}`, - params, - ) - return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) } -} - -export async function ensureWorkerWallet( - workerId: number | string, - now: string, -): Promise { - return withTransaction(async (client) => { - await ensureWorkerWalletWithClient(client, Number(workerId), now) - return getWorkerWalletWithClient(client, Number(workerId)) - }) -} - -export async function getWorkerWallet(workerId: number | string): Promise { - const result = await query( - 'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1', - [Number(workerId)], - ) - return result.rows[0] || null -} - -export async function listWorkerWalletLedgers({ - page = 1, - pageSize = 20, - workerId = 0, - ledgerType = '', -}: WalletLedgerListInput = {}): Promise<{ items: WorkerWalletLedgerRow[]; total: number }> { - const { whereClause, params } = buildWorkerWalletLedgerWhere({ - workerId, - ledgerType, - }) - const totalResult = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM worker_wallet_ledgers wwl ${whereClause}`, - params, - ) - const offset = (page - 1) * pageSize - params.push(pageSize, offset) - const itemsResult = await query( - ` - SELECT * - FROM worker_wallet_ledgers wwl - ${whereClause} - ORDER BY wwl.created_at DESC, wwl.id DESC - LIMIT $${params.length - 1} OFFSET $${params.length} - `, - params, - ) - return { - items: itemsResult.rows, - total: Number(totalResult.rows[0]?.total || 0), - } -} - -export async function addWorkerWalletCredit(input: { - workerId: number - amount: number - note: string - payloadJson: string - now: string -}): Promise { - return withTransaction(async (client) => { - await ensureWorkerWalletWithClient(client, input.workerId, input.now) - const wallet = await getWorkerWalletWithClient(client, input.workerId) - const nextAvailable = Number(wallet?.available_amount || 0) + input.amount - const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) - await client.query( - ` - UPDATE worker_wallets - SET - available_amount = $1, - total_credited_amount = total_credited_amount + $2, - updated_at = $3 - WHERE worker_id = $4 - `, - [nextAvailable, input.amount, input.now, input.workerId], - ) - await client.query( - ` - INSERT INTO worker_wallet_ledgers ( - worker_id, ledger_type, amount, balance_after, frozen_after, - audit_status, note, payload_json, created_at - ) VALUES ($1, 'manual_credit', $2, $3, $4, 'approved', $5, $6::jsonb, $7) - `, - [ - input.workerId, - input.amount, - nextAvailable, - nextFrozen, - input.note, - input.payloadJson, - input.now, - ], - ) - return getWorkerWalletWithClient(client, input.workerId) - }) -} - -export async function createWorkerFinanceRequest(input: { - workerId: number - requestType: string - amount: number - accountChannel: string - accountName: string - accountNo: string - note: string - payloadJson: string - now: string -}): Promise { - const result = await query( - ` - INSERT INTO worker_finance_requests ( - worker_id, request_type, status, amount, - account_channel, account_name, account_no, - note, reviewed_note, payload_json, created_at, updated_at - ) VALUES ($1, $2, 'pending', $3, $4, $5, $6, $7, '', $8::jsonb, $9, $10) - RETURNING * - `, - [ - input.workerId, - input.requestType, - input.amount, - input.accountChannel, - input.accountName, - input.accountNo, - input.note, - input.payloadJson, - input.now, - input.now, - ], - ) - return result.rows[0] || null -} - -export async function getWorkerFinanceRequestById( - requestId: number | string, -): Promise { - const result = await query( - `${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`, - [Number(requestId)], - ) - return result.rows[0] || null -} - -export async function listWorkerFinanceRequests({ - page = 1, - pageSize = 20, - workerId = 0, - status = '', - requestType = '', - keyword = '', -}: FinanceRequestListInput = {}): Promise<{ items: WorkerFinanceRequestRow[]; total: number }> { - const { whereClause, params } = buildWorkerFinanceRequestWhere({ - workerId, - status, - requestType, - keyword, - }) - const totalResult = await query<{ total: number }>( - `SELECT COUNT(*)::int AS total FROM worker_finance_requests wfr - LEFT JOIN worker_users wu ON wu.id = wfr.worker_id - ${whereClause}`, - params, - ) - const offset = (page - 1) * pageSize - params.push(pageSize, offset) - const itemsResult = await query( - `${WORKER_FINANCE_REQUEST_SELECT} - ${whereClause} - ORDER BY wfr.created_at DESC, wfr.id DESC - LIMIT $${params.length - 1} OFFSET $${params.length}`, - params, - ) - return { - items: itemsResult.rows, - total: Number(totalResult.rows[0]?.total || 0), - } -} - -export async function reviewWorkerFinanceRequest(input: { - requestId: number - status: string - reviewedNote: string - now: string -}): Promise<{ - request: WorkerFinanceRequestRow | null - failureReason: 'request_not_pending' | 'withdraw_insufficient' | null -}> { - return withTransaction(async (client) => { - const currentResult = await client.query( - ` - SELECT * - FROM worker_finance_requests - WHERE id = $1 - LIMIT 1 - FOR UPDATE - `, - [input.requestId], - ) - const current = currentResult.rows[0] || null - if (!current) { - return { - request: null, - failureReason: null, - } - } - - if (current.status !== 'pending') { - return { - request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId), - failureReason: 'request_not_pending', - } - } - - if (input.status === 'approved') { - await ensureWorkerWalletWithClient(client, Number(current.worker_id), input.now) - const wallet = await getWorkerWalletWithClient(client, Number(current.worker_id)) - const availableAmount = Number(wallet?.available_amount || 0) - const frozenAmount = Number(wallet?.frozen_deposit_amount || 0) - const amount = Number(current.amount || 0) - const note = - input.reviewedNote || - (current.request_type === 'withdraw' ? '提现申请审核通过' : '充值申请审核通过') - - if (current.request_type === 'withdraw') { - if (availableAmount < amount) { - return { - request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId), - failureReason: 'withdraw_insufficient', - } - } - - const nextAvailable = availableAmount - amount - await client.query( - ` - UPDATE worker_wallets - SET available_amount = $1, updated_at = $2 - WHERE worker_id = $3 - `, - [nextAvailable, input.now, Number(current.worker_id)], - ) - await client.query( - ` - INSERT INTO worker_wallet_ledgers ( - worker_id, ledger_type, amount, balance_after, frozen_after, - audit_status, note, payload_json, created_at - ) VALUES ($1, 'withdraw_paid', $2, $3, $4, 'approved', $5, $6::jsonb, $7) - `, - [ - Number(current.worker_id), - -amount, - nextAvailable, - frozenAmount, - note, - JSON.stringify({ - requestId: Number(current.id), - requestType: current.request_type, - source: 'admin_finance_review', - }), - input.now, - ], - ) - } else { - const nextAvailable = availableAmount + amount - await client.query( - ` - UPDATE worker_wallets - SET - available_amount = $1, - total_credited_amount = total_credited_amount + $2, - updated_at = $3 - WHERE worker_id = $4 - `, - [nextAvailable, amount, input.now, Number(current.worker_id)], - ) - await client.query( - ` - INSERT INTO worker_wallet_ledgers ( - worker_id, ledger_type, amount, balance_after, frozen_after, - audit_status, note, payload_json, created_at - ) VALUES ($1, 'manual_credit', $2, $3, $4, 'approved', $5, $6::jsonb, $7) - `, - [ - Number(current.worker_id), - amount, - nextAvailable, - frozenAmount, - note, - JSON.stringify({ - requestId: Number(current.id), - requestType: current.request_type, - source: 'admin_finance_review', - }), - input.now, - ], - ) - } - } - - await client.query( - ` - UPDATE worker_finance_requests - SET - status = $1, - reviewed_note = $2, - reviewed_at = $3, - updated_at = $3 - WHERE id = $4 - `, - [input.status, input.reviewedNote, input.now, input.requestId], - ) - - return { - request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId), - failureReason: null, - } - }) -} - -export async function getWorkerFinanceRequestSummary(workerId: number | string) { - const result = await query<{ - pending_withdraw_amount: number - approved_withdraw_amount: number - pending_recharge_amount: number - }>( - ` - SELECT - COALESCE( - SUM(CASE WHEN request_type = 'withdraw' AND status = 'pending' THEN amount ELSE 0 END), - 0 - )::int AS pending_withdraw_amount, - COALESCE( - SUM(CASE WHEN request_type = 'withdraw' AND status = 'approved' THEN amount ELSE 0 END), - 0 - )::int AS approved_withdraw_amount, - COALESCE( - SUM(CASE WHEN request_type = 'recharge' AND status = 'pending' THEN amount ELSE 0 END), - 0 - )::int AS pending_recharge_amount - FROM worker_finance_requests - WHERE worker_id = $1 - `, - [Number(workerId)], - ) - return { - pendingWithdrawAmount: Number(result.rows[0]?.pending_withdraw_amount || 0), - approvedWithdrawAmount: Number(result.rows[0]?.approved_withdraw_amount || 0), - pendingRechargeAmount: Number(result.rows[0]?.pending_recharge_amount || 0), - } -} - -export async function countWorkerAcceptedOrders(workerId: number | string): Promise { - const result = await query<{ total: number }>( - ` - SELECT - (SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted') - + - COALESCE( - (SELECT SUM(quantity)::int FROM work_order_shares WHERE worker_id = $1 AND status = 'accepted'), - 0 - ) AS total - `, - [Number(workerId)], - ) - return Number(result.rows[0]?.total || 0) -} - const WORK_ORDER_SHARE_SELECT = ` SELECT wos.*, @@ -1159,67 +289,6 @@ export async function submitWorkOrderShareAcceptance(input: { }) } -export async function maybeUpgradeWorkerLevelWithClient( - client: PoolClient, - workerId: number, - now: string, -): Promise { - const acceptedResult = await client.query<{ total: number }>( - ` - SELECT - (SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted') - + - COALESCE( - (SELECT SUM(quantity)::int FROM work_order_shares WHERE worker_id = $1 AND status = 'accepted'), - 0 - ) AS total - `, - [workerId], - ) - const acceptedCount = Number(acceptedResult.rows[0]?.total || 0) - - const nextLevelResult = await client.query<{ id: number }>( - ` - SELECT id - FROM worker_levels - WHERE status = 'active' - AND COALESCE((permission_json->>'upgradeThreshold')::int, 0) <= $1 - ORDER BY COALESCE((permission_json->>'upgradeThreshold')::int, 0) DESC, sort_order ASC - LIMIT 1 - `, - [acceptedCount], - ) - const nextLevelId = Number(nextLevelResult.rows[0]?.id || 0) - if (!nextLevelId) return null - - const current = await client.query<{ id: number; level_id: number | null }>( - 'SELECT id, level_id FROM worker_users WHERE id = $1 LIMIT 1', - [workerId], - ) - const currentRow = current.rows[0] - if (!currentRow || Number(currentRow.level_id || 0) === nextLevelId) return null - - await client.query( - 'UPDATE worker_users SET level_id = $1, updated_at = $2 WHERE id = $3', - [nextLevelId, now, 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, 'auto', $4::jsonb, $5) - `, - [ - workerId, - currentRow.level_id || null, - nextLevelId, - JSON.stringify({ acceptedCount }), - now, - ], - ) - return getWorkerUserByIdWithClient(client, workerId) -} - export async function getWorkCategoryByKey(categoryKey: string): Promise { const result = await query( 'SELECT * FROM work_categories WHERE category_key = $1 LIMIT 1', @@ -2186,35 +1255,6 @@ async function createWorkOrderEventWithClient( ) } -async function ensureWorkerWalletWithClient( - client: PoolClient, - workerId: number, - now: string, -): Promise { - if (!workerId) return - await client.query( - ` - INSERT INTO worker_wallets ( - worker_id, available_amount, frozen_deposit_amount, - total_credited_amount, total_settled_amount, created_at, updated_at - ) VALUES ($1, 0, 0, 0, 0, $2, $3) - ON CONFLICT (worker_id) DO NOTHING - `, - [workerId, now, now], - ) -} - -async function getWorkerWalletWithClient( - client: PoolClient, - workerId: number, -): Promise { - const result = await client.query( - 'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1 FOR UPDATE', - [workerId], - ) - return result.rows[0] || null -} - async function getOutstandingDepositAmountWithClient( client: PoolClient, workerId: number, @@ -2270,28 +1310,6 @@ export function resolveOutstandingDepositAmount( ) } -async function getWorkerUserByIdWithClient( - client: PoolClient, - workerId: number | string, -): Promise { - const result = await client.query( - `${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, - [Number(workerId)], - ) - return result.rows[0] || null -} - -async function getWorkerFinanceRequestByIdWithClient( - client: PoolClient, - requestId: number | string, -): Promise { - const result = await client.query( - `${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`, - [Number(requestId)], - ) - return result.rows[0] || null -} - async function getWorkOrderByIdWithClient( client: PoolClient, workOrderId: number | string, @@ -2302,28 +1320,6 @@ async function getWorkOrderByIdWithClient( return result.rows[0] || null } -function buildWorkerUserWhere({ - status = '', - keyword = '', -}: Pick) { - const filters: string[] = [] - const params: unknown[] = [] - if (status) { - params.push(status) - filters.push(`wu.status = $${params.length}`) - } - if (keyword) { - params.push(`%${keyword}%`) - filters.push( - `(wu.username ILIKE $${params.length} OR wu.display_name ILIKE $${params.length} OR wu.phone ILIKE $${params.length})`, - ) - } - return { - whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', - params, - } -} - function buildWorkOrderWhere({ status = '', keyword = '', @@ -2367,62 +1363,6 @@ function buildWorkOrderWhere({ } } -function buildWorkerWalletLedgerWhere({ - workerId = 0, - ledgerType = '', -}: WalletLedgerListInput) { - const filters: string[] = [] - const params: unknown[] = [] - if (workerId) { - params.push(workerId) - filters.push(`wwl.worker_id = $${params.length}`) - } - if (ledgerType) { - params.push(ledgerType) - filters.push(`wwl.ledger_type = $${params.length}`) - } - return { - whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', - params, - } -} - -function buildWorkerFinanceRequestWhere({ - workerId = 0, - status = '', - requestType = '', - keyword = '', -}: FinanceRequestListInput) { - const filters: string[] = [] - const params: unknown[] = [] - if (workerId) { - params.push(workerId) - filters.push(`wfr.worker_id = $${params.length}`) - } - if (status) { - params.push(status) - filters.push(`wfr.status = $${params.length}`) - } - if (requestType) { - params.push(requestType) - filters.push(`wfr.request_type = $${params.length}`) - } - if (keyword) { - params.push(`%${keyword}%`) - filters.push( - `(wu.username ILIKE $${params.length} - OR wu.display_name ILIKE $${params.length} - OR wu.phone ILIKE $${params.length} - OR wfr.account_name ILIKE $${params.length} - OR wfr.account_no ILIKE $${params.length})`, - ) - } - return { - whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', - params, - } -} - function buildWorkProductRuleWhere({ enabled = null, keyword = '' }: ProductRuleListInput) { const filters: string[] = [] const params: unknown[] = [] @@ -2443,14 +1383,3 @@ function buildWorkProductRuleWhere({ enabled = null, keyword = '' }: ProductRule params, } } - -function toJsonString(value: unknown): string { - if (typeof value === 'string') return value - return JSON.stringify(value ?? {}) -} - -function toPositiveInteger(value: unknown, fallback: number): number { - const parsed = Number(value ?? '') - if (!Number.isFinite(parsed)) return fallback - return Math.max(0, Math.round(parsed)) -} diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts new file mode 100644 index 00000000..d1107f5f --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -0,0 +1,794 @@ +import { query, withTransaction } from '../../db/client.js' +import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js' +import type { CreateWorkerInput, FinanceRequestListInput, ListInput, WalletLedgerListInput, WorkerFinanceRequestRow, WorkerLevelRow, WorkerUserRow, WorkerWalletLedgerRow, WorkerWalletRow } from './types.js' +import type { PoolClient } from 'pg' + + +const WORKER_USER_SELECT = ` + SELECT + wu.*, + wl.level_key, + wl.name AS level_name, + wl.permission_json AS level_permission_json, + inv.username AS inviter_username, + inv.display_name AS inviter_display_name, + ww.available_amount, + ww.frozen_deposit_amount, + ww.total_credited_amount, + ww.total_settled_amount + FROM worker_users wu + LEFT JOIN worker_levels wl ON wl.id = wu.level_id + LEFT JOIN worker_users inv ON inv.id = wu.inviter_id + LEFT JOIN worker_wallets ww ON ww.worker_id = wu.id +` + +const WORKER_FINANCE_REQUEST_SELECT = ` + SELECT + wfr.*, + wu.username AS worker_username, + wu.display_name AS worker_display_name, + wu.phone AS worker_phone + FROM worker_finance_requests wfr + LEFT JOIN worker_users wu ON wu.id = wfr.worker_id +` + +export async function getWorkerLevelByKey(levelKey: string): Promise { + const result = await query( + 'SELECT * FROM worker_levels WHERE level_key = $1 LIMIT 1', + [String(levelKey || '').trim()], + ) + return result.rows[0] || null +} + +export async function getWorkerLevelById(levelId: number | string): Promise { + const result = await query('SELECT * FROM worker_levels WHERE id = $1 LIMIT 1', [ + Number(levelId), + ]) + return result.rows[0] || null +} + +export async function listWorkerLevels(): Promise { + const result = await query( + 'SELECT * FROM worker_levels ORDER BY sort_order ASC, id ASC', + ) + return result.rows +} + +export async function upsertWorkerLevel(input: { + levelKey: string + name: string + sortOrder: number + status: string + permissionJson: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO worker_levels ( + level_key, name, sort_order, status, permission_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7) + ON CONFLICT (level_key) DO UPDATE + SET + name = EXCLUDED.name, + sort_order = EXCLUDED.sort_order, + status = EXCLUDED.status, + permission_json = EXCLUDED.permission_json, + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + input.levelKey, + input.name, + input.sortOrder, + input.status, + input.permissionJson, + input.now, + input.now, + ], + ) + return result.rows[0] || null +} + +export async function countWorkerLevelUsages(levelId: number | string): Promise { + const result = await query<{ total: number }>( + 'SELECT COUNT(*)::int AS total FROM worker_users WHERE level_id = $1', + [Number(levelId)], + ) + return Number(result.rows[0]?.total || 0) +} + +export async function deleteWorkerLevel(levelId: number | string): Promise<{ deleted: boolean }> { + const result = await query<{ id: number }>( + 'DELETE FROM worker_levels WHERE id = $1 RETURNING id', + [Number(levelId)], + ) + return { deleted: Boolean(result.rows[0]) } +} + +export async function getWorkerUserByUsername(username: string): Promise { + const result = await query( + `${WORKER_USER_SELECT} WHERE wu.username = $1 LIMIT 1`, + [ + String(username || '') + .trim() + .toLowerCase(), + ], + ) + return result.rows[0] || null +} + +export async function getWorkerUserByInviteCode( + inviteCode: string, +): Promise { + const result = await query( + `${WORKER_USER_SELECT} WHERE wu.invite_code = $1 LIMIT 1`, + [String(inviteCode || '').trim().toUpperCase()], + ) + return result.rows[0] || null +} + +export async function getWorkerUserById(workerId: number | string): Promise { + const result = await query(`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, [ + Number(workerId), + ]) + return result.rows[0] || null +} + +export async function createWorkerUser(input: CreateWorkerInput): Promise { + return withTransaction(async (client) => { + const result = await client.query<{ id: number }>( + ` + INSERT INTO worker_users ( + username, password_hash, display_name, phone, level_id, status, + inviter_id, invite_code, + created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + `, + [ + input.username, + input.passwordHash, + input.displayName, + input.phone, + input.levelId, + input.status, + input.inviterId || null, + input.inviteCode, + input.now, + input.now, + ], + ) + const workerId = Number(result.rows[0]?.id || 0) + await ensureWorkerWalletWithClient(client, workerId, input.now) + return getWorkerUserByIdWithClient(client, workerId) + }) +} + +export async function updateWorkerUser( + workerId: number | string, + patch: Partial< + Pick< + WorkerUserRow, + 'status' | 'review_note' | 'level_id' | 'session_version' | 'updated_at' | 'reviewed_at' + > + >, +): Promise { + const current = await getWorkerUserById(workerId) + if (!current) return null + const next = { ...current, ...patch } + + await query( + ` + UPDATE worker_users + SET + status = $1, + review_note = $2, + level_id = $3, + session_version = $4, + reviewed_at = $5, + updated_at = $6 + WHERE id = $7 + `, + [ + next.status, + next.review_note || '', + next.level_id || null, + Number(next.session_version || 1), + next.reviewed_at || null, + next.updated_at, + Number(workerId), + ], + ) + return getWorkerUserById(workerId) +} + +export async function updateWorkerPassword(input: { + workerId: number + passwordHash: string + sessionVersion: number + now: string +}): Promise { + const result = await query<{ id: number }>( + ` + UPDATE worker_users + SET + password_hash = $1, + session_version = $2, + updated_at = $3 + WHERE id = $4 + RETURNING id + `, + [input.passwordHash, input.sessionVersion, input.now, input.workerId], + ) + if (!result.rows[0]) { + return null + } + return getWorkerUserById(input.workerId) +} + +export async function incrementWorkerSessionVersion( + workerId: number | string, + now: string, +): Promise { + const result = await query<{ id: number }>( + ` + UPDATE worker_users + SET session_version = session_version + 1, updated_at = $2 + WHERE id = $1 + RETURNING id + `, + [Number(workerId), now], + ) + if (!result.rows[0]) { + return null + } + return getWorkerUserById(workerId) +} + +export async function listWorkerUsers({ + page = 1, + pageSize = 20, + status = '', + keyword = '', +}: ListInput = {}): Promise<{ items: WorkerUserRow[]; total: number }> { + const { whereClause, params } = buildWorkerUserWhere({ status, keyword }) + const totalResult = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM worker_users wu ${whereClause}`, + params, + ) + + const offset = (page - 1) * pageSize + params.push(pageSize, offset) + const itemsResult = await query( + `${WORKER_USER_SELECT} + ${whereClause} + ORDER BY wu.id DESC + LIMIT $${params.length - 1} OFFSET $${params.length}`, + params, + ) + return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) } +} + +export async function ensureWorkerWallet( + workerId: number | string, + now: string, +): Promise { + return withTransaction(async (client) => { + await ensureWorkerWalletWithClient(client, Number(workerId), now) + return getWorkerWalletWithClient(client, Number(workerId)) + }) +} + +export async function getWorkerWallet(workerId: number | string): Promise { + const result = await query( + 'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1', + [Number(workerId)], + ) + return result.rows[0] || null +} + +export async function listWorkerWalletLedgers({ + page = 1, + pageSize = 20, + workerId = 0, + ledgerType = '', +}: WalletLedgerListInput = {}): Promise<{ items: WorkerWalletLedgerRow[]; total: number }> { + const { whereClause, params } = buildWorkerWalletLedgerWhere({ + workerId, + ledgerType, + }) + const totalResult = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM worker_wallet_ledgers wwl ${whereClause}`, + params, + ) + const offset = (page - 1) * pageSize + params.push(pageSize, offset) + const itemsResult = await query( + ` + SELECT * + FROM worker_wallet_ledgers wwl + ${whereClause} + ORDER BY wwl.created_at DESC, wwl.id DESC + LIMIT $${params.length - 1} OFFSET $${params.length} + `, + params, + ) + return { + items: itemsResult.rows, + total: Number(totalResult.rows[0]?.total || 0), + } +} + +export async function addWorkerWalletCredit(input: { + workerId: number + amount: number + note: string + payloadJson: string + now: string +}): Promise { + return withTransaction(async (client) => { + await ensureWorkerWalletWithClient(client, input.workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, input.workerId) + const nextAvailable = Number(wallet?.available_amount || 0) + input.amount + const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + await client.query( + ` + UPDATE worker_wallets + SET + available_amount = $1, + total_credited_amount = total_credited_amount + $2, + updated_at = $3 + WHERE worker_id = $4 + `, + [nextAvailable, input.amount, input.now, input.workerId], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + audit_status, note, payload_json, created_at + ) VALUES ($1, 'manual_credit', $2, $3, $4, 'approved', $5, $6::jsonb, $7) + `, + [ + input.workerId, + input.amount, + nextAvailable, + nextFrozen, + input.note, + input.payloadJson, + input.now, + ], + ) + return getWorkerWalletWithClient(client, input.workerId) + }) +} + +export async function createWorkerFinanceRequest(input: { + workerId: number + requestType: string + amount: number + accountChannel: string + accountName: string + accountNo: string + note: string + payloadJson: string + now: string +}): Promise { + const result = await query( + ` + INSERT INTO worker_finance_requests ( + worker_id, request_type, status, amount, + account_channel, account_name, account_no, + note, reviewed_note, payload_json, created_at, updated_at + ) VALUES ($1, $2, 'pending', $3, $4, $5, $6, $7, '', $8::jsonb, $9, $10) + RETURNING * + `, + [ + input.workerId, + input.requestType, + input.amount, + input.accountChannel, + input.accountName, + input.accountNo, + input.note, + input.payloadJson, + input.now, + input.now, + ], + ) + return result.rows[0] || null +} + +export async function getWorkerFinanceRequestById( + requestId: number | string, +): Promise { + const result = await query( + `${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`, + [Number(requestId)], + ) + return result.rows[0] || null +} + +export async function listWorkerFinanceRequests({ + page = 1, + pageSize = 20, + workerId = 0, + status = '', + requestType = '', + keyword = '', +}: FinanceRequestListInput = {}): Promise<{ items: WorkerFinanceRequestRow[]; total: number }> { + const { whereClause, params } = buildWorkerFinanceRequestWhere({ + workerId, + status, + requestType, + keyword, + }) + const totalResult = await query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM worker_finance_requests wfr + LEFT JOIN worker_users wu ON wu.id = wfr.worker_id + ${whereClause}`, + params, + ) + const offset = (page - 1) * pageSize + params.push(pageSize, offset) + const itemsResult = await query( + `${WORKER_FINANCE_REQUEST_SELECT} + ${whereClause} + ORDER BY wfr.created_at DESC, wfr.id DESC + LIMIT $${params.length - 1} OFFSET $${params.length}`, + params, + ) + return { + items: itemsResult.rows, + total: Number(totalResult.rows[0]?.total || 0), + } +} + +export async function reviewWorkerFinanceRequest(input: { + requestId: number + status: string + reviewedNote: string + now: string +}): Promise<{ + request: WorkerFinanceRequestRow | null + failureReason: 'request_not_pending' | 'withdraw_insufficient' | null +}> { + return withTransaction(async (client) => { + const currentResult = await client.query( + ` + SELECT * + FROM worker_finance_requests + WHERE id = $1 + LIMIT 1 + FOR UPDATE + `, + [input.requestId], + ) + const current = currentResult.rows[0] || null + if (!current) { + return { + request: null, + failureReason: null, + } + } + + if (current.status !== 'pending') { + return { + request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId), + failureReason: 'request_not_pending', + } + } + + if (input.status === 'approved') { + await ensureWorkerWalletWithClient(client, Number(current.worker_id), input.now) + const wallet = await getWorkerWalletWithClient(client, Number(current.worker_id)) + const availableAmount = Number(wallet?.available_amount || 0) + const frozenAmount = Number(wallet?.frozen_deposit_amount || 0) + const amount = Number(current.amount || 0) + const note = + input.reviewedNote || + (current.request_type === 'withdraw' ? '提现申请审核通过' : '充值申请审核通过') + + if (current.request_type === 'withdraw') { + if (availableAmount < amount) { + return { + request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId), + failureReason: 'withdraw_insufficient', + } + } + + const nextAvailable = availableAmount - amount + await client.query( + ` + UPDATE worker_wallets + SET available_amount = $1, updated_at = $2 + WHERE worker_id = $3 + `, + [nextAvailable, input.now, Number(current.worker_id)], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + audit_status, note, payload_json, created_at + ) VALUES ($1, 'withdraw_paid', $2, $3, $4, 'approved', $5, $6::jsonb, $7) + `, + [ + Number(current.worker_id), + -amount, + nextAvailable, + frozenAmount, + note, + JSON.stringify({ + requestId: Number(current.id), + requestType: current.request_type, + source: 'admin_finance_review', + }), + input.now, + ], + ) + } else { + const nextAvailable = availableAmount + amount + await client.query( + ` + UPDATE worker_wallets + SET + available_amount = $1, + total_credited_amount = total_credited_amount + $2, + updated_at = $3 + WHERE worker_id = $4 + `, + [nextAvailable, amount, input.now, Number(current.worker_id)], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + audit_status, note, payload_json, created_at + ) VALUES ($1, 'manual_credit', $2, $3, $4, 'approved', $5, $6::jsonb, $7) + `, + [ + Number(current.worker_id), + amount, + nextAvailable, + frozenAmount, + note, + JSON.stringify({ + requestId: Number(current.id), + requestType: current.request_type, + source: 'admin_finance_review', + }), + input.now, + ], + ) + } + } + + await client.query( + ` + UPDATE worker_finance_requests + SET + status = $1, + reviewed_note = $2, + reviewed_at = $3, + updated_at = $3 + WHERE id = $4 + `, + [input.status, input.reviewedNote, input.now, input.requestId], + ) + + return { + request: await getWorkerFinanceRequestByIdWithClient(client, input.requestId), + failureReason: null, + } + }) +} + +export async function getWorkerFinanceRequestSummary(workerId: number | string) { + const result = await query<{ + pending_withdraw_amount: number + approved_withdraw_amount: number + pending_recharge_amount: number + }>( + ` + SELECT + COALESCE( + SUM(CASE WHEN request_type = 'withdraw' AND status = 'pending' THEN amount ELSE 0 END), + 0 + )::int AS pending_withdraw_amount, + COALESCE( + SUM(CASE WHEN request_type = 'withdraw' AND status = 'approved' THEN amount ELSE 0 END), + 0 + )::int AS approved_withdraw_amount, + COALESCE( + SUM(CASE WHEN request_type = 'recharge' AND status = 'pending' THEN amount ELSE 0 END), + 0 + )::int AS pending_recharge_amount + FROM worker_finance_requests + WHERE worker_id = $1 + `, + [Number(workerId)], + ) + return { + pendingWithdrawAmount: Number(result.rows[0]?.pending_withdraw_amount || 0), + approvedWithdrawAmount: Number(result.rows[0]?.approved_withdraw_amount || 0), + pendingRechargeAmount: Number(result.rows[0]?.pending_recharge_amount || 0), + } +} + +export async function countWorkerAcceptedOrders(workerId: number | string): Promise { + const result = await query<{ total: number }>( + ` + SELECT + (SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted') + + + COALESCE( + (SELECT SUM(quantity)::int FROM work_order_shares WHERE worker_id = $1 AND status = 'accepted'), + 0 + ) AS total + `, + [Number(workerId)], + ) + return Number(result.rows[0]?.total || 0) +} + +export async function maybeUpgradeWorkerLevelWithClient( + client: PoolClient, + workerId: number, + now: string, +): Promise { + const acceptedResult = await client.query<{ total: number }>( + ` + SELECT + (SELECT COUNT(*)::int FROM work_orders WHERE assigned_worker_id = $1 AND status = 'accepted') + + + COALESCE( + (SELECT SUM(quantity)::int FROM work_order_shares WHERE worker_id = $1 AND status = 'accepted'), + 0 + ) AS total + `, + [workerId], + ) + const acceptedCount = Number(acceptedResult.rows[0]?.total || 0) + + const nextLevelResult = await client.query<{ id: number }>( + ` + SELECT id + FROM worker_levels + WHERE status = 'active' + AND COALESCE((permission_json->>'upgradeThreshold')::int, 0) <= $1 + ORDER BY COALESCE((permission_json->>'upgradeThreshold')::int, 0) DESC, sort_order ASC + LIMIT 1 + `, + [acceptedCount], + ) + const nextLevelId = Number(nextLevelResult.rows[0]?.id || 0) + if (!nextLevelId) return null + + const current = await client.query<{ id: number; level_id: number | null }>( + 'SELECT id, level_id FROM worker_users WHERE id = $1 LIMIT 1', + [workerId], + ) + const currentRow = current.rows[0] + if (!currentRow || Number(currentRow.level_id || 0) === nextLevelId) return null + + await client.query( + 'UPDATE worker_users SET level_id = $1, updated_at = $2 WHERE id = $3', + [nextLevelId, now, 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, 'auto', $4::jsonb, $5) + `, + [ + workerId, + currentRow.level_id || null, + nextLevelId, + JSON.stringify({ acceptedCount }), + now, + ], + ) + return getWorkerUserByIdWithClient(client, workerId) +} + +async function getWorkerUserByIdWithClient( + client: PoolClient, + workerId: number | string, +): Promise { + const result = await client.query( + `${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, + [Number(workerId)], + ) + return result.rows[0] || null +} + +async function getWorkerFinanceRequestByIdWithClient( + client: PoolClient, + requestId: number | string, +): Promise { + const result = await client.query( + `${WORKER_FINANCE_REQUEST_SELECT} WHERE wfr.id = $1 LIMIT 1`, + [Number(requestId)], + ) + return result.rows[0] || null +} + +function buildWorkerUserWhere({ + status = '', + keyword = '', +}: Pick) { + const filters: string[] = [] + const params: unknown[] = [] + if (status) { + params.push(status) + filters.push(`wu.status = $${params.length}`) + } + if (keyword) { + params.push(`%${keyword}%`) + filters.push( + `(wu.username ILIKE $${params.length} OR wu.display_name ILIKE $${params.length} OR wu.phone ILIKE $${params.length})`, + ) + } + return { + whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', + params, + } +} + +function buildWorkerWalletLedgerWhere({ + workerId = 0, + ledgerType = '', +}: WalletLedgerListInput) { + const filters: string[] = [] + const params: unknown[] = [] + if (workerId) { + params.push(workerId) + filters.push(`wwl.worker_id = $${params.length}`) + } + if (ledgerType) { + params.push(ledgerType) + filters.push(`wwl.ledger_type = $${params.length}`) + } + return { + whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', + params, + } +} + +function buildWorkerFinanceRequestWhere({ + workerId = 0, + status = '', + requestType = '', + keyword = '', +}: FinanceRequestListInput) { + const filters: string[] = [] + const params: unknown[] = [] + if (workerId) { + params.push(workerId) + filters.push(`wfr.worker_id = $${params.length}`) + } + if (status) { + params.push(status) + filters.push(`wfr.status = $${params.length}`) + } + if (requestType) { + params.push(requestType) + filters.push(`wfr.request_type = $${params.length}`) + } + if (keyword) { + params.push(`%${keyword}%`) + filters.push( + `(wu.username ILIKE $${params.length} + OR wu.display_name ILIKE $${params.length} + OR wu.phone ILIKE $${params.length} + OR wfr.account_name ILIKE $${params.length} + OR wfr.account_no ILIKE $${params.length})`, + ) + } + return { + whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', + params, + } +} diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 8fd50aab..e4f2859f 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -28,7 +28,7 @@ import { syncAdminWorkerOrdersFromSource, unpublishAdminWorkOrder, updateAdminWorkOrderSharing, -} from '../../services/worker-platform/worker-platform-service.js' +} from '../../services/worker-platform/index.js' import { createJsonHandler, requireAdminRoles } from './session.js' const router = Router() diff --git a/apps/backend/src/routes/collect.ts b/apps/backend/src/routes/collect.ts index 7c1243e5..578c9ad5 100644 --- a/apps/backend/src/routes/collect.ts +++ b/apps/backend/src/routes/collect.ts @@ -4,7 +4,7 @@ import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middlewa import { collectLookupWorkOrder, collectSubmitWorkOrder, -} from '../services/worker-platform/worker-platform-service.js' +} from '../services/worker-platform/index.js' import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' const router = Router() diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index f0a7bfd1..eaa27c3a 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -20,7 +20,7 @@ import { logoutWorkerSession, registerWorker, submitWorkerOrderAcceptance, -} from '../services/worker-platform/worker-platform-service.js' +} from '../services/worker-platform/index.js' import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js' import { extractOptionalWorkerBearerToken, diff --git a/apps/backend/src/routes/worker/session.ts b/apps/backend/src/routes/worker/session.ts index 7273286c..5439a064 100644 --- a/apps/backend/src/routes/worker/session.ts +++ b/apps/backend/src/routes/worker/session.ts @@ -4,7 +4,7 @@ import { requireActiveWorkerSession, verifyWorkerSessionToken, type WorkerSession, -} from '../../services/worker-platform/worker-platform-service.js' +} from '../../services/worker-platform/index.js' import { createHttpError, sendRouteError } from '../../utils/http.js' export async function requireWorkerSession(req: Request, res: Response, next: NextFunction): Promise { diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index 4ec6ce76..8e361684 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -10,7 +10,7 @@ import { resolveOrderFulfillmentReadiness, syncOrderFulfillmentAttachments, } from '../fulfillment/order-fulfillment-readiness-service.js' -import { syncWorkerOrdersForSourceOrder } from '../worker-platform/worker-platform-service.js' +import { syncWorkerOrdersForSourceOrder } from '../worker-platform/index.js' import { nowIso } from '../../utils/time.js' import { logIntegration } from '../../utils/logger.js' import { createHttpError } from '../../utils/http.js' diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index 7fc45335..f691a2fb 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -11,7 +11,7 @@ import { } from '../../../repositories/order-item-repo.js' import { listTasksByOrderId } from '../../../repositories/task-repo.js' import { upsertOrderFromSource } from '../../order/order-service.js' -import { syncWorkerOrdersForSourceOrder } from '../../worker-platform/worker-platform-service.js' +import { syncWorkerOrdersForSourceOrder } from '../../worker-platform/index.js' import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js' import { asJsonObject, type JsonObject } from '../../../types/json.js' import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js' diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts new file mode 100644 index 00000000..55708b81 --- /dev/null +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -0,0 +1,955 @@ +import crypto from 'node:crypto' + +import { runtimeConfig } from '../../config/runtime.js' +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' +import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js' +import { + acceptWorkOrderAndSettle, + addWorkerWalletCredit, + countWorkerAcceptedOrders, + countWorkerActiveOrders, + countWorkCategoryUsages, + countWorkerLevelUsages, + countWorkOrderPendingSharingSubmissions, + cancelWorkerWorkOrder, + countWorkerCancellationsSince, + createWorkOrder, + createWorkOrderEvent, + createWorkerFinanceRequest, + createWorkerUser, + deleteWorkCategory, + deleteWorkerLevel, + getWorkerFinanceRequestById, + getWorkerFinanceRequestSummary, + getWorkCategoryById, + getWorkCategoryByKey, + getWorkerLevelById, + getWorkerLevelByKey, + getWorkerUserByInviteCode, + getWorkerUserById, + getWorkerUserByUsername, + getWorkOrderById, + getWorkOrderByOrderItemId, + getWorkOrderShare, + grabWorkOrder, + incrementWorkerSessionVersion, + joinWorkOrderShare, + listAllWorkCategories, + listPendingMaterialWorkOrdersByPlatformOrderId, + listWorkerFinanceRequests, + listWorkerSharesByWorker, + listWorkerWalletLedgers, + listWorkCategories, + listWorkOrders, + listWorkProductRules, + listWorkerLevels, + listWorkerUsers, + listWorkOrderShares, + listWorkOrderSharesByOrderIds, + resolveProblemWorkOrder, + reviewWorkerFinanceRequest, + submitWorkOrderShareAcceptance, + updateWorkOrder, + updateWorkerPassword, + updateWorkerUser, + upsertWorkCategory, + upsertWorkProductRule, + upsertWorkerLevel, + type WorkCategoryRow, + type WorkerFinanceRequestRow, + type WorkOrderRow, + type WorkProductRuleRow, + type WorkerLevelRow, + type WorkOrderShareRow, + type WorkerWalletLedgerRow, + type WorkerUserRow, + type GrabWorkOrderFailureReason, + type ProblemWorkOrderResolutionAction, +} from '../../repositories/worker-platform/index.js' +import type { JsonObject } from '../../types/json.js' +import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' +import { createHttpError } from '../../utils/http.js' +import { randomId } from '../../utils/random.js' +import { nowIso, addHours } from '../../utils/time.js' +import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' +import { + normalizeStoredFileUrl, + refreshStoredFileUrl, + refreshUploadedFileUrls, +} from '../file-storage/file-storage-service.js' +import { + getWorkerFinanceConfig, + saveWorkerFinanceConfig, +} from './worker-finance-config-service.js' + +import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, resolveMatchingProductRule, resolveRequirementFields } from './mappers.js' +import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker } 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 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), + } + const level = await upsertWorkerLevel({ + levelKey, + name, + sortOrder: normalizeInteger(payload.sortOrder, 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 enabledValue = String(query.enabled ?? '').trim() + const enabled = enabledValue + ? ['true', '1', 'enabled', 'active'].includes(enabledValue.toLowerCase()) + : null + const items = await listWorkProductRules({ + enabled, + keyword: String(query.keyword || '').trim(), + }) + return { items: items.map(mapWorkProductRule) } +} + +export async function saveAdminWorkProductRule(payload: JsonObject = {}) { + const defaults = await ensureWorkerPlatformDefaults() + const productName = String( + payload.productName || payload.product_name || payload.skuName || '', + ).trim() + const skuCode = String(payload.skuCode || payload.sku_code || '').trim() + if (!productName && !skuCode) { + throw createHttpError('请填写商品名或 SKU', { + statusCode: 400, + errorCode: 'work_product_rule_target_required', + }) + } + + const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0) + const sharingEnabled = normalizeBoolean( + payload.sharingEnabled ?? payload.sharing_enabled, + false, + ) + 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) { + if (resolvedSharingTotalAmount <= 0) { + throw createHttpError('启用拼单时请填写拼单总价或单价', { + statusCode: 400, + errorCode: 'work_product_rule_sharing_amount_invalid', + }) + } + } else if (rewardAmount <= 0) { + throw createHttpError('接单金额必须大于 0', { + statusCode: 400, + errorCode: 'work_product_rule_reward_invalid', + }) + } + const finalRewardAmount = sharingEnabled ? resolvedSharingTotalAmount : rewardAmount + + const depositThresholdAmount = normalizeAmountFen( + payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan, + DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + ) + const requiredDepositAmount = + payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined + ? finalRewardAmount > depositThresholdAmount + ? finalRewardAmount + : 0 + : normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0) + const fields = normalizeRequirementFieldsFromPayload(payload) + const rule = await upsertWorkProductRule({ + ruleKey: normalizeSlugKey( + payload.ruleKey || payload.rule_key || skuCode || productName, + `rule-${Date.now()}`, + ), + provider: String(payload.provider || '').trim(), + platform: String(payload.platform || '').trim(), + shopId: String(payload.shopId || payload.shop_id || '').trim(), + skuCode, + productName, + matchType: normalizeMatchType(payload.matchType || payload.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, false), + rewardAmount: finalRewardAmount, + requiredDepositAmount, + depositThresholdAmount, + sharingEnabled, + sharingTotalQuantity, + sharingUnitReward, + requirementJson: JSON.stringify({ fields }), + sortOrder: normalizeInteger(payload.sortOrder, 100), + now: nowIso(), + }) + return { rule: mapWorkProductRule(rule) } +} + +export async function listAdminWorkerUsers(query: JsonObject = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = await listWorkerUsers({ + page, + pageSize, + status: String(query.status || '').trim(), + keyword: String(query.keyword || '').trim(), + }) + return { + items: items.map(mapWorkerUser), + pagination: { page, pageSize, total }, + } +} + +export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) { + const worker = await getRequiredWorker(workerId) + const status = normalizeReviewStatus(payload.status) + const requestedLevelId = normalizeOptionalId(payload.levelId) + let levelId = worker.level_id || null + if (requestedLevelId) { + const level = await getWorkerLevelById(requestedLevelId) + if (!level) { + throw createHttpError('接单等级不存在', { + statusCode: 404, + errorCode: 'worker_level_not_found', + }) + } + levelId = Number(level.id) + } + if (!levelId) { + levelId = (await ensureWorkerPlatformDefaults()).level?.id || null + } + const now = nowIso() + const updated = await updateWorkerUser(worker.id, { + status, + level_id: levelId, + review_note: String(payload.reviewNote || payload.note || '').trim(), + session_version: normalizeSessionVersion(worker.session_version) + 1, + reviewed_at: now, + updated_at: now, + }) + return { worker: mapWorkerUser(updated || worker) } +} + +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(), + }) + return { wallet: mapWallet(wallet) } +} + +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 status = normalizeFinanceRequestStatus(query.status) + const requestType = normalizeFinanceRequestType(query.requestType) + const keyword = String(query.keyword || '').trim() + const { items, total } = await listWorkerFinanceRequests({ + page, + pageSize, + 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', + }) + } + + return { + request: mapFinanceRequest(reviewed.request), + } +} + +export async function listAdminWorkOrders(query: JsonObject = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = await listWorkOrders({ + page, + pageSize, + status: String(query.status || '').trim(), + keyword: String(query.keyword || '').trim(), + }) + const shares = await listWorkOrderSharesByOrderIds(items.map((item) => Number(item.id))) + const sharesByOrderId = new Map() + for (const share of shares) { + const orderId = Number(share.work_order_id) + const bucket = sharesByOrderId.get(orderId) || [] + bucket.push(share) + sharesByOrderId.set(orderId, bucket) + } + return { + items: items.map((item) => { + const orderShares = sharesByOrderId.get(Number(item.id)) || [] + const joinedQuantity = orderShares + .filter((share) => share.status !== 'cancelled') + .reduce((sum, share) => sum + Number(share.quantity || 0), 0) + const pendingSubmissionCount = orderShares.filter( + (share) => share.status === 'joined', + ).length + return { + ...mapWorkOrderAdmin(item), + sharingProgress: { + joinedQuantity, + pendingSubmissionCount, + }, + } + }), + pagination: { page, pageSize, total }, + } +} + +export async function getAdminWorkOrderSharing(workOrderId: number | string) { + const workOrder = await getRequiredWorkOrder(workOrderId) + const shares = await listWorkOrderShares(workOrder.id) + return { + order: mapWorkOrderAdmin(workOrder), + shares: shares.map(mapWorkOrderShare), + } +} + +export async function updateAdminWorkOrderSharing( + workOrderId: number | string, + payload: JsonObject = {}, +) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN].includes(workOrder.status as never)) { + throw createHttpError('只有待完善、未分配或待抢单的工单可以配置拼单', { + statusCode: 409, + errorCode: 'work_order_sharing_config_status_invalid', + }) + } + const enabled = normalizeBoolean( + payload.enabled ?? payload.sharingEnabled ?? payload.sharing_enabled, + false, + ) + const unitReward = normalizeAmountFen( + payload.unitReward ?? payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, + 0, + ) + const totalAmount = normalizeAmountFen( + payload.totalAmount ?? payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan, + 0, + ) + const totalQuantity = resolveSharingQuantity( + totalAmount, + normalizePositiveInteger( + payload.totalQuantity ?? payload.sharingTotalQuantity ?? payload.sharing_total_quantity, + 1, + ), + unitReward, + ) + const resolvedTotalAmount = totalAmount > 0 ? totalAmount : unitReward * totalQuantity + const now = nowIso() + const updated = await updateWorkOrder(workOrder.id, { + sharing_enabled: enabled, + sharing_total_quantity: totalQuantity, + sharing_unit_reward: unitReward, + reward_amount: enabled ? resolvedTotalAmount : workOrder.reward_amount, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: 'sharing_config_updated', + fromStatus: workOrder.status, + toStatus: updated?.status || workOrder.status, + payloadJson: JSON.stringify({ + enabled, + totalQuantity, + unitReward, + totalAmount: resolvedTotalAmount, + }), + now, + }) + 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) +} + +export async function submitAdminWorkOrderMaterial( + workOrderId: number | string, + payload: JsonObject = {}, +) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if ( + ![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED].includes( + workOrder.status as never, + ) + ) { + throw createHttpError('当前状态不能补充资料', { + statusCode: 409, + errorCode: 'work_order_material_status_invalid', + }) + } + + const fields = resolveRequirementFields(workOrder) + const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload) + const complete = fields.every( + (field) => !field.required || String(submittedFields[field.key] || '').trim(), + ) + const now = nowIso() + const material = { + ...safeParseJson(workOrder.material_json), + collect: { + fields: submittedFields, + submittedAt: now, + complete, + source: 'admin', + }, + } + const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL + const updated = await updateWorkOrder(workOrder.id, { + status: nextStatus, + material_json: material, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: 'material_completed', + fromStatus: workOrder.status, + toStatus: nextStatus, + payloadJson: JSON.stringify({ fields: submittedFields, complete }), + now, + }) + return { order: mapWorkOrderAdmin(updated || workOrder), complete } +} + +export async function syncAdminWorkerOrdersFromSource(payload: JsonObject = {}) { + const order = await resolveSourceOrder(payload) + const orderItems = await listOrderItemsByOrderId(order.id) + const result = await syncWorkerOrdersForSourceOrder(order, orderItems, { + source: 'admin_manual_sync', + autoOnly: false, + }) + return result +} + +export async function createAdminMockWorkOrder(payload: JsonObject = {}) { + const defaults = await ensureWorkerPlatformDefaults() + const now = nowIso() + const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 2500) + const depositThresholdAmount = normalizeAmountFen( + payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan, + DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + ) + const requiredDepositAmount = + payload.requiredDepositAmount === undefined + ? rewardAmount > depositThresholdAmount + ? rewardAmount + : 0 + : normalizeAmountFen(payload.requiredDepositAmount, 0) + const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true' + const fields = normalizeRequirementFields(payload.fields) + const platformOrderId = String( + payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`, + ).trim() + const productName = String(payload.productName || '指挥官秘钥1个').trim() + const material = materialComplete + ? { + collect: { + complete: true, + submittedAt: now, + fields: fields.reduce>((result, field) => { + result[field.key] = String(field.mockValue || `${field.label}测试值`) + return result + }, {}), + }, + } + : {} + const workOrder = await createWorkOrder({ + workOrderNo: randomId('WO'), + platformOrderId, + productName, + categoryId: defaults.category?.id || null, + status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL, + rewardAmount, + requiredDepositAmount, + depositThresholdAmount, + materialJson: JSON.stringify(material), + requirementJson: JSON.stringify({ fields }), + now, + }) + if (!workOrder) { + throw createHttpError('接单工单创建失败', { + statusCode: 500, + errorCode: 'work_order_create_failed', + }) + } + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: 'dev_mock', + eventType: 'mock_created', + fromStatus: '', + toStatus: workOrder.status, + payloadJson: JSON.stringify({ platformOrderId, productName, rewardAmount }), + now, + }) + return { order: mapWorkOrderAdmin(workOrder) } +} + +export async function publishAdminWorkOrder(workOrderId: number | string) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) { + throw createHttpError('只有未分配订单可以发布到大厅', { + statusCode: 409, + errorCode: 'work_order_publish_status_invalid', + }) + } + const now = nowIso() + const updated = await updateWorkOrder(workOrder.id, { + status: WORK_ORDER_STATUS.OPEN, + published_at: now, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: 'published', + fromStatus: workOrder.status, + toStatus: WORK_ORDER_STATUS.OPEN, + now, + }) + return { order: mapWorkOrderAdmin(updated || workOrder) } +} + +export async function unpublishAdminWorkOrder(workOrderId: number | string) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) { + throw createHttpError('只有待抢单且未被接走的订单可以下架', { + statusCode: 409, + errorCode: 'work_order_unpublish_status_invalid', + }) + } + const now = nowIso() + const updated = await updateWorkOrder(workOrder.id, { + status: WORK_ORDER_STATUS.UNASSIGNED, + published_at: null, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: 'unpublished', + fromStatus: workOrder.status, + toStatus: WORK_ORDER_STATUS.UNASSIGNED, + now, + }) + return { order: mapWorkOrderAdmin(updated || workOrder) } +} + +export async function markAdminWorkOrderProblem( + workOrderId: number | string, + payload: JsonObject = {}, +) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if ( + ![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes( + workOrder.status as never, + ) + ) { + throw createHttpError('当前状态不能标记问题单', { + statusCode: 409, + errorCode: 'work_order_problem_status_invalid', + }) + } + const now = nowIso() + const note = String(payload.note || payload.problemNote || '').trim() + const updated = await updateWorkOrder(workOrder.id, { + status: WORK_ORDER_STATUS.PROBLEM, + problem_note: note, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: 'marked_problem', + fromStatus: workOrder.status, + toStatus: WORK_ORDER_STATUS.PROBLEM, + payloadJson: JSON.stringify({ note }), + now, + }) + return { order: mapWorkOrderAdmin(updated || workOrder) } +} + +export async function resolveAdminProblemWorkOrder( + workOrderId: number | string, + payload: JsonObject = {}, +) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (workOrder.status !== WORK_ORDER_STATUS.PROBLEM) { + throw createHttpError('只有问题单可以执行处置', { + statusCode: 409, + errorCode: 'work_order_problem_resolution_status_invalid', + }) + } + + const action = normalizeProblemResolutionAction(payload.action) + const note = String(payload.note || '').trim() + if (!note) { + throw createHttpError('请填写处置备注', { + statusCode: 400, + errorCode: 'work_order_problem_resolution_note_required', + }) + } + + const updated = await resolveProblemWorkOrder({ + workOrderId: workOrder.id, + action, + note, + now: nowIso(), + }) + if (!updated) { + throw createHttpError('问题单处置失败,订单状态可能已变化', { + statusCode: 409, + errorCode: 'work_order_problem_resolution_conflict', + }) + } + return { order: mapWorkOrderAdmin(updated) } +} + +export async function acceptAdminWorkOrder(workOrderId: number | string) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) { + throw createHttpError('只有待验收订单可以通过验收', { + statusCode: 409, + errorCode: 'work_order_accept_status_invalid', + }) + } + const now = nowIso() + const { order: updated, failureReason } = await acceptWorkOrderAndSettle({ + workOrderId: workOrder.id, + now, + }) + if (failureReason === 'sharing_pending_submissions') { + throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', { + statusCode: 409, + errorCode: 'work_order_sharing_pending_submissions', + }) + } + if (!updated) { + throw createHttpError('验收失败,订单状态可能已变化', { + statusCode: 409, + errorCode: 'work_order_accept_conflict', + }) + } + return { order: mapWorkOrderAdmin(updated || workOrder) } +} + +export async function getAdminWorkerPlatformSummary() { + const [pendingWorkers, pendingMaterial, openOrders, inProgressOrders] = await Promise.all([ + listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }), + listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.PENDING_MATERIAL }), + listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.OPEN }), + listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.IN_PROGRESS }), + ]) + return { + pendingWorkerCount: pendingWorkers.total, + pendingMaterialCount: pendingMaterial.total, + openOrderCount: openOrders.total, + inProgressOrderCount: inProgressOrders.total, + } +} + +export async function syncWorkerOrdersForSourceOrder( + order: OrderRow, + orderItems: OrderItemRow[], + options: { source?: string; autoOnly?: boolean } = {}, +) { + const rules = await listWorkProductRules({ enabled: true }) + const created: WorkOrderRow[] = [] + const skipped: Array<{ orderItemId: number; reason: string }> = [] + + for (const item of orderItems) { + const rule = resolveMatchingProductRule(order, item, rules) + if (!rule) { + skipped.push({ orderItemId: Number(item.id), reason: 'rule_not_matched' }) + continue + } + if (options.autoOnly && !rule.auto_create) { + skipped.push({ orderItemId: Number(item.id), reason: 'auto_create_disabled' }) + continue + } + if (await getWorkOrderByOrderItemId(item.id)) { + skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' }) + continue + } + + const fields = normalizeRequirementFields(safeParseJson(rule.requirement_json).fields) + const rewardAmount = Number(rule.reward_amount || 0) + const depositThresholdAmount = Number( + rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + ) + const requiredDepositAmount = + Number(rule.required_deposit_amount || 0) || + (rewardAmount > depositThresholdAmount ? rewardAmount : 0) + const now = nowIso() + const materialComplete = fields.every((field) => !field.required) + const workOrder = await createWorkOrder({ + workOrderNo: randomId('WO'), + orderId: Number(order.id), + orderItemId: Number(item.id), + taskId: null, + platformOrderId: order.platform_order_id, + productName: item.sku_name || item.sku_code, + categoryId: rule.category_id, + status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL, + rewardAmount, + requiredDepositAmount, + depositThresholdAmount, + sharingEnabled: rule.sharing_enabled === true, + sharingTotalQuantity: Number(rule.sharing_total_quantity || 1), + sharingUnitReward: Number(rule.sharing_unit_reward || 0), + materialJson: JSON.stringify({ + source: { + orderId: Number(order.id), + orderItemId: Number(item.id), + provider: order.provider, + platform: order.platform, + shopId: order.shop_id, + skuCode: item.sku_code, + skuName: item.sku_name, + quantity: Number(item.quantity || 1), + syncSource: options.source || 'source_order', + }, + }), + requirementJson: JSON.stringify({ fields }), + now, + }) + + if (workOrder) { + created.push(workOrder) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'system', + actorId: options.source || 'source_order', + eventType: 'source_synced', + fromStatus: '', + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + ruleKey: rule.rule_key, + orderId: order.id, + orderItemId: item.id, + }), + now, + }) + } + } + + return { + created: created.map(mapWorkOrderAdmin), + skipped, + createdCount: created.length, + skippedCount: skipped.length, + } +} + +async function resolveSourceOrder(payload: JsonObject): Promise { + const orderId = normalizeOptionalId(payload.orderId || payload.sourceOrderId) + if (orderId) { + const order = await getOrderById(orderId) + if (order) { + return order + } + throw createHttpError('源订单不存在', { + statusCode: 404, + errorCode: 'source_order_not_found', + }) + } + + const platformOrderId = String(payload.platformOrderId || payload.orderNo || '').trim() + if (!platformOrderId) { + throw createHttpError('请填写源订单号', { + statusCode: 400, + errorCode: 'source_order_no_required', + }) + } + + const order = await findLatestOrderByAnyPlatformOrderId(platformOrderId) + if (!order) { + throw createHttpError('未找到源订单,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'source_order_not_found', + }) + } + return order +} diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts new file mode 100644 index 00000000..ab2019ed --- /dev/null +++ b/apps/backend/src/services/worker-platform/index.ts @@ -0,0 +1,3 @@ +export * from './mappers.js' +export * from './worker-service.js' +export * from './admin-service.js' diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts new file mode 100644 index 00000000..860783ee --- /dev/null +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -0,0 +1,870 @@ +import crypto from 'node:crypto' + +import { runtimeConfig } from '../../config/runtime.js' +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' +import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js' +import { + acceptWorkOrderAndSettle, + addWorkerWalletCredit, + countWorkerAcceptedOrders, + countWorkerActiveOrders, + countWorkCategoryUsages, + countWorkerLevelUsages, + countWorkOrderPendingSharingSubmissions, + cancelWorkerWorkOrder, + countWorkerCancellationsSince, + createWorkOrder, + createWorkOrderEvent, + createWorkerFinanceRequest, + createWorkerUser, + deleteWorkCategory, + deleteWorkerLevel, + getWorkerFinanceRequestById, + getWorkerFinanceRequestSummary, + getWorkCategoryById, + getWorkCategoryByKey, + getWorkerLevelById, + getWorkerLevelByKey, + getWorkerUserByInviteCode, + getWorkerUserById, + getWorkerUserByUsername, + getWorkOrderById, + getWorkOrderByOrderItemId, + getWorkOrderShare, + grabWorkOrder, + incrementWorkerSessionVersion, + joinWorkOrderShare, + listAllWorkCategories, + listPendingMaterialWorkOrdersByPlatformOrderId, + listWorkerFinanceRequests, + listWorkerSharesByWorker, + listWorkerWalletLedgers, + listWorkCategories, + listWorkOrders, + listWorkProductRules, + listWorkerLevels, + listWorkerUsers, + listWorkOrderShares, + listWorkOrderSharesByOrderIds, + resolveProblemWorkOrder, + reviewWorkerFinanceRequest, + submitWorkOrderShareAcceptance, + updateWorkOrder, + updateWorkerPassword, + updateWorkerUser, + upsertWorkCategory, + upsertWorkProductRule, + upsertWorkerLevel, + type WorkCategoryRow, + type WorkerFinanceRequestRow, + type WorkOrderRow, + type WorkProductRuleRow, + type WorkerLevelRow, + type WorkOrderShareRow, + type WorkerWalletLedgerRow, + type WorkerUserRow, + type GrabWorkOrderFailureReason, + type ProblemWorkOrderResolutionAction, +} from '../../repositories/worker-platform/index.js' +import type { JsonObject } from '../../types/json.js' +import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' +import { createHttpError } from '../../utils/http.js' +import { randomId } from '../../utils/random.js' +import { nowIso, addHours } from '../../utils/time.js' +import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' +import { + normalizeStoredFileUrl, + refreshStoredFileUrl, + refreshUploadedFileUrls, +} from '../file-storage/file-storage-service.js' +import { + getWorkerFinanceConfig, + saveWorkerFinanceConfig, +} from './worker-finance-config-service.js' + + + +export const DEFAULT_LEVEL_KEY = 'vip1' +export const DEFAULT_LEVEL_NAME = 'VIP1' +export const DEFAULT_CATEGORY_KEY = 'default' +export const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000 +export const INVITE_CODE_LENGTH = 8 +export const INVITE_CODE_RETRY_LIMIT = 5 +export const WORKER_CANCEL_LIMIT_PER_WINDOW = 5 +export const WORKER_CANCEL_LIMIT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000 +export function createWorkerSession(worker: WorkerUserRow) { + const issuedAt = nowIso() + const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin?.sessionTtlHours || 12)) + const payload = { + typ: 'worker', + sid: crypto.randomBytes(12).toString('hex'), + uid: Number(worker.id), + usr: worker.username, + status: worker.status, + ver: normalizeSessionVersion(worker.session_version), + iat: issuedAt, + exp: expiresAt, + } + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') + return { + token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`, + expiresAt, + worker: mapWorkerUser(worker), + } +} + +export function ensureWorkerAuthConfigured() { + if (String(runtimeConfig.admin?.sessionSecret || '').trim()) { + return + } + + throw createHttpError('接单登录态签名密钥未配置,请设置 ADMIN_SESSION_SECRET', { + statusCode: 503, + errorCode: 'worker_auth_not_configured', + }) +} + +export function hashWorkerPassword(password: string): string { + const salt = crypto.randomBytes(16).toString('hex') + const derived = crypto.scryptSync(password, salt, 64).toString('hex') + return `scrypt$${salt}$${derived}` +} + +export function verifyWorkerPassword(password: string, storedHash: string): boolean { + const [algorithm, salt, expectedHash] = String(storedHash || '').split('$') + if (algorithm !== 'scrypt' || !salt || !expectedHash) return false + return safeCompare(crypto.scryptSync(password, salt, 64).toString('hex'), expectedHash) +} + +export function signWorkerPayload(encodedPayload: string): string { + return crypto + .createHmac('sha256', String(runtimeConfig.admin?.sessionSecret || '')) + .update(`worker:${encodedPayload}`) + .digest('base64url') +} + +export function safeCompare(input: unknown, expected: unknown): boolean { + const left = Buffer.from(String(input || ''), 'utf8') + const right = Buffer.from(String(expected || ''), 'utf8') + if (left.length !== right.length) return false + return crypto.timingSafeEqual(left, right) +} + +export function resolveWorkerPermissions(worker: WorkerUserRow) { + const permission = safeParseJson(worker.level_permission_json) + return { + depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0), + maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1), + upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0), + } +} + +export function resolveFreezeDepositAmount( + workOrder: WorkOrderRow, + permissions: { depositFreeAmount: number }, +) { + return Math.max( + 0, + Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0), + ) +} + +export function mapWorkerLevel(level: WorkerLevelRow | null | undefined) { + if (!level) return null + const permission = safeParseJson(level.permission_json) + return { + levelId: Number(level.id), + levelKey: level.level_key, + name: level.name, + sortOrder: Number(level.sort_order || 100), + status: level.status, + depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0), + maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1), + upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0), + createdAt: level.created_at, + updatedAt: level.updated_at, + } +} + +export function mapWorkCategory(category: WorkCategoryRow | null | undefined) { + if (!category) return null + return { + categoryId: Number(category.id), + categoryKey: category.category_key, + name: category.name, + sortOrder: Number(category.sort_order || 100), + status: category.status, + createdAt: category.created_at, + updatedAt: category.updated_at, + } +} + +export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) { + if (!rule) return null + const requirement = safeParseJson(rule.requirement_json) + return { + ruleId: Number(rule.id), + ruleKey: rule.rule_key, + provider: rule.provider || '', + platform: rule.platform || '', + shopId: rule.shop_id || '', + skuCode: rule.sku_code || '', + productName: rule.product_name || '', + matchType: rule.match_type || 'contains', + categoryId: rule.category_id ? Number(rule.category_id) : null, + categoryName: rule.category_name || '', + enabled: Boolean(rule.enabled), + autoCreate: Boolean(rule.auto_create), + rewardAmount: Number(rule.reward_amount || 0), + requiredDepositAmount: Number(rule.required_deposit_amount || 0), + depositThresholdAmount: Number( + rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + ), + sharing: { + enabled: rule.sharing_enabled === true, + totalQuantity: Number(rule.sharing_total_quantity || 1), + unitReward: Number(rule.sharing_unit_reward || 0), + }, + requirement: { + fields: normalizeRequirementFields(requirement.fields), + }, + sortOrder: Number(rule.sort_order || 100), + createdAt: rule.created_at, + updatedAt: rule.updated_at, + } +} + +export function mapWorkerUser(worker: WorkerUserRow) { + return { + workerId: Number(worker.id), + username: worker.username, + displayName: worker.display_name || worker.username, + phone: worker.phone, + status: worker.status, + reviewNote: worker.review_note || '', + inviteCode: worker.invite_code || '', + inviter: worker.inviter_id + ? { + workerId: Number(worker.inviter_id), + username: worker.inviter_username || '', + displayName: worker.inviter_display_name || worker.inviter_username || '', + } + : null, + level: worker.level_id + ? { + levelId: Number(worker.level_id), + levelKey: worker.level_key || '', + name: worker.level_name || '', + permissions: resolveWorkerPermissions(worker), + } + : null, + wallet: { + availableAmount: Number(worker.available_amount || 0), + frozenDepositAmount: Number(worker.frozen_deposit_amount || 0), + totalCreditedAmount: Number(worker.total_credited_amount || 0), + totalSettledAmount: Number(worker.total_settled_amount || 0), + }, + createdAt: worker.created_at, + updatedAt: worker.updated_at, + reviewedAt: worker.reviewed_at, + } +} + +export function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined) { + if (!ledger) return null + return { + ledgerId: Number(ledger.id), + ledgerType: ledger.ledger_type, + amount: Number(ledger.amount || 0), + balanceAfter: Number(ledger.balance_after || 0), + frozenAfter: Number(ledger.frozen_after || 0), + relatedWorkOrderId: ledger.related_work_order_id + ? Number(ledger.related_work_order_id) + : null, + auditStatus: ledger.audit_status || '', + note: ledger.note || '', + payload: safeParseJson(ledger.payload_json), + createdAt: ledger.created_at, + } +} + +export function mapFinanceRequest(request: WorkerFinanceRequestRow | null | undefined) { + if (!request) return null + return { + requestId: Number(request.id), + requestType: request.request_type || '', + status: request.status || '', + amount: Number(request.amount || 0), + accountChannel: request.account_channel || '', + accountName: request.account_name || '', + accountNo: request.account_no || '', + note: request.note || '', + reviewedNote: request.reviewed_note || '', + payload: safeParseJson(request.payload_json), + createdAt: request.created_at, + updatedAt: request.updated_at, + reviewedAt: request.reviewed_at, + worker: request.worker_id + ? { + workerId: Number(request.worker_id), + username: request.worker_username || '', + displayName: request.worker_display_name || request.worker_username || '', + phone: request.worker_phone || '', + } + : null, + } +} + +export function mapWorkOrderAdmin(workOrder: WorkOrderRow) { + return { + workOrderId: Number(workOrder.id), + orderId: workOrder.order_id ? Number(workOrder.order_id) : null, + orderItemId: workOrder.order_item_id ? Number(workOrder.order_item_id) : null, + workOrderNo: workOrder.work_order_no, + platformOrderId: workOrder.platform_order_id, + productName: workOrder.product_name, + categoryName: workOrder.category_name || '', + status: workOrder.status, + rewardAmount: Number(workOrder.reward_amount || 0), + requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), + sharing: { + enabled: workOrder.sharing_enabled === true, + totalQuantity: Number(workOrder.sharing_total_quantity || 1), + unitReward: Number(workOrder.sharing_unit_reward || 0), + }, + depositThresholdAmount: Number( + workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + ), + material: safeParseJson(workOrder.material_json), + requirement: safeParseJson(workOrder.requirement_json), + acceptance: mapAcceptanceForResponse(workOrder.acceptance_json), + problemNote: workOrder.problem_note || '', + worker: workOrder.assigned_worker_id + ? { + workerId: Number(workOrder.assigned_worker_id), + username: workOrder.worker_username || '', + displayName: workOrder.worker_display_name || '', + } + : null, + publishedAt: workOrder.published_at, + assignedAt: workOrder.assigned_at, + submittedAt: workOrder.submitted_at, + acceptedAt: workOrder.accepted_at, + createdAt: workOrder.created_at, + updatedAt: workOrder.updated_at, + } +} + +export function mapWorkOrderForWorker( + workOrder: WorkOrderRow, + permissions: { depositFreeAmount: number }, + myShare?: WorkOrderShareRow | null, +) { + const mapped = mapWorkOrderAdmin(workOrder) + const { + requiredDepositAmount, + depositThresholdAmount, + worker: _worker, + orderId: _orderId, + orderItemId: _orderItemId, + ...visibleOrder + } = mapped + return { + ...visibleOrder, + freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions), + myShare: myShare ? mapWorkOrderShare(myShare) : null, + } +} + +export function mapWorkOrderShare(share: WorkOrderShareRow) { + return { + shareId: Number(share.id), + workOrderId: Number(share.work_order_id), + workerId: Number(share.worker_id), + quantity: Number(share.quantity || 0), + unitReward: Number(share.unit_reward || 0), + shareReward: Number(share.share_reward || 0), + shareDeposit: Number(share.share_deposit || 0), + status: share.status, + acceptance: safeParseJson(share.acceptance_json), + submittedAt: share.submitted_at, + acceptedAt: share.accepted_at, + createdAt: share.created_at, + updatedAt: share.updated_at, + worker: share.worker_id + ? { + workerId: Number(share.worker_id), + username: share.worker_username || '', + displayName: share.worker_display_name || share.worker_username || '', + } + : null, + } +} + +export function mapWorkOrderPublic(workOrder: WorkOrderRow) { + return { + workOrderId: Number(workOrder.id), + workOrderNo: workOrder.work_order_no, + platformOrderId: workOrder.platform_order_id, + productName: workOrder.product_name, + status: workOrder.status, + } +} + +export function mapCollectLookupOrder(workOrder: WorkOrderRow) { + return { + ...mapWorkOrderPublic(workOrder), + fields: resolveRequirementFields(workOrder), + values: resolveCollectSubmittedFieldValues(workOrder), + } +} + +export function mapWallet(wallet: Awaited>) { + return { + workerId: Number(wallet?.worker_id || 0), + availableAmount: Number(wallet?.available_amount || 0), + frozenDepositAmount: Number(wallet?.frozen_deposit_amount || 0), + totalCreditedAmount: Number(wallet?.total_credited_amount || 0), + totalSettledAmount: Number(wallet?.total_settled_amount || 0), + } +} + +export function resolveRequirementFields(workOrder: WorkOrderRow) { + return normalizeRequirementFields(safeParseJson(workOrder.requirement_json).fields) +} + +export function normalizeRequirementFields(value: unknown) { + const rawFields = Array.isArray(value) ? value : [] + const fields = rawFields + .map((item) => { + const source = + item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {} + const key = String(source.key || '').trim() + if (!key) return null + return { + key, + label: String(source.label || key).trim(), + required: source.required !== false, + mockValue: String(source.mockValue || '').trim(), + } + }) + .filter((item): item is { key: string; label: string; required: boolean; mockValue: string } => + Boolean(item), + ) + + return fields.length > 0 + ? fields + : [ + { key: 'gameAccount', label: '游戏账号', required: true, mockValue: 'test_account' }, + { key: 'serverName', label: '区服', required: true, mockValue: '安卓一区' }, + { key: 'roleName', label: '角色名', required: true, mockValue: '测试角色' }, + ] +} + +export function normalizeRequirementFieldsFromPayload(payload: JsonObject) { + const directFields = Array.isArray(payload.fields) ? payload.fields : null + const requirement = + payload.requirement && + typeof payload.requirement === 'object' && + !Array.isArray(payload.requirement) + ? (payload.requirement as JsonObject) + : {} + if (directFields) { + return normalizeRequirementFields(directFields) + } + if (Array.isArray(requirement.fields)) { + return normalizeRequirementFields(requirement.fields) + } + + const textFields = normalizeRequirementFieldsText( + payload.fieldsText || payload.requiredFieldsText, + ) + return textFields.length > 0 ? textFields : normalizeRequirementFields([]) +} + +export function normalizeRequirementFieldsText(value: unknown) { + return String(value || '') + .split(/\r?\n/) + .map((line, index) => { + const text = line.trim() + if (!text) return null + const delimiter = text.includes(':') ? ':' : ':' + if (text.includes(delimiter)) { + const [rawKey, ...labelParts] = text.split(delimiter) + const key = String(rawKey || '').trim() + if (!key) return null + return { + key, + label: labelParts.join(delimiter).trim() || key, + required: true, + mockValue: '', + } + } + return { + key: `field${index + 1}`, + label: text, + required: true, + mockValue: '', + } + }) + .filter((item): item is { key: string; label: string; required: boolean; mockValue: string } => + Boolean(item), + ) +} + +export function resolveMatchingProductRule( + order: OrderRow, + item: OrderItemRow, + rules: WorkProductRuleRow[], +) { + return ( + rules.find((rule) => { + if (!matchesOptionalText(rule.provider, order.provider)) return false + if (!matchesOptionalText(rule.platform, order.platform)) return false + if (!matchesOptionalText(rule.shop_id, order.shop_id)) return false + + const skuCode = String(item.sku_code || '') + .trim() + .toLowerCase() + const skuName = String(item.sku_name || '') + .trim() + .toLowerCase() + const ruleSkuCode = String(rule.sku_code || '') + .trim() + .toLowerCase() + const ruleProductName = String(rule.product_name || '') + .trim() + .toLowerCase() + + if (ruleSkuCode && ruleSkuCode !== skuCode) { + return false + } + if (!ruleProductName) { + return Boolean(ruleSkuCode) + } + if (rule.match_type === 'exact') { + return ruleProductName === skuName || ruleProductName === skuCode + } + return skuName.includes(ruleProductName) || skuCode.includes(ruleProductName) + }) || null + ) +} + +export function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) { + const ruleText = String(ruleValue || '') + .trim() + .toLowerCase() + if (!ruleText) { + return true + } + return ( + ruleText === + String(sourceValue || '') + .trim() + .toLowerCase() + ) +} + +export function normalizeSubmittedFields(value: unknown) { + const source = + value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} + return Object.fromEntries( + Object.entries(source) + .filter(([key]) => !['orderNo', 'platformOrderId', 'fields', 'material'].includes(key)) + .map(([key, currentValue]) => [key, String(currentValue || '').trim()]), + ) +} + +export function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.map((item) => String(item || '').trim()).filter(Boolean) +} + +export function normalizeUploadedFiles(value: unknown) { + if (!Array.isArray(value)) return [] + return value + .map((item) => { + const source = + item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {} + const objectKey = String(source.objectKey || source.object_key || '').trim() + const url = String(source.url || '').trim() + if (!objectKey && !url) return null + return { + objectKey, + url: normalizeStoredFileUrl(url, objectKey), + thumbnailUrl: normalizeStoredFileUrl( + source.thumbnailUrl || source.thumbnail_url || url, + objectKey, + ), + mediumUrl: normalizeStoredFileUrl(source.mediumUrl || source.medium_url || url, objectKey), + filename: String(source.filename || '').trim(), + contentType: String(source.contentType || source.content_type || '').trim(), + size: normalizeInteger(source.size, 0), + } + }) + .filter( + ( + item, + ): item is { + objectKey: string + url: string + thumbnailUrl: string + mediumUrl: string + filename: string + contentType: string + size: number + } => Boolean(item), + ) +} + +export function mapAcceptanceForResponse(value: unknown) { + const acceptance = safeParseJson(value) + const files = normalizeUploadedFiles(acceptance.files).map((file) => + refreshUploadedFileUrls(file), + ) + const imageUrls = normalizeStringArray(acceptance.imageUrls).map((url) => + refreshStoredFileUrl(url), + ) + return { + ...acceptance, + files, + imageUrls, + } +} + +export function resolveCollectSubmittedFieldValues(workOrder: WorkOrderRow) { + const material = safeParseJson(workOrder.material_json) + const collect = + material.collect && typeof material.collect === 'object' && !Array.isArray(material.collect) + ? safeParseJson(material.collect) + : {} + return normalizeSubmittedFields(collect.fields) +} + +export function normalizeReviewStatus(value: unknown) { + const status = String(value || '').trim() + if (['active', 'rejected', 'disabled', 'pending_review'].includes(status)) return status + return 'pending_review' +} + +export function normalizeProblemResolutionAction(value: unknown): ProblemWorkOrderResolutionAction { + const action = String(value || '').trim() + if (['return_to_worker', 'reopen', 'cancel_release', 'cancel_deduct'].includes(action)) { + return action as ProblemWorkOrderResolutionAction + } + throw createHttpError('问题单处置方式不正确', { + statusCode: 400, + errorCode: 'work_order_problem_resolution_action_invalid', + }) +} + +export function throwGrabWorkOrderFailure(failureReason: GrabWorkOrderFailureReason | null): never { + if (failureReason === 'worker_active_order_limit') { + throw createHttpError('已达到当前等级最大同时接单量', { + statusCode: 409, + errorCode: 'worker_active_order_limit', + }) + } + if (failureReason === 'worker_deposit_insufficient') { + throw createHttpError('余额不足,无法冻结所需押金', { + statusCode: 409, + errorCode: 'worker_deposit_insufficient', + }) + } + throw createHttpError('抢单失败,订单可能已被抢走', { + statusCode: 409, + errorCode: 'work_order_grab_conflict', + }) +} + +export function normalizeEnabledStatus(value: unknown) { + return String(value || 'active').trim() === 'disabled' ? 'disabled' : 'active' +} + +export function normalizeWalletLedgerType(value: unknown) { + const ledgerType = String(value || '').trim() + if (!ledgerType) return '' + if ( + [ + 'manual_credit', + 'withdraw_paid', + 'deposit_freeze', + 'deposit_release', + 'deposit_deduction', + 'reward_settlement', + ].includes(ledgerType) + ) { + return ledgerType + } + throw createHttpError('钱包流水类型不正确', { + statusCode: 400, + errorCode: 'worker_wallet_ledger_type_invalid', + }) +} + +export function normalizeFinanceRequestType(value: unknown) { + const requestType = String(value || '').trim() + if (!requestType) return '' + if (['recharge', 'withdraw'].includes(requestType)) { + return requestType + } + throw createHttpError('资金申请类型不正确', { + statusCode: 400, + errorCode: 'worker_finance_request_type_invalid', + }) +} + +export function normalizeFinanceRequestStatus(value: unknown) { + const status = String(value || '').trim() + if (!status) return '' + if (['pending', 'approved', 'rejected', 'cancelled'].includes(status)) { + return status + } + throw createHttpError('资金申请状态不正确', { + statusCode: 400, + errorCode: 'worker_finance_request_status_invalid', + }) +} + +export function normalizeAdminFinanceReviewStatus(value: unknown) { + const status = String(value || '').trim() + if (['approved', 'rejected', 'cancelled'].includes(status)) { + return status + } + throw createHttpError('审核状态不正确', { + statusCode: 400, + errorCode: 'worker_finance_review_status_invalid', + }) +} + +export function normalizeWithdrawChannel(value: unknown) { + const channel = String(value || '').trim() + if (['alipay', 'wechat', 'bank'].includes(channel)) { + return channel + } + throw createHttpError('提现方式不正确', { + statusCode: 400, + errorCode: 'worker_withdraw_channel_invalid', + }) +} + +export function normalizeMatchType(value: unknown) { + const matchType = String(value || '').trim() + if (matchType === 'exact') return 'exact' + return 'contains' +} + +export function normalizeBoolean(value: unknown, fallback: boolean) { + if (typeof value === 'boolean') return value + const text = String(value ?? '') + .trim() + .toLowerCase() + if (!text) return fallback + if (['true', '1', 'yes', 'enabled', 'active', 'on'].includes(text)) return true + if (['false', '0', 'no', 'disabled', 'off'].includes(text)) return false + return fallback +} + +export function normalizeSlugKey(value: unknown, fallback: string) { + const key = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + return key || fallback +} + +export function normalizeUsername(value: unknown): string { + return String(value || '') + .trim() + .toLowerCase() +} + +export function normalizePassword(value: unknown): string { + return String(value || '').trim() +} + +export function validateWorkerUsername(username: string) { + if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) { + throw createHttpError('接单账号格式无效,需为 3-32 位字母数字或 ._-', { + statusCode: 400, + errorCode: 'worker_username_invalid', + }) + } +} + +export function validateWorkerPassword(password: string) { + if (password.length < 6) { + throw createHttpError('接单密码至少 6 位', { + statusCode: 400, + errorCode: 'worker_password_invalid', + }) + } +} + +export function normalizeSessionVersion(value: unknown): number { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : 1 +} + +export function normalizeOptionalId(value: unknown): number | null { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +export function normalizeInteger(value: unknown, fallback: number): number { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : fallback +} + +export function normalizePositiveInteger(value: unknown, fallback: number): number { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + +export function normalizeAmountFen(value: unknown, fallback: number): number { + const text = String(value ?? '').trim() + if (!text) return fallback + const parsed = Number(text) + if (!Number.isFinite(parsed) || parsed < 0) return fallback + return Math.round(parsed * 100) +} + +export function normalizeProofFiles(value: unknown): Array> { + if (!Array.isArray(value)) return [] + const files: Array> = [] + for (const item of value) { + if (!item || typeof item !== 'object') continue + const file = item as Record + const url = String(file.url || '').trim() + if (!url) continue + files.push({ + url, + thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(), + mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(), + objectKey: String(file.objectKey || file.object_key || '').trim(), + originalFilename: String( + file.originalFilename || file.original_filename || file.name || '', + ).trim(), + contentType: String(file.contentType || file.content_type || '').trim(), + sizeBytes: Number(file.sizeBytes || file.size_bytes || 0), + }) + if (files.length >= 10) break + } + return files +} + +export function isIgnorableWorkerAuthError(error: unknown) { + return IGNORABLE_WORKER_AUTH_ERROR_CODES.has( + String((error as { errorCode?: string })?.errorCode || '').trim(), + ) +} + +export const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([ + 'worker_auth_required', + 'worker_auth_invalid', + 'worker_auth_expired', + 'worker_auth_user_invalid', + 'worker_auth_stale', +]) diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 11a23f37..feaf44f4 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict' import test from 'node:test' -import type { WorkOrderRow } from '../../repositories/worker-platform-repo.js' -import { resolveCollectSubmitTargetWorkOrder } from './worker-platform-service.js' +import type { WorkOrderRow } from '../../repositories/worker-platform/index.js' +import { resolveCollectSubmitTargetWorkOrder } from './index.js' function buildWorkOrderRow( overrides: Partial = {}, diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.ts b/apps/backend/src/services/worker-platform/worker-platform-service.ts deleted file mode 100644 index 55f07c18..00000000 --- a/apps/backend/src/services/worker-platform/worker-platform-service.ts +++ /dev/null @@ -1,2733 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../config/runtime.js' -import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' -import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' -import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js' -import { - acceptWorkOrderAndSettle, - addWorkerWalletCredit, - countWorkerAcceptedOrders, - countWorkerActiveOrders, - countWorkCategoryUsages, - countWorkerLevelUsages, - countWorkOrderPendingSharingSubmissions, - cancelWorkerWorkOrder, - countWorkerCancellationsSince, - createWorkOrder, - createWorkOrderEvent, - createWorkerFinanceRequest, - createWorkerUser, - deleteWorkCategory, - deleteWorkerLevel, - getWorkerFinanceRequestById, - getWorkerFinanceRequestSummary, - getWorkCategoryById, - getWorkCategoryByKey, - getWorkerLevelById, - getWorkerLevelByKey, - getWorkerUserByInviteCode, - getWorkerUserById, - getWorkerUserByUsername, - getWorkOrderById, - getWorkOrderByOrderItemId, - getWorkOrderShare, - grabWorkOrder, - incrementWorkerSessionVersion, - joinWorkOrderShare, - listAllWorkCategories, - listPendingMaterialWorkOrdersByPlatformOrderId, - listWorkerFinanceRequests, - listWorkerSharesByWorker, - listWorkerWalletLedgers, - listWorkCategories, - listWorkOrders, - listWorkProductRules, - listWorkerLevels, - listWorkerUsers, - listWorkOrderShares, - listWorkOrderSharesByOrderIds, - resolveProblemWorkOrder, - reviewWorkerFinanceRequest, - submitWorkOrderShareAcceptance, - updateWorkOrder, - updateWorkerPassword, - updateWorkerUser, - upsertWorkCategory, - upsertWorkProductRule, - upsertWorkerLevel, - type WorkCategoryRow, - type WorkerFinanceRequestRow, - type WorkOrderRow, - type WorkProductRuleRow, - type WorkerLevelRow, - type WorkOrderShareRow, - type WorkerWalletLedgerRow, - type WorkerUserRow, - type GrabWorkOrderFailureReason, - type ProblemWorkOrderResolutionAction, -} from '../../repositories/worker-platform-repo.js' -import type { JsonObject } from '../../types/json.js' -import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' -import { createHttpError } from '../../utils/http.js' -import { randomId } from '../../utils/random.js' -import { nowIso, addHours } from '../../utils/time.js' -import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' -import { - normalizeStoredFileUrl, - refreshStoredFileUrl, - refreshUploadedFileUrls, -} from '../file-storage/file-storage-service.js' -import { - getWorkerFinanceConfig, - saveWorkerFinanceConfig, -} from './worker-finance-config-service.js' - -export type WorkerSession = { - sessionId: string - workerId: number - username: string - status: string - expiresAt: string - sessionVersion: number -} - -const DEFAULT_LEVEL_KEY = 'vip1' -const DEFAULT_LEVEL_NAME = 'VIP1' -const DEFAULT_CATEGORY_KEY = 'default' -const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000 -const INVITE_CODE_LENGTH = 8 -const INVITE_CODE_RETRY_LIMIT = 5 -const WORKER_CANCEL_LIMIT_PER_WINDOW = 5 -const WORKER_CANCEL_LIMIT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000 -const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([ - 'worker_auth_required', - 'worker_auth_invalid', - 'worker_auth_expired', - 'worker_auth_user_invalid', - 'worker_auth_stale', -]) - -export async function ensureWorkerPlatformDefaults() { - const now = nowIso() - const existingLevel = await getWorkerLevelByKey(DEFAULT_LEVEL_KEY) - const level = - existingLevel || - (await upsertWorkerLevel({ - levelKey: DEFAULT_LEVEL_KEY, - name: DEFAULT_LEVEL_NAME, - sortOrder: 10, - status: 'active', - permissionJson: JSON.stringify({ - depositFreeAmount: 0, - maxActiveOrders: 1, - upgradeThreshold: 0, - }), - now, - })) - const existingCategory = await getWorkCategoryByKey(DEFAULT_CATEGORY_KEY) - const category = - existingCategory || - (await upsertWorkCategory({ - categoryKey: DEFAULT_CATEGORY_KEY, - name: '默认分类', - sortOrder: 100, - status: 'active', - now, - })) - return { level, category } -} - -export async function registerWorker(payload: JsonObject = {}) { - ensureWorkerAuthConfigured() - const username = normalizeUsername(payload.username) - const password = normalizePassword(payload.password) - const displayName = String(payload.displayName || payload.nickname || username).trim() - const phone = String(payload.phone || '').trim() - const inviteCode = String(payload.inviteCode || '').trim().toUpperCase() - - validateWorkerUsername(username) - validateWorkerPassword(password) - - if (await getWorkerUserByUsername(username)) { - throw createHttpError('接单账号已存在', { - statusCode: 409, - errorCode: 'worker_user_exists', - }) - } - - const inviter = inviteCode ? await getWorkerUserByInviteCode(inviteCode) : null - if (inviteCode && !inviter) { - throw createHttpError('邀请码无效', { - statusCode: 400, - errorCode: 'worker_invite_code_invalid', - }) - } - if (inviter && inviter.status !== 'active') { - throw createHttpError('邀请人账号不可用', { - statusCode: 400, - errorCode: 'worker_inviter_invalid', - }) - } - - const defaults = await ensureWorkerPlatformDefaults() - const now = nowIso() - const created = await createWorkerUser({ - username, - passwordHash: hashWorkerPassword(password), - displayName, - phone, - levelId: defaults.level?.id || null, - status: 'pending_review', - inviterId: inviter ? Number(inviter.id) : null, - inviteCode: await generateInviteCode(now), - now, - }) - - if (!created) { - throw createHttpError('接单账号注册失败', { - statusCode: 500, - errorCode: 'worker_register_failed', - }) - } - - return { - worker: mapWorkerUser(created), - reviewRequired: true, - } -} - -async function generateInviteCode(now: string): Promise { - const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - for (let attempt = 0; attempt < INVITE_CODE_RETRY_LIMIT; attempt++) { - const candidate = Array.from( - { length: INVITE_CODE_LENGTH }, - () => chars[Math.floor(crypto.randomInt(chars.length))], - ).join('') - if (!(await getWorkerUserByInviteCode(candidate))) { - return candidate - } - } - throw createHttpError('邀请码生成失败,请重试', { - statusCode: 500, - errorCode: 'worker_invite_code_generation_failed', - }) -} - -export async function loginWorker(username: unknown, password: unknown) { - ensureWorkerAuthConfigured() - const normalizedUsername = normalizeUsername(username) - const normalizedPassword = normalizePassword(password) - - if (!normalizedUsername || !normalizedPassword) { - throw createHttpError('缺少接单账号或密码', { - statusCode: 400, - errorCode: 'worker_credentials_required', - }) - } - - const worker = await getWorkerUserByUsername(normalizedUsername) - if (!worker || !verifyWorkerPassword(normalizedPassword, worker.password_hash)) { - throw createHttpError('账号或密码错误', { - statusCode: 401, - errorCode: 'worker_login_failed', - }) - } - - if (worker.status === 'disabled') { - throw createHttpError('接单账号已停用', { - statusCode: 403, - errorCode: 'worker_disabled', - }) - } - - return createWorkerSession(worker) -} - -export async function logoutWorkerSession(token: unknown) { - const normalizedToken = String(token || '').trim() - if (!normalizedToken) { - return { success: true, revoked: false } - } - - try { - const session = await verifyWorkerSessionToken(normalizedToken) - const revokedWorker = await incrementWorkerSessionVersion(session.workerId, nowIso()) - return { - success: true, - revoked: Boolean(revokedWorker), - } - } catch (error) { - if (isIgnorableWorkerAuthError(error)) { - return { success: true, revoked: false } - } - throw error - } -} - -export async function verifyWorkerSessionToken(token: unknown): Promise { - ensureWorkerAuthConfigured() - const normalizedToken = String(token || '').trim() - if (!normalizedToken) { - throw createHttpError('未登录或登录已失效', { - statusCode: 401, - errorCode: 'worker_auth_required', - }) - } - - const [encodedPayload, signature] = normalizedToken.split('.') - if (!encodedPayload || !signature || !safeCompare(signature, signWorkerPayload(encodedPayload))) { - throw createHttpError('接单登录态无效', { - statusCode: 401, - errorCode: 'worker_auth_invalid', - }) - } - - let payload: JsonObject = {} - try { - payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) - } catch { - throw createHttpError('接单登录态无效', { - statusCode: 401, - errorCode: 'worker_auth_invalid', - }) - } - - if (String(payload.typ || '') !== 'worker') { - throw createHttpError('接单登录态无效', { - statusCode: 401, - errorCode: 'worker_auth_invalid', - }) - } - - const expiresAt = String(payload.exp || '').trim() - if (!expiresAt || Date.parse(expiresAt) <= Date.now()) { - throw createHttpError('登录已过期,请重新登录', { - statusCode: 401, - errorCode: 'worker_auth_expired', - }) - } - - const workerId = Number(payload.uid || 0) - const worker = await getWorkerUserById(workerId) - if (!worker || worker.status === 'disabled') { - throw createHttpError('接单账号已不可用,请重新登录', { - statusCode: 401, - errorCode: 'worker_auth_user_invalid', - }) - } - - const currentSessionVersion = normalizeSessionVersion(worker.session_version) - if (Number(payload.ver || 0) !== currentSessionVersion) { - throw createHttpError('登录态已失效,请重新登录', { - statusCode: 401, - errorCode: 'worker_auth_stale', - }) - } - - return { - sessionId: String(payload.sid || '').trim(), - workerId: Number(worker.id), - username: worker.username, - status: worker.status, - expiresAt, - sessionVersion: currentSessionVersion, - } -} - -export async function getWorkerSessionSummary(token: unknown) { - const session = await verifyWorkerSessionToken(token) - const worker = await getRequiredWorker(session.workerId) - return { - authenticated: true, - expiresAt: session.expiresAt, - worker: mapWorkerUser(worker), - } -} - -export function requireActiveWorkerSession(session: WorkerSession | null | undefined) { - if (session?.status === 'active') { - return - } - - throw createHttpError('接单账号尚未通过审核', { - statusCode: 403, - errorCode: 'worker_not_active', - }) -} - -export async function getWorkerProfile(session: WorkerSession) { - const worker = await getRequiredWorker(session.workerId) - const [financeSummary, acceptedOrderCount, financeConfig] = await Promise.all([ - getWorkerFinanceRequestSummary(session.workerId), - countWorkerAcceptedOrders(session.workerId), - Promise.resolve(getWorkerFinanceConfig()), - ]) - const permissions = resolveWorkerPermissions(worker) - const levelProgress = await resolveLevelProgress(worker, acceptedOrderCount) - return { - worker: mapWorkerUser(worker), - permissions, - summary: { - acceptedOrderCount, - pendingWithdrawAmount: financeSummary.pendingWithdrawAmount, - approvedWithdrawAmount: financeSummary.approvedWithdrawAmount, - pendingRechargeAmount: financeSummary.pendingRechargeAmount, - }, - levelProgress, - financeConfig, - } -} - -async function resolveLevelProgress( - worker: WorkerUserRow, - acceptedOrderCount: number, -): Promise<{ - currentThreshold: number - nextThreshold: number | null - progressPercent: number -} | null> { - if (!worker.level_id) return null - const levels = await listWorkerLevels() - const active = levels.filter((level) => level.status === 'active') - const current = active.find((level) => Number(level.id) === Number(worker.level_id)) - if (!current) return null - const currentThreshold = normalizeInteger(safeParseJson(current.permission_json).upgradeThreshold, 0) - const next = active - .filter((level) => normalizeInteger(safeParseJson(level.permission_json).upgradeThreshold, 0) > currentThreshold) - .sort( - (a, b) => - normalizeInteger(safeParseJson(a.permission_json).upgradeThreshold, 0) - - normalizeInteger(safeParseJson(b.permission_json).upgradeThreshold, 0), - )[0] - const nextThreshold = next - ? normalizeInteger(safeParseJson(next.permission_json).upgradeThreshold, 0) - : null - return { - currentThreshold, - nextThreshold, - progressPercent: - nextThreshold && nextThreshold > currentThreshold - ? Math.min( - 100, - Math.round( - ((acceptedOrderCount - currentThreshold) / (nextThreshold - currentThreshold)) * 100, - ), - ) - : 100, - } -} - -export async function listWorkerProfileWalletLedgers( - query: JsonObject = {}, - session: WorkerSession, -) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const ledgerType = normalizeWalletLedgerType(query.ledgerType) - const { items, total } = await listWorkerWalletLedgers({ - page, - pageSize, - workerId: session.workerId, - ledgerType, - }) - return { - items: items.map(mapWalletLedger), - pagination: { page, pageSize, total }, - } -} - -export async function listWorkerProfileFinanceRequests( - query: JsonObject = {}, - session: WorkerSession, -) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const status = normalizeFinanceRequestStatus(query.status) - const requestType = normalizeFinanceRequestType(query.requestType) - const { items, total } = await listWorkerFinanceRequests({ - page, - pageSize, - workerId: session.workerId, - status, - requestType, - }) - return { - items: items.map(mapFinanceRequest), - pagination: { page, pageSize, total }, - } -} - -export async function createWorkerRechargeRequest( - payload: JsonObject = {}, - session: WorkerSession, -) { - requireActiveWorkerSession(session) - const worker = await getRequiredWorker(session.workerId) - const financeConfig = getWorkerFinanceConfig() - if (financeConfig.recharge.enabled === false) { - throw createHttpError('当前充值入口暂未开放,请联系管理员', { - statusCode: 409, - errorCode: 'worker_recharge_disabled', - }) - } - const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0) - if (amount <= 0) { - throw createHttpError('充值金额必须大于 0', { - statusCode: 400, - errorCode: 'worker_recharge_amount_invalid', - }) - } - - const note = String(payload.note || payload.remark || '').trim() - const proofFiles = normalizeProofFiles(payload.proofFiles || payload.proofFilesList) - const paidAt = String(payload.paidAt || '').trim() - const created = await createWorkerFinanceRequest({ - workerId: worker.id, - requestType: 'recharge', - amount, - accountChannel: 'manual', - accountName: worker.display_name || worker.username, - accountNo: worker.phone || '', - note: note || '个人中心充值申请', - payloadJson: JSON.stringify({ - source: 'worker_profile_recharge', - username: worker.username, - phone: worker.phone || '', - paidAt: paidAt || null, - proofFiles, - financeConfig: { - channelName: financeConfig.recharge.channelName, - accountName: financeConfig.recharge.accountName, - accountNo: financeConfig.recharge.accountNo, - qrCodeImage: financeConfig.recharge.qrCodeImage, - }, - }), - now: nowIso(), - }) - if (!created) { - throw createHttpError('充值申请创建失败', { - statusCode: 500, - errorCode: 'worker_recharge_request_create_failed', - }) - } - return { - request: mapFinanceRequest(created), - } -} - -export async function createWorkerWithdrawRequest( - payload: JsonObject = {}, - session: WorkerSession, -) { - requireActiveWorkerSession(session) - const worker = await getRequiredWorker(session.workerId) - const financeConfig = getWorkerFinanceConfig() - if (financeConfig.withdraw.enabled === false) { - throw createHttpError('当前提现入口暂未开放,请联系管理员', { - statusCode: 409, - errorCode: 'worker_withdraw_disabled', - }) - } - const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0) - if (amount <= 0) { - throw createHttpError('提现金额必须大于 0', { - statusCode: 400, - errorCode: 'worker_withdraw_amount_invalid', - }) - } - - const accountChannel = normalizeWithdrawChannel( - payload.accountChannel || payload.channel, - ) - const accountName = String(payload.accountName || payload.realName || '').trim() - const accountNo = String(payload.accountNo || payload.account || '').trim() - if (!accountName) { - throw createHttpError('请填写收款人姓名', { - statusCode: 400, - errorCode: 'worker_withdraw_account_name_required', - }) - } - if (!accountNo) { - throw createHttpError('请填写收款账号', { - statusCode: 400, - errorCode: 'worker_withdraw_account_no_required', - }) - } - - const financeSummary = await getWorkerFinanceRequestSummary(worker.id) - const availableAmount = Number(worker.available_amount || 0) - const availableForWithdraw = Math.max( - 0, - availableAmount - financeSummary.pendingWithdrawAmount, - ) - if (amount > availableForWithdraw) { - throw createHttpError('可提现余额不足,请先减少提现金额或等待已提交申请处理', { - statusCode: 409, - errorCode: 'worker_withdraw_amount_insufficient', - }) - } - - const note = String(payload.note || payload.remark || '').trim() - const created = await createWorkerFinanceRequest({ - workerId: worker.id, - requestType: 'withdraw', - amount, - accountChannel, - accountName, - accountNo, - note: note || '个人中心提现申请', - payloadJson: JSON.stringify({ - source: 'worker_profile_withdraw', - username: worker.username, - phone: worker.phone || '', - }), - now: nowIso(), - }) - if (!created) { - throw createHttpError('提现申请创建失败', { - statusCode: 500, - errorCode: 'worker_withdraw_request_create_failed', - }) - } - return { - request: mapFinanceRequest(created), - availableForWithdraw, - } -} - -export async function changeWorkerPassword( - payload: JsonObject = {}, - session: WorkerSession, -) { - const worker = await getRequiredWorker(session.workerId) - const currentPassword = normalizePassword( - payload.currentPassword || payload.oldPassword, - ) - const nextPassword = normalizePassword(payload.newPassword || payload.password) - - if (!currentPassword) { - throw createHttpError('请输入当前密码', { - statusCode: 400, - errorCode: 'worker_password_current_required', - }) - } - - if (!verifyWorkerPassword(currentPassword, worker.password_hash)) { - throw createHttpError('当前密码不正确', { - statusCode: 400, - errorCode: 'worker_password_current_invalid', - }) - } - - if (!nextPassword) { - throw createHttpError('请输入新密码', { - statusCode: 400, - errorCode: 'worker_password_new_required', - }) - } - - validateWorkerPassword(nextPassword) - - if (currentPassword === nextPassword) { - throw createHttpError('新密码不能与当前密码一致', { - statusCode: 400, - errorCode: 'worker_password_unchanged', - }) - } - - const updated = await updateWorkerPassword({ - workerId: worker.id, - passwordHash: hashWorkerPassword(nextPassword), - sessionVersion: normalizeSessionVersion(worker.session_version) + 1, - now: nowIso(), - }) - - return { - worker: mapWorkerUser(updated || worker), - reloginRequired: true, - } -} - -export async function listWorkerHallOrders(query: JsonObject = {}, session: WorkerSession) { - requireActiveWorkerSession(session) - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const categoryId = normalizeOptionalId(query.categoryId) - const { items, total } = await listWorkOrders({ - page, - pageSize, - status: WORK_ORDER_STATUS.OPEN, - keyword: String(query.keyword || '').trim(), - categoryId: categoryId || 0, - }) - const worker = await getRequiredWorker(session.workerId) - const permissions = resolveWorkerPermissions(worker) - const categories = (await listWorkCategories()).map(mapWorkCategory) - const myShares = await listWorkerSharesByWorker(worker.id) - const myShareByOrderId = new Map( - myShares - .filter((share) => Number(share.work_order_id)) - .map((share) => [Number(share.work_order_id), share]), - ) - return { - items: items.map((item) => - mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))), - ), - pagination: { page, pageSize, total }, - categories, - } -} - -export async function grabWorkerHallOrder(workOrderId: number | string, session: WorkerSession) { - requireActiveWorkerSession(session) - const worker = await getRequiredWorker(session.workerId) - const permissions = resolveWorkerPermissions(worker) - const activeCount = await countWorkerActiveOrders(worker.id) - if (activeCount >= permissions.maxActiveOrders) { - throw createHttpError('已达到当前等级最大同时接单量', { - statusCode: 409, - errorCode: 'worker_active_order_limit', - }) - } - - const workOrder = await getRequiredWorkOrder(workOrderId) - if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) { - throw createHttpError('该订单已不可抢', { - statusCode: 409, - errorCode: 'work_order_not_open', - }) - } - - const freezeAmount = resolveFreezeDepositAmount(workOrder, permissions) - if (Number(worker.available_amount || 0) < freezeAmount) { - throw createHttpError('余额不足,无法冻结所需押金', { - statusCode: 409, - errorCode: 'worker_deposit_insufficient', - }) - } - - const grabbed = await grabWorkOrder({ - workOrderId: Number(workOrder.id), - workerId: Number(worker.id), - depositAmount: freezeAmount, - maxActiveOrders: permissions.maxActiveOrders, - now: nowIso(), - }) - if (!grabbed.order) { - throwGrabWorkOrderFailure(grabbed.failureReason) - } - - return { order: mapWorkOrderForWorker(grabbed.order, permissions) } -} - -export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) { - requireActiveWorkerSession(session) - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const worker = await getRequiredWorker(session.workerId) - const { items, total } = await listWorkOrders({ - page, - pageSize, - status: String(query.status || '').trim(), - keyword: String(query.keyword || '').trim(), - workerSharingId: worker.id, - }) - const permissions = resolveWorkerPermissions(worker) - const myShares = await listWorkerSharesByWorker(worker.id) - const myShareByOrderId = new Map( - myShares.map((share) => [Number(share.work_order_id), share]), - ) - return { - items: items.map((item) => - mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))), - ), - pagination: { page, pageSize, total }, - } -} - -export async function joinWorkerSharingOrder( - workOrderId: number | string, - payload: JsonObject = {}, - session: WorkerSession, -) { - requireActiveWorkerSession(session) - const worker = await getRequiredWorker(session.workerId) - const permissions = resolveWorkerPermissions(worker) - const quantity = normalizePositiveInteger(payload.quantity, 1) - const now = nowIso() - const result = await joinWorkOrderShare({ - workOrderId: Number(workOrderId), - workerId: Number(worker.id), - quantity, - maxActiveOrders: permissions.maxActiveOrders, - now, - }) - if (!result.share) { - throw resolveWorkOrderShareJoinError(result.failureReason) - } - return { share: mapWorkOrderShare(result.share) } -} - -function resolveWorkOrderShareJoinError( - failureReason: 'work_order_not_open' | 'sharing_disabled' | 'work_order_owner_conflict' | 'sharing_quantity_full' | 'worker_active_order_limit' | 'worker_deposit_insufficient' | null, -): Error { - const messages: Record = { - work_order_not_open: '该订单当前不可拼单', - sharing_disabled: '该订单未启用拼单', - work_order_owner_conflict: '你已整单接取该订单,无需拼单', - sharing_quantity_full: '拼单数量已满或超出剩余数量', - worker_active_order_limit: '已达到当前等级最大同时接单量', - worker_deposit_insufficient: '余额不足,无法冻结所需押金', - } - return createHttpError(messages[failureReason || ''] || '拼单失败', { - statusCode: 409, - errorCode: `work_order_sharing_${failureReason || 'failed'}`, - }) -} - -export async function cancelWorkerOrder( - workOrderId: number | string, - session: WorkerSession, -) { - requireActiveWorkerSession(session) - const worker = await getRequiredWorker(session.workerId) - const now = nowIso() - const since = new Date(Date.now() - WORKER_CANCEL_LIMIT_WINDOW_MS).toISOString() - const cancelCount = await countWorkerCancellationsSince(worker.id, since) - if (cancelCount >= WORKER_CANCEL_LIMIT_PER_WINDOW) { - throw createHttpError( - `30 天内取消接单已达 ${WORKER_CANCEL_LIMIT_PER_WINDOW} 次上限,请谨慎接单`, - { - statusCode: 409, - errorCode: 'worker_cancel_limit_reached', - }, - ) - } - const result = await cancelWorkerWorkOrder({ - workOrderId: Number(workOrderId), - workerId: Number(worker.id), - now, - }) - if (result.failureReason === 'work_order_owner_required') { - throw createHttpError('只能取消自己接的订单', { - statusCode: 403, - errorCode: 'work_order_owner_required', - }) - } - if (!result.order) { - throw createHttpError('当前订单状态不能取消,仅进行中的订单可取消', { - statusCode: 409, - errorCode: 'work_order_cancel_status_invalid', - }) - } - return { - order: mapWorkOrderForWorker( - result.order, - resolveWorkerPermissions(await getRequiredWorker(session.workerId)), - ), - } -} - -export async function submitWorkerOrderAcceptance( - workOrderId: number | string, - payload: JsonObject = {}, - session: WorkerSession, -) { - requireActiveWorkerSession(session) - const workOrder = await getRequiredWorkOrder(workOrderId) - const workerId = Number(session.workerId) - const sharingShare = await getWorkOrderShare(workOrder.id, workerId) - if (sharingShare) { - return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session) - } - if (Number(workOrder.assigned_worker_id || 0) !== workerId) { - throw createHttpError('只能提交自己的订单', { - statusCode: 403, - errorCode: 'work_order_owner_required', - }) - } - if ( - ![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never) - ) { - throw createHttpError('当前状态不能提交验收', { - statusCode: 409, - errorCode: 'work_order_submit_status_invalid', - }) - } - - const now = nowIso() - const files = normalizeUploadedFiles(payload.files) - const imageUrls = [ - ...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean), - ...normalizeStringArray(payload.imageUrls), - ] - if (imageUrls.length === 0) { - throw createHttpError('请上传验收图片', { - statusCode: 400, - errorCode: 'work_order_acceptance_image_required', - }) - } - const acceptance = { - note: String(payload.note || '').trim(), - files, - imageUrls: [...new Set(imageUrls)], - submittedAt: now, - } - const updated = await updateWorkOrder(workOrder.id, { - status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, - acceptance_json: acceptance, - submitted_at: now, - updated_at: now, - }) - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'worker', - actorId: String(session.workerId), - eventType: 'acceptance_submitted', - fromStatus: workOrder.status, - toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, - payloadJson: JSON.stringify(acceptance), - now, - }) - return { - order: mapWorkOrderForWorker( - updated || workOrder, - resolveWorkerPermissions(await getRequiredWorker(session.workerId)), - ), - } -} - -async function submitWorkerSharingAcceptance( - workOrder: WorkOrderRow, - sharingShare: WorkOrderShareRow, - payload: JsonObject = {}, - session: WorkerSession, -) { - if (workOrder.status === WORK_ORDER_STATUS.ACCEPTED) { - throw createHttpError('该拼单已验收完成', { - statusCode: 409, - errorCode: 'work_order_sharing_accepted', - }) - } - const now = nowIso() - const files = normalizeUploadedFiles(payload.files) - const imageUrls = [ - ...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean), - ...normalizeStringArray(payload.imageUrls), - ] - if (imageUrls.length === 0) { - throw createHttpError('请上传验收图片', { - statusCode: 400, - errorCode: 'work_order_acceptance_image_required', - }) - } - const acceptance = { - note: String(payload.note || '').trim(), - files, - imageUrls: [...new Set(imageUrls)], - submittedAt: now, - } - const result = await submitWorkOrderShareAcceptance({ - workOrderId: Number(workOrder.id), - workerId: Number(session.workerId), - acceptanceJson: JSON.stringify(acceptance), - now, - }) - if (result.failureReason === 'share_status_invalid') { - throw createHttpError('当前拼单份额已提交,请勿重复提交', { - statusCode: 409, - errorCode: 'work_order_sharing_share_submitted', - }) - } - if (!result.share) { - throw createHttpError('未找到拼单份额', { - statusCode: 404, - errorCode: 'work_order_sharing_share_not_found', - }) - } - return { - share: mapWorkOrderShare(result.share), - sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)), - } -} - -export async function collectLookupWorkOrder(payload: JsonObject = {}) { - const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim() - if (!orderNo) { - throw createHttpError('请填写订单号', { - statusCode: 400, - errorCode: 'collect_order_no_required', - }) - } - const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo) - if (workOrders.length === 0) { - throw createHttpError('未找到待完善订单,请确认订单号是否正确', { - statusCode: 404, - errorCode: 'collect_order_not_found', - }) - } - const [primaryOrder] = workOrders - if (!primaryOrder) { - throw createHttpError('未找到待完善订单,请确认订单号是否正确', { - statusCode: 404, - errorCode: 'collect_order_not_found', - }) - } - return { - order: mapWorkOrderPublic(primaryOrder), - fields: resolveRequirementFields(primaryOrder), - orders: workOrders.map(mapCollectLookupOrder), - pendingCount: workOrders.length, - } -} - -export async function collectSubmitWorkOrder(payload: JsonObject = {}) { - const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim() - if (!orderNo) { - throw createHttpError('请填写订单号', { - statusCode: 400, - errorCode: 'collect_order_no_required', - }) - } - const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo) - if (workOrders.length === 0) { - throw createHttpError('未找到待完善订单,请确认订单号是否正确', { - statusCode: 404, - errorCode: 'collect_order_not_found', - }) - } - const workOrder = resolveCollectSubmitTargetWorkOrder( - orderNo, - workOrders, - payload.workOrderId || payload.work_order_id, - ) - - const fields = resolveRequirementFields(workOrder) - const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload) - const complete = fields.every( - (field) => !field.required || String(submittedFields[field.key] || '').trim(), - ) - const now = nowIso() - const material = { - ...safeParseJson(workOrder.material_json), - collect: { - fields: submittedFields, - submittedAt: now, - complete, - }, - } - const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL - const updated = await updateWorkOrder(workOrder.id, { - status: nextStatus, - material_json: material, - updated_at: now, - }) - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'collect', - actorId: orderNo, - eventType: 'material_submitted', - fromStatus: workOrder.status, - toStatus: nextStatus, - payloadJson: JSON.stringify({ fields: submittedFields, complete }), - now, - }) - return { - order: mapWorkOrderPublic(updated || workOrder), - complete, - } -} - -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 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), - } - const level = await upsertWorkerLevel({ - levelKey, - name, - sortOrder: normalizeInteger(payload.sortOrder, 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 enabledValue = String(query.enabled ?? '').trim() - const enabled = enabledValue - ? ['true', '1', 'enabled', 'active'].includes(enabledValue.toLowerCase()) - : null - const items = await listWorkProductRules({ - enabled, - keyword: String(query.keyword || '').trim(), - }) - return { items: items.map(mapWorkProductRule) } -} - -export async function saveAdminWorkProductRule(payload: JsonObject = {}) { - const defaults = await ensureWorkerPlatformDefaults() - const productName = String( - payload.productName || payload.product_name || payload.skuName || '', - ).trim() - const skuCode = String(payload.skuCode || payload.sku_code || '').trim() - if (!productName && !skuCode) { - throw createHttpError('请填写商品名或 SKU', { - statusCode: 400, - errorCode: 'work_product_rule_target_required', - }) - } - - const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0) - const sharingEnabled = normalizeBoolean( - payload.sharingEnabled ?? payload.sharing_enabled, - false, - ) - 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) { - if (resolvedSharingTotalAmount <= 0) { - throw createHttpError('启用拼单时请填写拼单总价或单价', { - statusCode: 400, - errorCode: 'work_product_rule_sharing_amount_invalid', - }) - } - } else if (rewardAmount <= 0) { - throw createHttpError('接单金额必须大于 0', { - statusCode: 400, - errorCode: 'work_product_rule_reward_invalid', - }) - } - const finalRewardAmount = sharingEnabled ? resolvedSharingTotalAmount : rewardAmount - - const depositThresholdAmount = normalizeAmountFen( - payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan, - DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, - ) - const requiredDepositAmount = - payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined - ? finalRewardAmount > depositThresholdAmount - ? finalRewardAmount - : 0 - : normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0) - const fields = normalizeRequirementFieldsFromPayload(payload) - const rule = await upsertWorkProductRule({ - ruleKey: normalizeSlugKey( - payload.ruleKey || payload.rule_key || skuCode || productName, - `rule-${Date.now()}`, - ), - provider: String(payload.provider || '').trim(), - platform: String(payload.platform || '').trim(), - shopId: String(payload.shopId || payload.shop_id || '').trim(), - skuCode, - productName, - matchType: normalizeMatchType(payload.matchType || payload.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, false), - rewardAmount: finalRewardAmount, - requiredDepositAmount, - depositThresholdAmount, - sharingEnabled, - sharingTotalQuantity, - sharingUnitReward, - requirementJson: JSON.stringify({ fields }), - sortOrder: normalizeInteger(payload.sortOrder, 100), - now: nowIso(), - }) - return { rule: mapWorkProductRule(rule) } -} - -export async function listAdminWorkerUsers(query: JsonObject = {}) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const { items, total } = await listWorkerUsers({ - page, - pageSize, - status: String(query.status || '').trim(), - keyword: String(query.keyword || '').trim(), - }) - return { - items: items.map(mapWorkerUser), - pagination: { page, pageSize, total }, - } -} - -export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) { - const worker = await getRequiredWorker(workerId) - const status = normalizeReviewStatus(payload.status) - const requestedLevelId = normalizeOptionalId(payload.levelId) - let levelId = worker.level_id || null - if (requestedLevelId) { - const level = await getWorkerLevelById(requestedLevelId) - if (!level) { - throw createHttpError('接单等级不存在', { - statusCode: 404, - errorCode: 'worker_level_not_found', - }) - } - levelId = Number(level.id) - } - if (!levelId) { - levelId = (await ensureWorkerPlatformDefaults()).level?.id || null - } - const now = nowIso() - const updated = await updateWorkerUser(worker.id, { - status, - level_id: levelId, - review_note: String(payload.reviewNote || payload.note || '').trim(), - session_version: normalizeSessionVersion(worker.session_version) + 1, - reviewed_at: now, - updated_at: now, - }) - return { worker: mapWorkerUser(updated || worker) } -} - -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(), - }) - return { wallet: mapWallet(wallet) } -} - -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 status = normalizeFinanceRequestStatus(query.status) - const requestType = normalizeFinanceRequestType(query.requestType) - const keyword = String(query.keyword || '').trim() - const { items, total } = await listWorkerFinanceRequests({ - page, - pageSize, - 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', - }) - } - - return { - request: mapFinanceRequest(reviewed.request), - } -} - -export async function listAdminWorkOrders(query: JsonObject = {}) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const { items, total } = await listWorkOrders({ - page, - pageSize, - status: String(query.status || '').trim(), - keyword: String(query.keyword || '').trim(), - }) - const shares = await listWorkOrderSharesByOrderIds(items.map((item) => Number(item.id))) - const sharesByOrderId = new Map() - for (const share of shares) { - const orderId = Number(share.work_order_id) - const bucket = sharesByOrderId.get(orderId) || [] - bucket.push(share) - sharesByOrderId.set(orderId, bucket) - } - return { - items: items.map((item) => { - const orderShares = sharesByOrderId.get(Number(item.id)) || [] - const joinedQuantity = orderShares - .filter((share) => share.status !== 'cancelled') - .reduce((sum, share) => sum + Number(share.quantity || 0), 0) - const pendingSubmissionCount = orderShares.filter( - (share) => share.status === 'joined', - ).length - return { - ...mapWorkOrderAdmin(item), - sharingProgress: { - joinedQuantity, - pendingSubmissionCount, - }, - } - }), - pagination: { page, pageSize, total }, - } -} - -export async function getAdminWorkOrderSharing(workOrderId: number | string) { - const workOrder = await getRequiredWorkOrder(workOrderId) - const shares = await listWorkOrderShares(workOrder.id) - return { - order: mapWorkOrderAdmin(workOrder), - shares: shares.map(mapWorkOrderShare), - } -} - -export async function updateAdminWorkOrderSharing( - workOrderId: number | string, - payload: JsonObject = {}, -) { - const workOrder = await getRequiredWorkOrder(workOrderId) - if (![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN].includes(workOrder.status as never)) { - throw createHttpError('只有待完善、未分配或待抢单的工单可以配置拼单', { - statusCode: 409, - errorCode: 'work_order_sharing_config_status_invalid', - }) - } - const enabled = normalizeBoolean( - payload.enabled ?? payload.sharingEnabled ?? payload.sharing_enabled, - false, - ) - const unitReward = normalizeAmountFen( - payload.unitReward ?? payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, - 0, - ) - const totalAmount = normalizeAmountFen( - payload.totalAmount ?? payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan, - 0, - ) - const totalQuantity = resolveSharingQuantity( - totalAmount, - normalizePositiveInteger( - payload.totalQuantity ?? payload.sharingTotalQuantity ?? payload.sharing_total_quantity, - 1, - ), - unitReward, - ) - const resolvedTotalAmount = totalAmount > 0 ? totalAmount : unitReward * totalQuantity - const now = nowIso() - const updated = await updateWorkOrder(workOrder.id, { - sharing_enabled: enabled, - sharing_total_quantity: totalQuantity, - sharing_unit_reward: unitReward, - reward_amount: enabled ? resolvedTotalAmount : workOrder.reward_amount, - updated_at: now, - }) - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'admin', - actorId: '', - eventType: 'sharing_config_updated', - fromStatus: workOrder.status, - toStatus: updated?.status || workOrder.status, - payloadJson: JSON.stringify({ - enabled, - totalQuantity, - unitReward, - totalAmount: resolvedTotalAmount, - }), - now, - }) - 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) -} - -export async function submitAdminWorkOrderMaterial( - workOrderId: number | string, - payload: JsonObject = {}, -) { - const workOrder = await getRequiredWorkOrder(workOrderId) - if ( - ![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED].includes( - workOrder.status as never, - ) - ) { - throw createHttpError('当前状态不能补充资料', { - statusCode: 409, - errorCode: 'work_order_material_status_invalid', - }) - } - - const fields = resolveRequirementFields(workOrder) - const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload) - const complete = fields.every( - (field) => !field.required || String(submittedFields[field.key] || '').trim(), - ) - const now = nowIso() - const material = { - ...safeParseJson(workOrder.material_json), - collect: { - fields: submittedFields, - submittedAt: now, - complete, - source: 'admin', - }, - } - const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL - const updated = await updateWorkOrder(workOrder.id, { - status: nextStatus, - material_json: material, - updated_at: now, - }) - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'admin', - actorId: '', - eventType: 'material_completed', - fromStatus: workOrder.status, - toStatus: nextStatus, - payloadJson: JSON.stringify({ fields: submittedFields, complete }), - now, - }) - return { order: mapWorkOrderAdmin(updated || workOrder), complete } -} - -export async function syncAdminWorkerOrdersFromSource(payload: JsonObject = {}) { - const order = await resolveSourceOrder(payload) - const orderItems = await listOrderItemsByOrderId(order.id) - const result = await syncWorkerOrdersForSourceOrder(order, orderItems, { - source: 'admin_manual_sync', - autoOnly: false, - }) - return result -} - -export async function createAdminMockWorkOrder(payload: JsonObject = {}) { - const defaults = await ensureWorkerPlatformDefaults() - const now = nowIso() - const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 2500) - const depositThresholdAmount = normalizeAmountFen( - payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan, - DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, - ) - const requiredDepositAmount = - payload.requiredDepositAmount === undefined - ? rewardAmount > depositThresholdAmount - ? rewardAmount - : 0 - : normalizeAmountFen(payload.requiredDepositAmount, 0) - const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true' - const fields = normalizeRequirementFields(payload.fields) - const platformOrderId = String( - payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`, - ).trim() - const productName = String(payload.productName || '指挥官秘钥1个').trim() - const material = materialComplete - ? { - collect: { - complete: true, - submittedAt: now, - fields: fields.reduce>((result, field) => { - result[field.key] = String(field.mockValue || `${field.label}测试值`) - return result - }, {}), - }, - } - : {} - const workOrder = await createWorkOrder({ - workOrderNo: randomId('WO'), - platformOrderId, - productName, - categoryId: defaults.category?.id || null, - status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL, - rewardAmount, - requiredDepositAmount, - depositThresholdAmount, - materialJson: JSON.stringify(material), - requirementJson: JSON.stringify({ fields }), - now, - }) - if (!workOrder) { - throw createHttpError('接单工单创建失败', { - statusCode: 500, - errorCode: 'work_order_create_failed', - }) - } - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'admin', - actorId: 'dev_mock', - eventType: 'mock_created', - fromStatus: '', - toStatus: workOrder.status, - payloadJson: JSON.stringify({ platformOrderId, productName, rewardAmount }), - now, - }) - return { order: mapWorkOrderAdmin(workOrder) } -} - -export async function publishAdminWorkOrder(workOrderId: number | string) { - const workOrder = await getRequiredWorkOrder(workOrderId) - if (workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) { - throw createHttpError('只有未分配订单可以发布到大厅', { - statusCode: 409, - errorCode: 'work_order_publish_status_invalid', - }) - } - const now = nowIso() - const updated = await updateWorkOrder(workOrder.id, { - status: WORK_ORDER_STATUS.OPEN, - published_at: now, - updated_at: now, - }) - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'admin', - actorId: '', - eventType: 'published', - fromStatus: workOrder.status, - toStatus: WORK_ORDER_STATUS.OPEN, - now, - }) - return { order: mapWorkOrderAdmin(updated || workOrder) } -} - -export async function unpublishAdminWorkOrder(workOrderId: number | string) { - const workOrder = await getRequiredWorkOrder(workOrderId) - if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) { - throw createHttpError('只有待抢单且未被接走的订单可以下架', { - statusCode: 409, - errorCode: 'work_order_unpublish_status_invalid', - }) - } - const now = nowIso() - const updated = await updateWorkOrder(workOrder.id, { - status: WORK_ORDER_STATUS.UNASSIGNED, - published_at: null, - updated_at: now, - }) - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'admin', - actorId: '', - eventType: 'unpublished', - fromStatus: workOrder.status, - toStatus: WORK_ORDER_STATUS.UNASSIGNED, - now, - }) - return { order: mapWorkOrderAdmin(updated || workOrder) } -} - -export async function markAdminWorkOrderProblem( - workOrderId: number | string, - payload: JsonObject = {}, -) { - const workOrder = await getRequiredWorkOrder(workOrderId) - if ( - ![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes( - workOrder.status as never, - ) - ) { - throw createHttpError('当前状态不能标记问题单', { - statusCode: 409, - errorCode: 'work_order_problem_status_invalid', - }) - } - const now = nowIso() - const note = String(payload.note || payload.problemNote || '').trim() - const updated = await updateWorkOrder(workOrder.id, { - status: WORK_ORDER_STATUS.PROBLEM, - problem_note: note, - updated_at: now, - }) - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'admin', - actorId: '', - eventType: 'marked_problem', - fromStatus: workOrder.status, - toStatus: WORK_ORDER_STATUS.PROBLEM, - payloadJson: JSON.stringify({ note }), - now, - }) - return { order: mapWorkOrderAdmin(updated || workOrder) } -} - -export async function resolveAdminProblemWorkOrder( - workOrderId: number | string, - payload: JsonObject = {}, -) { - const workOrder = await getRequiredWorkOrder(workOrderId) - if (workOrder.status !== WORK_ORDER_STATUS.PROBLEM) { - throw createHttpError('只有问题单可以执行处置', { - statusCode: 409, - errorCode: 'work_order_problem_resolution_status_invalid', - }) - } - - const action = normalizeProblemResolutionAction(payload.action) - const note = String(payload.note || '').trim() - if (!note) { - throw createHttpError('请填写处置备注', { - statusCode: 400, - errorCode: 'work_order_problem_resolution_note_required', - }) - } - - const updated = await resolveProblemWorkOrder({ - workOrderId: workOrder.id, - action, - note, - now: nowIso(), - }) - if (!updated) { - throw createHttpError('问题单处置失败,订单状态可能已变化', { - statusCode: 409, - errorCode: 'work_order_problem_resolution_conflict', - }) - } - return { order: mapWorkOrderAdmin(updated) } -} - -export async function acceptAdminWorkOrder(workOrderId: number | string) { - const workOrder = await getRequiredWorkOrder(workOrderId) - if (workOrder.status !== WORK_ORDER_STATUS.PENDING_ACCEPTANCE) { - throw createHttpError('只有待验收订单可以通过验收', { - statusCode: 409, - errorCode: 'work_order_accept_status_invalid', - }) - } - const now = nowIso() - const { order: updated, failureReason } = await acceptWorkOrderAndSettle({ - workOrderId: workOrder.id, - now, - }) - if (failureReason === 'sharing_pending_submissions') { - throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', { - statusCode: 409, - errorCode: 'work_order_sharing_pending_submissions', - }) - } - if (!updated) { - throw createHttpError('验收失败,订单状态可能已变化', { - statusCode: 409, - errorCode: 'work_order_accept_conflict', - }) - } - return { order: mapWorkOrderAdmin(updated || workOrder) } -} - -export async function getAdminWorkerPlatformSummary() { - const [pendingWorkers, pendingMaterial, openOrders, inProgressOrders] = await Promise.all([ - listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }), - listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.PENDING_MATERIAL }), - listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.OPEN }), - listWorkOrders({ page: 1, pageSize: 1, status: WORK_ORDER_STATUS.IN_PROGRESS }), - ]) - return { - pendingWorkerCount: pendingWorkers.total, - pendingMaterialCount: pendingMaterial.total, - openOrderCount: openOrders.total, - inProgressOrderCount: inProgressOrders.total, - } -} - -export async function syncWorkerOrdersForSourceOrder( - order: OrderRow, - orderItems: OrderItemRow[], - options: { source?: string; autoOnly?: boolean } = {}, -) { - const rules = await listWorkProductRules({ enabled: true }) - const created: WorkOrderRow[] = [] - const skipped: Array<{ orderItemId: number; reason: string }> = [] - - for (const item of orderItems) { - const rule = resolveMatchingProductRule(order, item, rules) - if (!rule) { - skipped.push({ orderItemId: Number(item.id), reason: 'rule_not_matched' }) - continue - } - if (options.autoOnly && !rule.auto_create) { - skipped.push({ orderItemId: Number(item.id), reason: 'auto_create_disabled' }) - continue - } - if (await getWorkOrderByOrderItemId(item.id)) { - skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' }) - continue - } - - const fields = normalizeRequirementFields(safeParseJson(rule.requirement_json).fields) - const rewardAmount = Number(rule.reward_amount || 0) - const depositThresholdAmount = Number( - rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, - ) - const requiredDepositAmount = - Number(rule.required_deposit_amount || 0) || - (rewardAmount > depositThresholdAmount ? rewardAmount : 0) - const now = nowIso() - const materialComplete = fields.every((field) => !field.required) - const workOrder = await createWorkOrder({ - workOrderNo: randomId('WO'), - orderId: Number(order.id), - orderItemId: Number(item.id), - taskId: null, - platformOrderId: order.platform_order_id, - productName: item.sku_name || item.sku_code, - categoryId: rule.category_id, - status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL, - rewardAmount, - requiredDepositAmount, - depositThresholdAmount, - sharingEnabled: rule.sharing_enabled === true, - sharingTotalQuantity: Number(rule.sharing_total_quantity || 1), - sharingUnitReward: Number(rule.sharing_unit_reward || 0), - materialJson: JSON.stringify({ - source: { - orderId: Number(order.id), - orderItemId: Number(item.id), - provider: order.provider, - platform: order.platform, - shopId: order.shop_id, - skuCode: item.sku_code, - skuName: item.sku_name, - quantity: Number(item.quantity || 1), - syncSource: options.source || 'source_order', - }, - }), - requirementJson: JSON.stringify({ fields }), - now, - }) - - if (workOrder) { - created.push(workOrder) - await createWorkOrderEvent({ - workOrderId: workOrder.id, - actorType: 'system', - actorId: options.source || 'source_order', - eventType: 'source_synced', - fromStatus: '', - toStatus: workOrder.status, - payloadJson: JSON.stringify({ - ruleKey: rule.rule_key, - orderId: order.id, - orderItemId: item.id, - }), - now, - }) - } - } - - return { - created: created.map(mapWorkOrderAdmin), - skipped, - createdCount: created.length, - skippedCount: skipped.length, - } -} - -function createWorkerSession(worker: WorkerUserRow) { - const issuedAt = nowIso() - const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin?.sessionTtlHours || 12)) - const payload = { - typ: 'worker', - sid: crypto.randomBytes(12).toString('hex'), - uid: Number(worker.id), - usr: worker.username, - status: worker.status, - ver: normalizeSessionVersion(worker.session_version), - iat: issuedAt, - exp: expiresAt, - } - const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') - return { - token: `${encodedPayload}.${signWorkerPayload(encodedPayload)}`, - expiresAt, - worker: mapWorkerUser(worker), - } -} - -export function resolveCollectSubmitTargetWorkOrder( - orderNo: string, - workOrders: WorkOrderRow[], - requestedWorkOrderId: unknown, -): WorkOrderRow { - const workOrderId = normalizeOptionalId(requestedWorkOrderId) - if (workOrderId) { - const matched = workOrders.find((item) => Number(item.id) === workOrderId) - if (matched) { - return matched - } - throw createHttpError('待完善订单不存在或已更新,请刷新后重试', { - statusCode: 404, - errorCode: 'collect_work_order_not_found', - context: { orderNo, workOrderId }, - }) - } - - if (workOrders.length === 1) { - const [singleWorkOrder] = workOrders - if (singleWorkOrder) { - return singleWorkOrder - } - } - - throw createHttpError('该订单存在多个待完善商品,请逐个提交资料', { - statusCode: 409, - errorCode: 'collect_work_order_required', - context: { - orderNo, - pendingCount: workOrders.length, - }, - }) -} - -function ensureWorkerAuthConfigured() { - if (String(runtimeConfig.admin?.sessionSecret || '').trim()) { - return - } - - throw createHttpError('接单登录态签名密钥未配置,请设置 ADMIN_SESSION_SECRET', { - statusCode: 503, - errorCode: 'worker_auth_not_configured', - }) -} - -function hashWorkerPassword(password: string): string { - const salt = crypto.randomBytes(16).toString('hex') - const derived = crypto.scryptSync(password, salt, 64).toString('hex') - return `scrypt$${salt}$${derived}` -} - -function verifyWorkerPassword(password: string, storedHash: string): boolean { - const [algorithm, salt, expectedHash] = String(storedHash || '').split('$') - if (algorithm !== 'scrypt' || !salt || !expectedHash) return false - return safeCompare(crypto.scryptSync(password, salt, 64).toString('hex'), expectedHash) -} - -function signWorkerPayload(encodedPayload: string): string { - return crypto - .createHmac('sha256', String(runtimeConfig.admin?.sessionSecret || '')) - .update(`worker:${encodedPayload}`) - .digest('base64url') -} - -function safeCompare(input: unknown, expected: unknown): boolean { - const left = Buffer.from(String(input || ''), 'utf8') - const right = Buffer.from(String(expected || ''), 'utf8') - if (left.length !== right.length) return false - return crypto.timingSafeEqual(left, right) -} - -async function getRequiredWorker(workerId: number | string): Promise { - const worker = await getWorkerUserById(workerId) - if (!worker) { - throw createHttpError('接单账号不存在', { - statusCode: 404, - errorCode: 'worker_not_found', - }) - } - return worker -} - -async function getRequiredWorkOrder(workOrderId: number | string): Promise { - const workOrder = await getWorkOrderById(workOrderId) - if (!workOrder) { - throw createHttpError('接单订单不存在', { - statusCode: 404, - errorCode: 'work_order_not_found', - }) - } - return workOrder -} - -async function resolveSourceOrder(payload: JsonObject): Promise { - const orderId = normalizeOptionalId(payload.orderId || payload.sourceOrderId) - if (orderId) { - const order = await getOrderById(orderId) - if (order) { - return order - } - throw createHttpError('源订单不存在', { - statusCode: 404, - errorCode: 'source_order_not_found', - }) - } - - const platformOrderId = String(payload.platformOrderId || payload.orderNo || '').trim() - if (!platformOrderId) { - throw createHttpError('请填写源订单号', { - statusCode: 400, - errorCode: 'source_order_no_required', - }) - } - - const order = await findLatestOrderByAnyPlatformOrderId(platformOrderId) - if (!order) { - throw createHttpError('未找到源订单,请确认订单号是否正确', { - statusCode: 404, - errorCode: 'source_order_not_found', - }) - } - return order -} - -function resolveWorkerPermissions(worker: WorkerUserRow) { - const permission = safeParseJson(worker.level_permission_json) - return { - depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0), - maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1), - upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0), - } -} - -function resolveFreezeDepositAmount( - workOrder: WorkOrderRow, - permissions: { depositFreeAmount: number }, -) { - return Math.max( - 0, - Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0), - ) -} - -function mapWorkerLevel(level: WorkerLevelRow | null | undefined) { - if (!level) return null - const permission = safeParseJson(level.permission_json) - return { - levelId: Number(level.id), - levelKey: level.level_key, - name: level.name, - sortOrder: Number(level.sort_order || 100), - status: level.status, - depositFreeAmount: normalizeInteger(permission.depositFreeAmount, 0), - maxActiveOrders: normalizePositiveInteger(permission.maxActiveOrders, 1), - upgradeThreshold: normalizeInteger(permission.upgradeThreshold, 0), - createdAt: level.created_at, - updatedAt: level.updated_at, - } -} - -function mapWorkCategory(category: WorkCategoryRow | null | undefined) { - if (!category) return null - return { - categoryId: Number(category.id), - categoryKey: category.category_key, - name: category.name, - sortOrder: Number(category.sort_order || 100), - status: category.status, - createdAt: category.created_at, - updatedAt: category.updated_at, - } -} - -function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) { - if (!rule) return null - const requirement = safeParseJson(rule.requirement_json) - return { - ruleId: Number(rule.id), - ruleKey: rule.rule_key, - provider: rule.provider || '', - platform: rule.platform || '', - shopId: rule.shop_id || '', - skuCode: rule.sku_code || '', - productName: rule.product_name || '', - matchType: rule.match_type || 'contains', - categoryId: rule.category_id ? Number(rule.category_id) : null, - categoryName: rule.category_name || '', - enabled: Boolean(rule.enabled), - autoCreate: Boolean(rule.auto_create), - rewardAmount: Number(rule.reward_amount || 0), - requiredDepositAmount: Number(rule.required_deposit_amount || 0), - depositThresholdAmount: Number( - rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, - ), - sharing: { - enabled: rule.sharing_enabled === true, - totalQuantity: Number(rule.sharing_total_quantity || 1), - unitReward: Number(rule.sharing_unit_reward || 0), - }, - requirement: { - fields: normalizeRequirementFields(requirement.fields), - }, - sortOrder: Number(rule.sort_order || 100), - createdAt: rule.created_at, - updatedAt: rule.updated_at, - } -} - -function mapWorkerUser(worker: WorkerUserRow) { - return { - workerId: Number(worker.id), - username: worker.username, - displayName: worker.display_name || worker.username, - phone: worker.phone, - status: worker.status, - reviewNote: worker.review_note || '', - inviteCode: worker.invite_code || '', - inviter: worker.inviter_id - ? { - workerId: Number(worker.inviter_id), - username: worker.inviter_username || '', - displayName: worker.inviter_display_name || worker.inviter_username || '', - } - : null, - level: worker.level_id - ? { - levelId: Number(worker.level_id), - levelKey: worker.level_key || '', - name: worker.level_name || '', - permissions: resolveWorkerPermissions(worker), - } - : null, - wallet: { - availableAmount: Number(worker.available_amount || 0), - frozenDepositAmount: Number(worker.frozen_deposit_amount || 0), - totalCreditedAmount: Number(worker.total_credited_amount || 0), - totalSettledAmount: Number(worker.total_settled_amount || 0), - }, - createdAt: worker.created_at, - updatedAt: worker.updated_at, - reviewedAt: worker.reviewed_at, - } -} - -function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined) { - if (!ledger) return null - return { - ledgerId: Number(ledger.id), - ledgerType: ledger.ledger_type, - amount: Number(ledger.amount || 0), - balanceAfter: Number(ledger.balance_after || 0), - frozenAfter: Number(ledger.frozen_after || 0), - relatedWorkOrderId: ledger.related_work_order_id - ? Number(ledger.related_work_order_id) - : null, - auditStatus: ledger.audit_status || '', - note: ledger.note || '', - payload: safeParseJson(ledger.payload_json), - createdAt: ledger.created_at, - } -} - -function mapFinanceRequest(request: WorkerFinanceRequestRow | null | undefined) { - if (!request) return null - return { - requestId: Number(request.id), - requestType: request.request_type || '', - status: request.status || '', - amount: Number(request.amount || 0), - accountChannel: request.account_channel || '', - accountName: request.account_name || '', - accountNo: request.account_no || '', - note: request.note || '', - reviewedNote: request.reviewed_note || '', - payload: safeParseJson(request.payload_json), - createdAt: request.created_at, - updatedAt: request.updated_at, - reviewedAt: request.reviewed_at, - worker: request.worker_id - ? { - workerId: Number(request.worker_id), - username: request.worker_username || '', - displayName: request.worker_display_name || request.worker_username || '', - phone: request.worker_phone || '', - } - : null, - } -} - -function mapWorkOrderAdmin(workOrder: WorkOrderRow) { - return { - workOrderId: Number(workOrder.id), - orderId: workOrder.order_id ? Number(workOrder.order_id) : null, - orderItemId: workOrder.order_item_id ? Number(workOrder.order_item_id) : null, - workOrderNo: workOrder.work_order_no, - platformOrderId: workOrder.platform_order_id, - productName: workOrder.product_name, - categoryName: workOrder.category_name || '', - status: workOrder.status, - rewardAmount: Number(workOrder.reward_amount || 0), - requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), - sharing: { - enabled: workOrder.sharing_enabled === true, - totalQuantity: Number(workOrder.sharing_total_quantity || 1), - unitReward: Number(workOrder.sharing_unit_reward || 0), - }, - depositThresholdAmount: Number( - workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, - ), - material: safeParseJson(workOrder.material_json), - requirement: safeParseJson(workOrder.requirement_json), - acceptance: mapAcceptanceForResponse(workOrder.acceptance_json), - problemNote: workOrder.problem_note || '', - worker: workOrder.assigned_worker_id - ? { - workerId: Number(workOrder.assigned_worker_id), - username: workOrder.worker_username || '', - displayName: workOrder.worker_display_name || '', - } - : null, - publishedAt: workOrder.published_at, - assignedAt: workOrder.assigned_at, - submittedAt: workOrder.submitted_at, - acceptedAt: workOrder.accepted_at, - createdAt: workOrder.created_at, - updatedAt: workOrder.updated_at, - } -} - -function mapWorkOrderForWorker( - workOrder: WorkOrderRow, - permissions: { depositFreeAmount: number }, - myShare?: WorkOrderShareRow | null, -) { - const mapped = mapWorkOrderAdmin(workOrder) - const { - requiredDepositAmount, - depositThresholdAmount, - worker: _worker, - orderId: _orderId, - orderItemId: _orderItemId, - ...visibleOrder - } = mapped - return { - ...visibleOrder, - freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions), - myShare: myShare ? mapWorkOrderShare(myShare) : null, - } -} - -function mapWorkOrderShare(share: WorkOrderShareRow) { - return { - shareId: Number(share.id), - workOrderId: Number(share.work_order_id), - workerId: Number(share.worker_id), - quantity: Number(share.quantity || 0), - unitReward: Number(share.unit_reward || 0), - shareReward: Number(share.share_reward || 0), - shareDeposit: Number(share.share_deposit || 0), - status: share.status, - acceptance: safeParseJson(share.acceptance_json), - submittedAt: share.submitted_at, - acceptedAt: share.accepted_at, - createdAt: share.created_at, - updatedAt: share.updated_at, - worker: share.worker_id - ? { - workerId: Number(share.worker_id), - username: share.worker_username || '', - displayName: share.worker_display_name || share.worker_username || '', - } - : null, - } -} - -function mapWorkOrderPublic(workOrder: WorkOrderRow) { - return { - workOrderId: Number(workOrder.id), - workOrderNo: workOrder.work_order_no, - platformOrderId: workOrder.platform_order_id, - productName: workOrder.product_name, - status: workOrder.status, - } -} - -function mapCollectLookupOrder(workOrder: WorkOrderRow) { - return { - ...mapWorkOrderPublic(workOrder), - fields: resolveRequirementFields(workOrder), - values: resolveCollectSubmittedFieldValues(workOrder), - } -} - -function mapWallet(wallet: Awaited>) { - return { - workerId: Number(wallet?.worker_id || 0), - availableAmount: Number(wallet?.available_amount || 0), - frozenDepositAmount: Number(wallet?.frozen_deposit_amount || 0), - totalCreditedAmount: Number(wallet?.total_credited_amount || 0), - totalSettledAmount: Number(wallet?.total_settled_amount || 0), - } -} - -function resolveRequirementFields(workOrder: WorkOrderRow) { - return normalizeRequirementFields(safeParseJson(workOrder.requirement_json).fields) -} - -function normalizeRequirementFields(value: unknown) { - const rawFields = Array.isArray(value) ? value : [] - const fields = rawFields - .map((item) => { - const source = - item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {} - const key = String(source.key || '').trim() - if (!key) return null - return { - key, - label: String(source.label || key).trim(), - required: source.required !== false, - mockValue: String(source.mockValue || '').trim(), - } - }) - .filter((item): item is { key: string; label: string; required: boolean; mockValue: string } => - Boolean(item), - ) - - return fields.length > 0 - ? fields - : [ - { key: 'gameAccount', label: '游戏账号', required: true, mockValue: 'test_account' }, - { key: 'serverName', label: '区服', required: true, mockValue: '安卓一区' }, - { key: 'roleName', label: '角色名', required: true, mockValue: '测试角色' }, - ] -} - -function normalizeRequirementFieldsFromPayload(payload: JsonObject) { - const directFields = Array.isArray(payload.fields) ? payload.fields : null - const requirement = - payload.requirement && - typeof payload.requirement === 'object' && - !Array.isArray(payload.requirement) - ? (payload.requirement as JsonObject) - : {} - if (directFields) { - return normalizeRequirementFields(directFields) - } - if (Array.isArray(requirement.fields)) { - return normalizeRequirementFields(requirement.fields) - } - - const textFields = normalizeRequirementFieldsText( - payload.fieldsText || payload.requiredFieldsText, - ) - return textFields.length > 0 ? textFields : normalizeRequirementFields([]) -} - -function normalizeRequirementFieldsText(value: unknown) { - return String(value || '') - .split(/\r?\n/) - .map((line, index) => { - const text = line.trim() - if (!text) return null - const delimiter = text.includes(':') ? ':' : ':' - if (text.includes(delimiter)) { - const [rawKey, ...labelParts] = text.split(delimiter) - const key = String(rawKey || '').trim() - if (!key) return null - return { - key, - label: labelParts.join(delimiter).trim() || key, - required: true, - mockValue: '', - } - } - return { - key: `field${index + 1}`, - label: text, - required: true, - mockValue: '', - } - }) - .filter((item): item is { key: string; label: string; required: boolean; mockValue: string } => - Boolean(item), - ) -} - -function resolveMatchingProductRule( - order: OrderRow, - item: OrderItemRow, - rules: WorkProductRuleRow[], -) { - return ( - rules.find((rule) => { - if (!matchesOptionalText(rule.provider, order.provider)) return false - if (!matchesOptionalText(rule.platform, order.platform)) return false - if (!matchesOptionalText(rule.shop_id, order.shop_id)) return false - - const skuCode = String(item.sku_code || '') - .trim() - .toLowerCase() - const skuName = String(item.sku_name || '') - .trim() - .toLowerCase() - const ruleSkuCode = String(rule.sku_code || '') - .trim() - .toLowerCase() - const ruleProductName = String(rule.product_name || '') - .trim() - .toLowerCase() - - if (ruleSkuCode && ruleSkuCode !== skuCode) { - return false - } - if (!ruleProductName) { - return Boolean(ruleSkuCode) - } - if (rule.match_type === 'exact') { - return ruleProductName === skuName || ruleProductName === skuCode - } - return skuName.includes(ruleProductName) || skuCode.includes(ruleProductName) - }) || null - ) -} - -function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) { - const ruleText = String(ruleValue || '') - .trim() - .toLowerCase() - if (!ruleText) { - return true - } - return ( - ruleText === - String(sourceValue || '') - .trim() - .toLowerCase() - ) -} - -function normalizeSubmittedFields(value: unknown) { - const source = - value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} - return Object.fromEntries( - Object.entries(source) - .filter(([key]) => !['orderNo', 'platformOrderId', 'fields', 'material'].includes(key)) - .map(([key, currentValue]) => [key, String(currentValue || '').trim()]), - ) -} - -function normalizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.map((item) => String(item || '').trim()).filter(Boolean) -} - -function normalizeUploadedFiles(value: unknown) { - if (!Array.isArray(value)) return [] - return value - .map((item) => { - const source = - item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {} - const objectKey = String(source.objectKey || source.object_key || '').trim() - const url = String(source.url || '').trim() - if (!objectKey && !url) return null - return { - objectKey, - url: normalizeStoredFileUrl(url, objectKey), - thumbnailUrl: normalizeStoredFileUrl( - source.thumbnailUrl || source.thumbnail_url || url, - objectKey, - ), - mediumUrl: normalizeStoredFileUrl(source.mediumUrl || source.medium_url || url, objectKey), - filename: String(source.filename || '').trim(), - contentType: String(source.contentType || source.content_type || '').trim(), - size: normalizeInteger(source.size, 0), - } - }) - .filter( - ( - item, - ): item is { - objectKey: string - url: string - thumbnailUrl: string - mediumUrl: string - filename: string - contentType: string - size: number - } => Boolean(item), - ) -} - -function mapAcceptanceForResponse(value: unknown) { - const acceptance = safeParseJson(value) - const files = normalizeUploadedFiles(acceptance.files).map((file) => - refreshUploadedFileUrls(file), - ) - const imageUrls = normalizeStringArray(acceptance.imageUrls).map((url) => - refreshStoredFileUrl(url), - ) - return { - ...acceptance, - files, - imageUrls, - } -} - -function resolveCollectSubmittedFieldValues(workOrder: WorkOrderRow) { - const material = safeParseJson(workOrder.material_json) - const collect = - material.collect && typeof material.collect === 'object' && !Array.isArray(material.collect) - ? safeParseJson(material.collect) - : {} - return normalizeSubmittedFields(collect.fields) -} - -function normalizeReviewStatus(value: unknown) { - const status = String(value || '').trim() - if (['active', 'rejected', 'disabled', 'pending_review'].includes(status)) return status - return 'pending_review' -} - -function normalizeProblemResolutionAction(value: unknown): ProblemWorkOrderResolutionAction { - const action = String(value || '').trim() - if (['return_to_worker', 'reopen', 'cancel_release', 'cancel_deduct'].includes(action)) { - return action as ProblemWorkOrderResolutionAction - } - throw createHttpError('问题单处置方式不正确', { - statusCode: 400, - errorCode: 'work_order_problem_resolution_action_invalid', - }) -} - -function throwGrabWorkOrderFailure(failureReason: GrabWorkOrderFailureReason | null): never { - if (failureReason === 'worker_active_order_limit') { - throw createHttpError('已达到当前等级最大同时接单量', { - statusCode: 409, - errorCode: 'worker_active_order_limit', - }) - } - if (failureReason === 'worker_deposit_insufficient') { - throw createHttpError('余额不足,无法冻结所需押金', { - statusCode: 409, - errorCode: 'worker_deposit_insufficient', - }) - } - throw createHttpError('抢单失败,订单可能已被抢走', { - statusCode: 409, - errorCode: 'work_order_grab_conflict', - }) -} - -function normalizeEnabledStatus(value: unknown) { - return String(value || 'active').trim() === 'disabled' ? 'disabled' : 'active' -} - -function normalizeWalletLedgerType(value: unknown) { - const ledgerType = String(value || '').trim() - if (!ledgerType) return '' - if ( - [ - 'manual_credit', - 'withdraw_paid', - 'deposit_freeze', - 'deposit_release', - 'deposit_deduction', - 'reward_settlement', - ].includes(ledgerType) - ) { - return ledgerType - } - throw createHttpError('钱包流水类型不正确', { - statusCode: 400, - errorCode: 'worker_wallet_ledger_type_invalid', - }) -} - -function normalizeFinanceRequestType(value: unknown) { - const requestType = String(value || '').trim() - if (!requestType) return '' - if (['recharge', 'withdraw'].includes(requestType)) { - return requestType - } - throw createHttpError('资金申请类型不正确', { - statusCode: 400, - errorCode: 'worker_finance_request_type_invalid', - }) -} - -function normalizeFinanceRequestStatus(value: unknown) { - const status = String(value || '').trim() - if (!status) return '' - if (['pending', 'approved', 'rejected', 'cancelled'].includes(status)) { - return status - } - throw createHttpError('资金申请状态不正确', { - statusCode: 400, - errorCode: 'worker_finance_request_status_invalid', - }) -} - -function normalizeAdminFinanceReviewStatus(value: unknown) { - const status = String(value || '').trim() - if (['approved', 'rejected', 'cancelled'].includes(status)) { - return status - } - throw createHttpError('审核状态不正确', { - statusCode: 400, - errorCode: 'worker_finance_review_status_invalid', - }) -} - -function normalizeWithdrawChannel(value: unknown) { - const channel = String(value || '').trim() - if (['alipay', 'wechat', 'bank'].includes(channel)) { - return channel - } - throw createHttpError('提现方式不正确', { - statusCode: 400, - errorCode: 'worker_withdraw_channel_invalid', - }) -} - -function normalizeMatchType(value: unknown) { - const matchType = String(value || '').trim() - if (matchType === 'exact') return 'exact' - return 'contains' -} - -function normalizeBoolean(value: unknown, fallback: boolean) { - if (typeof value === 'boolean') return value - const text = String(value ?? '') - .trim() - .toLowerCase() - if (!text) return fallback - if (['true', '1', 'yes', 'enabled', 'active', 'on'].includes(text)) return true - if (['false', '0', 'no', 'disabled', 'off'].includes(text)) return false - return fallback -} - -function normalizeSlugKey(value: unknown, fallback: string) { - const key = String(value || '') - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, '-') - .replace(/^-+|-+$/g, '') - return key || fallback -} - -function normalizeUsername(value: unknown): string { - return String(value || '') - .trim() - .toLowerCase() -} - -function normalizePassword(value: unknown): string { - return String(value || '').trim() -} - -function validateWorkerUsername(username: string) { - if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) { - throw createHttpError('接单账号格式无效,需为 3-32 位字母数字或 ._-', { - statusCode: 400, - errorCode: 'worker_username_invalid', - }) - } -} - -function validateWorkerPassword(password: string) { - if (password.length < 6) { - throw createHttpError('接单密码至少 6 位', { - statusCode: 400, - errorCode: 'worker_password_invalid', - }) - } -} - -function normalizeSessionVersion(value: unknown): number { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : 1 -} - -function normalizeOptionalId(value: unknown): number | null { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : null -} - -function normalizeInteger(value: unknown, fallback: number): number { - const parsed = Number(value) - return Number.isInteger(parsed) ? parsed : fallback -} - -function normalizePositiveInteger(value: unknown, fallback: number): number { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback -} - -function normalizeAmountFen(value: unknown, fallback: number): number { - const text = String(value ?? '').trim() - if (!text) return fallback - const parsed = Number(text) - if (!Number.isFinite(parsed) || parsed < 0) return fallback - return Math.round(parsed * 100) -} - -function normalizeProofFiles(value: unknown): Array> { - if (!Array.isArray(value)) return [] - const files: Array> = [] - for (const item of value) { - if (!item || typeof item !== 'object') continue - const file = item as Record - const url = String(file.url || '').trim() - if (!url) continue - files.push({ - url, - thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(), - mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(), - objectKey: String(file.objectKey || file.object_key || '').trim(), - originalFilename: String( - file.originalFilename || file.original_filename || file.name || '', - ).trim(), - contentType: String(file.contentType || file.content_type || '').trim(), - sizeBytes: Number(file.sizeBytes || file.size_bytes || 0), - }) - if (files.length >= 10) break - } - return files -} - -function isIgnorableWorkerAuthError(error: unknown) { - return IGNORABLE_WORKER_AUTH_ERROR_CODES.has( - String((error as { errorCode?: string })?.errorCode || '').trim(), - ) -} diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts new file mode 100644 index 00000000..1af3956b --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -0,0 +1,1084 @@ +import crypto from 'node:crypto' + +import { runtimeConfig } from '../../config/runtime.js' +import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' +import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js' +import { + acceptWorkOrderAndSettle, + addWorkerWalletCredit, + countWorkerAcceptedOrders, + countWorkerActiveOrders, + countWorkCategoryUsages, + countWorkerLevelUsages, + countWorkOrderPendingSharingSubmissions, + cancelWorkerWorkOrder, + countWorkerCancellationsSince, + createWorkOrder, + createWorkOrderEvent, + createWorkerFinanceRequest, + createWorkerUser, + deleteWorkCategory, + deleteWorkerLevel, + getWorkerFinanceRequestById, + getWorkerFinanceRequestSummary, + getWorkCategoryById, + getWorkCategoryByKey, + getWorkerLevelById, + getWorkerLevelByKey, + getWorkerUserByInviteCode, + getWorkerUserById, + getWorkerUserByUsername, + getWorkOrderById, + getWorkOrderByOrderItemId, + getWorkOrderShare, + grabWorkOrder, + incrementWorkerSessionVersion, + joinWorkOrderShare, + listAllWorkCategories, + listPendingMaterialWorkOrdersByPlatformOrderId, + listWorkerFinanceRequests, + listWorkerSharesByWorker, + listWorkerWalletLedgers, + listWorkCategories, + listWorkOrders, + listWorkProductRules, + listWorkerLevels, + listWorkerUsers, + listWorkOrderShares, + listWorkOrderSharesByOrderIds, + resolveProblemWorkOrder, + reviewWorkerFinanceRequest, + submitWorkOrderShareAcceptance, + updateWorkOrder, + updateWorkerPassword, + updateWorkerUser, + upsertWorkCategory, + upsertWorkProductRule, + upsertWorkerLevel, + type WorkCategoryRow, + type WorkerFinanceRequestRow, + type WorkOrderRow, + type WorkProductRuleRow, + type WorkerLevelRow, + type WorkOrderShareRow, + type WorkerWalletLedgerRow, + type WorkerUserRow, + type GrabWorkOrderFailureReason, + type ProblemWorkOrderResolutionAction, +} from '../../repositories/worker-platform/index.js' +import type { JsonObject } from '../../types/json.js' +import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' +import { createHttpError } from '../../utils/http.js' +import { randomId } from '../../utils/random.js' +import { nowIso, addHours } from '../../utils/time.js' +import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' +import { + normalizeStoredFileUrl, + refreshStoredFileUrl, + refreshUploadedFileUrls, +} from '../file-storage/file-storage-service.js' +import { + getWorkerFinanceConfig, + saveWorkerFinanceConfig, +} from './worker-finance-config-service.js' + +import { DEFAULT_CATEGORY_KEY, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, INVITE_CODE_LENGTH, INVITE_CODE_RETRY_LIMIT, WORKER_CANCEL_LIMIT_PER_WINDOW, WORKER_CANCEL_LIMIT_WINDOW_MS, createWorkerSession, ensureWorkerAuthConfigured, hashWorkerPassword, isIgnorableWorkerAuthError, mapCollectLookupOrder, mapFinanceRequest, mapWalletLedger, mapWorkCategory, mapWorkOrderForWorker, mapWorkOrderPublic, mapWorkOrderShare, mapWorkerUser, normalizeAmountFen, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeOptionalId, normalizePassword, normalizePositiveInteger, normalizeProofFiles, normalizeSessionVersion, normalizeStringArray, normalizeSubmittedFields, normalizeUploadedFiles, normalizeUsername, normalizeWalletLedgerType, normalizeWithdrawChannel, resolveFreezeDepositAmount, resolveRequirementFields, resolveWorkerPermissions, safeCompare, signWorkerPayload, throwGrabWorkOrderFailure, validateWorkerPassword, validateWorkerUsername, verifyWorkerPassword } from './mappers.js' + +export type WorkerSession = { + sessionId: string + workerId: number + username: string + status: string + expiresAt: string + sessionVersion: number +} + + +export async function ensureWorkerPlatformDefaults() { + const now = nowIso() + const existingLevel = await getWorkerLevelByKey(DEFAULT_LEVEL_KEY) + const level = + existingLevel || + (await upsertWorkerLevel({ + levelKey: DEFAULT_LEVEL_KEY, + name: DEFAULT_LEVEL_NAME, + sortOrder: 10, + status: 'active', + permissionJson: JSON.stringify({ + depositFreeAmount: 0, + maxActiveOrders: 1, + upgradeThreshold: 0, + }), + now, + })) + const existingCategory = await getWorkCategoryByKey(DEFAULT_CATEGORY_KEY) + const category = + existingCategory || + (await upsertWorkCategory({ + categoryKey: DEFAULT_CATEGORY_KEY, + name: '默认分类', + sortOrder: 100, + status: 'active', + now, + })) + return { level, category } +} + +export async function registerWorker(payload: JsonObject = {}) { + ensureWorkerAuthConfigured() + const username = normalizeUsername(payload.username) + const password = normalizePassword(payload.password) + const displayName = String(payload.displayName || payload.nickname || username).trim() + const phone = String(payload.phone || '').trim() + const inviteCode = String(payload.inviteCode || '').trim().toUpperCase() + + validateWorkerUsername(username) + validateWorkerPassword(password) + + if (await getWorkerUserByUsername(username)) { + throw createHttpError('接单账号已存在', { + statusCode: 409, + errorCode: 'worker_user_exists', + }) + } + + const inviter = inviteCode ? await getWorkerUserByInviteCode(inviteCode) : null + if (inviteCode && !inviter) { + throw createHttpError('邀请码无效', { + statusCode: 400, + errorCode: 'worker_invite_code_invalid', + }) + } + if (inviter && inviter.status !== 'active') { + throw createHttpError('邀请人账号不可用', { + statusCode: 400, + errorCode: 'worker_inviter_invalid', + }) + } + + const defaults = await ensureWorkerPlatformDefaults() + const now = nowIso() + const created = await createWorkerUser({ + username, + passwordHash: hashWorkerPassword(password), + displayName, + phone, + levelId: defaults.level?.id || null, + status: 'pending_review', + inviterId: inviter ? Number(inviter.id) : null, + inviteCode: await generateInviteCode(now), + now, + }) + + if (!created) { + throw createHttpError('接单账号注册失败', { + statusCode: 500, + errorCode: 'worker_register_failed', + }) + } + + return { + worker: mapWorkerUser(created), + reviewRequired: true, + } +} + +async function generateInviteCode(now: string): Promise { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + for (let attempt = 0; attempt < INVITE_CODE_RETRY_LIMIT; attempt++) { + const candidate = Array.from( + { length: INVITE_CODE_LENGTH }, + () => chars[Math.floor(crypto.randomInt(chars.length))], + ).join('') + if (!(await getWorkerUserByInviteCode(candidate))) { + return candidate + } + } + throw createHttpError('邀请码生成失败,请重试', { + statusCode: 500, + errorCode: 'worker_invite_code_generation_failed', + }) +} + +export async function loginWorker(username: unknown, password: unknown) { + ensureWorkerAuthConfigured() + const normalizedUsername = normalizeUsername(username) + const normalizedPassword = normalizePassword(password) + + if (!normalizedUsername || !normalizedPassword) { + throw createHttpError('缺少接单账号或密码', { + statusCode: 400, + errorCode: 'worker_credentials_required', + }) + } + + const worker = await getWorkerUserByUsername(normalizedUsername) + if (!worker || !verifyWorkerPassword(normalizedPassword, worker.password_hash)) { + throw createHttpError('账号或密码错误', { + statusCode: 401, + errorCode: 'worker_login_failed', + }) + } + + if (worker.status === 'disabled') { + throw createHttpError('接单账号已停用', { + statusCode: 403, + errorCode: 'worker_disabled', + }) + } + + return createWorkerSession(worker) +} + +export async function logoutWorkerSession(token: unknown) { + const normalizedToken = String(token || '').trim() + if (!normalizedToken) { + return { success: true, revoked: false } + } + + try { + const session = await verifyWorkerSessionToken(normalizedToken) + const revokedWorker = await incrementWorkerSessionVersion(session.workerId, nowIso()) + return { + success: true, + revoked: Boolean(revokedWorker), + } + } catch (error) { + if (isIgnorableWorkerAuthError(error)) { + return { success: true, revoked: false } + } + throw error + } +} + +export async function verifyWorkerSessionToken(token: unknown): Promise { + ensureWorkerAuthConfigured() + const normalizedToken = String(token || '').trim() + if (!normalizedToken) { + throw createHttpError('未登录或登录已失效', { + statusCode: 401, + errorCode: 'worker_auth_required', + }) + } + + const [encodedPayload, signature] = normalizedToken.split('.') + if (!encodedPayload || !signature || !safeCompare(signature, signWorkerPayload(encodedPayload))) { + throw createHttpError('接单登录态无效', { + statusCode: 401, + errorCode: 'worker_auth_invalid', + }) + } + + let payload: JsonObject = {} + try { + payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) + } catch { + throw createHttpError('接单登录态无效', { + statusCode: 401, + errorCode: 'worker_auth_invalid', + }) + } + + if (String(payload.typ || '') !== 'worker') { + throw createHttpError('接单登录态无效', { + statusCode: 401, + errorCode: 'worker_auth_invalid', + }) + } + + const expiresAt = String(payload.exp || '').trim() + if (!expiresAt || Date.parse(expiresAt) <= Date.now()) { + throw createHttpError('登录已过期,请重新登录', { + statusCode: 401, + errorCode: 'worker_auth_expired', + }) + } + + const workerId = Number(payload.uid || 0) + const worker = await getWorkerUserById(workerId) + if (!worker || worker.status === 'disabled') { + throw createHttpError('接单账号已不可用,请重新登录', { + statusCode: 401, + errorCode: 'worker_auth_user_invalid', + }) + } + + const currentSessionVersion = normalizeSessionVersion(worker.session_version) + if (Number(payload.ver || 0) !== currentSessionVersion) { + throw createHttpError('登录态已失效,请重新登录', { + statusCode: 401, + errorCode: 'worker_auth_stale', + }) + } + + return { + sessionId: String(payload.sid || '').trim(), + workerId: Number(worker.id), + username: worker.username, + status: worker.status, + expiresAt, + sessionVersion: currentSessionVersion, + } +} + +export async function getWorkerSessionSummary(token: unknown) { + const session = await verifyWorkerSessionToken(token) + const worker = await getRequiredWorker(session.workerId) + return { + authenticated: true, + expiresAt: session.expiresAt, + worker: mapWorkerUser(worker), + } +} + +export function requireActiveWorkerSession(session: WorkerSession | null | undefined) { + if (session?.status === 'active') { + return + } + + throw createHttpError('接单账号尚未通过审核', { + statusCode: 403, + errorCode: 'worker_not_active', + }) +} + +export async function getWorkerProfile(session: WorkerSession) { + const worker = await getRequiredWorker(session.workerId) + const [financeSummary, acceptedOrderCount, financeConfig] = await Promise.all([ + getWorkerFinanceRequestSummary(session.workerId), + countWorkerAcceptedOrders(session.workerId), + Promise.resolve(getWorkerFinanceConfig()), + ]) + const permissions = resolveWorkerPermissions(worker) + const levelProgress = await resolveLevelProgress(worker, acceptedOrderCount) + return { + worker: mapWorkerUser(worker), + permissions, + summary: { + acceptedOrderCount, + pendingWithdrawAmount: financeSummary.pendingWithdrawAmount, + approvedWithdrawAmount: financeSummary.approvedWithdrawAmount, + pendingRechargeAmount: financeSummary.pendingRechargeAmount, + }, + levelProgress, + financeConfig, + } +} + +async function resolveLevelProgress( + worker: WorkerUserRow, + acceptedOrderCount: number, +): Promise<{ + currentThreshold: number + nextThreshold: number | null + progressPercent: number +} | null> { + if (!worker.level_id) return null + const levels = await listWorkerLevels() + const active = levels.filter((level) => level.status === 'active') + const current = active.find((level) => Number(level.id) === Number(worker.level_id)) + if (!current) return null + const currentThreshold = normalizeInteger(safeParseJson(current.permission_json).upgradeThreshold, 0) + const next = active + .filter((level) => normalizeInteger(safeParseJson(level.permission_json).upgradeThreshold, 0) > currentThreshold) + .sort( + (a, b) => + normalizeInteger(safeParseJson(a.permission_json).upgradeThreshold, 0) - + normalizeInteger(safeParseJson(b.permission_json).upgradeThreshold, 0), + )[0] + const nextThreshold = next + ? normalizeInteger(safeParseJson(next.permission_json).upgradeThreshold, 0) + : null + return { + currentThreshold, + nextThreshold, + progressPercent: + nextThreshold && nextThreshold > currentThreshold + ? Math.min( + 100, + Math.round( + ((acceptedOrderCount - currentThreshold) / (nextThreshold - currentThreshold)) * 100, + ), + ) + : 100, + } +} + +export async function listWorkerProfileWalletLedgers( + query: JsonObject = {}, + session: WorkerSession, +) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const ledgerType = normalizeWalletLedgerType(query.ledgerType) + const { items, total } = await listWorkerWalletLedgers({ + page, + pageSize, + workerId: session.workerId, + ledgerType, + }) + return { + items: items.map(mapWalletLedger), + pagination: { page, pageSize, total }, + } +} + +export async function listWorkerProfileFinanceRequests( + query: JsonObject = {}, + session: WorkerSession, +) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const status = normalizeFinanceRequestStatus(query.status) + const requestType = normalizeFinanceRequestType(query.requestType) + const { items, total } = await listWorkerFinanceRequests({ + page, + pageSize, + workerId: session.workerId, + status, + requestType, + }) + return { + items: items.map(mapFinanceRequest), + pagination: { page, pageSize, total }, + } +} + +export async function createWorkerRechargeRequest( + payload: JsonObject = {}, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const worker = await getRequiredWorker(session.workerId) + const financeConfig = getWorkerFinanceConfig() + if (financeConfig.recharge.enabled === false) { + throw createHttpError('当前充值入口暂未开放,请联系管理员', { + statusCode: 409, + errorCode: 'worker_recharge_disabled', + }) + } + const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0) + if (amount <= 0) { + throw createHttpError('充值金额必须大于 0', { + statusCode: 400, + errorCode: 'worker_recharge_amount_invalid', + }) + } + + const note = String(payload.note || payload.remark || '').trim() + const proofFiles = normalizeProofFiles(payload.proofFiles || payload.proofFilesList) + const paidAt = String(payload.paidAt || '').trim() + const created = await createWorkerFinanceRequest({ + workerId: worker.id, + requestType: 'recharge', + amount, + accountChannel: 'manual', + accountName: worker.display_name || worker.username, + accountNo: worker.phone || '', + note: note || '个人中心充值申请', + payloadJson: JSON.stringify({ + source: 'worker_profile_recharge', + username: worker.username, + phone: worker.phone || '', + paidAt: paidAt || null, + proofFiles, + financeConfig: { + channelName: financeConfig.recharge.channelName, + accountName: financeConfig.recharge.accountName, + accountNo: financeConfig.recharge.accountNo, + qrCodeImage: financeConfig.recharge.qrCodeImage, + }, + }), + now: nowIso(), + }) + if (!created) { + throw createHttpError('充值申请创建失败', { + statusCode: 500, + errorCode: 'worker_recharge_request_create_failed', + }) + } + return { + request: mapFinanceRequest(created), + } +} + +export async function createWorkerWithdrawRequest( + payload: JsonObject = {}, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const worker = await getRequiredWorker(session.workerId) + const financeConfig = getWorkerFinanceConfig() + if (financeConfig.withdraw.enabled === false) { + throw createHttpError('当前提现入口暂未开放,请联系管理员', { + statusCode: 409, + errorCode: 'worker_withdraw_disabled', + }) + } + const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0) + if (amount <= 0) { + throw createHttpError('提现金额必须大于 0', { + statusCode: 400, + errorCode: 'worker_withdraw_amount_invalid', + }) + } + + const accountChannel = normalizeWithdrawChannel( + payload.accountChannel || payload.channel, + ) + const accountName = String(payload.accountName || payload.realName || '').trim() + const accountNo = String(payload.accountNo || payload.account || '').trim() + if (!accountName) { + throw createHttpError('请填写收款人姓名', { + statusCode: 400, + errorCode: 'worker_withdraw_account_name_required', + }) + } + if (!accountNo) { + throw createHttpError('请填写收款账号', { + statusCode: 400, + errorCode: 'worker_withdraw_account_no_required', + }) + } + + const financeSummary = await getWorkerFinanceRequestSummary(worker.id) + const availableAmount = Number(worker.available_amount || 0) + const availableForWithdraw = Math.max( + 0, + availableAmount - financeSummary.pendingWithdrawAmount, + ) + if (amount > availableForWithdraw) { + throw createHttpError('可提现余额不足,请先减少提现金额或等待已提交申请处理', { + statusCode: 409, + errorCode: 'worker_withdraw_amount_insufficient', + }) + } + + const note = String(payload.note || payload.remark || '').trim() + const created = await createWorkerFinanceRequest({ + workerId: worker.id, + requestType: 'withdraw', + amount, + accountChannel, + accountName, + accountNo, + note: note || '个人中心提现申请', + payloadJson: JSON.stringify({ + source: 'worker_profile_withdraw', + username: worker.username, + phone: worker.phone || '', + }), + now: nowIso(), + }) + if (!created) { + throw createHttpError('提现申请创建失败', { + statusCode: 500, + errorCode: 'worker_withdraw_request_create_failed', + }) + } + return { + request: mapFinanceRequest(created), + availableForWithdraw, + } +} + +export async function changeWorkerPassword( + payload: JsonObject = {}, + session: WorkerSession, +) { + const worker = await getRequiredWorker(session.workerId) + const currentPassword = normalizePassword( + payload.currentPassword || payload.oldPassword, + ) + const nextPassword = normalizePassword(payload.newPassword || payload.password) + + if (!currentPassword) { + throw createHttpError('请输入当前密码', { + statusCode: 400, + errorCode: 'worker_password_current_required', + }) + } + + if (!verifyWorkerPassword(currentPassword, worker.password_hash)) { + throw createHttpError('当前密码不正确', { + statusCode: 400, + errorCode: 'worker_password_current_invalid', + }) + } + + if (!nextPassword) { + throw createHttpError('请输入新密码', { + statusCode: 400, + errorCode: 'worker_password_new_required', + }) + } + + validateWorkerPassword(nextPassword) + + if (currentPassword === nextPassword) { + throw createHttpError('新密码不能与当前密码一致', { + statusCode: 400, + errorCode: 'worker_password_unchanged', + }) + } + + const updated = await updateWorkerPassword({ + workerId: worker.id, + passwordHash: hashWorkerPassword(nextPassword), + sessionVersion: normalizeSessionVersion(worker.session_version) + 1, + now: nowIso(), + }) + + return { + worker: mapWorkerUser(updated || worker), + reloginRequired: true, + } +} + +export async function listWorkerHallOrders(query: JsonObject = {}, session: WorkerSession) { + requireActiveWorkerSession(session) + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const categoryId = normalizeOptionalId(query.categoryId) + const { items, total } = await listWorkOrders({ + page, + pageSize, + status: WORK_ORDER_STATUS.OPEN, + keyword: String(query.keyword || '').trim(), + categoryId: categoryId || 0, + }) + const worker = await getRequiredWorker(session.workerId) + const permissions = resolveWorkerPermissions(worker) + const categories = (await listWorkCategories()).map(mapWorkCategory) + const myShares = await listWorkerSharesByWorker(worker.id) + const myShareByOrderId = new Map( + myShares + .filter((share) => Number(share.work_order_id)) + .map((share) => [Number(share.work_order_id), share]), + ) + return { + items: items.map((item) => + mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))), + ), + pagination: { page, pageSize, total }, + categories, + } +} + +export async function grabWorkerHallOrder(workOrderId: number | string, session: WorkerSession) { + requireActiveWorkerSession(session) + const worker = await getRequiredWorker(session.workerId) + const permissions = resolveWorkerPermissions(worker) + const activeCount = await countWorkerActiveOrders(worker.id) + if (activeCount >= permissions.maxActiveOrders) { + throw createHttpError('已达到当前等级最大同时接单量', { + statusCode: 409, + errorCode: 'worker_active_order_limit', + }) + } + + const workOrder = await getRequiredWorkOrder(workOrderId) + if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) { + throw createHttpError('该订单已不可抢', { + statusCode: 409, + errorCode: 'work_order_not_open', + }) + } + + const freezeAmount = resolveFreezeDepositAmount(workOrder, permissions) + if (Number(worker.available_amount || 0) < freezeAmount) { + throw createHttpError('余额不足,无法冻结所需押金', { + statusCode: 409, + errorCode: 'worker_deposit_insufficient', + }) + } + + const grabbed = await grabWorkOrder({ + workOrderId: Number(workOrder.id), + workerId: Number(worker.id), + depositAmount: freezeAmount, + maxActiveOrders: permissions.maxActiveOrders, + now: nowIso(), + }) + if (!grabbed.order) { + throwGrabWorkOrderFailure(grabbed.failureReason) + } + + return { order: mapWorkOrderForWorker(grabbed.order, permissions) } +} + +export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) { + requireActiveWorkerSession(session) + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const worker = await getRequiredWorker(session.workerId) + const { items, total } = await listWorkOrders({ + page, + pageSize, + status: String(query.status || '').trim(), + keyword: String(query.keyword || '').trim(), + workerSharingId: worker.id, + }) + const permissions = resolveWorkerPermissions(worker) + const myShares = await listWorkerSharesByWorker(worker.id) + const myShareByOrderId = new Map( + myShares.map((share) => [Number(share.work_order_id), share]), + ) + return { + items: items.map((item) => + mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))), + ), + pagination: { page, pageSize, total }, + } +} + +export async function joinWorkerSharingOrder( + workOrderId: number | string, + payload: JsonObject = {}, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const worker = await getRequiredWorker(session.workerId) + const permissions = resolveWorkerPermissions(worker) + const quantity = normalizePositiveInteger(payload.quantity, 1) + const now = nowIso() + const result = await joinWorkOrderShare({ + workOrderId: Number(workOrderId), + workerId: Number(worker.id), + quantity, + maxActiveOrders: permissions.maxActiveOrders, + now, + }) + if (!result.share) { + throw resolveWorkOrderShareJoinError(result.failureReason) + } + return { share: mapWorkOrderShare(result.share) } +} + +function resolveWorkOrderShareJoinError( + failureReason: 'work_order_not_open' | 'sharing_disabled' | 'work_order_owner_conflict' | 'sharing_quantity_full' | 'worker_active_order_limit' | 'worker_deposit_insufficient' | null, +): Error { + const messages: Record = { + work_order_not_open: '该订单当前不可拼单', + sharing_disabled: '该订单未启用拼单', + work_order_owner_conflict: '你已整单接取该订单,无需拼单', + sharing_quantity_full: '拼单数量已满或超出剩余数量', + worker_active_order_limit: '已达到当前等级最大同时接单量', + worker_deposit_insufficient: '余额不足,无法冻结所需押金', + } + return createHttpError(messages[failureReason || ''] || '拼单失败', { + statusCode: 409, + errorCode: `work_order_sharing_${failureReason || 'failed'}`, + }) +} + +export async function cancelWorkerOrder( + workOrderId: number | string, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const worker = await getRequiredWorker(session.workerId) + const now = nowIso() + const since = new Date(Date.now() - WORKER_CANCEL_LIMIT_WINDOW_MS).toISOString() + const cancelCount = await countWorkerCancellationsSince(worker.id, since) + if (cancelCount >= WORKER_CANCEL_LIMIT_PER_WINDOW) { + throw createHttpError( + `30 天内取消接单已达 ${WORKER_CANCEL_LIMIT_PER_WINDOW} 次上限,请谨慎接单`, + { + statusCode: 409, + errorCode: 'worker_cancel_limit_reached', + }, + ) + } + const result = await cancelWorkerWorkOrder({ + workOrderId: Number(workOrderId), + workerId: Number(worker.id), + now, + }) + if (result.failureReason === 'work_order_owner_required') { + throw createHttpError('只能取消自己接的订单', { + statusCode: 403, + errorCode: 'work_order_owner_required', + }) + } + if (!result.order) { + throw createHttpError('当前订单状态不能取消,仅进行中的订单可取消', { + statusCode: 409, + errorCode: 'work_order_cancel_status_invalid', + }) + } + return { + order: mapWorkOrderForWorker( + result.order, + resolveWorkerPermissions(await getRequiredWorker(session.workerId)), + ), + } +} + +export async function submitWorkerOrderAcceptance( + workOrderId: number | string, + payload: JsonObject = {}, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const workOrder = await getRequiredWorkOrder(workOrderId) + const workerId = Number(session.workerId) + const sharingShare = await getWorkOrderShare(workOrder.id, workerId) + if (sharingShare) { + return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session) + } + if (Number(workOrder.assigned_worker_id || 0) !== workerId) { + throw createHttpError('只能提交自己的订单', { + statusCode: 403, + errorCode: 'work_order_owner_required', + }) + } + if ( + ![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never) + ) { + throw createHttpError('当前状态不能提交验收', { + statusCode: 409, + errorCode: 'work_order_submit_status_invalid', + }) + } + + const now = nowIso() + const files = normalizeUploadedFiles(payload.files) + const imageUrls = [ + ...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean), + ...normalizeStringArray(payload.imageUrls), + ] + if (imageUrls.length === 0) { + throw createHttpError('请上传验收图片', { + statusCode: 400, + errorCode: 'work_order_acceptance_image_required', + }) + } + const acceptance = { + note: String(payload.note || '').trim(), + files, + imageUrls: [...new Set(imageUrls)], + submittedAt: now, + } + const updated = await updateWorkOrder(workOrder.id, { + status: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + acceptance_json: acceptance, + submitted_at: now, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'worker', + actorId: String(session.workerId), + eventType: 'acceptance_submitted', + fromStatus: workOrder.status, + toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + payloadJson: JSON.stringify(acceptance), + now, + }) + return { + order: mapWorkOrderForWorker( + updated || workOrder, + resolveWorkerPermissions(await getRequiredWorker(session.workerId)), + ), + } +} + +async function submitWorkerSharingAcceptance( + workOrder: WorkOrderRow, + sharingShare: WorkOrderShareRow, + payload: JsonObject = {}, + session: WorkerSession, +) { + if (workOrder.status === WORK_ORDER_STATUS.ACCEPTED) { + throw createHttpError('该拼单已验收完成', { + statusCode: 409, + errorCode: 'work_order_sharing_accepted', + }) + } + const now = nowIso() + const files = normalizeUploadedFiles(payload.files) + const imageUrls = [ + ...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean), + ...normalizeStringArray(payload.imageUrls), + ] + if (imageUrls.length === 0) { + throw createHttpError('请上传验收图片', { + statusCode: 400, + errorCode: 'work_order_acceptance_image_required', + }) + } + const acceptance = { + note: String(payload.note || '').trim(), + files, + imageUrls: [...new Set(imageUrls)], + submittedAt: now, + } + const result = await submitWorkOrderShareAcceptance({ + workOrderId: Number(workOrder.id), + workerId: Number(session.workerId), + acceptanceJson: JSON.stringify(acceptance), + now, + }) + if (result.failureReason === 'share_status_invalid') { + throw createHttpError('当前拼单份额已提交,请勿重复提交', { + statusCode: 409, + errorCode: 'work_order_sharing_share_submitted', + }) + } + if (!result.share) { + throw createHttpError('未找到拼单份额', { + statusCode: 404, + errorCode: 'work_order_sharing_share_not_found', + }) + } + return { + share: mapWorkOrderShare(result.share), + sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)), + } +} + +export async function collectLookupWorkOrder(payload: JsonObject = {}) { + const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim() + if (!orderNo) { + throw createHttpError('请填写订单号', { + statusCode: 400, + errorCode: 'collect_order_no_required', + }) + } + const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo) + if (workOrders.length === 0) { + throw createHttpError('未找到待完善订单,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'collect_order_not_found', + }) + } + const [primaryOrder] = workOrders + if (!primaryOrder) { + throw createHttpError('未找到待完善订单,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'collect_order_not_found', + }) + } + return { + order: mapWorkOrderPublic(primaryOrder), + fields: resolveRequirementFields(primaryOrder), + orders: workOrders.map(mapCollectLookupOrder), + pendingCount: workOrders.length, + } +} + +export async function collectSubmitWorkOrder(payload: JsonObject = {}) { + const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim() + if (!orderNo) { + throw createHttpError('请填写订单号', { + statusCode: 400, + errorCode: 'collect_order_no_required', + }) + } + const workOrders = await listPendingMaterialWorkOrdersByPlatformOrderId(orderNo) + if (workOrders.length === 0) { + throw createHttpError('未找到待完善订单,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'collect_order_not_found', + }) + } + const workOrder = resolveCollectSubmitTargetWorkOrder( + orderNo, + workOrders, + payload.workOrderId || payload.work_order_id, + ) + + const fields = resolveRequirementFields(workOrder) + const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload) + const complete = fields.every( + (field) => !field.required || String(submittedFields[field.key] || '').trim(), + ) + const now = nowIso() + const material = { + ...safeParseJson(workOrder.material_json), + collect: { + fields: submittedFields, + submittedAt: now, + complete, + }, + } + const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL + const updated = await updateWorkOrder(workOrder.id, { + status: nextStatus, + material_json: material, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'collect', + actorId: orderNo, + eventType: 'material_submitted', + fromStatus: workOrder.status, + toStatus: nextStatus, + payloadJson: JSON.stringify({ fields: submittedFields, complete }), + now, + }) + return { + order: mapWorkOrderPublic(updated || workOrder), + complete, + } +} + +export function resolveCollectSubmitTargetWorkOrder( + orderNo: string, + workOrders: WorkOrderRow[], + requestedWorkOrderId: unknown, +): WorkOrderRow { + const workOrderId = normalizeOptionalId(requestedWorkOrderId) + if (workOrderId) { + const matched = workOrders.find((item) => Number(item.id) === workOrderId) + if (matched) { + return matched + } + throw createHttpError('待完善订单不存在或已更新,请刷新后重试', { + statusCode: 404, + errorCode: 'collect_work_order_not_found', + context: { orderNo, workOrderId }, + }) + } + + if (workOrders.length === 1) { + const [singleWorkOrder] = workOrders + if (singleWorkOrder) { + return singleWorkOrder + } + } + + throw createHttpError('该订单存在多个待完善商品,请逐个提交资料', { + statusCode: 409, + errorCode: 'collect_work_order_required', + context: { + orderNo, + pendingCount: workOrders.length, + }, + }) +} + +export async function getRequiredWorker(workerId: number | string): Promise { + const worker = await getWorkerUserById(workerId) + if (!worker) { + throw createHttpError('接单账号不存在', { + statusCode: 404, + errorCode: 'worker_not_found', + }) + } + return worker +} + +export async function getRequiredWorkOrder(workOrderId: number | string): Promise { + const workOrder = await getWorkOrderById(workOrderId) + if (!workOrder) { + throw createHttpError('接单订单不存在', { + statusCode: 404, + errorCode: 'work_order_not_found', + }) + } + return workOrder +} diff --git a/apps/backend/src/types/express.d.ts b/apps/backend/src/types/express.d.ts index 2cd611bf..b5ac0948 100644 --- a/apps/backend/src/types/express.d.ts +++ b/apps/backend/src/types/express.d.ts @@ -1,5 +1,5 @@ import type { AdminSession } from '../services/admin/admin-auth-service.js' -import type { WorkerSession } from '../services/worker-platform/worker-platform-service.js' +import type { WorkerSession } from '../services/worker-platform/index.js' declare global { namespace Express { diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index d5e9e837..7422cbd6 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -82,23 +82,12 @@ import { buildAdminTablePagination, } from '@/utils/admin-pagination' import { formatAdminDateTime } from '@/utils/admin-time' - -type FinanceConfigFormValues = { - recharge?: { - enabled?: boolean - channelName?: string - accountName?: string - accountNo?: string - qrCodeImageList?: UploadedFile[] - instructions?: string - } - withdraw?: { - enabled?: boolean - instructions?: string - } -} - -type FinanceReviewAction = 'approved' | 'rejected' | 'cancelled' +import CategoriesPanel from './panels/CategoriesPanel' +import FinancePanel from './panels/FinancePanel' +import LevelsPanel from './panels/LevelsPanel' +import ProductRulesPanel from './panels/ProductRulesPanel' +import WorkersPanel from './panels/WorkersPanel' +import WorkOrdersPanel from './panels/WorkOrdersPanel' export default function AdminWorkerPlatformPage() { return ( @@ -154,2631 +143,3 @@ function SummaryCards() { ) } -function WorkOrdersPanel() { - const { message, modal } = App.useApp() - const navigate = useNavigate() - const queryClient = useQueryClient() - const [status, setStatus] = useState('') - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) - const [detailOrder, setDetailOrder] = useState(null) - const [problemOrder, setProblemOrder] = useState(null) - const [resolutionOrder, setResolutionOrder] = useState(null) - const [materialOrder, setMaterialOrder] = useState(null) - const [sharingOrder, setSharingOrder] = useState(null) - const [problemForm] = Form.useForm() - const [resolutionForm] = Form.useForm() - const [materialForm] = Form.useForm() - const [sharingForm] = Form.useForm<{ - enabled?: boolean - totalQuantity?: number - unitReward?: number - totalAmount?: number - }>() - - const ordersQuery = useQuery({ - queryKey: ['admin-worker-platform-orders', status, page, pageSize], - queryFn: () => fetchAdminWorkOrders({ status, page, pageSize }), - }) - const ordersPagination = ordersQuery.data?.data.pagination - const sharingQuery = useQuery({ - queryKey: ['admin-worker-platform-order-sharing', sharingOrder?.workOrderId], - queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)), - enabled: Boolean(sharingOrder), - }) - - function openSharingConfigModal(row: WorkOrder) { - setSharingOrder(row) - const unitRewardFen = Number(row.sharing?.unitReward || 0) - const totalQuantity = Number(row.sharing?.totalQuantity || 1) - const unitRewardYuan = unitRewardFen / 100 - sharingForm.setFieldsValue({ - enabled: row.sharing?.enabled, - totalQuantity, - unitReward: unitRewardYuan, - totalAmount: unitRewardYuan * totalQuantity, - }) - } - - function syncSharingForm(next: { - totalQuantity?: number - unitReward?: number - totalAmount?: number - }) { - const values = sharingForm.getFieldsValue() - const unitReward = Number(next.unitReward ?? values.unitReward ?? 0) - const totalQuantity = Number(next.totalQuantity ?? values.totalQuantity ?? 1) - const totalAmount = Number(next.totalAmount ?? values.totalAmount ?? 0) - if (next.totalAmount !== undefined) { - const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1 - sharingForm.setFieldsValue({ - totalQuantity: quantity, - totalAmount: quantity * unitReward, - }) - return - } - sharingForm.setFieldsValue({ - totalAmount: unitReward * totalQuantity, - }) - } - - async function submitSharingConfig(values: { - enabled?: boolean - totalQuantity?: number - unitReward?: number - totalAmount?: number - }) { - if (!sharingOrder) return - const succeeded = await runAction( - () => - updateAdminWorkOrderSharing(sharingOrder.workOrderId, { - enabled: values.enabled !== false, - totalQuantity: Number(values.totalQuantity || 1), - unitReward: Number(values.unitReward || 0), - totalAmount: Number(values.totalAmount || 0), - }), - '拼单配置已保存', - ) - if (!succeeded) return - setSharingOrder(null) - sharingForm.resetFields() - } - - async function refreshAll() { - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['admin-worker-platform-orders'], - }), - queryClient.invalidateQueries({ - queryKey: ['admin-worker-platform-summary'], - }), - ]) - } - - async function runAction( - action: () => Promise, - successMessage: string, - ) { - try { - await action() - message.success(successMessage) - await refreshAll() - return true - } catch (error) { - message.error(error instanceof Error ? error.message : '操作失败') - return false - } - } - - async function submitProblem(values: { note?: string }) { - if (!problemOrder) return - const succeeded = await runAction( - () => - markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''), - '已标记问题单', - ) - if (!succeeded) return - setProblemOrder(null) - problemForm.resetFields() - } - - async function submitResolution(values: { - action?: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct' - note?: string - }) { - if (!resolutionOrder || !values.action) return - const action = values.action - const succeeded = await runAction( - () => - resolveAdminProblemWorkOrder(resolutionOrder.workOrderId, { - action, - note: values.note || '', - }), - '问题单已处置', - ) - if (!succeeded) return - setResolutionOrder(null) - resolutionForm.resetFields() - } - - async function submitMaterial(values: { fields?: Record }) { - if (!materialOrder) return - const succeeded = await runAction( - () => - submitAdminWorkOrderMaterial( - materialOrder.workOrderId, - values.fields || {}, - ), - '订单资料已保存', - ) - if (!succeeded) return - setMaterialOrder(null) - materialForm.resetFields() - } - - async function submitSourceSync(values: { platformOrderId?: string }) { - try { - const result = await syncAdminWorkerOrdersFromSource({ - platformOrderId: String(values.platformOrderId || '').trim(), - }) - message.success( - `同步完成,新增 ${result.data.createdCount} 个,跳过 ${result.data.skippedCount} 个`, - ) - await refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '同步失败') - } - } - - function openMaterialModal(row: WorkOrder) { - setMaterialOrder(row) - materialForm.setFieldsValue({ fields: getMaterialFields(row) }) - } - - function openDetailDrawer(row: WorkOrder) { - setDetailOrder(row) - } - - const columns: TableColumnsType = [ - { - title: '订单', - minWidth: 300, - render: (_, row) => ( -
- openDetailDrawer(row)}> - {row.productName || row.workOrderNo} - - - {row.categoryName ? {row.categoryName} : null} - {row.orderId ? ( - - 源订单 #{row.orderId} - - ) : null} - - - {row.platformOrderId} · {row.workOrderNo} - -
- ), - }, - { - title: '金额', - width: 110, - render: (_, row) => formatMoney(row.rewardAmount), - }, - { - title: '押金', - width: 110, - render: (_, row) => formatMoney(row.requiredDepositAmount), - }, - { - title: '拼单', - width: 190, - render: (_, row) => - row.sharing?.enabled ? ( -
- - 单价 {formatMoney(row.sharing.unitReward)} / 共 {row.sharing.totalQuantity} 份 - - - 已拼 {row.sharingProgress?.joinedQuantity || 0} / {row.sharing.totalQuantity} 份 - {row.sharingProgress?.pendingSubmissionCount - ? ` · ${row.sharingProgress.pendingSubmissionCount} 人待提交` - : ''} - -
- ) : ( - 未启用 - ), - }, - { - title: '状态', - width: 130, - render: (_, row) => ( - - {formatStatus(row.status)} - - ), - }, - { - title: '打手', - width: 140, - render: (_, row) => - row.worker?.displayName || row.worker?.username || '-', - }, - { - title: '资料', - width: 160, - render: (_, row) => renderMaterialSummary(row), - }, - { - title: '验收图', - width: 170, - render: (_, row) => ( - - ), - }, - { - title: '操作', - width: 400, - render: (_, row) => ( - - - {['pending_material', 'unassigned', 'open'].includes(row.status) ? ( - - ) : null} - {row.status === 'pending_material' ? ( - - ) : null} - {row.status === 'unassigned' ? ( - - ) : null} - {row.status === 'open' ? ( - - ) : null} - {row.status === 'pending_acceptance' ? ( - - ) : null} - {['in_progress', 'pending_acceptance'].includes(row.status) ? ( - - ) : null} - {row.status === 'problem' ? ( - - ) : null} - - ), - }, - ] - - return ( -
- -
- - - - - - -
-
- - - - - - - - - - - - - { - setMaterialOrder(null) - materialForm.resetFields() - }} - onOk={() => materialForm.submit()} - destroyOnHidden - > -
- {getRequirementFields(materialOrder).map((field) => ( - - - - ))} -
-
- - { - setSharingOrder(null) - sharingForm.resetFields() - }} - onOk={() => sharingForm.submit()} - destroyOnHidden - width={720} - > - {sharingOrder ? ( - -
- - - - - {({ getFieldValue }) => - getFieldValue('enabled') ? ( - - - - syncSharingForm({ - totalQuantity: Number(value) || 0, - }) - } - /> - - - - syncSharingForm({ unitReward: Number(value) || 0 }) - } - /> - - - - syncSharingForm({ totalAmount: Number(value) || 0 }) - } - /> - - - ) : null - } - -
- - - {sharingQuery.isLoading ? ( - 加载中... - ) : (sharingQuery.data?.data.shares || []).length === 0 ? ( - 暂无打手参与拼单 - ) : ( - - rowKey="shareId" - size="small" - dataSource={sharingQuery.data?.data.shares || []} - pagination={false} - columns={[ - { - title: '打手', - render: (_, share) => - share.worker?.displayName || share.worker?.username || '-', - }, - { title: '数量', dataIndex: 'quantity' }, - { - title: '份额报酬', - render: (_, share) => formatMoney(share.shareReward), - }, - { - title: '状态', - render: (_, share) => ( - - {formatSharingShareStatus(share.status)} - - ), - }, - { - title: '提交时间', - render: (_, share) => - share.submittedAt - ? formatAdminDateTime(share.submittedAt) - : '-', - }, - ]} - /> - )} - -
- ) : null} -
-
- ) -} - -function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise }) { - const { message } = App.useApp() - const [loading, setLoading] = useState(false) - - async function submit(values: { - platformOrderId?: string - productName?: string - rewardAmount?: number - materialComplete?: boolean - }) { - setLoading(true) - try { - await createAdminMockWorkOrder(values) - message.success('Mock 工单已生成') - await onCreated() - } catch (error) { - message.error(error instanceof Error ? error.message : '生成失败') - } finally { - setLoading(false) - } - } - - return ( -
- - - - - - - - - - - - - - - -
- ) -} - -function ProductRulesPanel() { - const { message } = App.useApp() - const queryClient = useQueryClient() - const [form] = Form.useForm() - const rulesQuery = useQuery({ - queryKey: ['admin-worker-platform-product-rules'], - queryFn: () => fetchAdminWorkProductRules(), - }) - const categoriesQuery = useQuery({ - queryKey: ['admin-worker-platform-categories'], - queryFn: () => fetchAdminWorkCategories(), - }) - - async function saveRule(values: { - ruleKey: string - provider?: string - platform?: string - shopId?: string - skuCode?: string - productName?: string - matchType?: string - categoryId?: number - rewardAmount?: number - requiredDepositAmount?: number - depositThresholdAmount?: number - sharingEnabled?: boolean - sharingTotalQuantity?: number - sharingUnitReward?: number - sharingTotalAmount?: number - fieldsText?: string - enabled?: boolean - autoCreate?: boolean - }) { - try { - await saveAdminWorkProductRule(values) - message.success('物品规则已保存') - form.resetFields() - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['admin-worker-platform-product-rules'], - }), - queryClient.invalidateQueries({ - queryKey: ['admin-worker-platform-summary'], - }), - ]) - } catch (error) { - message.error(error instanceof Error ? error.message : '保存失败') - } - } - - function syncSharingRuleForm(next: { - sharingTotalQuantity?: number - sharingUnitReward?: number - sharingTotalAmount?: number - }) { - const values = form.getFieldsValue() - const unitReward = Number(next.sharingUnitReward ?? values.sharingUnitReward ?? 0) - const totalQuantity = Number( - next.sharingTotalQuantity ?? values.sharingTotalQuantity ?? 1, - ) - const totalAmount = Number(next.sharingTotalAmount ?? values.sharingTotalAmount ?? 0) - if (next.sharingTotalAmount !== undefined) { - const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1 - form.setFieldsValue({ - sharingTotalQuantity: quantity, - sharingTotalAmount: quantity * unitReward, - }) - return - } - form.setFieldsValue({ - sharingTotalAmount: unitReward * totalQuantity, - }) - } - - const columns: TableColumnsType = [ - { - title: '规则', - minWidth: 260, - render: (_, row) => ( -
- - {row.productName || row.skuCode} - - {row.ruleKey} -
- ), - }, - { - title: 'SKU', - dataIndex: 'skuCode', - width: 180, - render: (value) => String(value || '-'), - }, - { title: '分类', width: 140, render: (_, row) => row.categoryName || '-' }, - { - title: '接单金额', - width: 120, - render: (_, row) => formatMoney(row.rewardAmount), - }, - { - title: '押金', - width: 120, - render: (_, row) => formatMoney(row.requiredDepositAmount), - }, - { - title: '匹配', - width: 130, - render: (_, row) => (row.matchType === 'exact' ? '精确' : '包含'), - }, - { - title: '状态', - width: 150, - render: (_, row) => ( - - - {row.enabled ? '启用' : '停用'} - - {row.autoCreate ? 自动创建 : null} - {row.sharing?.enabled ? ( - - 拼单 {formatMoney(row.sharing.unitReward)}/份 - - ) : null} - - ), - }, - ] - - return ( -
- -
-
- - - - - - - - - - - - - - - - - - - - ({ - value: item.categoryId, - label: item.name, - }), - )} - /> - - - - - - - - - - - - - - - - - - - - - - -
- - {({ getFieldValue }) => - getFieldValue('sharingEnabled') ? ( - - - - syncSharingRuleForm({ - sharingTotalQuantity: Number(value) || 0, - }) - } - /> - - - - syncSharingRuleForm({ - sharingUnitReward: Number(value) || 0, - }) - } - /> - - - - syncSharingRuleForm({ - sharingTotalAmount: Number(value) || 0, - }) - } - /> - - - ) : null - } - - - - - -
-
- } - loading={rulesQuery.isFetching} - onClick={() => rulesQuery.refetch()} - > - 刷新 - - } - bordered={false} - > - - rowKey="ruleId" - loading={rulesQuery.isLoading} - dataSource={rulesQuery.data?.data.items || []} - columns={columns} - pagination={false} - scroll={{ x: 1200 }} - /> - -
- ) -} - -function CategoriesPanel() { - const { message } = App.useApp() - const queryClient = useQueryClient() - const [form] = Form.useForm() - const [editingCategory, setEditingCategory] = useState(null) - const [deletingCategory, setDeletingCategory] = useState(null) - const categoriesQuery = useQuery({ - queryKey: ['admin-worker-platform-categories'], - queryFn: () => fetchAdminWorkCategories(), - }) - - async function refreshCategories() { - await categoriesQuery.refetch() - } - - function startEdit(category: WorkCategory) { - setEditingCategory(category) - form.setFieldsValue({ - categoryKey: category.categoryKey, - name: category.name, - sortOrder: category.sortOrder, - status: category.status, - }) - } - - function cancelEdit() { - setEditingCategory(null) - form.resetFields() - } - - async function saveCategory(values: { - categoryKey: string - name: string - sortOrder?: number - status?: string - }) { - try { - await saveAdminWorkCategory(values) - message.success(editingCategory ? '分类已更新' : '分类已保存') - cancelEdit() - await refreshCategories() - } catch (error) { - message.error(error instanceof Error ? error.message : '保存失败') - } - } - - async function toggleCategory(category: WorkCategory) { - try { - await saveAdminWorkCategory({ - categoryKey: category.categoryKey, - name: category.name, - sortOrder: category.sortOrder, - status: category.status === 'active' ? 'disabled' : 'active', - }) - message.success(category.status === 'active' ? '分类已停用' : '分类已启用') - await refreshCategories() - } catch (error) { - message.error(error instanceof Error ? error.message : '操作失败') - } - } - - async function submitDelete() { - if (!deletingCategory) return - try { - await deleteAdminWorkCategory(deletingCategory.categoryId) - message.success('分类已删除') - setDeletingCategory(null) - await refreshCategories() - } catch (error) { - message.error(error instanceof Error ? error.message : '删除失败') - setDeletingCategory(null) - } - } - - const columns: TableColumnsType = [ - { title: '分类', dataIndex: 'name' }, - { title: '标识', dataIndex: 'categoryKey' }, - { title: '排序', dataIndex: 'sortOrder', width: 100 }, - { - title: '状态', - width: 120, - render: (_, row) => ( - - {row.status === 'active' ? '启用' : '停用'} - - ), - }, - { - title: '操作', - width: 240, - render: (_, row) => ( - - - - - - ), - }, - ] - - return ( -
- -
- - - - - - - - - - - { - setStatus(nextStatus) - setPage(1) - }} - /> - - - } - bordered={false} - > - - rowKey="workerId" - loading={workersQuery.isLoading} - dataSource={workersQuery.data?.data.items || []} - columns={columns} - pagination={buildAdminTablePagination({ - current: workersPagination?.page || page, - pageSize: workersPagination?.pageSize || pageSize, - total: workersPagination?.total || 0, - onChange: (nextPage, nextPageSize) => { - setPage(nextPage) - setPageSize(nextPageSize) - }, - })} - scroll={{ x: 1000 }} - /> - - { - setLevelWorker(null) - levelForm.resetFields() - }} - onOk={() => levelForm.submit()} - destroyOnHidden - > - - - - {levelWorker?.displayName || levelWorker?.username || '-'} - - - - - - - -
- ) -} - -function FinancePanel() { - const { message } = App.useApp() - const queryClient = useQueryClient() - const [configForm] = Form.useForm() - const [reviewForm] = Form.useForm<{ reviewedNote?: string }>() - const [requestStatus, setRequestStatus] = useState('') - const [requestType, setRequestType] = useState('') - const [keywordInput, setKeywordInput] = useState('') - const [keyword, setKeyword] = useState('') - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) - const [savingConfig, setSavingConfig] = useState(false) - const [reviewing, setReviewing] = useState(false) - const [reviewState, setReviewState] = useState<{ - action: FinanceReviewAction - request: WorkerFinanceRequest - } | null>(null) - - const financeConfigQuery = useQuery({ - queryKey: ['admin-worker-platform-finance-config'], - queryFn: () => fetchAdminWorkerFinanceConfig(), - }) - const financeRequestsQuery = useQuery({ - queryKey: [ - 'admin-worker-platform-finance-requests', - requestStatus, - requestType, - keyword, - page, - pageSize, - ], - queryFn: () => - fetchAdminWorkerFinanceRequests({ - status: requestStatus, - requestType, - keyword, - page, - pageSize, - }), - }) - const financeRequestsPagination = financeRequestsQuery.data?.data.pagination - - useEffect(() => { - const config = financeConfigQuery.data?.data - if (!config) { - return - } - configForm.setFieldsValue(mapFinanceConfigToFormValues(config)) - }, [financeConfigQuery.data, configForm]) - - async function refreshAll() { - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['admin-worker-platform-finance-config'], - }), - queryClient.invalidateQueries({ - queryKey: ['admin-worker-platform-finance-requests'], - }), - queryClient.invalidateQueries({ - queryKey: ['admin-worker-platform-workers'], - }), - ]) - } - - async function submitFinanceConfig(values: FinanceConfigFormValues) { - setSavingConfig(true) - try { - await saveAdminWorkerFinanceConfig({ - recharge: { - enabled: values.recharge?.enabled !== false, - channelName: String(values.recharge?.channelName || '').trim(), - accountName: String(values.recharge?.accountName || '').trim(), - accountNo: String(values.recharge?.accountNo || '').trim(), - qrCodeImage: values.recharge?.qrCodeImageList?.[0] || null, - instructions: String(values.recharge?.instructions || '').trim(), - }, - withdraw: { - enabled: values.withdraw?.enabled !== false, - instructions: String(values.withdraw?.instructions || '').trim(), - }, - }) - message.success('资金配置已保存') - await refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '保存资金配置失败') - } finally { - setSavingConfig(false) - } - } - - function openReviewModal(request: WorkerFinanceRequest, action: FinanceReviewAction) { - setReviewState({ request, action }) - reviewForm.setFieldsValue({ reviewedNote: '' }) - } - - async function submitFinanceReview(values: { reviewedNote?: string }) { - if (!reviewState) return - setReviewing(true) - try { - await reviewAdminWorkerFinanceRequest(reviewState.request.requestId, { - status: reviewState.action, - reviewedNote: String(values.reviewedNote || '').trim(), - }) - message.success(`${formatFinanceReviewAction(reviewState.action)}成功`) - setReviewState(null) - reviewForm.resetFields() - await refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '处理资金申请失败') - } finally { - setReviewing(false) - } - } - - const requestColumns: TableColumnsType = [ - { - title: '打手', - minWidth: 180, - render: (_, row) => ( -
- - {row.worker?.displayName || row.worker?.username || '-'} - - - {row.worker?.username || '-'} - -
- ), - }, - { - title: '类型', - width: 120, - render: (_, row) => ( - - {formatFinanceRequestType(row.requestType)} - - ), - }, - { - title: '金额', - width: 120, - render: (_, row) => formatMoney(row.amount), - }, - { - title: '收款信息', - minWidth: 240, - render: (_, row) => renderFinanceRequestAccount(row), - }, - { - title: '付款凭证', - minWidth: 140, - render: (_, row) => - row.requestType === 'recharge' ? ( - - ) : ( - '-' - ), - }, - { - title: '备注', - minWidth: 260, - render: (_, row) => ( -
- {row.note || '-'} - {row.reviewedNote ? ( - - 审核备注:{row.reviewedNote} - - ) : null} -
- ), - }, - { - title: '状态', - width: 120, - render: (_, row) => ( - - {formatFinanceRequestStatus(row.status)} - - ), - }, - { - title: '时间', - width: 180, - render: (_, row) => ( -
- {formatAdminDateTime(row.createdAt)} - - {row.reviewedAt ? `审核:${formatAdminDateTime(row.reviewedAt)}` : '待审核'} - -
- ), - }, - { - title: '操作', - width: 240, - render: (_, row) => - row.status === 'pending' ? ( - - - - - - ) : ( - 已处理 - ), - }, - ] - - return ( -
- } - loading={financeConfigQuery.isFetching} - onClick={() => financeConfigQuery.refetch()} - > - 刷新 - - } - > - {financeConfigQuery.error ? ( - - {financeConfigQuery.error instanceof Error - ? financeConfigQuery.error.message - : '读取资金配置失败'} - - ) : null} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - setKeywordInput(event.target.value)} - onSearch={(value) => { - setKeyword(String(value || '').trim()) - setPage(1) - }} - /> - { - setRequestStatus(nextStatus) - setPage(1) - }} - /> - - - } - > - - rowKey="requestId" - loading={financeRequestsQuery.isLoading} - dataSource={financeRequestsQuery.data?.data.items || []} - columns={requestColumns} - pagination={buildAdminTablePagination({ - current: financeRequestsPagination?.page || page, - pageSize: financeRequestsPagination?.pageSize || pageSize, - total: financeRequestsPagination?.total || 0, - onChange: (nextPage, nextPageSize) => { - setPage(nextPage) - setPageSize(nextPageSize) - }, - })} - scroll={{ x: 1380 }} - /> - - - { - setReviewState(null) - reviewForm.resetFields() - }} - onOk={() => reviewForm.submit()} - > - {reviewState ? ( - - - - {reviewState.request.worker?.displayName || - reviewState.request.worker?.username || - '-'} - - - {formatFinanceRequestType(reviewState.request.requestType)} - - - {formatMoney(reviewState.request.amount)} - - - {renderFinanceRequestAccountText(reviewState.request)} - - {reviewState.request.requestType === 'recharge' ? ( - <> - - {getRechargePaidAt(reviewState.request) - ? formatAdminDateTime(getRechargePaidAt(reviewState.request)) - : '-'} - - - - - - ) : null} - - {reviewState.request.note || '-'} - - - -
- - - -
-
- ) : null} -
-
- ) -} - -function LevelsPanel() { - const { message } = App.useApp() - const queryClient = useQueryClient() - const levelsQuery = useQuery({ - queryKey: ['admin-worker-platform-levels'], - queryFn: () => fetchAdminWorkerLevels(), - }) - const [form] = Form.useForm() - const [editingLevel, setEditingLevel] = useState(null) - const [deletingLevel, setDeletingLevel] = useState(null) - - async function refreshLevels() { - await levelsQuery.refetch() - } - - function startEdit(level: WorkerLevel) { - setEditingLevel(level) - form.setFieldsValue({ - levelKey: level.levelKey, - name: level.name, - depositFreeAmount: level.depositFreeAmount, - maxActiveOrders: level.maxActiveOrders, - upgradeThreshold: level.upgradeThreshold, - }) - } - - function cancelEdit() { - setEditingLevel(null) - form.resetFields() - } - - async function saveLevel(values: { - levelKey: string - name: string - depositFreeAmount?: number - maxActiveOrders?: number - upgradeThreshold?: number - }) { - try { - await saveAdminWorkerLevel(values) - message.success(editingLevel ? '等级已更新' : '等级已保存') - cancelEdit() - await refreshLevels() - } catch (error) { - message.error(error instanceof Error ? error.message : '保存失败') - } - } - - async function toggleLevel(level: WorkerLevel) { - try { - await saveAdminWorkerLevel({ - levelKey: level.levelKey, - name: level.name, - depositFreeAmount: level.depositFreeAmount, - maxActiveOrders: level.maxActiveOrders, - upgradeThreshold: level.upgradeThreshold, - status: level.status === 'active' ? 'disabled' : 'active', - }) - message.success(level.status === 'active' ? '等级已停用' : '等级已启用') - await refreshLevels() - } catch (error) { - message.error(error instanceof Error ? error.message : '操作失败') - } - } - - async function submitDelete() { - if (!deletingLevel) return - try { - await deleteAdminWorkerLevel(deletingLevel.levelId) - message.success('等级已删除') - setDeletingLevel(null) - await refreshLevels() - await queryClient.invalidateQueries({ - queryKey: ['admin-worker-platform-workers'], - }) - } catch (error) { - message.error(error instanceof Error ? error.message : '删除失败') - setDeletingLevel(null) - } - } - - const columns: TableColumnsType = [ - { title: '等级', dataIndex: 'name' }, - { title: '标识', dataIndex: 'levelKey' }, - { - title: '免押额度', - render: (_, row) => formatMoney(row.depositFreeAmount), - }, - { title: '最大同时接单', dataIndex: 'maxActiveOrders' }, - { title: '升级所需累计完成', dataIndex: 'upgradeThreshold' }, - { - title: '状态', - width: 110, - render: (_, row) => ( - - {row.status === 'active' ? '启用' : '停用'} - - ), - }, - { - title: '操作', - width: 240, - render: (_, row) => ( - - - - - - ), - }, - ] - - return ( -
- -
- - - - - - - - - - - - - - - - - - - {editingLevel ? : null} - - -
-
- - - rowKey="levelId" - loading={levelsQuery.isLoading} - dataSource={levelsQuery.data?.data.items || []} - columns={columns} - pagination={false} - /> - - setDeletingLevel(null)} - okButtonProps={{ danger: true }} - okText="删除" - cancelText="取消" - > - - 确定删除等级「{deletingLevel?.name}」?正在使用该等级的打手将无法删除,需先调整。 - - -
- ) -} - -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)) -} - -type DetailFieldItem = { - key: string - label: string - required: boolean - value: string -} - -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 || '')]), - ) -} - -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 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 renderRequirementFieldValues(order: WorkOrder) { - const fields = getRequirementFields(order) - if (fields.length === 0) { - return 当前工单未配置资料要求 - } - - return ( - - {fields.map((field) => ( - - - {field.key} - - {field.required ? '必填' : '选填'} - - - - ))} - - ) -} - -function renderMaterialSummary(order: WorkOrder) { - const fieldLabelMap = new Map( - getRequirementFields(order).map((field) => [field.key, field.label]), - ) - const fields = getMaterialFields(order) - const entries = Object.entries(fields).filter(([, value]) => - String(value || '').trim(), - ) - if (entries.length === 0) { - return - - } - - return ( -
- {entries.slice(0, 2).map(([key, value]) => ( - - {fieldLabelMap.get(key) || key}: {value} - - ))} - {entries.length > 2 ? ( - - 还有 {entries.length - 2} 项 - - ) : null} -
- ) -} - -function getAcceptanceFiles(row: WorkOrder): UploadedFile[] { - return Array.isArray(row.acceptance?.files) ? row.acceptance.files : [] -} - -function getAcceptanceImageUrls(row: WorkOrder): string[] { - return Array.isArray(row.acceptance?.imageUrls) - ? row.acceptance.imageUrls - .map((item) => String(item || '').trim()) - .filter(Boolean) - : [] -} - -function getAcceptanceSubmittedAt(order: WorkOrder): string | null { - 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 formatStatus(status: string) { - const labels: Record = { - pending_material: '待完善', - unassigned: '未分配', - open: '待抢单', - in_progress: '代练中', - pending_acceptance: '待验收', - problem: '问题单', - accepted: '已验收', - cancelled: '已取消', - } - return labels[status] || status -} - -function resolveStatusColor(status: string) { - if (status === 'accepted') return 'green' - if (status === 'problem') return 'red' - if (status === 'pending_acceptance') return 'gold' - if (status === 'open') return 'blue' - return 'default' -} - -function formatWorkerStatus(status: string) { - const labels: Record = { - pending_review: '待审核', - active: '已通过', - rejected: '已拒绝', - disabled: '已停用', - } - return labels[status] || status -} - -function mapFinanceConfigToFormValues( - config?: WorkerFinanceConfig, -): FinanceConfigFormValues { - return { - recharge: { - enabled: config?.recharge.enabled !== false, - channelName: config?.recharge.channelName || '', - accountName: config?.recharge.accountName || '', - accountNo: config?.recharge.accountNo || '', - qrCodeImageList: config?.recharge.qrCodeImage ? [config.recharge.qrCodeImage] : [], - instructions: config?.recharge.instructions || '', - }, - withdraw: { - enabled: config?.withdraw.enabled !== false, - instructions: config?.withdraw.instructions || '', - }, - } -} - -function formatFinanceReviewAction(action: FinanceReviewAction) { - if (action === 'approved') return '通过' - if (action === 'rejected') return '拒绝' - return '取消' -} - -function formatFinanceRequestType(requestType: string) { - if (requestType === 'recharge') return '充值申请' - if (requestType === 'withdraw') return '提现申请' - return requestType || '-' -} - -function formatFinanceRequestStatus(status: string) { - const labels: Record = { - pending: '待处理', - approved: '已通过', - rejected: '已拒绝', - cancelled: '已取消', - } - return labels[status] || status || '-' -} - -function resolveFinanceRequestStatusColor(status: string) { - if (status === 'approved') return 'green' - if (status === 'pending') return 'gold' - if (status === 'rejected') return 'red' - if (status === 'cancelled') return 'default' - return 'blue' -} - -function renderFinanceRequestAccount(request: WorkerFinanceRequest) { - return ( -
- {renderFinanceRequestAccountText(request)} - {request.requestType === 'withdraw' && request.accountNo ? ( - - {maskFinanceAccount(request.accountNo)} - - ) : null} -
- ) -} - -function renderFinanceRequestAccountText(request: WorkerFinanceRequest) { - if (request.requestType === 'withdraw') { - const channel = formatFinanceChannel(request.accountChannel) - const accountName = request.accountName || '-' - return `${channel} / ${accountName}` - } - - const rechargeSnapshot = getRechargeSnapshot(request) - if ( - rechargeSnapshot.channelName || - rechargeSnapshot.accountName || - rechargeSnapshot.accountNo - ) { - return [ - rechargeSnapshot.channelName, - rechargeSnapshot.accountName, - rechargeSnapshot.accountNo ? maskFinanceAccount(rechargeSnapshot.accountNo) : '', - ] - .filter(Boolean) - .join(' / ') - } - - return '线下充值,等待管理员核对' -} - -function getRechargeSnapshot(request: WorkerFinanceRequest) { - const payload = asRecord(request.payload) - const financeConfig = asRecord(payload.financeConfig) - return { - channelName: String(financeConfig.channelName || '').trim(), - accountName: String(financeConfig.accountName || '').trim(), - accountNo: String(financeConfig.accountNo || '').trim(), - } -} - -function getRechargeProofs(request: WorkerFinanceRequest): UploadedFile[] { - if (request.requestType !== 'recharge') return [] - const payload = asRecord(request.payload) - const rawFiles = Array.isArray(payload.proofFiles) ? payload.proofFiles : [] - return rawFiles - .map((item) => { - const file = asRecord(item) - const url = String(file.url || '').trim() - if (!url) return null - return { - url, - mediumUrl: String(file.mediumUrl || '').trim(), - thumbnailUrl: String(file.thumbnailUrl || '').trim(), - } as UploadedFile - }) - .filter((item): item is UploadedFile => Boolean(item)) -} - -function getRechargePaidAt(request: WorkerFinanceRequest): string { - const payload = asRecord(request.payload) - return String(payload.paidAt || '').trim() -} - -function formatFinanceChannel(channel: string) { - if (channel === 'alipay') return '支付宝' - if (channel === 'wechat') return '微信收款' - if (channel === 'bank') return '银行卡' - if (channel === 'manual') return '线下转账' - return channel || '-' -} - -function formatSharingShareStatus(status: string) { - const labels: Record = { - joined: '已参与', - submitted: '已提交验收', - accepted: '已验收', - cancelled: '已取消', - } - return labels[status] || status -} - -function resolveSharingShareStatusColor(status: string) { - if (status === 'accepted') return 'green' - if (status === 'submitted') return 'gold' - if (status === 'cancelled') return 'default' - return 'purple' -} - -function maskFinanceAccount(value: string) { - const text = String(value || '').trim() - if (!text) return '-' - if (text.length <= 8) return text - return `${text.slice(0, 4)} **** ${text.slice(-4)}` -} diff --git a/apps/frontend/src/pages/admin/panels/CategoriesPanel.tsx b/apps/frontend/src/pages/admin/panels/CategoriesPanel.tsx new file mode 100644 index 00000000..b5a6f1a0 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/CategoriesPanel.tsx @@ -0,0 +1,262 @@ +import { + CheckOutlined, + FormOutlined, + PlusOutlined, + ReloadOutlined, + SendOutlined, + SyncOutlined, + TeamOutlined, + WarningOutlined, +} from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + App, + Button, + Card, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Statistic, + Switch, + Table, + Tabs, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' + +import JsonPreview from '@/components/admin/JsonPreview' +import ImageUpload from '@/components/files/ImageUpload' +import ImagePreviewList from '@/components/files/ImagePreviewList' +import PageHeader from '@/components/admin/PageHeader' +import { + acceptAdminWorkOrder, + createAdminMockWorkOrder, + creditAdminWorkerWallet, + deleteAdminWorkCategory, + deleteAdminWorkerLevel, + fetchAdminWorkerFinanceConfig, + fetchAdminWorkerFinanceRequests, + fetchAdminWorkCategories, + fetchAdminWorkOrderSharing, + fetchAdminWorkProductRules, + fetchAdminWorkerLevels, + fetchAdminWorkerPlatformSummary, + fetchAdminWorkerUsers, + fetchAdminWorkOrders, + markAdminWorkOrderProblem, + publishAdminWorkOrder, + reviewAdminWorkerFinanceRequest, + resolveAdminProblemWorkOrder, + reviewAdminWorkerUser, + saveAdminWorkerFinanceConfig, + saveAdminWorkCategory, + saveAdminWorkProductRule, + saveAdminWorkerLevel, + submitAdminWorkOrderMaterial, + syncAdminWorkerOrdersFromSource, + unpublishAdminWorkOrder, + updateAdminWorkOrderSharing, +} from '@/services/admin' +import type { + CollectField, + UploadedFile, + WorkCategory, + WorkOrder, + WorkOrderShare, + WorkProductRule, + WorkerFinanceConfig, + WorkerFinanceRequest, + WorkerLevel, + WorkerUser, +} from '@/types/worker-platform' +import { + ADMIN_DEFAULT_PAGE_SIZE, + buildAdminTablePagination, +} from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' + +export default function CategoriesPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [editingCategory, setEditingCategory] = useState(null) + const [deletingCategory, setDeletingCategory] = useState(null) + const categoriesQuery = useQuery({ + queryKey: ['admin-worker-platform-categories'], + queryFn: () => fetchAdminWorkCategories(), + }) + + async function refreshCategories() { + await categoriesQuery.refetch() + } + + function startEdit(category: WorkCategory) { + setEditingCategory(category) + form.setFieldsValue({ + categoryKey: category.categoryKey, + name: category.name, + sortOrder: category.sortOrder, + status: category.status, + }) + } + + function cancelEdit() { + setEditingCategory(null) + form.resetFields() + } + + async function saveCategory(values: { + categoryKey: string + name: string + sortOrder?: number + status?: string + }) { + try { + await saveAdminWorkCategory(values) + message.success(editingCategory ? '分类已更新' : '分类已保存') + cancelEdit() + await refreshCategories() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败') + } + } + + async function toggleCategory(category: WorkCategory) { + try { + await saveAdminWorkCategory({ + categoryKey: category.categoryKey, + name: category.name, + sortOrder: category.sortOrder, + status: category.status === 'active' ? 'disabled' : 'active', + }) + message.success(category.status === 'active' ? '分类已停用' : '分类已启用') + await refreshCategories() + } catch (error) { + message.error(error instanceof Error ? error.message : '操作失败') + } + } + + async function submitDelete() { + if (!deletingCategory) return + try { + await deleteAdminWorkCategory(deletingCategory.categoryId) + message.success('分类已删除') + setDeletingCategory(null) + await refreshCategories() + } catch (error) { + message.error(error instanceof Error ? error.message : '删除失败') + setDeletingCategory(null) + } + } + + const columns: TableColumnsType = [ + { title: '分类', dataIndex: 'name' }, + { title: '标识', dataIndex: 'categoryKey' }, + { title: '排序', dataIndex: 'sortOrder', width: 100 }, + { + title: '状态', + width: 120, + render: (_, row) => ( + + {row.status === 'active' ? '启用' : '停用'} + + ), + }, + { + title: '操作', + width: 240, + render: (_, row) => ( + + + + + + ), + }, + ] + + return ( +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + setKeywordInput(event.target.value)} + onSearch={(value) => { + setKeyword(String(value || '').trim()) + setPage(1) + }} + /> + { + setRequestStatus(nextStatus) + setPage(1) + }} + /> + + + } + > + + rowKey="requestId" + loading={financeRequestsQuery.isLoading} + dataSource={financeRequestsQuery.data?.data.items || []} + columns={requestColumns} + pagination={buildAdminTablePagination({ + current: financeRequestsPagination?.page || page, + pageSize: financeRequestsPagination?.pageSize || pageSize, + total: financeRequestsPagination?.total || 0, + onChange: (nextPage, nextPageSize) => { + setPage(nextPage) + setPageSize(nextPageSize) + }, + })} + scroll={{ x: 1380 }} + /> + + + { + setReviewState(null) + reviewForm.resetFields() + }} + onOk={() => reviewForm.submit()} + > + {reviewState ? ( + + + + {reviewState.request.worker?.displayName || + reviewState.request.worker?.username || + '-'} + + + {formatFinanceRequestType(reviewState.request.requestType)} + + + {formatMoney(reviewState.request.amount)} + + + {renderFinanceRequestAccountText(reviewState.request)} + + {reviewState.request.requestType === 'recharge' ? ( + <> + + {getRechargePaidAt(reviewState.request) + ? formatAdminDateTime(getRechargePaidAt(reviewState.request)) + : '-'} + + + + + + ) : null} + + {reviewState.request.note || '-'} + + + +
+ + + +
+
+ ) : null} +
+
+ ) +} + +function mapFinanceConfigToFormValues( + config?: WorkerFinanceConfig, +): FinanceConfigFormValues { + return { + recharge: { + enabled: config?.recharge.enabled !== false, + channelName: config?.recharge.channelName || '', + accountName: config?.recharge.accountName || '', + accountNo: config?.recharge.accountNo || '', + qrCodeImageList: config?.recharge.qrCodeImage ? [config.recharge.qrCodeImage] : [], + instructions: config?.recharge.instructions || '', + }, + withdraw: { + enabled: config?.withdraw.enabled !== false, + instructions: config?.withdraw.instructions || '', + }, + } +} + +function formatFinanceReviewAction(action: FinanceReviewAction) { + if (action === 'approved') return '通过' + if (action === 'rejected') return '拒绝' + return '取消' +} + +function formatFinanceRequestType(requestType: string) { + if (requestType === 'recharge') return '充值申请' + if (requestType === 'withdraw') return '提现申请' + return requestType || '-' +} + +function formatFinanceRequestStatus(status: string) { + const labels: Record = { + pending: '待处理', + approved: '已通过', + rejected: '已拒绝', + cancelled: '已取消', + } + return labels[status] || status || '-' +} + +function resolveFinanceRequestStatusColor(status: string) { + if (status === 'approved') return 'green' + if (status === 'pending') return 'gold' + if (status === 'rejected') return 'red' + if (status === 'cancelled') return 'default' + return 'blue' +} + +function renderFinanceRequestAccount(request: WorkerFinanceRequest) { + return ( +
+ {renderFinanceRequestAccountText(request)} + {request.requestType === 'withdraw' && request.accountNo ? ( + + {maskFinanceAccount(request.accountNo)} + + ) : null} +
+ ) +} + +function renderFinanceRequestAccountText(request: WorkerFinanceRequest) { + if (request.requestType === 'withdraw') { + const channel = formatFinanceChannel(request.accountChannel) + const accountName = request.accountName || '-' + return `${channel} / ${accountName}` + } + + const rechargeSnapshot = getRechargeSnapshot(request) + if ( + rechargeSnapshot.channelName || + rechargeSnapshot.accountName || + rechargeSnapshot.accountNo + ) { + return [ + rechargeSnapshot.channelName, + rechargeSnapshot.accountName, + rechargeSnapshot.accountNo ? maskFinanceAccount(rechargeSnapshot.accountNo) : '', + ] + .filter(Boolean) + .join(' / ') + } + + return '线下充值,等待管理员核对' +} + +function getRechargeSnapshot(request: WorkerFinanceRequest) { + const payload = asRecord(request.payload) + const financeConfig = asRecord(payload.financeConfig) + return { + channelName: String(financeConfig.channelName || '').trim(), + accountName: String(financeConfig.accountName || '').trim(), + accountNo: String(financeConfig.accountNo || '').trim(), + } +} + +function getRechargeProofs(request: WorkerFinanceRequest): UploadedFile[] { + if (request.requestType !== 'recharge') return [] + const payload = asRecord(request.payload) + const rawFiles = Array.isArray(payload.proofFiles) ? payload.proofFiles : [] + return rawFiles + .map((item) => { + const file = asRecord(item) + const url = String(file.url || '').trim() + if (!url) return null + return { + url, + mediumUrl: String(file.mediumUrl || '').trim(), + thumbnailUrl: String(file.thumbnailUrl || '').trim(), + } as UploadedFile + }) + .filter((item): item is UploadedFile => Boolean(item)) +} + +function getRechargePaidAt(request: WorkerFinanceRequest): string { + const payload = asRecord(request.payload) + return String(payload.paidAt || '').trim() +} + +function formatFinanceChannel(channel: string) { + if (channel === 'alipay') return '支付宝' + if (channel === 'wechat') return '微信收款' + if (channel === 'bank') return '银行卡' + if (channel === 'manual') return '线下转账' + return channel || '-' +} + +function maskFinanceAccount(value: string) { + const text = String(value || '').trim() + if (!text) return '-' + if (text.length <= 8) return text + return `${text.slice(0, 4)} **** ${text.slice(-4)}` +} diff --git a/apps/frontend/src/pages/admin/panels/LevelsPanel.tsx b/apps/frontend/src/pages/admin/panels/LevelsPanel.tsx new file mode 100644 index 00000000..8d8e178a --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/LevelsPanel.tsx @@ -0,0 +1,285 @@ +import { + CheckOutlined, + FormOutlined, + PlusOutlined, + ReloadOutlined, + SendOutlined, + SyncOutlined, + TeamOutlined, + WarningOutlined, +} from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + App, + Button, + Card, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Statistic, + Switch, + Table, + Tabs, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' + +import JsonPreview from '@/components/admin/JsonPreview' +import ImageUpload from '@/components/files/ImageUpload' +import ImagePreviewList from '@/components/files/ImagePreviewList' +import PageHeader from '@/components/admin/PageHeader' +import { + acceptAdminWorkOrder, + createAdminMockWorkOrder, + creditAdminWorkerWallet, + deleteAdminWorkCategory, + deleteAdminWorkerLevel, + fetchAdminWorkerFinanceConfig, + fetchAdminWorkerFinanceRequests, + fetchAdminWorkCategories, + fetchAdminWorkOrderSharing, + fetchAdminWorkProductRules, + fetchAdminWorkerLevels, + fetchAdminWorkerPlatformSummary, + fetchAdminWorkerUsers, + fetchAdminWorkOrders, + markAdminWorkOrderProblem, + publishAdminWorkOrder, + reviewAdminWorkerFinanceRequest, + resolveAdminProblemWorkOrder, + reviewAdminWorkerUser, + saveAdminWorkerFinanceConfig, + saveAdminWorkCategory, + saveAdminWorkProductRule, + saveAdminWorkerLevel, + submitAdminWorkOrderMaterial, + syncAdminWorkerOrdersFromSource, + unpublishAdminWorkOrder, + updateAdminWorkOrderSharing, +} from '@/services/admin' +import type { + CollectField, + UploadedFile, + WorkCategory, + WorkOrder, + WorkOrderShare, + WorkProductRule, + WorkerFinanceConfig, + WorkerFinanceRequest, + WorkerLevel, + WorkerUser, +} from '@/types/worker-platform' +import { + ADMIN_DEFAULT_PAGE_SIZE, + buildAdminTablePagination, +} from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' +import { formatMoney } from './shared' + +export default function LevelsPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const levelsQuery = useQuery({ + queryKey: ['admin-worker-platform-levels'], + queryFn: () => fetchAdminWorkerLevels(), + }) + const [form] = Form.useForm() + const [editingLevel, setEditingLevel] = useState(null) + const [deletingLevel, setDeletingLevel] = useState(null) + + async function refreshLevels() { + await levelsQuery.refetch() + } + + function startEdit(level: WorkerLevel) { + setEditingLevel(level) + form.setFieldsValue({ + levelKey: level.levelKey, + name: level.name, + depositFreeAmount: level.depositFreeAmount, + maxActiveOrders: level.maxActiveOrders, + upgradeThreshold: level.upgradeThreshold, + }) + } + + function cancelEdit() { + setEditingLevel(null) + form.resetFields() + } + + async function saveLevel(values: { + levelKey: string + name: string + depositFreeAmount?: number + maxActiveOrders?: number + upgradeThreshold?: number + }) { + try { + await saveAdminWorkerLevel(values) + message.success(editingLevel ? '等级已更新' : '等级已保存') + cancelEdit() + await refreshLevels() + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败') + } + } + + async function toggleLevel(level: WorkerLevel) { + try { + await saveAdminWorkerLevel({ + levelKey: level.levelKey, + name: level.name, + depositFreeAmount: level.depositFreeAmount, + maxActiveOrders: level.maxActiveOrders, + upgradeThreshold: level.upgradeThreshold, + status: level.status === 'active' ? 'disabled' : 'active', + }) + message.success(level.status === 'active' ? '等级已停用' : '等级已启用') + await refreshLevels() + } catch (error) { + message.error(error instanceof Error ? error.message : '操作失败') + } + } + + async function submitDelete() { + if (!deletingLevel) return + try { + await deleteAdminWorkerLevel(deletingLevel.levelId) + message.success('等级已删除') + setDeletingLevel(null) + await refreshLevels() + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-workers'], + }) + } catch (error) { + message.error(error instanceof Error ? error.message : '删除失败') + setDeletingLevel(null) + } + } + + const columns: TableColumnsType = [ + { title: '等级', dataIndex: 'name' }, + { title: '标识', dataIndex: 'levelKey' }, + { + title: '免押额度', + render: (_, row) => formatMoney(row.depositFreeAmount), + }, + { title: '最大同时接单', dataIndex: 'maxActiveOrders' }, + { title: '升级所需累计完成', dataIndex: 'upgradeThreshold' }, + { + title: '状态', + width: 110, + render: (_, row) => ( + + {row.status === 'active' ? '启用' : '停用'} + + ), + }, + { + title: '操作', + width: 240, + render: (_, row) => ( + + + + + + ), + }, + ] + + return ( +
+ +
+ + + + + + + + + + + + + + + + + + + {editingLevel ? : null} + + +
+
+ + + rowKey="levelId" + loading={levelsQuery.isLoading} + dataSource={levelsQuery.data?.data.items || []} + columns={columns} + pagination={false} + /> + + setDeletingLevel(null)} + okButtonProps={{ danger: true }} + okText="删除" + cancelText="取消" + > + + 确定删除等级「{deletingLevel?.name}」?正在使用该等级的打手将无法删除,需先调整。 + + +
+ ) +} + diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx new file mode 100644 index 00000000..36450180 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -0,0 +1,418 @@ +import { + CheckOutlined, + FormOutlined, + PlusOutlined, + ReloadOutlined, + SendOutlined, + SyncOutlined, + TeamOutlined, + WarningOutlined, +} from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + App, + Button, + Card, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Statistic, + Switch, + Table, + Tabs, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' + +import JsonPreview from '@/components/admin/JsonPreview' +import ImageUpload from '@/components/files/ImageUpload' +import ImagePreviewList from '@/components/files/ImagePreviewList' +import PageHeader from '@/components/admin/PageHeader' +import { + acceptAdminWorkOrder, + createAdminMockWorkOrder, + creditAdminWorkerWallet, + deleteAdminWorkCategory, + deleteAdminWorkerLevel, + fetchAdminWorkerFinanceConfig, + fetchAdminWorkerFinanceRequests, + fetchAdminWorkCategories, + fetchAdminWorkOrderSharing, + fetchAdminWorkProductRules, + fetchAdminWorkerLevels, + fetchAdminWorkerPlatformSummary, + fetchAdminWorkerUsers, + fetchAdminWorkOrders, + markAdminWorkOrderProblem, + publishAdminWorkOrder, + reviewAdminWorkerFinanceRequest, + resolveAdminProblemWorkOrder, + reviewAdminWorkerUser, + saveAdminWorkerFinanceConfig, + saveAdminWorkCategory, + saveAdminWorkProductRule, + saveAdminWorkerLevel, + submitAdminWorkOrderMaterial, + syncAdminWorkerOrdersFromSource, + unpublishAdminWorkOrder, + updateAdminWorkOrderSharing, +} from '@/services/admin' +import type { + CollectField, + UploadedFile, + WorkCategory, + WorkOrder, + WorkOrderShare, + WorkProductRule, + WorkerFinanceConfig, + WorkerFinanceRequest, + WorkerLevel, + WorkerUser, +} from '@/types/worker-platform' +import { + ADMIN_DEFAULT_PAGE_SIZE, + buildAdminTablePagination, +} from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' +import { formatMoney } from './shared' + +export default function ProductRulesPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const rulesQuery = useQuery({ + queryKey: ['admin-worker-platform-product-rules'], + queryFn: () => fetchAdminWorkProductRules(), + }) + const categoriesQuery = useQuery({ + queryKey: ['admin-worker-platform-categories'], + queryFn: () => fetchAdminWorkCategories(), + }) + + async function saveRule(values: { + ruleKey: string + provider?: string + platform?: string + shopId?: string + skuCode?: string + productName?: string + matchType?: string + categoryId?: number + rewardAmount?: number + requiredDepositAmount?: number + depositThresholdAmount?: number + sharingEnabled?: boolean + sharingTotalQuantity?: number + sharingUnitReward?: number + sharingTotalAmount?: number + fieldsText?: string + enabled?: boolean + autoCreate?: boolean + }) { + try { + await saveAdminWorkProductRule(values) + message.success('物品规则已保存') + form.resetFields() + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-product-rules'], + }), + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-summary'], + }), + ]) + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败') + } + } + + function syncSharingRuleForm(next: { + sharingTotalQuantity?: number + sharingUnitReward?: number + sharingTotalAmount?: number + }) { + const values = form.getFieldsValue() + const unitReward = Number(next.sharingUnitReward ?? values.sharingUnitReward ?? 0) + const totalQuantity = Number( + next.sharingTotalQuantity ?? values.sharingTotalQuantity ?? 1, + ) + const totalAmount = Number(next.sharingTotalAmount ?? values.sharingTotalAmount ?? 0) + if (next.sharingTotalAmount !== undefined) { + const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1 + form.setFieldsValue({ + sharingTotalQuantity: quantity, + sharingTotalAmount: quantity * unitReward, + }) + return + } + form.setFieldsValue({ + sharingTotalAmount: unitReward * totalQuantity, + }) + } + + const columns: TableColumnsType = [ + { + title: '规则', + minWidth: 260, + render: (_, row) => ( +
+ + {row.productName || row.skuCode} + + {row.ruleKey} +
+ ), + }, + { + title: 'SKU', + dataIndex: 'skuCode', + width: 180, + render: (value) => String(value || '-'), + }, + { title: '分类', width: 140, render: (_, row) => row.categoryName || '-' }, + { + title: '接单金额', + width: 120, + render: (_, row) => formatMoney(row.rewardAmount), + }, + { + title: '押金', + width: 120, + render: (_, row) => formatMoney(row.requiredDepositAmount), + }, + { + title: '匹配', + width: 130, + render: (_, row) => (row.matchType === 'exact' ? '精确' : '包含'), + }, + { + title: '状态', + width: 150, + render: (_, row) => ( + + + {row.enabled ? '启用' : '停用'} + + {row.autoCreate ? 自动创建 : null} + {row.sharing?.enabled ? ( + + 拼单 {formatMoney(row.sharing.unitReward)}/份 + + ) : null} + + ), + }, + ] + + return ( +
+ +
+
+ + + + + + + + + + + + + + + + + + + + ({ + value: item.categoryId, + label: item.name, + }), + )} + /> + + + + + + + + + + + + + + + + + + + + + + +
+ + {({ getFieldValue }) => + getFieldValue('sharingEnabled') ? ( + + + + syncSharingRuleForm({ + sharingTotalQuantity: Number(value) || 0, + }) + } + /> + + + + syncSharingRuleForm({ + sharingUnitReward: Number(value) || 0, + }) + } + /> + + + + syncSharingRuleForm({ + sharingTotalAmount: Number(value) || 0, + }) + } + /> + + + ) : null + } + + + + + +
+
+ } + loading={rulesQuery.isFetching} + onClick={() => rulesQuery.refetch()} + > + 刷新 + + } + bordered={false} + > + + rowKey="ruleId" + loading={rulesQuery.isLoading} + dataSource={rulesQuery.data?.data.items || []} + columns={columns} + pagination={false} + scroll={{ x: 1200 }} + /> + +
+ ) +} + diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx new file mode 100644 index 00000000..77b9a4ae --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -0,0 +1,984 @@ +import { + CheckOutlined, + FormOutlined, + PlusOutlined, + ReloadOutlined, + SendOutlined, + SyncOutlined, + TeamOutlined, + WarningOutlined, +} from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + App, + Button, + Card, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Statistic, + Switch, + Table, + Tabs, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' + +import JsonPreview from '@/components/admin/JsonPreview' +import ImageUpload from '@/components/files/ImageUpload' +import ImagePreviewList from '@/components/files/ImagePreviewList' +import PageHeader from '@/components/admin/PageHeader' +import { + acceptAdminWorkOrder, + createAdminMockWorkOrder, + creditAdminWorkerWallet, + deleteAdminWorkCategory, + deleteAdminWorkerLevel, + fetchAdminWorkerFinanceConfig, + fetchAdminWorkerFinanceRequests, + fetchAdminWorkCategories, + fetchAdminWorkOrderSharing, + fetchAdminWorkProductRules, + fetchAdminWorkerLevels, + fetchAdminWorkerPlatformSummary, + fetchAdminWorkerUsers, + fetchAdminWorkOrders, + markAdminWorkOrderProblem, + publishAdminWorkOrder, + reviewAdminWorkerFinanceRequest, + resolveAdminProblemWorkOrder, + reviewAdminWorkerUser, + saveAdminWorkerFinanceConfig, + saveAdminWorkCategory, + saveAdminWorkProductRule, + saveAdminWorkerLevel, + submitAdminWorkOrderMaterial, + syncAdminWorkerOrdersFromSource, + unpublishAdminWorkOrder, + updateAdminWorkOrderSharing, +} from '@/services/admin' +import type { + CollectField, + UploadedFile, + WorkCategory, + WorkOrder, + WorkOrderShare, + WorkProductRule, + WorkerFinanceConfig, + WorkerFinanceRequest, + WorkerLevel, + WorkerUser, +} from '@/types/worker-platform' +import { + ADMIN_DEFAULT_PAGE_SIZE, + buildAdminTablePagination, +} from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' +import { + getAcceptanceFiles, + getAcceptanceImageUrls, + getAcceptanceSubmittedAt, + getMaterialFields, + getMaterialDetailItems, + getRequirementFields, + renderDetailFieldValues, + renderMaterialSummary, + renderRequirementFieldValues, + formatMoney, + formatSharingShareStatus, + formatStatus, + resolveSharingShareStatusColor, + resolveStatusColor, + type DetailFieldItem, +} from './shared' + +export default function WorkOrdersPanel() { + const { message, modal } = App.useApp() + const navigate = useNavigate() + const queryClient = useQueryClient() + const [status, setStatus] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [detailOrder, setDetailOrder] = useState(null) + const [problemOrder, setProblemOrder] = useState(null) + const [resolutionOrder, setResolutionOrder] = useState(null) + const [materialOrder, setMaterialOrder] = useState(null) + const [sharingOrder, setSharingOrder] = useState(null) + const [problemForm] = Form.useForm() + const [resolutionForm] = Form.useForm() + const [materialForm] = Form.useForm() + const [sharingForm] = Form.useForm<{ + enabled?: boolean + totalQuantity?: number + unitReward?: number + totalAmount?: number + }>() + + const ordersQuery = useQuery({ + queryKey: ['admin-worker-platform-orders', status, page, pageSize], + queryFn: () => fetchAdminWorkOrders({ status, page, pageSize }), + }) + const ordersPagination = ordersQuery.data?.data.pagination + const sharingQuery = useQuery({ + queryKey: ['admin-worker-platform-order-sharing', sharingOrder?.workOrderId], + queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)), + enabled: Boolean(sharingOrder), + }) + + function openSharingConfigModal(row: WorkOrder) { + setSharingOrder(row) + const unitRewardFen = Number(row.sharing?.unitReward || 0) + const totalQuantity = Number(row.sharing?.totalQuantity || 1) + const unitRewardYuan = unitRewardFen / 100 + sharingForm.setFieldsValue({ + enabled: row.sharing?.enabled, + totalQuantity, + unitReward: unitRewardYuan, + totalAmount: unitRewardYuan * totalQuantity, + }) + } + + function syncSharingForm(next: { + totalQuantity?: number + unitReward?: number + totalAmount?: number + }) { + const values = sharingForm.getFieldsValue() + const unitReward = Number(next.unitReward ?? values.unitReward ?? 0) + const totalQuantity = Number(next.totalQuantity ?? values.totalQuantity ?? 1) + const totalAmount = Number(next.totalAmount ?? values.totalAmount ?? 0) + if (next.totalAmount !== undefined) { + const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1 + sharingForm.setFieldsValue({ + totalQuantity: quantity, + totalAmount: quantity * unitReward, + }) + return + } + sharingForm.setFieldsValue({ + totalAmount: unitReward * totalQuantity, + }) + } + + async function submitSharingConfig(values: { + enabled?: boolean + totalQuantity?: number + unitReward?: number + totalAmount?: number + }) { + if (!sharingOrder) return + const succeeded = await runAction( + () => + updateAdminWorkOrderSharing(sharingOrder.workOrderId, { + enabled: values.enabled !== false, + totalQuantity: Number(values.totalQuantity || 1), + unitReward: Number(values.unitReward || 0), + totalAmount: Number(values.totalAmount || 0), + }), + '拼单配置已保存', + ) + if (!succeeded) return + setSharingOrder(null) + sharingForm.resetFields() + } + + async function refreshAll() { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-orders'], + }), + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-summary'], + }), + ]) + } + + async function runAction( + action: () => Promise, + successMessage: string, + ) { + try { + await action() + message.success(successMessage) + await refreshAll() + return true + } catch (error) { + message.error(error instanceof Error ? error.message : '操作失败') + return false + } + } + + async function submitProblem(values: { note?: string }) { + if (!problemOrder) return + const succeeded = await runAction( + () => + markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''), + '已标记问题单', + ) + if (!succeeded) return + setProblemOrder(null) + problemForm.resetFields() + } + + async function submitResolution(values: { + action?: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct' + note?: string + }) { + if (!resolutionOrder || !values.action) return + const action = values.action + const succeeded = await runAction( + () => + resolveAdminProblemWorkOrder(resolutionOrder.workOrderId, { + action, + note: values.note || '', + }), + '问题单已处置', + ) + if (!succeeded) return + setResolutionOrder(null) + resolutionForm.resetFields() + } + + async function submitMaterial(values: { fields?: Record }) { + if (!materialOrder) return + const succeeded = await runAction( + () => + submitAdminWorkOrderMaterial( + materialOrder.workOrderId, + values.fields || {}, + ), + '订单资料已保存', + ) + if (!succeeded) return + setMaterialOrder(null) + materialForm.resetFields() + } + + async function submitSourceSync(values: { platformOrderId?: string }) { + try { + const result = await syncAdminWorkerOrdersFromSource({ + platformOrderId: String(values.platformOrderId || '').trim(), + }) + message.success( + `同步完成,新增 ${result.data.createdCount} 个,跳过 ${result.data.skippedCount} 个`, + ) + await refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '同步失败') + } + } + + function openMaterialModal(row: WorkOrder) { + setMaterialOrder(row) + materialForm.setFieldsValue({ fields: getMaterialFields(row) }) + } + + function openDetailDrawer(row: WorkOrder) { + setDetailOrder(row) + } + + const columns: TableColumnsType = [ + { + title: '订单', + minWidth: 300, + render: (_, row) => ( +
+ openDetailDrawer(row)}> + {row.productName || row.workOrderNo} + + + {row.categoryName ? {row.categoryName} : null} + {row.orderId ? ( + + 源订单 #{row.orderId} + + ) : null} + + + {row.platformOrderId} · {row.workOrderNo} + +
+ ), + }, + { + title: '金额', + width: 110, + render: (_, row) => formatMoney(row.rewardAmount), + }, + { + title: '押金', + width: 110, + render: (_, row) => formatMoney(row.requiredDepositAmount), + }, + { + title: '拼单', + width: 190, + render: (_, row) => + row.sharing?.enabled ? ( +
+ + 单价 {formatMoney(row.sharing.unitReward)} / 共 {row.sharing.totalQuantity} 份 + + + 已拼 {row.sharingProgress?.joinedQuantity || 0} / {row.sharing.totalQuantity} 份 + {row.sharingProgress?.pendingSubmissionCount + ? ` · ${row.sharingProgress.pendingSubmissionCount} 人待提交` + : ''} + +
+ ) : ( + 未启用 + ), + }, + { + title: '状态', + width: 130, + render: (_, row) => ( + + {formatStatus(row.status)} + + ), + }, + { + title: '打手', + width: 140, + render: (_, row) => + row.worker?.displayName || row.worker?.username || '-', + }, + { + title: '资料', + width: 160, + render: (_, row) => renderMaterialSummary(row), + }, + { + title: '验收图', + width: 170, + render: (_, row) => ( + + ), + }, + { + title: '操作', + width: 400, + render: (_, row) => ( + + + {['pending_material', 'unassigned', 'open'].includes(row.status) ? ( + + ) : null} + {row.status === 'pending_material' ? ( + + ) : null} + {row.status === 'unassigned' ? ( + + ) : null} + {row.status === 'open' ? ( + + ) : null} + {row.status === 'pending_acceptance' ? ( + + ) : null} + {['in_progress', 'pending_acceptance'].includes(row.status) ? ( + + ) : null} + {row.status === 'problem' ? ( + + ) : null} + + ), + }, + ] + + return ( +
+ +
+ + + + + + +
+
+ + + + + + + + + + + + + { + setMaterialOrder(null) + materialForm.resetFields() + }} + onOk={() => materialForm.submit()} + destroyOnHidden + > +
+ {getRequirementFields(materialOrder).map((field) => ( + + + + ))} +
+
+ + { + setSharingOrder(null) + sharingForm.resetFields() + }} + onOk={() => sharingForm.submit()} + destroyOnHidden + width={720} + > + {sharingOrder ? ( + +
+ + + + + {({ getFieldValue }) => + getFieldValue('enabled') ? ( + + + + syncSharingForm({ + totalQuantity: Number(value) || 0, + }) + } + /> + + + + syncSharingForm({ unitReward: Number(value) || 0 }) + } + /> + + + + syncSharingForm({ totalAmount: Number(value) || 0 }) + } + /> + + + ) : null + } + +
+ + + {sharingQuery.isLoading ? ( + 加载中... + ) : (sharingQuery.data?.data.shares || []).length === 0 ? ( + 暂无打手参与拼单 + ) : ( + + rowKey="shareId" + size="small" + dataSource={sharingQuery.data?.data.shares || []} + pagination={false} + columns={[ + { + title: '打手', + render: (_, share) => + share.worker?.displayName || share.worker?.username || '-', + }, + { title: '数量', dataIndex: 'quantity' }, + { + title: '份额报酬', + render: (_, share) => formatMoney(share.shareReward), + }, + { + title: '状态', + render: (_, share) => ( + + {formatSharingShareStatus(share.status)} + + ), + }, + { + title: '提交时间', + render: (_, share) => + share.submittedAt + ? formatAdminDateTime(share.submittedAt) + : '-', + }, + ]} + /> + )} + +
+ ) : null} +
+
+ ) +} + +function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise }) { + const { message } = App.useApp() + const [loading, setLoading] = useState(false) + + async function submit(values: { + platformOrderId?: string + productName?: string + rewardAmount?: number + materialComplete?: boolean + }) { + setLoading(true) + try { + await createAdminMockWorkOrder(values) + message.success('Mock 工单已生成') + await onCreated() + } catch (error) { + message.error(error instanceof Error ? error.message : '生成失败') + } finally { + setLoading(false) + } + } + + return ( +
+ + + + + + + + + + + + + + + +
+ ) +} + diff --git a/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx new file mode 100644 index 00000000..dbba4bd6 --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/WorkersPanel.tsx @@ -0,0 +1,346 @@ +import { + CheckOutlined, + FormOutlined, + PlusOutlined, + ReloadOutlined, + SendOutlined, + SyncOutlined, + TeamOutlined, + WarningOutlined, +} from '@ant-design/icons' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + App, + Button, + Card, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Statistic, + Switch, + Table, + Tabs, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' + +import JsonPreview from '@/components/admin/JsonPreview' +import ImageUpload from '@/components/files/ImageUpload' +import ImagePreviewList from '@/components/files/ImagePreviewList' +import PageHeader from '@/components/admin/PageHeader' +import { + acceptAdminWorkOrder, + createAdminMockWorkOrder, + creditAdminWorkerWallet, + deleteAdminWorkCategory, + deleteAdminWorkerLevel, + fetchAdminWorkerFinanceConfig, + fetchAdminWorkerFinanceRequests, + fetchAdminWorkCategories, + fetchAdminWorkOrderSharing, + fetchAdminWorkProductRules, + fetchAdminWorkerLevels, + fetchAdminWorkerPlatformSummary, + fetchAdminWorkerUsers, + fetchAdminWorkOrders, + markAdminWorkOrderProblem, + publishAdminWorkOrder, + reviewAdminWorkerFinanceRequest, + resolveAdminProblemWorkOrder, + reviewAdminWorkerUser, + saveAdminWorkerFinanceConfig, + saveAdminWorkCategory, + saveAdminWorkProductRule, + saveAdminWorkerLevel, + submitAdminWorkOrderMaterial, + syncAdminWorkerOrdersFromSource, + unpublishAdminWorkOrder, + updateAdminWorkOrderSharing, +} from '@/services/admin' +import type { + CollectField, + UploadedFile, + WorkCategory, + WorkOrder, + WorkOrderShare, + WorkProductRule, + WorkerFinanceConfig, + WorkerFinanceRequest, + WorkerLevel, + WorkerUser, +} from '@/types/worker-platform' +import { + ADMIN_DEFAULT_PAGE_SIZE, + buildAdminTablePagination, +} from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' +import { formatMoney, formatWorkerStatus } from './shared' + +export default function WorkersPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [status, setStatus] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [creditWorker, setCreditWorker] = useState(null) + const [levelWorker, setLevelWorker] = useState(null) + const [creditForm] = Form.useForm() + const [levelForm] = Form.useForm<{ levelId?: number }>() + + const workersQuery = useQuery({ + queryKey: ['admin-worker-platform-workers', status, page, pageSize], + queryFn: () => fetchAdminWorkerUsers({ status, page, pageSize }), + }) + const levelsQuery = useQuery({ + queryKey: ['admin-worker-platform-levels'], + queryFn: () => fetchAdminWorkerLevels(), + }) + const workersPagination = workersQuery.data?.data.pagination + + async function refreshWorkers() { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-workers'], + }), + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-summary'], + }), + ]) + } + + async function review(worker: WorkerUser, nextStatus: string) { + try { + await reviewAdminWorkerUser(worker.workerId, { status: nextStatus }) + message.success('打手状态已更新') + await refreshWorkers() + } catch (error) { + message.error(error instanceof Error ? error.message : '操作失败') + } + } + + async function submitCredit(values: { amount?: number; note?: string }) { + if (!creditWorker) return + try { + await creditAdminWorkerWallet(creditWorker.workerId, { + amount: Number(values.amount || 0), + note: values.note, + }) + message.success('余额已增加') + setCreditWorker(null) + creditForm.resetFields() + await refreshWorkers() + } catch (error) { + message.error(error instanceof Error ? error.message : '充值失败') + } + } + + async function submitLevel(values: { levelId?: number }) { + if (!levelWorker) return + try { + await reviewAdminWorkerUser(levelWorker.workerId, { + status: levelWorker.status, + levelId: Number(values.levelId || 0), + reviewNote: levelWorker.reviewNote, + }) + message.success('打手等级已更新') + setLevelWorker(null) + levelForm.resetFields() + await refreshWorkers() + } catch (error) { + message.error(error instanceof Error ? error.message : '调整等级失败') + } + } + + const columns: TableColumnsType = [ + { + title: '打手', + minWidth: 220, + render: (_, row) => ( +
+ + {row.displayName || row.username} + + {row.username} +
+ ), + }, + { title: '等级', width: 140, render: (_, row) => row.level?.name || '-' }, + { + title: '邀请人', + width: 160, + render: (_, row) => + row.inviter ? ( +
+ {row.inviter.displayName || row.inviter.username} + {row.inviter.username} +
+ ) : ( + '-' + ), + }, + { + title: '余额', + width: 120, + render: (_, row) => formatMoney(row.wallet.availableAmount), + }, + { + title: '冻结', + width: 120, + render: (_, row) => formatMoney(row.wallet.frozenDepositAmount), + }, + { + title: '状态', + width: 130, + render: (_, row) => ( + + {formatWorkerStatus(row.status)} + + ), + }, + { + title: '操作', + width: 340, + render: (_, row) => ( + + {row.status !== 'active' ? ( + + ) : null} + {row.status !== 'rejected' ? ( + + ) : null} + + + + ), + }, + ] + + return ( + + ({ + value: item.levelId, + label: `${item.name} / 最多同时 ${item.maxActiveOrders} 单`, + }))} + /> + + + + + { + setCreditWorker(null) + creditForm.resetFields() + }} + onOk={() => creditForm.submit()} + destroyOnHidden + > +
+ + + + + + +
+
+
+ ) +} + diff --git a/apps/frontend/src/pages/admin/panels/shared.tsx b/apps/frontend/src/pages/admin/panels/shared.tsx new file mode 100644 index 00000000..ed9747db --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/shared.tsx @@ -0,0 +1,225 @@ +import { Descriptions, Space, Tag, Typography } from 'antd' +import ImagePreviewList from '@/components/files/ImagePreviewList' +import type { CollectField, UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform' +import { formatAdminDateTime } from '@/utils/admin-time' + +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 type DetailFieldItem = { + key: string + label: string + required: boolean + value: string +} + +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 || '')]), + ) +} + +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 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 renderRequirementFieldValues(order: WorkOrder) { + const fields = getRequirementFields(order) + if (fields.length === 0) { + return 当前工单未配置资料要求 + } + + return ( + + {fields.map((field) => ( + + + {field.key} + + {field.required ? '必填' : '选填'} + + + + ))} + + ) +} + +export function renderMaterialSummary(order: WorkOrder) { + const fieldLabelMap = new Map( + getRequirementFields(order).map((field) => [field.key, field.label]), + ) + const fields = getMaterialFields(order) + const entries = Object.entries(fields).filter(([, value]) => + String(value || '').trim(), + ) + if (entries.length === 0) { + return - + } + + return ( +
+ {entries.slice(0, 2).map(([key, value]) => ( + + {fieldLabelMap.get(key) || key}: {value} + + ))} + {entries.length > 2 ? ( + + 还有 {entries.length - 2} 项 + + ) : null} +
+ ) +} + +export function getAcceptanceFiles(row: WorkOrder): UploadedFile[] { + return Array.isArray(row.acceptance?.files) ? row.acceptance.files : [] +} + +export function getAcceptanceImageUrls(row: WorkOrder): string[] { + return Array.isArray(row.acceptance?.imageUrls) + ? row.acceptance.imageUrls + .map((item) => String(item || '').trim()) + .filter(Boolean) + : [] +} + +export function getAcceptanceSubmittedAt(order: WorkOrder): string | null { + 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 formatStatus(status: string) { + const labels: Record = { + pending_material: '待完善', + unassigned: '未分配', + open: '待抢单', + in_progress: '代练中', + pending_acceptance: '待验收', + problem: '问题单', + accepted: '已验收', + cancelled: '已取消', + } + return labels[status] || status +} + +export function resolveStatusColor(status: string) { + if (status === 'accepted') return 'green' + if (status === 'problem') return 'red' + if (status === 'pending_acceptance') return 'gold' + if (status === 'open') return 'blue' + return 'default' +} + +export function formatWorkerStatus(status: string) { + const labels: Record = { + pending_review: '待审核', + active: '已通过', + rejected: '已拒绝', + disabled: '已停用', + } + return labels[status] || status +} + +export function formatSharingShareStatus(status: string) { + const labels: Record = { + joined: '已参与', + submitted: '已提交验收', + accepted: '已验收', + cancelled: '已取消', + } + return labels[status] || status +} + +export function resolveSharingShareStatusColor(status: string) { + if (status === 'accepted') return 'green' + if (status === 'submitted') return 'gold' + if (status === 'cancelled') return 'default' + return 'purple' +} +