diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 2359dd9b..cc036c92 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -165,6 +165,11 @@ import { normalizeWorkOrderTimeoutPolicy, } from './worker-service.js' import { reprocessKuaishouSendCodeWorkOrders } from './sync-work-orders-from-send-code.js' +import { + getKuaishouIndustrySourceConfig, + listKuaishouIndustryShopConfigs, + type KuaishouIndustryShopConfig, +} from '../platforms/kuaishou-industry/source-config-service.js' export async function listAdminWorkerLevels() { await ensureWorkerPlatformDefaults() @@ -1881,7 +1886,11 @@ export function resolveAdminMaterialRewardAmount(input: { export async function createAdminMockWorkOrder(payload: JsonObject = {}) { const defaults = await ensureWorkerPlatformDefaults() const now = nowIso() - const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 2500) + const input = resolveAdminMockWorkOrderInput( + payload, + listKuaishouIndustryShopConfigs(getKuaishouIndustrySourceConfig()), + ) + const rewardAmount = input.rewardAmount const depositThresholdAmount = normalizeAmountFen( payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, @@ -1892,21 +1901,10 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { ? rewardAmount : 0 : normalizeAmountFen(payload.requiredDepositAmount, 0) - const paymentFen = - payload.paymentAmount === undefined && - payload.paymentAmountYuan === undefined && - payload.userPaymentAmount === undefined - ? rewardAmount - : normalizeAmountFen( - payload.paymentAmount ?? payload.paymentAmountYuan ?? payload.userPaymentAmount, - rewardAmount, - ) + const paymentFen = input.paymentAmount 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 { platformOrderId, productName, shop } = input const material = { ...(materialComplete ? { @@ -1926,7 +1924,11 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { mock: true, provider: 'mock', platform: 'mock', - shopName: '模拟店铺', + sellerId: String(shop.sellerId || shop.shopId || '').trim(), + shopId: String(shop.shopId || shop.sellerId || '').trim(), + shopName: String( + shop.customShopName || shop.shopName || shop.shopId || shop.sellerId || '', + ).trim(), }, } const workOrder = await createWorkOrder({ @@ -1957,12 +1959,94 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { eventType: 'mock_created', fromStatus: '', toStatus: workOrder.status, - payloadJson: JSON.stringify({ platformOrderId, productName, rewardAmount }), + payloadJson: JSON.stringify({ + platformOrderId, + productName, + rewardAmount, + sellerId: String(shop.sellerId || shop.shopId || '').trim(), + }), now, }) return { order: mapWorkOrderAdmin(workOrder) } } +/** 本地 Mock 与真实订单保持相同的关键字段约束,避免生成无法追溯的测试工单。 */ +export function resolveAdminMockWorkOrderInput( + payload: JsonObject, + shops: KuaishouIndustryShopConfig[], +) { + const platformOrderId = String(payload.platformOrderId || '').trim() + if (!platformOrderId) { + throw createHttpError('请填写订单号', { + statusCode: 400, + errorCode: 'mock_work_order_no_required', + }) + } + + const productName = String(payload.productName || '').trim() + if (!productName) { + throw createHttpError('请填写商品名称', { + statusCode: 400, + errorCode: 'mock_work_order_product_name_required', + }) + } + + const shopId = String(payload.sellerId || payload.shopId || '').trim() + if (!shopId) { + throw createHttpError('请选择店铺', { + statusCode: 400, + errorCode: 'mock_work_order_shop_required', + }) + } + const shop = + shops.find( + (item) => + String(item.sellerId || item.shopId || '').trim() === shopId || + String(item.shopId || '').trim() === shopId, + ) || null + if (!shop) { + throw createHttpError('所选店铺不存在', { + statusCode: 400, + errorCode: 'mock_work_order_shop_invalid', + }) + } + if (shop.enabled === false) { + throw createHttpError('所选店铺已停用', { + statusCode: 409, + errorCode: 'mock_work_order_shop_disabled', + }) + } + + return { + platformOrderId, + productName, + shop, + rewardAmount: requireMockWorkOrderAmount(payload.rewardAmount, '接单金额', 'reward_amount'), + paymentAmount: requireMockWorkOrderAmount( + payload.paymentAmount, + '用户付款金额', + 'payment_amount', + ), + } +} + +function requireMockWorkOrderAmount(value: unknown, label: string, field: string): number { + if (value === undefined || value === null || String(value).trim() === '') { + throw createHttpError(`请填写${label}`, { + statusCode: 400, + errorCode: `mock_work_order_${field}_required`, + }) + } + const amount = normalizeAmountFen(value, 0) + if (amount <= 0) { + throw createHttpError(`${label}必须大于 0`, { + statusCode: 400, + errorCode: `mock_work_order_${field}_invalid`, + }) + } + return amount +} + export async function updateAdminWorkOrder( workOrderId: number | string, payload: JsonObject = {}, 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 ab4709ca..4e644176 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 @@ -32,6 +32,7 @@ import { maskWorkerLeaderboardDisplayName, resolveCollectSubmitTargetWorkOrder, resolveAdminMaterialRewardAmount, + resolveAdminMockWorkOrderInput, resolveMatchingProductRuleDecision, resolveWorkerPermissions, resolveSkuNameQuantity, @@ -172,6 +173,78 @@ test('接单密码至少八位且必须包含英文字母和数字', () => { assert.throws(() => validateWorkerPassword('12345678'), { errorCode: 'worker_password_invalid' }) }) +test('本地 Mock 工单要求订单、店铺、商品与两个金额完整有效', () => { + const shops = [ + { + enabled: true, + sellerId: 'seller-1', + shopId: 'shop-1', + shopName: '测试店铺', + customShopName: '', + authState: '', + accessToken: '', + refreshToken: '', + accessTokenExpiresAt: '', + refreshTokenExpiresAt: '', + openId: '', + grantedScopes: '', + lastRefreshedAt: '', + lastRefreshError: '', + }, + { + enabled: false, + sellerId: 'seller-disabled', + shopId: 'shop-disabled', + shopName: '已停用店铺', + customShopName: '', + authState: '', + accessToken: '', + refreshToken: '', + accessTokenExpiresAt: '', + refreshTokenExpiresAt: '', + openId: '', + grantedScopes: '', + lastRefreshedAt: '', + lastRefreshError: '', + }, + ] + const baseInput = { + platformOrderId: 'MOCK-1', + productName: '测试商品', + sellerId: 'seller-1', + rewardAmount: 25, + paymentAmount: 30, + } + + assert.throws(() => resolveAdminMockWorkOrderInput({}, shops), { + errorCode: 'mock_work_order_no_required', + }) + assert.throws(() => resolveAdminMockWorkOrderInput({ platformOrderId: 'MOCK-1' }, shops), { + errorCode: 'mock_work_order_product_name_required', + }) + assert.throws( + () => + resolveAdminMockWorkOrderInput({ platformOrderId: 'MOCK-1', productName: '测试商品' }, shops), + { errorCode: 'mock_work_order_shop_required' }, + ) + assert.throws( + () => resolveAdminMockWorkOrderInput({ ...baseInput, sellerId: 'unknown' }, shops), + { errorCode: 'mock_work_order_shop_invalid' }, + ) + assert.throws( + () => resolveAdminMockWorkOrderInput({ ...baseInput, sellerId: 'seller-disabled' }, shops), + { errorCode: 'mock_work_order_shop_disabled' }, + ) + assert.throws( + () => resolveAdminMockWorkOrderInput({ ...baseInput, rewardAmount: undefined }, shops), + { errorCode: 'mock_work_order_reward_amount_required' }, + ) + assert.throws(() => resolveAdminMockWorkOrderInput({ ...baseInput, paymentAmount: 0 }, shops), { + errorCode: 'mock_work_order_payment_amount_invalid', + }) + assert.deepEqual(resolveAdminMockWorkOrderInput(baseInput, shops).rewardAmount, 2500) +}) + test('排行榜打手名称仅展示首字符', () => { assert.equal(maskWorkerLeaderboardDisplayName('张三'), '张****') assert.equal(maskWorkerLeaderboardDisplayName('Alice'), 'A****') diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index b46bb6f7..192bb4e8 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -68,6 +68,7 @@ import { updateAdminWorkOrder, updateAdminWorkOrderSharing, } from '@/services/admin' +import { fetchAdminKuaishouIndustryShops } from '@/services/admin/kuaishou-industry' import type { CollectField, UploadedFile, @@ -78,6 +79,7 @@ import type { import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' +import { buildMockWorkOrderShopOptions } from '@/utils/mock-work-order' import { DeadlineCountdown, formatTimeoutPolicyLabel, @@ -2293,12 +2295,18 @@ function formatRequirementFieldsText(fields: CollectField[]): string { function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise }) { const { message } = App.useApp() const [loading, setLoading] = useState(false) + const shopsQuery = useQuery({ + queryKey: ['admin-kuaishou-industry-shops', 'mock-work-order'], + queryFn: fetchAdminKuaishouIndustryShops, + }) + const shopOptions = buildMockWorkOrderShopOptions(shopsQuery.data?.data.shops || []) async function submit(values: { - platformOrderId?: string - productName?: string - rewardAmount?: number - paymentAmount?: number + platformOrderId: string + sellerId: string + productName: string + rewardAmount: number + paymentAmount: number materialComplete?: boolean }) { setLoading(true) @@ -2314,32 +2322,58 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise }) { } return ( -
- - + + + - - + + + + - + ('/api/v1/admin/worker-platform/orders/mock', payload) diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index 6ffcc04f..fa49a445 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -115,23 +115,24 @@ .worker-orders-toolbar { display: flex; - align-items: center; - gap: 24px; + flex-direction: column; + align-items: stretch; + gap: 16px; min-width: 0; } .worker-orders-search-controls { - flex: 0 0 auto; + width: 100%; } .worker-orders-mock { - flex: 1; + width: 100%; min-width: 0; display: flex; align-items: center; gap: 12px; - padding-left: 24px; - border-left: 1px solid #edf0f5; + padding-top: 16px; + border-top: 1px solid #edf0f5; } .worker-orders-mock > .ant-typography { @@ -157,6 +158,10 @@ width: 150px; } +.worker-order-mock-field-shop .ant-select { + width: 190px; +} + .worker-order-mock-field-product .ant-input { width: 190px; } diff --git a/apps/frontend/src/utils/mock-work-order.ts b/apps/frontend/src/utils/mock-work-order.ts new file mode 100644 index 00000000..b71b1b90 --- /dev/null +++ b/apps/frontend/src/utils/mock-work-order.ts @@ -0,0 +1,20 @@ +export type MockWorkOrderShop = { + sellerId: string + shopId: string + shopName: string + enabled: boolean +} + +export function buildMockWorkOrderShopOptions(shops: MockWorkOrderShop[]) { + return shops + .filter((shop) => shop.enabled) + .map((shop) => { + const value = String(shop.sellerId || shop.shopId || '').trim() + const name = String(shop.shopName || shop.shopId || value).trim() + return { + value, + label: `${name}(${value})`, + } + }) + .filter((shop) => Boolean(shop.value)) +} diff --git a/apps/frontend/tests/mock-work-order.test.ts b/apps/frontend/tests/mock-work-order.test.ts new file mode 100644 index 00000000..418af832 --- /dev/null +++ b/apps/frontend/tests/mock-work-order.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { buildMockWorkOrderShopOptions } from '../src/utils/mock-work-order.ts' + +test('本地 Mock 店铺选择仅展示启用店铺', () => { + assert.deepEqual( + buildMockWorkOrderShopOptions([ + { sellerId: 'seller-1', shopId: 'shop-1', shopName: '测试店铺', enabled: true }, + { sellerId: 'seller-2', shopId: 'shop-2', shopName: '已停用店铺', enabled: false }, + ]), + [{ value: 'seller-1', label: '测试店铺(seller-1)' }], + ) +})