修复接单平台并发、分页与补资料
This commit is contained in:
@@ -18,6 +18,8 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
||||
'CADDY_SITE_ADDR',
|
||||
'CHOKIDAR_USEPOLLING',
|
||||
'KUASHOU_INDUSTRY_RATE_LIMIT_MAX',
|
||||
'MINIO_API_PORT',
|
||||
'MINIO_CONSOLE_PORT',
|
||||
'MINIO_ROOT_PASSWORD',
|
||||
'MINIO_ROOT_USER',
|
||||
'NPM_CONFIG_REGISTRY',
|
||||
|
||||
@@ -119,13 +119,13 @@ ALTER TABLE worker_wallet_ledgers
|
||||
ADD CONSTRAINT fk_worker_wallet_ledgers_work_order
|
||||
FOREIGN KEY (related_work_order_id) REFERENCES work_orders(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON TABLE worker_levels IS '接单员等级与权限配置';
|
||||
COMMENT ON TABLE worker_levels IS '打手等级与权限配置';
|
||||
COMMENT ON COLUMN worker_levels.permission_json IS '等级权限:免押额度、最大同时接单量等';
|
||||
COMMENT ON TABLE worker_users IS '接单员账号,注册后需后台审核';
|
||||
COMMENT ON TABLE worker_wallets IS '接单员余额与冻结押金汇总';
|
||||
COMMENT ON TABLE worker_wallet_ledgers IS '接单员钱包流水,人工充值、冻结、解冻、结算均记录';
|
||||
COMMENT ON TABLE worker_users IS '打手账号,注册后需后台审核';
|
||||
COMMENT ON TABLE worker_wallets IS '打手余额与冻结押金汇总';
|
||||
COMMENT ON TABLE worker_wallet_ledgers IS '打手钱包流水,人工充值、冻结、解冻、结算均记录';
|
||||
COMMENT ON TABLE work_categories IS '接单大厅分类';
|
||||
COMMENT ON TABLE work_orders IS '接单平台工单,独立于现有履约任务状态机';
|
||||
COMMENT ON COLUMN work_orders.reward_amount IS '接单员看到并结算的金额,单位分';
|
||||
COMMENT ON COLUMN work_orders.reward_amount IS '打手看到并结算的金额,单位分';
|
||||
COMMENT ON COLUMN work_orders.required_deposit_amount IS '单笔工单所需冻结押金,单位分';
|
||||
COMMENT ON TABLE work_order_events IS '接单工单状态与操作流水';
|
||||
|
||||
@@ -63,5 +63,5 @@ COMMENT ON COLUMN file_assets.object_key IS '对象存储 key';
|
||||
COMMENT ON COLUMN file_assets.thumbnail_object_key IS '缩略图对象 key';
|
||||
COMMENT ON COLUMN file_assets.medium_object_key IS '中图对象 key';
|
||||
COMMENT ON TABLE work_product_rules IS '接单平台物品规则:决定哪些源订单商品生成接单工单';
|
||||
COMMENT ON COLUMN work_product_rules.reward_amount IS '后台发布给接单员的单笔金额,单位分';
|
||||
COMMENT ON COLUMN work_product_rules.reward_amount IS '后台发布给打手的单笔金额,单位分';
|
||||
COMMENT ON COLUMN work_product_rules.auto_create IS '源订单入库后是否自动创建接单工单';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveOutstandingDepositAmount } from './worker-platform-repo.js'
|
||||
|
||||
test('resolveOutstandingDepositAmount deducts released and deducted history', () => {
|
||||
assert.equal(
|
||||
resolveOutstandingDepositAmount({
|
||||
frozen_amount: 20_000,
|
||||
released_amount: 5_000,
|
||||
deducted_amount: 3_000,
|
||||
}),
|
||||
12_000,
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveOutstandingDepositAmount never returns a negative deposit', () => {
|
||||
assert.equal(
|
||||
resolveOutstandingDepositAmount({
|
||||
frozen_amount: 10_000,
|
||||
released_amount: 12_000,
|
||||
deducted_amount: 1_000,
|
||||
}),
|
||||
0,
|
||||
)
|
||||
assert.equal(resolveOutstandingDepositAmount(null), 0)
|
||||
})
|
||||
@@ -104,6 +104,22 @@ export type WorkOrderRow = {
|
||||
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
|
||||
@@ -317,6 +333,25 @@ export async function updateWorkerUser(
|
||||
return getWorkerUserById(workerId)
|
||||
}
|
||||
|
||||
export async function incrementWorkerSessionVersion(
|
||||
workerId: number | string,
|
||||
now: string,
|
||||
): Promise<WorkerUserRow | null> {
|
||||
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,
|
||||
@@ -604,6 +639,18 @@ export async function findPendingMaterialWorkOrderByPlatformOrderId(
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listPendingMaterialWorkOrdersByPlatformOrderId(
|
||||
platformOrderId: string,
|
||||
): Promise<WorkOrderRow[]> {
|
||||
const result = await query<WorkOrderRow>(
|
||||
`${WORK_ORDER_SELECT}
|
||||
WHERE wo.platform_order_id = $1 AND wo.status = 'pending_material'
|
||||
ORDER BY wo.id ASC`,
|
||||
[String(platformOrderId || '').trim()],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function listWorkOrders({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
@@ -690,14 +737,26 @@ export async function grabWorkOrder(input: {
|
||||
workOrderId: number
|
||||
workerId: number
|
||||
depositAmount: number
|
||||
maxActiveOrders: number
|
||||
now: string
|
||||
}): Promise<WorkOrderRow | null> {
|
||||
}): Promise<GrabWorkOrderResult> {
|
||||
return withTransaction(async (client) => {
|
||||
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||
const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId)
|
||||
if (activeOrderCount >= input.maxActiveOrders) {
|
||||
return {
|
||||
order: null,
|
||||
failureReason: 'worker_active_order_limit',
|
||||
}
|
||||
}
|
||||
|
||||
const available = Number(wallet?.available_amount || 0)
|
||||
if (available < input.depositAmount) {
|
||||
return null
|
||||
return {
|
||||
order: null,
|
||||
failureReason: 'worker_deposit_insufficient',
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.query<{ id: number }>(
|
||||
@@ -716,7 +775,10 @@ export async function grabWorkOrder(input: {
|
||||
[input.workerId, input.now, input.workOrderId],
|
||||
)
|
||||
if (!result.rows[0]) {
|
||||
return null
|
||||
return {
|
||||
order: null,
|
||||
failureReason: 'work_order_not_open',
|
||||
}
|
||||
}
|
||||
|
||||
if (input.depositAmount > 0) {
|
||||
@@ -759,7 +821,10 @@ export async function grabWorkOrder(input: {
|
||||
now: input.now,
|
||||
})
|
||||
|
||||
return getWorkOrderByIdWithClient(client, input.workOrderId)
|
||||
return {
|
||||
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
|
||||
failureReason: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -796,18 +861,8 @@ export async function acceptWorkOrderAndSettle(input: {
|
||||
if (workerId > 0) {
|
||||
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, workerId)
|
||||
const frozenLedgerResult = await client.query<{ amount: number }>(
|
||||
`
|
||||
SELECT COALESCE(ABS(SUM(amount)), 0)::int AS amount
|
||||
FROM worker_wallet_ledgers
|
||||
WHERE worker_id = $1
|
||||
AND related_work_order_id = $2
|
||||
AND ledger_type = 'deposit_freeze'
|
||||
`,
|
||||
[workerId, input.workOrderId],
|
||||
)
|
||||
const releaseAmount = Math.min(
|
||||
Number(frozenLedgerResult.rows[0]?.amount || 0),
|
||||
await getOutstandingDepositAmountWithClient(client, workerId, input.workOrderId),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
const rewardAmount = Number(workOrder.reward_amount || 0)
|
||||
@@ -867,6 +922,124 @@ export async function acceptWorkOrderAndSettle(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveProblemWorkOrder(input: {
|
||||
workOrderId: number
|
||||
action: ProblemWorkOrderResolutionAction
|
||||
note: string
|
||||
now: string
|
||||
}): Promise<WorkOrderRow | null> {
|
||||
return withTransaction(async (client) => {
|
||||
const currentResult = await client.query<WorkOrderRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM work_orders
|
||||
WHERE id = $1
|
||||
AND status = 'problem'
|
||||
FOR UPDATE
|
||||
`,
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = currentResult.rows[0] || null
|
||||
if (!workOrder) return null
|
||||
|
||||
const shouldReleaseDeposit = ['reopen', 'cancel_release'].includes(input.action)
|
||||
const shouldDeductDeposit = input.action === 'cancel_deduct'
|
||||
const workerId = Number(workOrder.assigned_worker_id || 0)
|
||||
let resolvedDepositAmount = 0
|
||||
|
||||
if (workerId > 0 && (shouldReleaseDeposit || shouldDeductDeposit)) {
|
||||
await ensureWorkerWalletWithClient(client, workerId, input.now)
|
||||
const wallet = await getWorkerWalletWithClient(client, workerId)
|
||||
resolvedDepositAmount = Math.min(
|
||||
await getOutstandingDepositAmountWithClient(client, workerId, input.workOrderId),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
|
||||
if (resolvedDepositAmount > 0) {
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) + (shouldReleaseDeposit ? resolvedDepositAmount : 0)
|
||||
const nextFrozen = Math.max(
|
||||
0,
|
||||
Number(wallet?.frozen_deposit_amount || 0) - resolvedDepositAmount,
|
||||
)
|
||||
const ledgerType = shouldReleaseDeposit ? 'deposit_release' : 'deposit_deduction'
|
||||
const ledgerAmount = shouldReleaseDeposit ? resolvedDepositAmount : -resolvedDepositAmount
|
||||
const ledgerNote = shouldReleaseDeposit ? '问题单处置释放押金' : '问题单处置扣除押金'
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE worker_wallets
|
||||
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
|
||||
WHERE worker_id = $4
|
||||
`,
|
||||
[nextAvailable, nextFrozen, input.now, workerId],
|
||||
)
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
|
||||
`,
|
||||
[
|
||||
workerId,
|
||||
ledgerType,
|
||||
ledgerAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
ledgerNote,
|
||||
JSON.stringify({ action: input.action, note: input.note }),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const targetStatus =
|
||||
input.action === 'return_to_worker'
|
||||
? 'in_progress'
|
||||
: input.action === 'reopen'
|
||||
? 'open'
|
||||
: 'cancelled'
|
||||
const shouldClearAssignment = input.action === 'reopen'
|
||||
const shouldClearSubmission = ['return_to_worker', 'reopen'].includes(input.action)
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET
|
||||
status = $1,
|
||||
assigned_worker_id = CASE WHEN $2 THEN NULL ELSE assigned_worker_id END,
|
||||
assigned_at = CASE WHEN $2 THEN NULL ELSE assigned_at END,
|
||||
acceptance_json = CASE WHEN $3 THEN '{}'::jsonb ELSE acceptance_json END,
|
||||
submitted_at = CASE WHEN $3 THEN NULL ELSE submitted_at END,
|
||||
problem_note = CASE WHEN $2 THEN '' ELSE problem_note END,
|
||||
updated_at = $4
|
||||
WHERE id = $5
|
||||
`,
|
||||
[targetStatus, shouldClearAssignment, shouldClearSubmission, input.now, input.workOrderId],
|
||||
)
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
workOrderId: input.workOrderId,
|
||||
actorType: 'admin',
|
||||
actorId: '',
|
||||
eventType: `problem_${input.action}`,
|
||||
fromStatus: 'problem',
|
||||
toStatus: targetStatus,
|
||||
payloadJson: JSON.stringify({
|
||||
action: input.action,
|
||||
note: input.note,
|
||||
depositAmount: resolvedDepositAmount,
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
|
||||
return getWorkOrderByIdWithClient(client, input.workOrderId)
|
||||
})
|
||||
}
|
||||
|
||||
export async function countWorkerActiveOrders(workerId: number | string): Promise<number> {
|
||||
const result = await query<{ total: number }>(
|
||||
`
|
||||
@@ -955,12 +1128,67 @@ async function getWorkerWalletWithClient(
|
||||
workerId: number,
|
||||
): Promise<WorkerWalletRow | null> {
|
||||
const result = await client.query<WorkerWalletRow>(
|
||||
'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1',
|
||||
'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,
|
||||
workOrderId: number,
|
||||
) {
|
||||
const result = await client.query<{
|
||||
frozen_amount: number
|
||||
released_amount: number
|
||||
deducted_amount: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN ledger_type = 'deposit_freeze' THEN ABS(amount) ELSE 0 END), 0)::int AS frozen_amount,
|
||||
COALESCE(SUM(CASE WHEN ledger_type = 'deposit_release' THEN ABS(amount) ELSE 0 END), 0)::int AS released_amount,
|
||||
COALESCE(SUM(CASE WHEN ledger_type = 'deposit_deduction' THEN ABS(amount) ELSE 0 END), 0)::int AS deducted_amount
|
||||
FROM worker_wallet_ledgers
|
||||
WHERE worker_id = $1
|
||||
AND related_work_order_id = $2
|
||||
`,
|
||||
[workerId, workOrderId],
|
||||
)
|
||||
return resolveOutstandingDepositAmount(result.rows[0])
|
||||
}
|
||||
|
||||
async function countWorkerActiveOrdersWithClient(client: PoolClient, workerId: number): Promise<number> {
|
||||
const result = await client.query<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM work_orders
|
||||
WHERE assigned_worker_id = $1
|
||||
AND status IN ('in_progress', 'pending_acceptance', 'problem')
|
||||
`,
|
||||
[workerId],
|
||||
)
|
||||
return Number(result.rows[0]?.total || 0)
|
||||
}
|
||||
|
||||
export function resolveOutstandingDepositAmount(
|
||||
totals:
|
||||
| {
|
||||
frozen_amount?: number
|
||||
released_amount?: number
|
||||
deducted_amount?: number
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return Math.max(
|
||||
0,
|
||||
Number(totals?.frozen_amount || 0) -
|
||||
Number(totals?.released_amount || 0) -
|
||||
Number(totals?.deducted_amount || 0),
|
||||
)
|
||||
}
|
||||
|
||||
async function getWorkerUserByIdWithClient(
|
||||
client: PoolClient,
|
||||
workerId: number | string,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
listAdminWorkOrders,
|
||||
markAdminWorkOrderProblem,
|
||||
publishAdminWorkOrder,
|
||||
resolveAdminProblemWorkOrder,
|
||||
reviewAdminWorkerUser,
|
||||
saveAdminWorkCategory,
|
||||
saveAdminWorkProductRule,
|
||||
@@ -117,7 +118,7 @@ router.get(
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler((req) => listAdminWorkerUsers(req.query), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取接单员列表失败',
|
||||
errorMessage: '读取打手列表失败',
|
||||
scope: '[admin/worker-platform/workers]',
|
||||
}),
|
||||
)
|
||||
@@ -128,8 +129,8 @@ router.post(
|
||||
createJsonHandler(
|
||||
(req) => reviewAdminWorkerUser(String(req.params.workerId || ''), req.body || {}),
|
||||
{
|
||||
successMessage: '接单员审核状态已更新',
|
||||
errorMessage: '审核接单员失败',
|
||||
successMessage: '打手审核状态已更新',
|
||||
errorMessage: '审核打手失败',
|
||||
scope: '[admin/worker-platform/workers/:workerId/review]',
|
||||
audit: (req, data) => ({
|
||||
action: 'worker_reviewed',
|
||||
@@ -147,7 +148,7 @@ router.post(
|
||||
createJsonHandler(
|
||||
(req) => creditAdminWorkerWallet(String(req.params.workerId || ''), req.body || {}),
|
||||
{
|
||||
successMessage: '接单员余额已增加',
|
||||
successMessage: '打手余额已增加',
|
||||
errorMessage: '人工充值失败',
|
||||
scope: '[admin/worker-platform/workers/:workerId/wallet-credit]',
|
||||
audit: (req, data) => ({
|
||||
@@ -256,6 +257,25 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/problem/resolve',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(
|
||||
(req) => resolveAdminProblemWorkOrder(String(req.params.workOrderId || ''), req.body || {}),
|
||||
{
|
||||
successMessage: '问题单已处置',
|
||||
errorMessage: '处置问题单失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/problem/resolve]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_problem_resolved',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/accept',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
|
||||
@@ -7,7 +7,10 @@ const router = Router()
|
||||
|
||||
router.get('/object', async (req, res) => {
|
||||
try {
|
||||
const object = await getStoredFileObject(req.query.key, req.query.variant)
|
||||
const object = await getStoredFileObject(req.query.key, req.query.variant, {
|
||||
expires: req.query.expires,
|
||||
signature: req.query.signature,
|
||||
})
|
||||
res.setHeader('Content-Type', object.contentType || 'application/octet-stream')
|
||||
res.setHeader('Cache-Control', 'private, max-age=300')
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
listWorkerHallOrders,
|
||||
listWorkerMyOrders,
|
||||
loginWorker,
|
||||
logoutWorkerSession,
|
||||
registerWorker,
|
||||
submitWorkerOrderAcceptance,
|
||||
} from '../services/worker-platform/worker-platform-service.js'
|
||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||
import {
|
||||
extractOptionalWorkerBearerToken,
|
||||
extractWorkerBearerToken,
|
||||
getRequiredWorkerSession,
|
||||
requireActiveWorker,
|
||||
@@ -64,7 +66,7 @@ router.get(
|
||||
|
||||
router.post(
|
||||
'/auth/logout',
|
||||
createRouteHandler(() => ({ success: true }), {
|
||||
createRouteHandler((req) => logoutWorkerSession(extractOptionalWorkerBearerToken(req)), {
|
||||
successMessage: '已退出登录',
|
||||
errorMessage: '退出失败',
|
||||
scope: '[worker/auth/logout]',
|
||||
@@ -77,7 +79,7 @@ router.get(
|
||||
'/profile',
|
||||
createRouteHandler((req) => getWorkerProfile(getRequiredWorkerSession(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取接单员信息失败',
|
||||
errorMessage: '读取打手信息失败',
|
||||
scope: '[worker/profile]',
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -37,14 +37,19 @@ export function getRequiredWorkerSession(req: Request): WorkerSession {
|
||||
}
|
||||
|
||||
export function extractWorkerBearerToken(req: Request): string {
|
||||
const authorization = String(req.headers.authorization || '').trim()
|
||||
const matched = authorization.match(/^Bearer\s+(.+)$/i)
|
||||
if (!matched?.[1]) {
|
||||
throw createHttpError('未登录或登录已失效', {
|
||||
statusCode: 401,
|
||||
errorCode: 'worker_auth_required',
|
||||
})
|
||||
const token = extractOptionalWorkerBearerToken(req)
|
||||
if (token) {
|
||||
return token
|
||||
}
|
||||
|
||||
return matched[1].trim()
|
||||
throw createHttpError('未登录或登录已失效', {
|
||||
statusCode: 401,
|
||||
errorCode: 'worker_auth_required',
|
||||
})
|
||||
}
|
||||
|
||||
export function extractOptionalWorkerBearerToken(req: Request): string {
|
||||
const authorization = String(req.headers.authorization || '').trim()
|
||||
const matched = authorization.match(/^Bearer\s+(.+)$/i)
|
||||
return matched?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildFileUrl,
|
||||
normalizeStoredFileUrl,
|
||||
refreshStoredFileUrl,
|
||||
verifyFileAccessSignature,
|
||||
} from './file-storage-service.js'
|
||||
|
||||
const NOW_SECONDS = 1_800_000_000
|
||||
const OBJECT_KEY = 'worker-acceptance/2026/07/26/example.jpg'
|
||||
|
||||
test('file access URL carries a valid short-lived signature', () => {
|
||||
const url = buildFileUrl(OBJECT_KEY, NOW_SECONDS)
|
||||
const parsed = new URL(url, 'http://localhost')
|
||||
|
||||
assert.equal(parsed.searchParams.get('key'), OBJECT_KEY)
|
||||
assert.doesNotThrow(() =>
|
||||
verifyFileAccessSignature(
|
||||
OBJECT_KEY,
|
||||
parsed.searchParams.get('expires'),
|
||||
parsed.searchParams.get('signature'),
|
||||
NOW_SECONDS + 899,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('file access URL rejects expired and tampered signatures', () => {
|
||||
const parsed = new URL(buildFileUrl(OBJECT_KEY, NOW_SECONDS), 'http://localhost')
|
||||
const expires = parsed.searchParams.get('expires')
|
||||
const signature = parsed.searchParams.get('signature')
|
||||
|
||||
assert.throws(() => verifyFileAccessSignature(OBJECT_KEY, expires, signature, NOW_SECONDS + 901))
|
||||
assert.throws(() =>
|
||||
verifyFileAccessSignature(`${OBJECT_KEY}.changed`, expires, signature, NOW_SECONDS),
|
||||
)
|
||||
})
|
||||
|
||||
test('stored file URL drops signatures and refreshes them for responses', () => {
|
||||
const signedUrl = buildFileUrl(OBJECT_KEY, NOW_SECONDS)
|
||||
const storedUrl = normalizeStoredFileUrl(signedUrl)
|
||||
const refreshedUrl = refreshStoredFileUrl(storedUrl)
|
||||
|
||||
assert.equal(storedUrl, `/api/v1/files/object?key=${encodeURIComponent(OBJECT_KEY)}`)
|
||||
assert.match(refreshedUrl, /[?&]expires=\d+/)
|
||||
assert.match(refreshedUrl, /[?&]signature=/)
|
||||
assert.equal(
|
||||
refreshStoredFileUrl('https://example.com/image.jpg'),
|
||||
'https://example.com/image.jpg',
|
||||
)
|
||||
})
|
||||
@@ -13,6 +13,7 @@ const IMAGE_VARIANT_THUMB = 'thumb'
|
||||
const IMAGE_VARIANT_MEDIUM = 'medium'
|
||||
const THUMB_MAX_SIDE = 480
|
||||
const MEDIUM_MAX_SIDE = 1280
|
||||
const FILE_URL_TTL_SECONDS = 15 * 60
|
||||
|
||||
const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf'])
|
||||
|
||||
@@ -98,9 +99,9 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const fileUrl = buildFileUrl(objectKey)
|
||||
const thumbnailUrl = thumbnailObjectKey ? buildFileUrl(thumbnailObjectKey) : fileUrl
|
||||
const mediumUrl = mediumObjectKey ? buildFileUrl(mediumObjectKey) : fileUrl
|
||||
const fileUrl = buildUnsignedFileUrl(objectKey)
|
||||
const thumbnailUrl = thumbnailObjectKey ? buildUnsignedFileUrl(thumbnailObjectKey) : fileUrl
|
||||
const mediumUrl = mediumObjectKey ? buildUnsignedFileUrl(mediumObjectKey) : fileUrl
|
||||
const asset = await createFileAsset({
|
||||
objectKey,
|
||||
scene,
|
||||
@@ -134,8 +135,13 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoredFileObject(key: unknown, variant: unknown = '') {
|
||||
export async function getStoredFileObject(
|
||||
key: unknown,
|
||||
variant: unknown = '',
|
||||
access: { expires?: unknown; signature?: unknown } = {},
|
||||
) {
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
verifyFileAccessSignature(objectKey, access.expires, access.signature)
|
||||
const variantName = normalizeVariant(variant)
|
||||
const candidates = variantName ? imageVariantFallbackKeys(objectKey, variantName) : [objectKey]
|
||||
let lastError: unknown = null
|
||||
@@ -159,8 +165,67 @@ export function getMaxUploadSizeBytes() {
|
||||
return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
|
||||
}
|
||||
|
||||
export function buildFileUrl(objectKey: string) {
|
||||
return `/api/v1/files/object?key=${encodeURIComponent(objectKey)}`
|
||||
export function buildFileUrl(objectKey: string, nowSeconds = Math.floor(Date.now() / 1000)) {
|
||||
const normalizedKey = normalizeObjectKey(objectKey)
|
||||
const expires = nowSeconds + FILE_URL_TTL_SECONDS
|
||||
const signature = createFileAccessSignature(normalizedKey, expires)
|
||||
return `${buildUnsignedFileUrl(normalizedKey)}&expires=${expires}&signature=${encodeURIComponent(signature)}`
|
||||
}
|
||||
|
||||
export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '') {
|
||||
const rawUrl = String(value || '').trim()
|
||||
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
|
||||
return objectKey ? buildFileUrl(objectKey) : rawUrl
|
||||
}
|
||||
|
||||
export function normalizeStoredFileUrl(value: unknown, fallbackObjectKey = '') {
|
||||
const rawUrl = String(value || '').trim()
|
||||
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
|
||||
return objectKey ? buildUnsignedFileUrl(objectKey) : rawUrl
|
||||
}
|
||||
|
||||
export function refreshUploadedFileUrls(value: unknown) {
|
||||
const source =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
const objectKey = String(source.objectKey || source.object_key || '').trim()
|
||||
if (!objectKey) return source
|
||||
return {
|
||||
...source,
|
||||
objectKey,
|
||||
url: buildFileUrl(objectKey),
|
||||
thumbnailUrl: refreshStoredFileUrl(source.thumbnailUrl || source.thumbnail_url, objectKey),
|
||||
mediumUrl: refreshStoredFileUrl(source.mediumUrl || source.medium_url, objectKey),
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyFileAccessSignature(
|
||||
objectKey: string,
|
||||
expiresValue: unknown,
|
||||
signatureValue: unknown,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
) {
|
||||
const expires = Number(expiresValue || 0)
|
||||
const signature = String(signatureValue || '').trim()
|
||||
if (!Number.isSafeInteger(expires) || expires < nowSeconds || !signature) {
|
||||
throw createHttpError('文件访问链接无效或已过期', {
|
||||
statusCode: 403,
|
||||
errorCode: 'file_access_expired',
|
||||
})
|
||||
}
|
||||
const expected = createFileAccessSignature(normalizeObjectKey(objectKey), expires)
|
||||
const expectedBuffer = Buffer.from(expected)
|
||||
const actualBuffer = Buffer.from(signature)
|
||||
if (
|
||||
expectedBuffer.length !== actualBuffer.length ||
|
||||
!crypto.timingSafeEqual(expectedBuffer, actualBuffer)
|
||||
) {
|
||||
throw createHttpError('文件访问签名不正确', {
|
||||
statusCode: 403,
|
||||
errorCode: 'file_access_signature_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function imageVariantKey(objectKey: string, variant: string) {
|
||||
@@ -183,15 +248,45 @@ function mapUploadedFile(
|
||||
): UploadedFileDto {
|
||||
return {
|
||||
objectKey: asset?.object_key || fallback.objectKey,
|
||||
url: asset?.url || fallback.fileUrl,
|
||||
thumbnailUrl: asset?.thumbnail_url || fallback.thumbnailUrl,
|
||||
mediumUrl: asset?.medium_url || fallback.mediumUrl,
|
||||
url: buildFileUrl(asset?.object_key || fallback.objectKey),
|
||||
thumbnailUrl: buildFileUrl(
|
||||
asset?.thumbnail_object_key ||
|
||||
extractObjectKeyFromFileUrl(fallback.thumbnailUrl) ||
|
||||
fallback.objectKey,
|
||||
),
|
||||
mediumUrl: buildFileUrl(
|
||||
asset?.medium_object_key ||
|
||||
extractObjectKeyFromFileUrl(fallback.mediumUrl) ||
|
||||
fallback.objectKey,
|
||||
),
|
||||
filename: asset?.original_filename || fallback.filename,
|
||||
contentType: asset?.content_type || fallback.contentType,
|
||||
size: Number(asset?.size_bytes || fallback.size || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function buildUnsignedFileUrl(objectKey: string) {
|
||||
return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}`
|
||||
}
|
||||
|
||||
function createFileAccessSignature(objectKey: string, expires: number) {
|
||||
const secret =
|
||||
String(runtimeConfig.admin.sessionSecret || '').trim() ||
|
||||
String(runtimeConfig.storage.secretAccessKey || '').trim()
|
||||
return crypto.createHmac('sha256', secret).update(`${objectKey}\n${expires}`).digest('base64url')
|
||||
}
|
||||
|
||||
function extractObjectKeyFromFileUrl(value: string) {
|
||||
if (!value) return ''
|
||||
try {
|
||||
const parsed = new URL(value, 'http://localhost')
|
||||
if (parsed.pathname !== '/api/v1/files/object') return ''
|
||||
return normalizeObjectKey(parsed.searchParams.get('key'))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContentType(mimeType: unknown, data: Buffer) {
|
||||
const detected = detectContentType(data)
|
||||
if (detected) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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'
|
||||
|
||||
function buildWorkOrderRow(
|
||||
overrides: Partial<WorkOrderRow> = {},
|
||||
): WorkOrderRow {
|
||||
return {
|
||||
id: 1,
|
||||
work_order_no: 'WO0001',
|
||||
order_id: 101,
|
||||
order_item_id: 201,
|
||||
task_id: null,
|
||||
platform_order_id: 'ORDER-001',
|
||||
product_name: '测试商品',
|
||||
category_id: null,
|
||||
status: 'pending_material',
|
||||
reward_amount: 2500,
|
||||
required_deposit_amount: 0,
|
||||
deposit_threshold_amount: 20000,
|
||||
assigned_worker_id: null,
|
||||
material_json: '{}',
|
||||
requirement_json: '{"fields":[]}',
|
||||
acceptance_json: '{}',
|
||||
problem_note: '',
|
||||
published_at: null,
|
||||
assigned_at: null,
|
||||
submitted_at: null,
|
||||
accepted_at: null,
|
||||
created_at: '2026-07-28T00:00:00.000Z',
|
||||
updated_at: '2026-07-28T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder returns the only pending work order when no id is provided', () => {
|
||||
const workOrder = buildWorkOrderRow()
|
||||
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', [workOrder], '')
|
||||
assert.equal(selected.id, workOrder.id)
|
||||
})
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder requires explicit work order id when multiple orders are pending', () => {
|
||||
const workOrders = [
|
||||
buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' }),
|
||||
buildWorkOrderRow({ id: 2, work_order_no: 'WO0002', order_item_id: 202 }),
|
||||
]
|
||||
|
||||
assert.throws(
|
||||
() => resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, ''),
|
||||
(error: unknown) =>
|
||||
Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
(error as { statusCode?: number }).statusCode === 409 &&
|
||||
(error as { errorCode?: string }).errorCode === 'collect_work_order_required',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder returns the matching work order when workOrderId is provided', () => {
|
||||
const workOrders = [
|
||||
buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' }),
|
||||
buildWorkOrderRow({ id: 2, work_order_no: 'WO0002', order_item_id: 202 }),
|
||||
]
|
||||
|
||||
const selected = resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, '2')
|
||||
assert.equal(selected.id, 2)
|
||||
})
|
||||
|
||||
test('resolveCollectSubmitTargetWorkOrder rejects unknown workOrderId', () => {
|
||||
const workOrders = [buildWorkOrderRow({ id: 1, work_order_no: 'WO0001' })]
|
||||
|
||||
assert.throws(
|
||||
() => resolveCollectSubmitTargetWorkOrder('ORDER-001', workOrders, 999),
|
||||
(error: unknown) =>
|
||||
Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
(error as { statusCode?: number }).statusCode === 404 &&
|
||||
(error as { errorCode?: string }).errorCode === 'collect_work_order_not_found',
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -11,21 +11,19 @@ import {
|
||||
createWorkOrder,
|
||||
createWorkOrderEvent,
|
||||
createWorkerUser,
|
||||
findPendingMaterialWorkOrderByPlatformOrderId,
|
||||
getWorkCategoryByKey,
|
||||
incrementWorkerSessionVersion,
|
||||
getWorkOrderById,
|
||||
getWorkOrderByOrderItemId,
|
||||
getWorkerLevelById,
|
||||
getWorkerLevelByKey,
|
||||
getWorkerUserById,
|
||||
getWorkerUserByUsername,
|
||||
listAllWorkCategories,
|
||||
listPendingMaterialWorkOrdersByPlatformOrderId,
|
||||
grabWorkOrder,
|
||||
listWorkCategories,
|
||||
listWorkOrders,
|
||||
listWorkProductRules,
|
||||
listWorkerLevels,
|
||||
listWorkerUsers,
|
||||
resolveProblemWorkOrder,
|
||||
updateWorkOrder,
|
||||
updateWorkerUser,
|
||||
upsertWorkCategory,
|
||||
@@ -36,6 +34,8 @@ import {
|
||||
type WorkProductRuleRow,
|
||||
type WorkerLevelRow,
|
||||
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'
|
||||
@@ -43,6 +43,11 @@ 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'
|
||||
|
||||
export type WorkerSession = {
|
||||
sessionId: string
|
||||
@@ -56,12 +61,19 @@ export type WorkerSession = {
|
||||
const DEFAULT_LEVEL_KEY = 'regular'
|
||||
const DEFAULT_CATEGORY_KEY = 'default'
|
||||
const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000
|
||||
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 level = await upsertWorkerLevel({
|
||||
levelKey: DEFAULT_LEVEL_KEY,
|
||||
name: '普通接单员',
|
||||
name: '普通打手',
|
||||
sortOrder: 100,
|
||||
status: 'active',
|
||||
permissionJson: JSON.stringify({
|
||||
@@ -152,6 +164,27 @@ export async function loginWorker(username: unknown, password: unknown) {
|
||||
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<WorkerSession> {
|
||||
ensureWorkerAuthConfigured()
|
||||
const normalizedToken = String(token || '').trim()
|
||||
@@ -301,16 +334,14 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
|
||||
workOrderId: Number(workOrder.id),
|
||||
workerId: Number(worker.id),
|
||||
depositAmount: freezeAmount,
|
||||
maxActiveOrders: permissions.maxActiveOrders,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (!grabbed) {
|
||||
throw createHttpError('抢单失败,订单可能已被抢走', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_grab_conflict',
|
||||
})
|
||||
if (!grabbed.order) {
|
||||
throwGrabWorkOrderFailure(grabbed.failureReason)
|
||||
}
|
||||
|
||||
return { order: mapWorkOrderForWorker(grabbed, permissions) }
|
||||
return { order: mapWorkOrderForWorker(grabbed.order, permissions) }
|
||||
}
|
||||
|
||||
export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) {
|
||||
@@ -404,16 +435,25 @@ export async function collectLookupWorkOrder(payload: JsonObject = {}) {
|
||||
errorCode: 'collect_order_no_required',
|
||||
})
|
||||
}
|
||||
const workOrder = await findPendingMaterialWorkOrderByPlatformOrderId(orderNo)
|
||||
if (!workOrder) {
|
||||
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(workOrder),
|
||||
fields: resolveRequirementFields(workOrder),
|
||||
order: mapWorkOrderPublic(primaryOrder),
|
||||
fields: resolveRequirementFields(primaryOrder),
|
||||
orders: workOrders.map(mapCollectLookupOrder),
|
||||
pendingCount: workOrders.length,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,13 +465,18 @@ export async function collectSubmitWorkOrder(payload: JsonObject = {}) {
|
||||
errorCode: 'collect_order_no_required',
|
||||
})
|
||||
}
|
||||
const workOrder = await findPendingMaterialWorkOrderByPlatformOrderId(orderNo)
|
||||
if (!workOrder) {
|
||||
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)
|
||||
@@ -499,7 +544,7 @@ export async function saveAdminWorkCategory(payload: JsonObject = {}) {
|
||||
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() || '普通接单员'
|
||||
const name = String(payload.name || '').trim() || '普通打手'
|
||||
const permissions = {
|
||||
depositFreeAmount: normalizeAmountFen(
|
||||
payload.depositFreeAmount ?? payload.depositFreeAmountYuan,
|
||||
@@ -844,6 +889,42 @@ export async function markAdminWorkOrderProblem(
|
||||
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) {
|
||||
@@ -989,6 +1070,41 @@ function createWorkerSession(worker: WorkerUserRow) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1197,7 +1313,7 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
||||
),
|
||||
material: safeParseJson(workOrder.material_json),
|
||||
requirement: safeParseJson(workOrder.requirement_json),
|
||||
acceptance: safeParseJson(workOrder.acceptance_json),
|
||||
acceptance: mapAcceptanceForResponse(workOrder.acceptance_json),
|
||||
problemNote: workOrder.problem_note || '',
|
||||
worker: workOrder.assigned_worker_id
|
||||
? {
|
||||
@@ -1219,14 +1335,17 @@ function mapWorkOrderForWorker(
|
||||
workOrder: WorkOrderRow,
|
||||
permissions: { depositFreeAmount: number },
|
||||
) {
|
||||
const mapped = mapWorkOrderAdmin(workOrder)
|
||||
const { requiredDepositAmount, depositThresholdAmount, worker: _worker, ...visibleOrder } = mapped
|
||||
return {
|
||||
...mapWorkOrderAdmin(workOrder),
|
||||
...visibleOrder,
|
||||
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkOrderPublic(workOrder: WorkOrderRow) {
|
||||
return {
|
||||
workOrderId: Number(workOrder.id),
|
||||
workOrderNo: workOrder.work_order_no,
|
||||
platformOrderId: workOrder.platform_order_id,
|
||||
productName: workOrder.product_name,
|
||||
@@ -1234,6 +1353,14 @@ function mapWorkOrderPublic(workOrder: WorkOrderRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapCollectLookupOrder(workOrder: WorkOrderRow) {
|
||||
return {
|
||||
...mapWorkOrderPublic(workOrder),
|
||||
fields: resolveRequirementFields(workOrder),
|
||||
values: resolveCollectSubmittedFieldValues(workOrder),
|
||||
}
|
||||
}
|
||||
|
||||
function mapWallet(wallet: Awaited<ReturnType<typeof addWorkerWalletCredit>>) {
|
||||
return {
|
||||
workerId: Number(wallet?.worker_id || 0),
|
||||
@@ -1406,9 +1533,12 @@ function normalizeUploadedFiles(value: unknown) {
|
||||
if (!objectKey && !url) return null
|
||||
return {
|
||||
objectKey,
|
||||
url,
|
||||
thumbnailUrl: String(source.thumbnailUrl || source.thumbnail_url || url).trim(),
|
||||
mediumUrl: String(source.mediumUrl || source.medium_url || url).trim(),
|
||||
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),
|
||||
@@ -1429,12 +1559,66 @@ function normalizeUploadedFiles(value: unknown) {
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
@@ -1520,3 +1704,9 @@ function normalizeAmountFen(value: unknown, fallback: number): number {
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return fallback
|
||||
return Math.round(parsed * 100)
|
||||
}
|
||||
|
||||
function isIgnorableWorkerAuthError(error: unknown) {
|
||||
return IGNORABLE_WORKER_AUTH_ERROR_CODES.has(
|
||||
String((error as { errorCode?: string })?.errorCode || '').trim(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function WorkerLayout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { message } = App.useApp()
|
||||
const username = getWorkerUsername() || '接单员'
|
||||
const username = getWorkerUsername() || '打手'
|
||||
const status = getWorkerStatus()
|
||||
|
||||
async function submitLogout() {
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
fetchAdminWorkOrders,
|
||||
markAdminWorkOrderProblem,
|
||||
publishAdminWorkOrder,
|
||||
resolveAdminProblemWorkOrder,
|
||||
reviewAdminWorkerUser,
|
||||
saveAdminWorkCategory,
|
||||
saveAdminWorkProductRule,
|
||||
@@ -58,13 +59,17 @@ import type {
|
||||
WorkerLevel,
|
||||
WorkerUser,
|
||||
} from '@/types/worker-platform'
|
||||
import {
|
||||
ADMIN_DEFAULT_PAGE_SIZE,
|
||||
buildAdminTablePagination,
|
||||
} from '@/utils/admin-pagination'
|
||||
|
||||
export default function AdminWorkerPlatformPage() {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="接单平台"
|
||||
description="管理接单员、接单工单、等级权限和本地 Mock 工单。"
|
||||
description="管理打手、接单工单、等级权限和本地 Mock 工单。"
|
||||
/>
|
||||
<SummaryCards />
|
||||
<Tabs
|
||||
@@ -73,7 +78,7 @@ export default function AdminWorkerPlatformPage() {
|
||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||
{ key: 'rules', label: '物品规则', children: <ProductRulesPanel /> },
|
||||
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
||||
{ key: 'workers', label: '接单员', children: <WorkersPanel /> },
|
||||
{ key: 'workers', label: '打手', children: <WorkersPanel /> },
|
||||
{ key: 'levels', label: '等级权限', children: <LevelsPanel /> },
|
||||
]}
|
||||
/>
|
||||
@@ -92,7 +97,7 @@ function SummaryCards() {
|
||||
<div className="metric-grid four">
|
||||
<Card>
|
||||
<Statistic
|
||||
title="待审核接单员"
|
||||
title="待审核打手"
|
||||
value={summary?.pendingWorkerCount || 0}
|
||||
/>
|
||||
</Card>
|
||||
@@ -116,15 +121,20 @@ function WorkOrdersPanel() {
|
||||
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 [problemOrder, setProblemOrder] = useState<WorkOrder | null>(null)
|
||||
const [resolutionOrder, setResolutionOrder] = useState<WorkOrder | null>(null)
|
||||
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
|
||||
const [problemForm] = Form.useForm()
|
||||
const [resolutionForm] = Form.useForm()
|
||||
const [materialForm] = Form.useForm()
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-orders', status],
|
||||
queryFn: () => fetchAdminWorkOrders({ status }),
|
||||
queryKey: ['admin-worker-platform-orders', status, page, pageSize],
|
||||
queryFn: () => fetchAdminWorkOrders({ status, page, pageSize }),
|
||||
})
|
||||
const ordersPagination = ordersQuery.data?.data.pagination
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
@@ -145,25 +155,47 @@ function WorkOrdersPanel() {
|
||||
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
|
||||
await runAction(
|
||||
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<string, string> }) {
|
||||
if (!materialOrder) return
|
||||
await runAction(
|
||||
const succeeded = await runAction(
|
||||
() =>
|
||||
submitAdminWorkOrderMaterial(
|
||||
materialOrder.workOrderId,
|
||||
@@ -171,6 +203,7 @@ function WorkOrdersPanel() {
|
||||
),
|
||||
'订单资料已保存',
|
||||
)
|
||||
if (!succeeded) return
|
||||
setMaterialOrder(null)
|
||||
materialForm.resetFields()
|
||||
}
|
||||
@@ -227,7 +260,7 @@ function WorkOrdersPanel() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '接单员',
|
||||
title: '打手',
|
||||
width: 140,
|
||||
render: (_, row) =>
|
||||
row.worker?.displayName || row.worker?.username || '-',
|
||||
@@ -297,6 +330,11 @@ function WorkOrdersPanel() {
|
||||
问题单
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'problem' ? (
|
||||
<Button icon={<FormOutlined />} onClick={() => setResolutionOrder(row)}>
|
||||
处置
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -342,8 +380,12 @@ function WorkOrdersPanel() {
|
||||
{ value: 'pending_acceptance', label: '待验收' },
|
||||
{ value: 'problem', label: '问题单' },
|
||||
{ value: 'accepted', label: '已验收' },
|
||||
{ value: 'cancelled', label: '已取消' },
|
||||
]}
|
||||
onChange={setStatus}
|
||||
onChange={(nextStatus) => {
|
||||
setStatus(nextStatus)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
@@ -361,7 +403,15 @@ function WorkOrdersPanel() {
|
||||
loading={ordersQuery.isLoading}
|
||||
dataSource={ordersQuery.data?.data.items || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: ordersPagination?.page || page,
|
||||
pageSize: ordersPagination?.pageSize || pageSize,
|
||||
total: ordersPagination?.total || 0,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</Card>
|
||||
@@ -369,7 +419,10 @@ function WorkOrdersPanel() {
|
||||
<Modal
|
||||
title="标记问题单"
|
||||
open={Boolean(problemOrder)}
|
||||
onCancel={() => setProblemOrder(null)}
|
||||
onCancel={() => {
|
||||
setProblemOrder(null)
|
||||
problemForm.resetFields()
|
||||
}}
|
||||
onOk={() => problemForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
@@ -384,10 +437,53 @@ function WorkOrdersPanel() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="处置问题单"
|
||||
open={Boolean(resolutionOrder)}
|
||||
onCancel={() => {
|
||||
setResolutionOrder(null)
|
||||
resolutionForm.resetFields()
|
||||
}}
|
||||
onOk={() => resolutionForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={resolutionForm}
|
||||
layout="vertical"
|
||||
initialValues={{ action: 'return_to_worker' }}
|
||||
onFinish={submitResolution}
|
||||
>
|
||||
<Form.Item
|
||||
label="处置方式"
|
||||
name="action"
|
||||
rules={[{ required: true, message: '请选择处置方式' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'return_to_worker', label: '退回打手继续代练' },
|
||||
{ value: 'reopen', label: '释放押金并重新放回大厅' },
|
||||
{ value: 'cancel_release', label: '取消订单并退还押金' },
|
||||
{ value: 'cancel_deduct', label: '取消订单并扣除押金' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="处置备注"
|
||||
name="note"
|
||||
rules={[{ required: true, message: '请填写处置备注' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="人工补充资料"
|
||||
open={Boolean(materialOrder)}
|
||||
onCancel={() => setMaterialOrder(null)}
|
||||
onCancel={() => {
|
||||
setMaterialOrder(null)
|
||||
materialForm.resetFields()
|
||||
}}
|
||||
onOk={() => materialForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
@@ -804,13 +900,16 @@ 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<WorkerUser | null>(null)
|
||||
const [creditForm] = Form.useForm()
|
||||
|
||||
const workersQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-workers', status],
|
||||
queryFn: () => fetchAdminWorkerUsers({ status }),
|
||||
queryKey: ['admin-worker-platform-workers', status, page, pageSize],
|
||||
queryFn: () => fetchAdminWorkerUsers({ status, page, pageSize }),
|
||||
})
|
||||
const workersPagination = workersQuery.data?.data.pagination
|
||||
|
||||
async function refreshWorkers() {
|
||||
await Promise.all([
|
||||
@@ -826,7 +925,7 @@ function WorkersPanel() {
|
||||
async function review(worker: WorkerUser, nextStatus: string) {
|
||||
try {
|
||||
await reviewAdminWorkerUser(worker.workerId, { status: nextStatus })
|
||||
message.success('接单员状态已更新')
|
||||
message.success('打手状态已更新')
|
||||
await refreshWorkers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '操作失败')
|
||||
@@ -851,7 +950,7 @@ function WorkersPanel() {
|
||||
|
||||
const columns: TableColumnsType<WorkerUser> = [
|
||||
{
|
||||
title: '接单员',
|
||||
title: '打手',
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
@@ -901,7 +1000,7 @@ function WorkersPanel() {
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="接单员"
|
||||
title="打手"
|
||||
extra={
|
||||
<Space>
|
||||
<Select
|
||||
@@ -914,7 +1013,10 @@ function WorkersPanel() {
|
||||
{ value: 'rejected', label: '已拒绝' },
|
||||
{ value: 'disabled', label: '已停用' },
|
||||
]}
|
||||
onChange={setStatus}
|
||||
onChange={(nextStatus) => {
|
||||
setStatus(nextStatus)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
@@ -932,14 +1034,25 @@ function WorkersPanel() {
|
||||
loading={workersQuery.isLoading}
|
||||
dataSource={workersQuery.data?.data.items || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: workersPagination?.page || page,
|
||||
pageSize: workersPagination?.pageSize || pageSize,
|
||||
total: workersPagination?.total || 0,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="人工增加余额"
|
||||
open={Boolean(creditWorker)}
|
||||
onCancel={() => setCreditWorker(null)}
|
||||
onCancel={() => {
|
||||
setCreditWorker(null)
|
||||
creditForm.resetFields()
|
||||
}}
|
||||
onOk={() => creditForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
@@ -1010,7 +1123,7 @@ function LevelsPanel() {
|
||||
layout="inline"
|
||||
initialValues={{
|
||||
levelKey: 'regular',
|
||||
name: '普通接单员',
|
||||
name: '普通打手',
|
||||
depositFreeAmount: 0,
|
||||
maxActiveOrders: 3,
|
||||
}}
|
||||
|
||||
@@ -3,51 +3,45 @@ import { App, Button, Card, Form, Input, Result, Space, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { lookupCollectOrder, submitCollectOrder } from '@/services/worker'
|
||||
import type { CollectField } from '@/types/worker-platform'
|
||||
|
||||
type LookupOrder = {
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
status: string
|
||||
}
|
||||
import type { CollectLookupOrder } from '@/types/worker-platform'
|
||||
|
||||
export default function CollectPage() {
|
||||
const { message } = App.useApp()
|
||||
const [orderNo, setOrderNo] = useState('')
|
||||
const [order, setOrder] = useState<LookupOrder | null>(null)
|
||||
const [fields, setFields] = useState<CollectField[]>([])
|
||||
const [lookupOrders, setLookupOrders] = useState<CollectLookupOrder[]>([])
|
||||
const [lookupLoading, setLookupLoading] = useState(false)
|
||||
const [submitLoading, setSubmitLoading] = useState(false)
|
||||
const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0)
|
||||
const [complete, setComplete] = useState(false)
|
||||
|
||||
async function submitLookup(values: { orderNo: string }) {
|
||||
setLookupLoading(true)
|
||||
setComplete(false)
|
||||
try {
|
||||
const response = await lookupCollectOrder(values.orderNo)
|
||||
setOrderNo(values.orderNo)
|
||||
setOrder(response.data.order)
|
||||
setFields(response.data.fields)
|
||||
const nextOrderNo = String(values.orderNo || '').trim()
|
||||
setOrderNo(nextOrderNo)
|
||||
await refreshLookupOrders(nextOrderNo)
|
||||
} catch (error) {
|
||||
setOrder(null)
|
||||
setFields([])
|
||||
setLookupOrders([])
|
||||
message.error(error instanceof Error ? error.message : '查询失败')
|
||||
} finally {
|
||||
setLookupLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMaterial(values: Record<string, string>) {
|
||||
setSubmitLoading(true)
|
||||
async function submitMaterial(order: CollectLookupOrder, values: Record<string, string>) {
|
||||
setSubmittingWorkOrderId(order.workOrderId)
|
||||
try {
|
||||
const response = await submitCollectOrder({ orderNo, fields: values })
|
||||
setComplete(response.data.complete)
|
||||
message.success(response.data.complete ? '资料已完善' : '资料已提交')
|
||||
const response = await submitCollectOrder({
|
||||
orderNo,
|
||||
workOrderId: order.workOrderId,
|
||||
fields: values,
|
||||
})
|
||||
message.success(response.data.complete ? '当前商品资料已完善' : '当前商品资料已提交')
|
||||
await refreshLookupOrders(orderNo, { allowCompleteWhenNotFound: true })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '提交失败')
|
||||
} finally {
|
||||
setSubmitLoading(false)
|
||||
setSubmittingWorkOrderId(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,13 +72,30 @@ export default function CollectPage() {
|
||||
<Result status="success" title="资料已提交完成" subTitle="订单已进入待分配状态,请等待处理。" />
|
||||
) : null}
|
||||
|
||||
{order && !complete ? (
|
||||
<Card className="platform-section-gap" title={order.productName} size="small">
|
||||
{!complete && lookupOrders.length > 1 ? (
|
||||
<Typography.Paragraph type="secondary" className="platform-section-gap">
|
||||
当前订单有 {lookupOrders.length} 个待完善商品,请逐个提交资料。
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
|
||||
{lookupOrders.map((currentOrder) => (
|
||||
<Card
|
||||
key={currentOrder.workOrderId}
|
||||
className="platform-section-gap"
|
||||
title={currentOrder.productName}
|
||||
extra={<Typography.Text type="secondary">{currentOrder.workOrderNo}</Typography.Text>}
|
||||
size="small"
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
订单号:{order.platformOrderId}
|
||||
订单号:{currentOrder.platformOrderId}
|
||||
</Typography.Paragraph>
|
||||
<Form layout="vertical" onFinish={submitMaterial}>
|
||||
{fields.map((field) => (
|
||||
<Form
|
||||
key={currentOrder.workOrderId}
|
||||
layout="vertical"
|
||||
initialValues={currentOrder.values || {}}
|
||||
onFinish={(values) => submitMaterial(currentOrder, values)}
|
||||
>
|
||||
{currentOrder.fields.map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
@@ -94,13 +105,39 @@ export default function CollectPage() {
|
||||
<Input />
|
||||
</Form.Item>
|
||||
))}
|
||||
<Button type="primary" htmlType="submit" loading={submitLoading}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={submittingWorkOrderId === currentOrder.workOrderId}
|
||||
>
|
||||
提交资料
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
) : null}
|
||||
))}
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
|
||||
async function refreshLookupOrders(
|
||||
currentOrderNo: string,
|
||||
options: { allowCompleteWhenNotFound?: boolean } = {},
|
||||
) {
|
||||
try {
|
||||
const response = await lookupCollectOrder(currentOrderNo)
|
||||
setLookupOrders(response.data.orders || [])
|
||||
setComplete(false)
|
||||
} catch (error) {
|
||||
if (options.allowCompleteWhenNotFound && isCollectOrderNotFoundError(error)) {
|
||||
setLookupOrders([])
|
||||
setComplete(true)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isCollectOrderNotFoundError(error: unknown) {
|
||||
return String((error as { errorCode?: string })?.errorCode || '').trim() === 'collect_order_not_found'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { App, Button, Card, Empty, Input, Space, Spin, Tag, Typography } from 'antd'
|
||||
import { App, Button, Card, Empty, Input, Pagination, Space, Spin, Tag, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { fetchWorkerHallOrders, grabWorkerOrder } from '@/services/worker'
|
||||
@@ -9,16 +9,20 @@ import type { WorkOrder } from '@/types/worker-platform'
|
||||
export default function WorkerHallPage() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [keywordInput, setKeywordInput] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [grabbingId, setGrabbingId] = useState(0)
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['worker-hall-orders', keyword],
|
||||
queryFn: () => fetchWorkerHallOrders({ keyword }),
|
||||
queryKey: ['worker-hall-orders', keyword, page, pageSize],
|
||||
queryFn: () => fetchWorkerHallOrders({ keyword, page, pageSize }),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const orders = ordersQuery.data?.data.items || []
|
||||
const pagination = ordersQuery.data?.data.pagination
|
||||
|
||||
async function submitGrab(order: WorkOrder) {
|
||||
setGrabbingId(order.workOrderId)
|
||||
@@ -33,6 +37,11 @@ export default function WorkerHallPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyKeywordSearch() {
|
||||
setKeyword(keywordInput.trim())
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="worker-page-head">
|
||||
@@ -40,13 +49,31 @@ export default function WorkerHallPage() {
|
||||
<Space.Compact>
|
||||
<Input
|
||||
allowClear
|
||||
value={keyword}
|
||||
value={keywordInput}
|
||||
placeholder="搜索商品或订单号"
|
||||
prefix={<SearchOutlined />}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onPressEnter={() => void ordersQuery.refetch()}
|
||||
onChange={(event) => {
|
||||
const nextKeyword = event.target.value
|
||||
setKeywordInput(nextKeyword)
|
||||
if (!nextKeyword.trim()) {
|
||||
setKeyword('')
|
||||
setPage(1)
|
||||
}
|
||||
}}
|
||||
onPressEnter={applyKeywordSearch}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} loading={ordersQuery.isFetching} onClick={() => ordersQuery.refetch()}>
|
||||
<Button
|
||||
icon={<SearchOutlined />}
|
||||
loading={ordersQuery.isFetching}
|
||||
onClick={applyKeywordSearch}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={ordersQuery.isFetching}
|
||||
onClick={() => ordersQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
@@ -79,7 +106,7 @@ export default function WorkerHallPage() {
|
||||
{formatMoney(order.rewardAmount)}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
所需冻结押金:{formatMoney(order.freezeDepositAmount ?? order.requiredDepositAmount)}
|
||||
所需冻结押金:{formatMoney(order.freezeDepositAmount)}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -93,6 +120,21 @@ export default function WorkerHallPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pagination?.total || 0) > 0 ? (
|
||||
<Pagination
|
||||
current={pagination?.page || page}
|
||||
pageSize={pagination?.pageSize || pageSize}
|
||||
total={pagination?.total || 0}
|
||||
showSizeChanger
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
showTotal={(total) => `共 ${total} 条`}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,21 +26,25 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'pending_acceptance', label: '待验收' },
|
||||
{ value: 'problem', label: '问题单' },
|
||||
{ value: 'accepted', label: '已验收' },
|
||||
{ value: 'cancelled', label: '已取消' },
|
||||
]
|
||||
|
||||
export default function WorkerOrdersPage() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [status, setStatus] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [submittingOrder, setSubmittingOrder] = useState<WorkOrder | null>(null)
|
||||
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['worker-my-orders', status],
|
||||
queryFn: () => fetchWorkerMyOrders({ status }),
|
||||
queryKey: ['worker-my-orders', status, page, pageSize],
|
||||
queryFn: () => fetchWorkerMyOrders({ status, page, pageSize }),
|
||||
retry: false,
|
||||
})
|
||||
const pagination = ordersQuery.data?.data.pagination
|
||||
|
||||
async function submitAcceptance(values: { note?: string }) {
|
||||
if (!submittingOrder) return
|
||||
@@ -121,6 +125,7 @@ export default function WorkerOrdersPage() {
|
||||
onClick={() => {
|
||||
setSubmittingOrder(row)
|
||||
setAcceptanceFiles([])
|
||||
form.resetFields()
|
||||
}}
|
||||
>
|
||||
提交验收
|
||||
@@ -138,7 +143,10 @@ export default function WorkerOrdersPage() {
|
||||
value={status}
|
||||
options={STATUS_OPTIONS}
|
||||
style={{ width: 140 }}
|
||||
onChange={setStatus}
|
||||
onChange={(nextStatus) => {
|
||||
setStatus(nextStatus)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
@@ -155,7 +163,18 @@ export default function WorkerOrdersPage() {
|
||||
loading={ordersQuery.isLoading}
|
||||
dataSource={ordersQuery.data?.data.items || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
pagination={{
|
||||
current: pagination?.page || page,
|
||||
pageSize: pagination?.pageSize || pageSize,
|
||||
total: pagination?.total || 0,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
|
||||
@@ -165,6 +184,7 @@ export default function WorkerOrdersPage() {
|
||||
onCancel={() => {
|
||||
setSubmittingOrder(null)
|
||||
setAcceptanceFiles([])
|
||||
form.resetFields()
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnHidden
|
||||
@@ -210,12 +230,14 @@ function formatStatus(status: string) {
|
||||
pending_acceptance: '待验收',
|
||||
problem: '问题单',
|
||||
accepted: '已验收',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
function resolveStatusColor(status: string) {
|
||||
if (status === 'accepted') return 'green'
|
||||
if (status === 'cancelled') return 'default'
|
||||
if (status === 'problem') return 'red'
|
||||
if (status === 'pending_acceptance') return 'gold'
|
||||
return 'blue'
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function WorkerProfilePage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!worker && !profileQuery.isLoading ? <Empty description="暂无接单员信息" /> : null}
|
||||
{!worker && !profileQuery.isLoading ? <Empty description="暂无打手信息" /> : null}
|
||||
|
||||
{worker ? (
|
||||
<>
|
||||
|
||||
@@ -164,6 +164,19 @@ export function markAdminWorkOrderProblem(workOrderId: number, note: string) {
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveAdminProblemWorkOrder(
|
||||
workOrderId: number,
|
||||
payload: {
|
||||
action: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct'
|
||||
note: string
|
||||
},
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/problem/resolve`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function acceptAdminWorkOrder(workOrderId: number) {
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/accept`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
CollectField,
|
||||
CollectLookupResponse,
|
||||
UploadedFile,
|
||||
WorkOrder,
|
||||
WorkerListResponse,
|
||||
@@ -70,17 +70,12 @@ export function submitWorkerAcceptance(
|
||||
}
|
||||
|
||||
export function lookupCollectOrder(orderNo: string) {
|
||||
return apiPost<{
|
||||
order: Pick<
|
||||
WorkOrder,
|
||||
'workOrderNo' | 'platformOrderId' | 'productName' | 'status'
|
||||
>
|
||||
fields: CollectField[]
|
||||
}>('/api/v1/collect/lookup', { orderNo })
|
||||
return apiPost<CollectLookupResponse>('/api/v1/collect/lookup', { orderNo })
|
||||
}
|
||||
|
||||
export function submitCollectOrder(payload: {
|
||||
orderNo: string
|
||||
workOrderId?: number
|
||||
fields: Record<string, string>
|
||||
}) {
|
||||
return apiPost<{ complete: boolean; order: Record<string, unknown> }>(
|
||||
|
||||
@@ -98,8 +98,8 @@ export type WorkOrder = {
|
||||
categoryName: string
|
||||
status: WorkOrderStatus
|
||||
rewardAmount: number
|
||||
requiredDepositAmount: number
|
||||
depositThresholdAmount: number
|
||||
requiredDepositAmount?: number
|
||||
depositThresholdAmount?: number
|
||||
freezeDepositAmount?: number
|
||||
material: Record<string, unknown>
|
||||
requirement: Record<string, unknown>
|
||||
@@ -146,3 +146,23 @@ export type CollectField = {
|
||||
label: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
export type CollectLookupOrder = {
|
||||
workOrderId: number
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
status: string
|
||||
fields: CollectField[]
|
||||
values?: Record<string, string>
|
||||
}
|
||||
|
||||
export type CollectLookupResponse = {
|
||||
order: Pick<
|
||||
WorkOrder,
|
||||
'workOrderId' | 'workOrderNo' | 'platformOrderId' | 'productName' | 'status'
|
||||
>
|
||||
fields: CollectField[]
|
||||
orders: CollectLookupOrder[]
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user