From 4c395103a98c86c0939c6f860008db788e4b8fae Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 9 Apr 2026 15:51:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=BB=E5=BA=95=E9=87=8D=E6=9E=84-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/package-lock.json | 1 - apps/backend/package.json | 1 + apps/backend/scripts/seed-dev-data.js | 512 +++++++++++++++++ apps/backend/src/index.js | 2 +- .../{cdk-repo.js => inventory-repo.js} | 57 +- .../src/repositories/task-event-repo.js | 38 ++ .../task-inventory-binding-repo.js | 93 +++ apps/backend/src/repositories/task-repo.js | 22 +- apps/backend/src/routes/admin.js | 4 +- apps/backend/src/routes/admin/cdks.js | 79 --- apps/backend/src/routes/admin/inventory.js | 79 +++ apps/backend/src/routes/admin/tasks.js | 59 +- .../src/services/admin/admin-service.js | 536 +++++++++++++++--- .../services/claim/claim-session-service.js | 24 +- .../backend/src/services/order/cdk-service.js | 33 -- .../services/order/delivery-task-service.js | 20 +- .../src/services/order/inventory-service.js | 37 ++ apps/frontend/src/router/index.ts | 6 +- apps/frontend/src/services/admin.ts | 42 +- apps/frontend/src/types/admin.ts | 104 +++- apps/frontend/src/utils/admin-display.ts | 20 +- apps/frontend/src/utils/admin-options.ts | 10 +- ...minCdksView.vue => AdminInventoryView.vue} | 78 +-- apps/frontend/src/views/admin/AdminLayout.vue | 2 +- .../src/views/admin/AdminOrderDetailView.vue | 24 + .../src/views/admin/AdminTaskDetailView.vue | 229 +++++++- .../src/views/admin/AdminTasksView.vue | 58 +- docs/backend-refactor-status.md | 151 ++++- 28 files changed, 1917 insertions(+), 404 deletions(-) create mode 100644 apps/backend/scripts/seed-dev-data.js rename apps/backend/src/repositories/{cdk-repo.js => inventory-repo.js} (76%) create mode 100644 apps/backend/src/repositories/task-event-repo.js create mode 100644 apps/backend/src/repositories/task-inventory-binding-repo.js delete mode 100644 apps/backend/src/routes/admin/cdks.js create mode 100644 apps/backend/src/routes/admin/inventory.js delete mode 100644 apps/backend/src/services/order/cdk-service.js create mode 100644 apps/backend/src/services/order/inventory-service.js rename apps/frontend/src/views/admin/{AdminCdksView.vue => AdminInventoryView.vue} (70%) diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 8d405516..8b3e41fe 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -590,7 +590,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", diff --git a/apps/backend/package.json b/apps/backend/package.json index 899f4089..47b03ef4 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -7,6 +7,7 @@ "browser:install": "playwright install chromium", "browser:install:linux": "playwright install --with-deps chromium", "cleanup:dev-data": "node scripts/cleanup-dev-data.js", + "seed:dev-data": "node scripts/seed-dev-data.js", "db:migrate": "node src/db/migrate.js", "dev": "node --watch-path=src --watch-path=config --watch-preserve-output src/index.js", "start": "node src/index.js" diff --git a/apps/backend/scripts/seed-dev-data.js b/apps/backend/scripts/seed-dev-data.js new file mode 100644 index 00000000..1eda9581 --- /dev/null +++ b/apps/backend/scripts/seed-dev-data.js @@ -0,0 +1,512 @@ +#!/usr/bin/env node + +import process from 'node:process' + +import { runtimeConfig } from '../src/config/runtime.js' +import { closeDb, query } from '../src/db/client.js' +import { runDatabaseMigrations } from '../src/db/migrate.js' +import { updateClaimToken } from '../src/repositories/claim-token-repo.js' +import { getFulfillmentProfileByKey, upsertSkuFulfillmentBinding } from '../src/repositories/fulfillment-profile-repo.js' +import { createInventoryItems, releaseReservedInventoryItem } from '../src/repositories/inventory-repo.js' +import { getTaskById, updateTask } from '../src/repositories/task-repo.js' +import { ensureFulfillmentCatalogBootstrapped } from '../src/services/bootstrap/fulfillment-bootstrap-service.js' +import { completeAdminTaskManualDispatch } from '../src/services/admin/admin-service.js' +import { upsertOrderFromWebhook } from '../src/services/order/order-service.js' +import { addHours, nowIso } from '../src/utils/time.js' + +const RESET_TABLES = [ + 'message_deliveries', + 'tencent_browser_contexts', + 'task_events', + 'claim_tokens', + 'task_inventory_bindings', + 'fulfillment_tasks', + 'order_items', + 'orders', + 'webhook_events', + 'inventory_items', +] + +const SEED_PROVIDER = 'seed' +const SEED_PLATFORM = 'sandbox' +const SEED_SHOP_ID = 'seed-shop' +const SEED_SHOP_NAME = 'Seed Sandbox Shop' + +const options = parseArgs(process.argv.slice(2)) + +if (options.help) { + printHelp() + process.exit(0) +} + +try { + printPreview() + + if (!options.apply) { + console.log('\n当前为预览模式。确认后追加 `--apply`,需要清库时再加 `--reset`。') + process.exit(0) + } + + await runDatabaseMigrations() + await ensureFulfillmentCatalogBootstrapped() + + if (options.reset) { + await query(`TRUNCATE TABLE ${RESET_TABLES.join(', ')} RESTART IDENTITY CASCADE`) + console.log('\n已先清空测试业务数据。') + } + + await ensureSeedBindings() + await createSeedInventory() + + const manualPending = await seedScenario({ + scenarioKey: 'manual_pending', + orderLabel: 'SEED-MANUAL-PENDING', + skuCode: 'seed-manual-pending', + skuName: '开发种子-人工履约待处理', + }) + + const manualCompleted = await seedScenario({ + scenarioKey: 'manual_completed', + orderLabel: 'SEED-MANUAL-COMPLETED', + skuCode: 'seed-manual-completed', + skuName: '开发种子-人工履约已完成', + afterCreate: async (result) => { + const task = result.tasks[0] + if (!task) { + return null + } + + await completeAdminTaskManualDispatch(task.id, { + outcome: 'delivered', + resultMessage: '开发种子数据:人工履约已完成', + deliveryReference: 'SEED-MANUAL-REF-001', + deliveredCredential: '开发种子数据:已通过人工渠道发放', + }) + + return getTaskById(task.id) + }, + }) + + const claimReady = await seedScenario({ + scenarioKey: 'claim_ready', + orderLabel: 'SEED-CLAIM-READY', + skuCode: 'seed-claim-ready', + skuName: '开发种子-领取兑换可用', + }) + + const claimClaimed = await seedScenario({ + scenarioKey: 'claim_claimed', + orderLabel: 'SEED-CLAIM-CLAIMED', + skuCode: 'seed-claim-claimed', + skuName: '开发种子-已打开领取链接', + afterCreate: async (result) => applyClaimedState(result.tasks[0]), + }) + + const claimRoleConfirmed = await seedScenario({ + scenarioKey: 'claim_role_confirmed', + orderLabel: 'SEED-CLAIM-ROLE', + skuCode: 'seed-claim-role-confirmed', + skuName: '开发种子-已确认角色', + afterCreate: async (result) => applyRoleConfirmedState(result.tasks[0]), + }) + + const claimRetryPending = await seedScenario({ + scenarioKey: 'claim_retry_pending', + orderLabel: 'SEED-CLAIM-RETRY', + skuCode: 'seed-claim-retry', + skuName: '开发种子-等待重试', + afterCreate: async (result) => applyRetryPendingState(result.tasks[0]), + }) + + const claimExpired = await seedScenario({ + scenarioKey: 'claim_expired', + orderLabel: 'SEED-CLAIM-EXPIRED', + skuCode: 'seed-claim-expired', + skuName: '开发种子-领取链接已过期', + afterCreate: async (result) => applyExpiredState(result.tasks[0]), + }) + + const claimWaitingInventory = await seedScenario({ + scenarioKey: 'claim_waiting_inventory', + orderLabel: 'SEED-CLAIM-WAIT', + skuCode: 'seed-claim-wait', + skuName: '开发种子-等待库存', + }) + + printResultSummary([ + manualPending, + manualCompleted, + claimReady, + claimClaimed, + claimRoleConfirmed, + claimRetryPending, + claimExpired, + claimWaitingInventory, + ]) +} catch (error) { + console.error('\n写入开发种子数据失败:', error instanceof Error ? error.message : String(error || '未知错误')) + process.exitCode = 1 +} finally { + await closeDb() +} + +async function ensureSeedBindings() { + const timestamp = nowIso() + const manualProfile = await getRequiredProfile('manual_review') + const claimProfile = await getRequiredProfile('tencent_claim_redeem') + + const bindings = [ + { skuCode: 'seed-manual-pending', profileId: manualProfile.id, priority: 10 }, + { skuCode: 'seed-manual-completed', profileId: manualProfile.id, priority: 20 }, + { skuCode: 'seed-claim-ready', profileId: claimProfile.id, priority: 30 }, + { skuCode: 'seed-claim-claimed', profileId: claimProfile.id, priority: 40 }, + { skuCode: 'seed-claim-role-confirmed', profileId: claimProfile.id, priority: 50 }, + { skuCode: 'seed-claim-retry', profileId: claimProfile.id, priority: 60 }, + { skuCode: 'seed-claim-expired', profileId: claimProfile.id, priority: 70 }, + { skuCode: 'seed-claim-wait', profileId: claimProfile.id, priority: 80 }, + ] + + for (const binding of bindings) { + await upsertSkuFulfillmentBinding({ + skuCode: binding.skuCode, + provider: SEED_PROVIDER, + platform: SEED_PLATFORM, + shopId: SEED_SHOP_ID, + profileId: binding.profileId, + enabled: true, + priority: binding.priority, + configJson: JSON.stringify({ + seededBy: 'scripts/seed-dev-data.js', + scenario: binding.skuCode, + }), + createdAt: timestamp, + updatedAt: timestamp, + }) + } +} + +async function createSeedInventory() { + const timestamp = nowIso() + + await createInventoryItems([ + { + skuCode: 'seed-claim-ready', + displayValue: 'SEED-CLAIM-CODE-READY-001', + batchNo: 'seed-20260409', + credentialType: 'tencent_code', + payload: { + code: 'SEED-CLAIM-CODE-READY-001', + scenario: 'claim_ready', + }, + createdAt: timestamp, + updatedAt: timestamp, + }, + { + skuCode: 'seed-claim-claimed', + displayValue: 'SEED-CLAIM-CODE-CLAIMED-001', + batchNo: 'seed-20260409', + credentialType: 'tencent_code', + payload: { + code: 'SEED-CLAIM-CODE-CLAIMED-001', + scenario: 'claim_claimed', + }, + createdAt: timestamp, + updatedAt: timestamp, + }, + { + skuCode: 'seed-claim-role-confirmed', + displayValue: 'SEED-CLAIM-CODE-ROLE-001', + batchNo: 'seed-20260409', + credentialType: 'tencent_code', + payload: { + code: 'SEED-CLAIM-CODE-ROLE-001', + scenario: 'claim_role_confirmed', + }, + createdAt: timestamp, + updatedAt: timestamp, + }, + { + skuCode: 'seed-claim-retry', + displayValue: 'SEED-CLAIM-CODE-RETRY-001', + batchNo: 'seed-20260409', + credentialType: 'tencent_code', + payload: { + code: 'SEED-CLAIM-CODE-RETRY-001', + scenario: 'claim_retry_pending', + }, + createdAt: timestamp, + updatedAt: timestamp, + }, + { + skuCode: 'seed-claim-expired', + displayValue: 'SEED-CLAIM-CODE-EXPIRED-001', + batchNo: 'seed-20260409', + credentialType: 'tencent_code', + payload: { + code: 'SEED-CLAIM-CODE-EXPIRED-001', + scenario: 'claim_expired', + }, + createdAt: timestamp, + updatedAt: timestamp, + }, + { + skuCode: 'seed-spare-available', + displayValue: 'SEED-SPARE-CODE-001', + batchNo: 'seed-20260409', + credentialType: 'tencent_code', + payload: { + code: 'SEED-SPARE-CODE-001', + scenario: 'spare_available', + }, + createdAt: timestamp, + updatedAt: timestamp, + }, + ]) +} + +async function seedScenario({ scenarioKey, orderLabel, skuCode, skuName, afterCreate = null }) { + const result = await upsertOrderFromWebhook(buildSeedOrderEvent({ + scenarioKey, + orderLabel, + skuCode, + skuName, + })) + + const task = typeof afterCreate === 'function' + ? await afterCreate(result) + : result.tasks[0] || null + + return { + scenarioKey, + orderId: result.order.id, + platformOrderId: result.order.platform_order_id, + taskId: task?.id || null, + taskNo: task?.task_no || '', + taskStatus: task?.task_status || '', + deliveryStatus: task?.delivery_status || '', + inventoryStatus: task?.inventory_status || '', + inventoryItemId: Number(task?.primary_inventory_item_id || 0) || null, + claimTokenId: Number(task?.primary_claim_token_id || 0) || null, + executorKey: String(task?.executor_key || '').trim(), + } +} + +async function applyClaimedState(task) { + if (!task) { + return null + } + + const now = nowIso() + await updateTask(task.id, { + task_status: 'claimed', + user_action_status: 'claimed', + browser_session_id: `seed-browser-${task.id}`, + login_type: 'qq', + claimed_at: now, + updated_at: now, + last_error: '', + }) + + return getTaskById(task.id) +} + +async function applyRoleConfirmedState(task) { + if (!task) { + return null + } + + const claimedAt = addHours(nowIso(), -1) + const roleConfirmedAt = nowIso() + await updateTask(task.id, { + task_status: 'role_confirmed', + user_action_status: 'role_confirmed', + browser_session_id: `seed-browser-${task.id}`, + login_type: 'qq', + nickname: 'SeedHero', + role_id: 'seed-role-1001', + role_name: '开发角色-已确认', + area: 'seed-area', + partition_name: 'seed-partition', + claimed_at: claimedAt, + role_confirmed_at: roleConfirmedAt, + updated_at: roleConfirmedAt, + last_error: '', + }) + + return getTaskById(task.id) +} + +async function applyRetryPendingState(task) { + if (!task) { + return null + } + + const claimedAt = addHours(nowIso(), -2) + const roleConfirmedAt = addHours(nowIso(), -1) + const updatedAt = nowIso() + await updateTask(task.id, { + task_status: 'retry_pending', + delivery_status: 'pending', + inventory_status: 'reserved', + user_action_status: 'role_confirmed', + browser_session_id: `seed-browser-${task.id}`, + login_type: 'qq', + nickname: 'SeedRetryUser', + role_id: 'seed-role-retry', + role_name: '开发角色-待重试', + area: 'seed-area', + partition_name: 'seed-partition', + claimed_at: claimedAt, + role_confirmed_at: roleConfirmedAt, + attempt_count: 2, + last_error: '开发种子数据:模拟兑换失败,等待重试', + updated_at: updatedAt, + }) + + return getTaskById(task.id) +} + +async function applyExpiredState(task) { + if (!task) { + return null + } + + const now = nowIso() + if (task.primary_claim_token_id) { + await updateClaimToken(task.primary_claim_token_id, { + status: 'expired', + expired_at: addHours(now, -2), + updated_at: now, + }) + } + + if (task.primary_inventory_item_id) { + await releaseReservedInventoryItem(task.primary_inventory_item_id, now) + } + + await updateTask(task.id, { + task_status: 'expired', + inventory_status: 'pending', + user_action_status: 'expired', + last_error: '领取链接已过期,预占库存项已释放', + updated_at: now, + }) + + return getTaskById(task.id) +} + +function buildSeedOrderEvent({ scenarioKey, orderLabel, skuCode, skuName }) { + const timestamp = nowIso() + + return { + provider: SEED_PROVIDER, + platform: SEED_PLATFORM, + shopId: SEED_SHOP_ID, + shopName: SEED_SHOP_NAME, + platformOrderId: orderLabel, + orderStatus: 'paid', + payStatus: 'paid', + buyerId: `${scenarioKey}-buyer`, + buyerName: `开发种子-${scenarioKey}`, + receiverContact: `seed+${scenarioKey}@example.com`, + totalAmount: 100, + currency: 'CNY', + paidAt: timestamp, + rawPayload: { + source: 'seed-dev-data', + scenarioKey, + platformOrderId: orderLabel, + }, + items: [ + { + skuCode, + skuName, + quantity: 1, + spec: { + title: skuName, + scenarioKey, + }, + snapshot: { + title: skuName, + scenarioKey, + seeded: true, + }, + }, + ], + } +} + +async function getRequiredProfile(profileKey) { + const profile = await getFulfillmentProfileByKey(profileKey) + + if (!profile) { + throw new Error(`缺少履约档案: ${profileKey}`) + } + + return profile +} + +function parseArgs(argv) { + return { + apply: argv.includes('--apply'), + reset: argv.includes('--reset'), + help: argv.includes('-h') || argv.includes('--help'), + } +} + +function printHelp() { + console.log(` +用法: + node scripts/seed-dev-data.js [--apply] [--reset] + +说明: + 基于当前 PostgreSQL fulfillment schema 生成一组可重复验证的开发种子数据。 + +场景: + - manual_pending: 人工履约待处理 + - manual_completed: 人工履约已完成 + - claim_ready: 已预占库存并生成领取链接 + - claim_claimed: 用户已打开领取链接 + - claim_role_confirmed: 用户已确认角色 + - claim_retry_pending: 自动履约失败等待重试 + - claim_expired: 领取链接已过期 + - claim_waiting_inventory: 等待库存 + +参数: + --apply 实际写入数据 + --reset 写入前先清空测试业务数据 + +数据库连接: + ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'} +`.trim()) +} + +function printPreview() { + console.log('准备生成开发种子数据:') + console.log('- manual_pending -> 人工履约待处理') + console.log('- manual_completed -> 人工履约已完成') + console.log('- claim_ready -> 领取兑换已就绪') + console.log('- claim_claimed -> 用户已打开领取链接') + console.log('- claim_role_confirmed -> 用户已确认角色') + console.log('- claim_retry_pending -> 自动履约失败等待重试') + console.log('- claim_expired -> 领取链接已过期') + console.log('- claim_waiting_inventory -> 等待库存') + console.log(`- database: ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}`) +} + +function printResultSummary(rows) { + console.log('\n开发种子数据已写入:') + + for (const row of rows) { + console.log(`- ${row.scenarioKey}`) + console.log(` orderId=${row.orderId} platformOrderId=${row.platformOrderId}`) + console.log(` taskId=${row.taskId || '-'} taskNo=${row.taskNo || '-'} executor=${row.executorKey || '-'}`) + console.log(` taskStatus=${row.taskStatus || '-'} deliveryStatus=${row.deliveryStatus || '-'} inventoryStatus=${row.inventoryStatus || '-'}`) + console.log(` inventoryItemId=${row.inventoryItemId || '-'} claimTokenId=${row.claimTokenId || '-'}`) + } + + console.log('\n建议验证入口:') + console.log('- `/api/v1/admin/orders?platformOrderId=SEED-`') + console.log('- `/api/v1/admin/tasks?taskNo=DT`') + console.log('- `/api/v1/admin/inventory`') +} diff --git a/apps/backend/src/index.js b/apps/backend/src/index.js index 808e88b9..058de7cb 100644 --- a/apps/backend/src/index.js +++ b/apps/backend/src/index.js @@ -16,7 +16,7 @@ import tencentRouter from './routes/tencent.js' const app = express() const port = Number(runtimeConfig.server.port || 3000) -runDatabaseMigrations() +await runDatabaseMigrations() await ensureFulfillmentCatalogBootstrapped() await ensureAdminUsersBootstrapped() diff --git a/apps/backend/src/repositories/cdk-repo.js b/apps/backend/src/repositories/inventory-repo.js similarity index 76% rename from apps/backend/src/repositories/cdk-repo.js rename to apps/backend/src/repositories/inventory-repo.js index ef2ada3d..3b1413f5 100644 --- a/apps/backend/src/repositories/cdk-repo.js +++ b/apps/backend/src/repositories/inventory-repo.js @@ -1,12 +1,11 @@ import { query, withTransaction } from '../db/client.js' -const CDK_SELECT = ` +const INVENTORY_ITEM_SELECT = ` SELECT ii.id, ii.sku_code, ii.batch_no, ii.credential_type, - ii.display_value AS cdk_code, ii.display_value, ii.status, ii.invalid_reason, @@ -27,9 +26,9 @@ const CDK_SELECT = ` LEFT JOIN fulfillment_tasks ft ON ft.id = tib.task_id ` -export async function findFirstAvailableCdkBySkuCode(skuCode, credentialType = 'tencent_code') { +export async function findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType = 'tencent_code') { const result = await query( - `${CDK_SELECT} + `${INVENTORY_ITEM_SELECT} WHERE ii.sku_code = $1 AND ii.credential_type = $2 AND ii.status = 'available' ORDER BY ii.id ASC LIMIT 1`, @@ -39,7 +38,7 @@ export async function findFirstAvailableCdkBySkuCode(skuCode, credentialType = ' return result.rows[0] || null } -export async function assignReservedCdk(cdkId, taskId, updatedAt, roleKey = 'primary_code') { +export async function assignReservedInventoryItem(inventoryItemId, taskId, updatedAt, roleKey = 'primary_code') { return withTransaction(async (client) => { const inventoryResult = await client.query( ` @@ -48,11 +47,11 @@ export async function assignReservedCdk(cdkId, taskId, updatedAt, roleKey = 'pri WHERE id = $2 AND status = 'available' RETURNING id `, - [updatedAt, Number(cdkId)], + [updatedAt, Number(inventoryItemId)], ) if (!inventoryResult.rows[0]) { - return getCdkById(cdkId) + return getInventoryItemById(inventoryItemId) } await client.query( @@ -70,25 +69,25 @@ export async function assignReservedCdk(cdkId, taskId, updatedAt, roleKey = 'pri ON CONFLICT (task_id, role_key, inventory_item_id) DO UPDATE SET binding_status = 'reserved', updated_at = EXCLUDED.updated_at, released_at = NULL `, - [Number(taskId), Number(cdkId), roleKey, updatedAt], + [Number(taskId), Number(inventoryItemId), roleKey, updatedAt], ) - return getCdkById(cdkId) + return getInventoryItemById(inventoryItemId) }) } -export async function getCdkById(cdkId) { +export async function getInventoryItemById(inventoryItemId) { const result = await query( - `${CDK_SELECT} + `${INVENTORY_ITEM_SELECT} WHERE ii.id = $1 LIMIT 1`, - [Number(cdkId)], + [Number(inventoryItemId)], ) return result.rows[0] || null } -export async function markCdkDelivered(cdkId, deliveredAt) { +export async function markInventoryItemDelivered(inventoryItemId, deliveredAt) { return withTransaction(async (client) => { await client.query( ` @@ -96,7 +95,7 @@ export async function markCdkDelivered(cdkId, deliveredAt) { SET status = 'consumed', consumed_at = $1, updated_at = $1 WHERE id = $2 `, - [deliveredAt, Number(cdkId)], + [deliveredAt, Number(inventoryItemId)], ) await client.query( @@ -105,14 +104,14 @@ export async function markCdkDelivered(cdkId, deliveredAt) { SET binding_status = 'consumed', consumed_at = $1, updated_at = $1 WHERE inventory_item_id = $2 AND binding_status = 'reserved' `, - [deliveredAt, Number(cdkId)], + [deliveredAt, Number(inventoryItemId)], ) - return getCdkById(cdkId) + return getInventoryItemById(inventoryItemId) }) } -export async function listCdks({ +export async function listInventoryItems({ page = 1, pageSize = 20, skuCode = '', @@ -147,7 +146,7 @@ export async function listCdks({ params.push(pageSize) params.push(offset) const itemsResult = await query( - `${CDK_SELECT} + `${INVENTORY_ITEM_SELECT} ${whereClause} ORDER BY ii.id DESC LIMIT $${params.length - 1} OFFSET $${params.length}`, @@ -160,12 +159,12 @@ export async function listCdks({ } } -export async function createCdks(rows) { +export async function createInventoryItems(rows) { let created = 0 for (const row of rows) { - const payloadJson = JSON.stringify(row.payload || { code: row.cdkCode }) - const displayValue = String(row.cdkCode || row.displayValue || '').trim() + const payloadJson = JSON.stringify(row.payload || { code: row.displayValue }) + const displayValue = String(row.displayValue || '').trim() const result = await query( ` INSERT INTO inventory_items ( @@ -203,7 +202,7 @@ export async function createCdks(rows) { return created } -export async function releaseReservedCdk(cdkId, updatedAt) { +export async function releaseReservedInventoryItem(inventoryItemId, updatedAt) { return withTransaction(async (client) => { await client.query( ` @@ -211,7 +210,7 @@ export async function releaseReservedCdk(cdkId, updatedAt) { SET binding_status = 'released', released_at = $1, updated_at = $1 WHERE inventory_item_id = $2 AND binding_status = 'reserved' `, - [updatedAt, Number(cdkId)], + [updatedAt, Number(inventoryItemId)], ) await client.query( @@ -220,14 +219,14 @@ export async function releaseReservedCdk(cdkId, updatedAt) { SET status = 'available', updated_at = $1 WHERE id = $2 AND status = 'reserved' `, - [updatedAt, Number(cdkId)], + [updatedAt, Number(inventoryItemId)], ) - return getCdkById(cdkId) + return getInventoryItemById(inventoryItemId) }) } -export async function invalidateCdk(cdkId, invalidReason, updatedAt) { +export async function invalidateInventoryItem(inventoryItemId, invalidReason, updatedAt) { const result = await query( ` UPDATE inventory_items @@ -235,12 +234,12 @@ export async function invalidateCdk(cdkId, invalidReason, updatedAt) { WHERE id = $3 AND status = 'available' RETURNING id `, - [invalidReason, updatedAt, Number(cdkId)], + [invalidReason, updatedAt, Number(inventoryItemId)], ) if (!result.rows[0]) { - return getCdkById(cdkId) + return getInventoryItemById(inventoryItemId) } - return getCdkById(cdkId) + return getInventoryItemById(inventoryItemId) } diff --git a/apps/backend/src/repositories/task-event-repo.js b/apps/backend/src/repositories/task-event-repo.js new file mode 100644 index 00000000..2d07611b --- /dev/null +++ b/apps/backend/src/repositories/task-event-repo.js @@ -0,0 +1,38 @@ +import { query } from '../db/client.js' + +export async function createTaskEvent(taskId, eventType, payload = {}, createdAt) { + const result = await query( + ` + INSERT INTO task_events ( + task_id, + event_type, + payload_json, + created_at + ) VALUES ($1, $2, $3::jsonb, $4) + RETURNING * + `, + [ + Number(taskId), + String(eventType || '').trim(), + JSON.stringify(payload || {}), + createdAt, + ], + ) + + return result.rows[0] || null +} + +export async function listTaskEventsByTaskId(taskId, { limit = 20 } = {}) { + const result = await query( + ` + SELECT * + FROM task_events + WHERE task_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2 + `, + [Number(taskId), Math.max(1, Number(limit || 20))], + ) + + return result.rows +} diff --git a/apps/backend/src/repositories/task-inventory-binding-repo.js b/apps/backend/src/repositories/task-inventory-binding-repo.js new file mode 100644 index 00000000..fe08237d --- /dev/null +++ b/apps/backend/src/repositories/task-inventory-binding-repo.js @@ -0,0 +1,93 @@ +import { query } from '../db/client.js' + +export async function listTaskInventoryBindingsByTaskId(taskId) { + const result = await query( + ` + SELECT + tib.id, + tib.task_id, + tib.inventory_item_id, + tib.role_key, + tib.quantity, + tib.binding_status, + tib.consumed_at, + tib.released_at, + tib.metadata_json, + tib.created_at, + tib.updated_at, + ii.sku_code, + ii.batch_no, + ii.credential_type, + ii.display_value, + ii.status AS inventory_item_status, + ii.invalid_reason + FROM task_inventory_bindings tib + JOIN inventory_items ii ON ii.id = tib.inventory_item_id + WHERE tib.task_id = $1 + ORDER BY tib.id ASC + `, + [Number(taskId)], + ) + + return result.rows +} + +export async function getTaskInventoryBindingById(bindingId) { + const result = await query( + ` + SELECT + tib.id, + tib.task_id, + tib.inventory_item_id, + tib.role_key, + tib.quantity, + tib.binding_status, + tib.consumed_at, + tib.released_at, + tib.metadata_json, + tib.created_at, + tib.updated_at, + ii.sku_code, + ii.batch_no, + ii.credential_type, + ii.display_value, + ii.status AS inventory_item_status, + ii.invalid_reason + FROM task_inventory_bindings tib + JOIN inventory_items ii ON ii.id = tib.inventory_item_id + WHERE tib.id = $1 + LIMIT 1 + `, + [Number(bindingId)], + ) + + return result.rows[0] || null +} + +export async function listTaskInventoryBindingSummariesByTaskIds(taskIds = []) { + const normalizedTaskIds = Array.from(new Set((Array.isArray(taskIds) ? taskIds : []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0))) + + if (normalizedTaskIds.length === 0) { + return [] + } + + const result = await query( + ` + SELECT + tib.task_id, + COUNT(*)::int AS total_binding_count, + COUNT(*) FILTER (WHERE tib.binding_status = 'reserved')::int AS reserved_binding_count, + COUNT(*) FILTER (WHERE tib.binding_status = 'consumed')::int AS consumed_binding_count, + COUNT(*) FILTER (WHERE tib.binding_status = 'released')::int AS released_binding_count, + ARRAY_AGG(DISTINCT tib.role_key ORDER BY tib.role_key) AS role_keys + FROM task_inventory_bindings tib + WHERE tib.task_id = ANY($1::bigint[]) + GROUP BY tib.task_id + `, + [normalizedTaskIds], + ) + + return result.rows +} diff --git a/apps/backend/src/repositories/task-repo.js b/apps/backend/src/repositories/task-repo.js index 4b8c2f5e..c1b65f88 100644 --- a/apps/backend/src/repositories/task-repo.js +++ b/apps/backend/src/repositories/task-repo.js @@ -129,7 +129,7 @@ export async function createTask(input) { await upsertTencentBrowserContextWithClient(client, taskId, input.tencentContext, input.createdAt) } - return getTaskById(taskId) + return getTaskByIdWithExecutor(client.query.bind(client), taskId) }) } @@ -202,18 +202,12 @@ export async function updateTask(taskId, patch) { }, patch.updated_at || current.updated_at) } - return getTaskById(taskId) + return getTaskByIdWithExecutor(client.query.bind(client), taskId) }) } export async function getTaskById(taskId) { - const result = await query( - `${buildTaskSelect()} - WHERE ft.id = $1 - LIMIT 1`, - [Number(taskId)], - ) - return result.rows[0] || null + return getTaskByIdWithExecutor(query, taskId) } export async function findTaskByClaimTokenId(claimTokenId) { @@ -399,6 +393,16 @@ async function upsertTencentBrowserContextWithClient(client, taskId, patch = {}, ) } +async function getTaskByIdWithExecutor(executor, taskId) { + const result = await executor( + `${buildTaskSelect()} + WHERE ft.id = $1 + LIMIT 1`, + [Number(taskId)], + ) + return result.rows[0] || null +} + function containsTencentPatch(patch = {}) { return [ 'browser_session_id', diff --git a/apps/backend/src/routes/admin.js b/apps/backend/src/routes/admin.js index 95ef23d5..6c1533de 100644 --- a/apps/backend/src/routes/admin.js +++ b/apps/backend/src/routes/admin.js @@ -2,8 +2,8 @@ import { Router } from 'express' import authRouter from './admin/auth.js' import auditLogsRouter from './admin/audit-logs.js' -import cdksRouter from './admin/cdks.js' import dashboardRouter from './admin/dashboard.js' +import inventoryRouter from './admin/inventory.js' import ordersRouter from './admin/orders.js' import platformConfigRouter from './admin/platform-config.js' import { requireAdminSession } from './admin/shared.js' @@ -22,7 +22,7 @@ router.use(auditLogsRouter) router.use(platformConfigRouter) router.use(ordersRouter) router.use(tasksRouter) -router.use(cdksRouter) +router.use(inventoryRouter) router.use(webhookEventsRouter) router.use((req, res) => { diff --git a/apps/backend/src/routes/admin/cdks.js b/apps/backend/src/routes/admin/cdks.js deleted file mode 100644 index 39d96fb8..00000000 --- a/apps/backend/src/routes/admin/cdks.js +++ /dev/null @@ -1,79 +0,0 @@ -import { Router } from 'express' - -import { - createAdminCdk, - getAdminCdks, - importAdminCdks, - invalidateAdminCdk, - releaseAdminInventoryCdk, -} from '../../services/admin/admin-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' - -const router = Router() - -router.get('/cdks', createJsonHandler( - (req) => getAdminCdks(req.query), - { - successMessage: 'ok', - errorMessage: '读取 CDK 列表失败', - scope: '[admin/cdks]', - }, -)) - -router.post('/cdks', createJsonHandler( - (req) => createAdminCdk(req.body), - { - successMessage: 'CDK 已新增', - errorMessage: '新增 CDK 失败', - scope: '[admin/cdks]', - }, -)) - -router.post('/cdks/import', createJsonHandler( - (req) => importAdminCdks(req.body), - { - successMessage: '导入完成', - errorMessage: '导入 CDK 失败', - scope: '[admin/cdks/import]', - }, -)) - -router.post('/cdks/:cdkId/release', requireAdminRoles(['admin']), createJsonHandler( - (req) => releaseAdminInventoryCdk(req.params.cdkId), - { - successMessage: 'CDK 已释放', - errorMessage: '释放库存 CDK 失败', - scope: '[admin/cdks/:cdkId/release]', - audit: (req, data) => ({ - action: 'inventory_cdk_released', - targetType: 'cdk', - targetId: String(req.params.cdkId), - data: { - cdkId: data.cdk?.cdkId, - skuCode: data.cdk?.skuCode, - cdkCode: data.cdk?.cdkCode, - }, - }), - }, -)) - -router.post('/cdks/:cdkId/invalidate', requireAdminRoles(['admin']), createJsonHandler( - (req) => invalidateAdminCdk(req.params.cdkId, req.body), - { - successMessage: 'CDK 已作废', - errorMessage: '作废 CDK 失败', - scope: '[admin/cdks/:cdkId/invalidate]', - audit: (req, data) => ({ - action: 'inventory_cdk_invalidated', - targetType: 'cdk', - targetId: String(req.params.cdkId), - data: { - cdkId: data.cdk?.cdkId, - skuCode: data.cdk?.skuCode, - invalidReason: data.cdk?.invalidReason, - }, - }), - }, -)) - -export default router diff --git a/apps/backend/src/routes/admin/inventory.js b/apps/backend/src/routes/admin/inventory.js new file mode 100644 index 00000000..d3237baa --- /dev/null +++ b/apps/backend/src/routes/admin/inventory.js @@ -0,0 +1,79 @@ +import { Router } from 'express' + +import { + createAdminInventoryItem, + getAdminInventoryItems, + importAdminInventoryItems, + invalidateAdminInventoryItem, + releaseAdminInventoryItem, +} from '../../services/admin/admin-service.js' +import { createJsonHandler, requireAdminRoles } from './shared.js' + +const router = Router() + +router.get('/inventory', createJsonHandler( + (req) => getAdminInventoryItems(req.query), + { + successMessage: 'ok', + errorMessage: '读取库存列表失败', + scope: '[admin/inventory]', + }, +)) + +router.post('/inventory', createJsonHandler( + (req) => createAdminInventoryItem(req.body), + { + successMessage: '库存项已新增', + errorMessage: '新增库存项失败', + scope: '[admin/inventory]', + }, +)) + +router.post('/inventory/import', createJsonHandler( + (req) => importAdminInventoryItems(req.body), + { + successMessage: '导入完成', + errorMessage: '导入库存凭据失败', + scope: '[admin/inventory/import]', + }, +)) + +router.post('/inventory/:inventoryItemId/release', requireAdminRoles(['admin']), createJsonHandler( + (req) => releaseAdminInventoryItem(req.params.inventoryItemId), + { + successMessage: '库存项已释放', + errorMessage: '释放库存项失败', + scope: '[admin/inventory/:inventoryItemId/release]', + audit: (req, data) => ({ + action: 'inventory_item_released', + targetType: 'inventory_item', + targetId: String(req.params.inventoryItemId), + data: { + inventoryItemId: data.inventoryItem?.inventoryItemId, + skuCode: data.inventoryItem?.skuCode, + displayValue: data.inventoryItem?.displayValue, + }, + }), + }, +)) + +router.post('/inventory/:inventoryItemId/invalidate', requireAdminRoles(['admin']), createJsonHandler( + (req) => invalidateAdminInventoryItem(req.params.inventoryItemId, req.body), + { + successMessage: '库存项已作废', + errorMessage: '作废库存项失败', + scope: '[admin/inventory/:inventoryItemId/invalidate]', + audit: (req, data) => ({ + action: 'inventory_item_invalidated', + targetType: 'inventory_item', + targetId: String(req.params.inventoryItemId), + data: { + inventoryItemId: data.inventoryItem?.inventoryItemId, + skuCode: data.inventoryItem?.skuCode, + invalidReason: data.inventoryItem?.invalidReason, + }, + }), + }, +)) + +export default router diff --git a/apps/backend/src/routes/admin/tasks.js b/apps/backend/src/routes/admin/tasks.js index 6f238518..e6dbe518 100644 --- a/apps/backend/src/routes/admin/tasks.js +++ b/apps/backend/src/routes/admin/tasks.js @@ -2,12 +2,14 @@ import { Router } from 'express' import { closeAdminTask, + completeAdminTaskManualDispatch, getAdminTaskDetail, getAdminTaskScreenshotPath, getAdminTasks, markAdminTaskManualReview, regenerateAdminTaskClaimLink, - releaseAdminTaskCdk, + releaseAdminTaskInventoryBinding, + releaseAdminTaskInventory, retryAdminTask, } from '../../services/admin/admin-service.js' import { createFileHandler, createJsonHandler, requireAdminRoles } from './shared.js' @@ -49,14 +51,14 @@ router.post('/tasks/:taskId/retry', createJsonHandler( }, )) -router.post('/tasks/:taskId/release-cdk', requireAdminRoles(['admin']), createJsonHandler( - (req) => releaseAdminTaskCdk(req.params.taskId), +router.post('/tasks/:taskId/release-inventory', requireAdminRoles(['admin']), createJsonHandler( + (req) => releaseAdminTaskInventory(req.params.taskId), { - successMessage: 'CDK 已释放', - errorMessage: '释放 CDK 失败', - scope: '[admin/tasks/:taskId/release-cdk]', + successMessage: '库存凭据已释放', + errorMessage: '释放库存凭据失败', + scope: '[admin/tasks/:taskId/release-inventory]', audit: (req, data) => ({ - action: 'task_release_cdk', + action: 'task_release_inventory', targetType: 'task', targetId: String(req.params.taskId), data: { @@ -68,6 +70,26 @@ router.post('/tasks/:taskId/release-cdk', requireAdminRoles(['admin']), createJs }, )) +router.post('/tasks/:taskId/inventory-bindings/:bindingId/release', requireAdminRoles(['admin']), createJsonHandler( + (req) => releaseAdminTaskInventoryBinding(req.params.taskId, req.params.bindingId), + { + successMessage: '库存绑定已释放', + errorMessage: '释放库存绑定失败', + scope: '[admin/tasks/:taskId/inventory-bindings/:bindingId/release]', + audit: (req, data) => ({ + action: 'task_release_inventory_binding', + targetType: 'task_inventory_binding', + targetId: String(req.params.bindingId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + bindingId: data.bindingId, + inventoryItemId: data.inventoryItemId, + }, + }), + }, +)) + router.post('/tasks/:taskId/regenerate-claim-link', requireAdminRoles(['admin']), createJsonHandler( (req) => regenerateAdminTaskClaimLink(req.params.taskId), { @@ -81,7 +103,7 @@ router.post('/tasks/:taskId/regenerate-claim-link', requireAdminRoles(['admin']) data: { taskId: data.task.taskId, taskNo: data.task.taskNo, - claimTokenId: data.task.claimTokenId, + primaryClaimTokenId: data.task.primaryClaimTokenId, }, }), }, @@ -125,4 +147,25 @@ router.post('/tasks/:taskId/mark-manual-review', requireAdminRoles(['admin']), c }, )) +router.post('/tasks/:taskId/complete-manual-dispatch', requireAdminRoles(['admin']), createJsonHandler( + (req) => completeAdminTaskManualDispatch(req.params.taskId, req.body, req.adminSession), + { + successMessage: '人工履约结果已回写', + errorMessage: '回写人工履约结果失败', + scope: '[admin/tasks/:taskId/complete-manual-dispatch]', + audit: (req, data) => ({ + action: 'task_complete_manual_dispatch', + targetType: 'task', + targetId: String(req.params.taskId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + outcome: data.outcome, + deliveryStatus: data.task.deliveryStatus, + resultCode: data.task.resultCode, + }, + }), + }, +)) + export default router diff --git a/apps/backend/src/services/admin/admin-service.js b/apps/backend/src/services/admin/admin-service.js index c658f310..68a3324d 100644 --- a/apps/backend/src/services/admin/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -8,15 +8,28 @@ import { saveAgisoShopConfigMap, } from '../platforms/agiso/shop-config-service.js' import { getClaimTokenById, updateClaimToken } from '../../repositories/claim-token-repo.js' -import { createCdks, getCdkById, invalidateCdk, listCdks, releaseReservedCdk } from '../../repositories/cdk-repo.js' +import { + createInventoryItems, + getInventoryItemById, + invalidateInventoryItem, + listInventoryItems, + markInventoryItemDelivered, + releaseReservedInventoryItem, +} from '../../repositories/inventory-repo.js' import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' import { getOrderById, listOrders } from '../../repositories/order-repo.js' import { getTaskById, listTasks, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js' +import { + getTaskInventoryBindingById, + listTaskInventoryBindingsByTaskId, + listTaskInventoryBindingSummariesByTaskIds, +} from '../../repositories/task-inventory-binding-repo.js' +import { createTaskEvent, listTaskEventsByTaskId } from '../../repositories/task-event-repo.js' import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../../repositories/webhook-event-repo.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../../utils/money.js' import { nowIso } from '../../utils/time.js' -import { reserveCdkForTask } from '../order/cdk-service.js' +import { reserveInventoryForTask } from '../order/inventory-service.js' import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js' import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' @@ -102,6 +115,7 @@ export async function getAdminOrderDetail(orderId) { listTasksByOrderId(order.id), listWebhookEventsByOrderId(order.id), ]) + const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id)) const itemSummary = summarizeOrderItems(items) return { @@ -125,7 +139,7 @@ export async function getAdminOrderDetail(orderId) { updatedAt: order.updated_at, itemSummary, rawPayload: safeParseJson(order.raw_payload_json), - bindingSummary: buildOrderBindingSummary(tasks), + bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap), }, items: items.map((item) => ({ orderItemId: item.id, @@ -136,7 +150,7 @@ export async function getAdminOrderDetail(orderId) { deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id), spec: safeParseJson(item.spec_json), })), - tasks: tasks.map(mapAdminTaskSummary), + tasks: tasks.map((task) => mapAdminTaskSummary(task, getTaskBindingSummary(taskBindingSummaryMap, task.id))), webhookEvents: webhookEvents.map((event) => ({ eventId: event.id, provider: event.provider || 'agiso', @@ -167,9 +181,10 @@ export async function getAdminTasks(query = {}) { dateFrom: normalizeDateQuery(query.dateFrom), dateTo: normalizeDateQuery(query.dateTo, true), }) + const taskBindingSummaryMap = await getTaskBindingSummaryMap(items.map((item) => item.id)) return { - items: items.map(mapAdminTaskListItem), + items: items.map((item) => mapAdminTaskListItem(item, getTaskBindingSummary(taskBindingSummaryMap, item.id))), pagination: { page, pageSize, total }, } } @@ -184,22 +199,27 @@ export async function getAdminTaskDetail(taskId) { }) } - const [order, claimToken, cdk] = await Promise.all([ + const [order, claimToken, inventoryItem, taskEvents, inventoryBindings] = await Promise.all([ getOrderById(task.order_id), getTaskPrimaryClaimTokenId(task) ? getClaimTokenById(getTaskPrimaryClaimTokenId(task)) : Promise.resolve(null), - getTaskPrimaryInventoryItemId(task) ? getCdkById(getTaskPrimaryInventoryItemId(task)) : Promise.resolve(null), + getTaskPrimaryInventoryItemId(task) ? getInventoryItemById(getTaskPrimaryInventoryItemId(task)) : Promise.resolve(null), + listTaskEventsByTaskId(task.id), + listTaskInventoryBindingsByTaskId(task.id), ]) const orderItems = order ? await listOrderItemsByOrderId(order.id) : [] const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null + const taskContext = parseTaskContext(task) + const taskBindingSummary = createTaskBindingSummaryFromBindings(inventoryBindings) + const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task)) return { task: mapAdminTaskListItem({ ...task, sku_code: orderItem?.sku_code || '', sku_name: orderItem?.sku_name || '', - cdk_code: cdk?.cdk_code || '', + inventory_display_value: inventoryItem?.display_value || '', claim_token: claimToken?.token || '', - }), + }, taskBindingSummary), order: order ? { orderId: order.id, @@ -222,29 +242,34 @@ export async function getAdminTaskDetail(taskId) { : null, claimToken: claimToken ? { - claimTokenId: claimToken.id, + primaryClaimTokenId: claimToken.id, token: claimToken.token, status: claimToken.status, expiredAt: claimToken.expired_at, } : null, - cdk: cdk + inventory: inventoryItem ? { - cdkId: cdk.id, - skuCode: cdk.sku_code, - batchNo: cdk.batch_no, - cdkCode: cdk.cdk_code, - status: cdk.status, + inventoryItemId: inventoryItem.id, + skuCode: inventoryItem.sku_code, + batchNo: inventoryItem.batch_no, + credentialType: inventoryItem.credential_type || 'tencent_code', + displayValue: inventoryItem.display_value, + status: inventoryItem.status, } : null, + inventoryBindings: mappedInventoryBindings, artifacts: safeParseJson(task.artifacts_json), screenshotUrl: task.screenshot_path ? `/api/v1/admin/tasks/${task.id}/screenshot` : '', + manualDispatch: mapManualDispatchContext(taskContext.manualDispatch), + events: taskEvents.map(mapAdminTaskEvent), operations: { - canRetry: ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status), - canReleaseCdk: Boolean(getTaskPrimaryInventoryItemId(task) && ['link_generated', 'retry_pending', 'manual_review', 'waiting_inventory', 'closed'].includes(task.task_status)), - canRegenerateClaimLink: ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status), + canRetry: !isManualDispatchTask(task) && ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status), + canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease), + canRegenerateClaimLink: !isManualDispatchTask(task) && ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status), canClose: !['redeemed', 'closed'].includes(task.task_status), canMarkManualReview: !['redeemed', 'closed', 'manual_review'].includes(task.task_status), + canCompleteManualDispatch: isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status), }, } } @@ -262,10 +287,10 @@ export async function getAdminTaskScreenshotPath(taskId) { return task.screenshot_path } -export async function getAdminCdks(query = {}) { +export async function getAdminInventoryItems(query = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) - const { items, total } = await listCdks({ + const { items, total } = await listInventoryItems({ page, pageSize, skuCode: String(query.skuCode || '').trim(), @@ -274,29 +299,29 @@ export async function getAdminCdks(query = {}) { }) return { - items: await Promise.all(items.map((item) => mapAdminCdkListItem(item))), + items: await Promise.all(items.map((item) => mapAdminInventoryListItem(item))), pagination: { page, pageSize, total }, } } -export async function createAdminCdk(payload = {}) { +export async function createAdminInventoryItem(payload = {}) { const skuCode = String(payload.skuCode || '').trim() - const cdkCode = String(payload.cdkCode || '').trim() + const displayValue = String(payload.displayValue || '').trim() const batchNo = String(payload.batchNo || '').trim() const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' - if (!skuCode || !cdkCode) { - throw createHttpError('缺少 skuCode 或 cdkCode', { + if (!skuCode || !displayValue) { + throw createHttpError('缺少 skuCode 或 displayValue', { statusCode: 400, - errorCode: 'admin_cdk_create_invalid', + errorCode: 'admin_inventory_create_invalid', }) } const now = nowIso() - const created = await createCdks([ + const created = await createInventoryItems([ { skuCode, - cdkCode, + displayValue, batchNo, credentialType, createdAt: now, @@ -305,31 +330,31 @@ export async function createAdminCdk(payload = {}) { ]) if (created === 0) { - throw createHttpError('CDK 已存在,不能重复新增', { + throw createHttpError('库存凭据已存在,不能重复新增', { statusCode: 409, - errorCode: 'admin_cdk_duplicate', + errorCode: 'admin_inventory_duplicate', }) } - const { items } = await listCdks({ + const { items } = await listInventoryItems({ page: 1, pageSize: 1, skuCode, }) - const createdItem = items.find((item) => item.cdk_code === cdkCode) || null + const createdItem = items.find((item) => item.display_value === displayValue) || null return { - cdk: createdItem ? await mapAdminCdkListItem(createdItem) : null, + inventoryItem: createdItem ? await mapAdminInventoryListItem(createdItem) : null, } } -export async function importAdminCdks(payload = {}) { - const rows = normalizeCdkImportRows(payload) +export async function importAdminInventoryItems(payload = {}) { + const rows = normalizeInventoryImportRows(payload) if (rows.length === 0) { - throw createHttpError('没有可导入的 CDK 数据', { + throw createHttpError('没有可导入的库存凭据数据', { statusCode: 400, - errorCode: 'admin_cdk_import_empty', + errorCode: 'admin_inventory_import_empty', }) } @@ -337,13 +362,13 @@ export async function importAdminCdks(payload = {}) { const normalizedRows = rows.map((row) => ({ skuCode: row.skuCode, batchNo: row.batchNo, - cdkCode: row.cdkCode, + displayValue: row.displayValue, credentialType: row.credentialType, createdAt: now, updatedAt: now, })) - const created = await createCdks(normalizedRows) + const created = await createInventoryItems(normalizedRows) return { total: normalizedRows.length, @@ -352,38 +377,38 @@ export async function importAdminCdks(payload = {}) { } } -export async function releaseAdminInventoryCdk(cdkId) { - const cdk = await getRequiredCdk(cdkId) +export async function releaseAdminInventoryItem(inventoryItemId) { + const inventoryItem = await getRequiredInventoryItem(inventoryItemId) - if (cdk.status !== 'reserved') { - throw createHttpError('当前 CDK 不是预占状态,不能释放', { + if (inventoryItem.status !== 'reserved') { + throw createHttpError('当前库存项不是预占状态,不能释放', { statusCode: 409, - errorCode: 'admin_cdk_release_not_allowed', + errorCode: 'admin_inventory_release_not_allowed', }) } - const updated = await releaseReservedCdk(cdk.id, nowIso()) + const updated = await releaseReservedInventoryItem(inventoryItem.id, nowIso()) return { - cdk: await mapAdminCdkListItem(updated), + inventoryItem: await mapAdminInventoryListItem(updated), } } -export async function invalidateAdminCdk(cdkId, payload = {}) { - const cdk = await getRequiredCdk(cdkId) +export async function invalidateAdminInventoryItem(inventoryItemId, payload = {}) { + const inventoryItem = await getRequiredInventoryItem(inventoryItemId) - if (cdk.status !== 'available') { - throw createHttpError('只有可用 CDK 才能作废,请先释放预占', { + if (inventoryItem.status !== 'available') { + throw createHttpError('只有可用库存项才能作废,请先释放预占', { statusCode: 409, - errorCode: 'admin_cdk_invalidate_not_allowed', + errorCode: 'admin_inventory_invalidate_not_allowed', }) } const reason = String(payload.reason || '').trim() || '后台手动作废' - const updated = await invalidateCdk(cdk.id, reason, nowIso()) + const updated = await invalidateInventoryItem(inventoryItem.id, reason, nowIso()) return { - cdk: await mapAdminCdkListItem(updated), + inventoryItem: await mapAdminInventoryListItem(updated), } } @@ -560,6 +585,13 @@ export async function retryAdminTask(taskId) { const task = await getRequiredTask(taskId) const now = nowIso() + if (isManualDispatchTask(task)) { + throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', { + statusCode: 409, + errorCode: 'admin_task_manual_dispatch_retry_not_allowed', + }) + } + if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) { throw createHttpError('当前任务状态不允许重试', { statusCode: 409, @@ -580,7 +612,7 @@ export async function retryAdminTask(taskId) { let token = '' if (!reservedInventoryItemId) { - const reserved = await reserveCdkForTask({ + const reserved = await reserveInventoryForTask({ skuCode: orderItem?.sku_code || '', taskId: task.id, credentialType: primaryRequirement?.credentialType || 'tencent_code', @@ -589,7 +621,7 @@ export async function retryAdminTask(taskId) { if (!reserved) { nextStatus = 'waiting_inventory' - lastError = '库存不足,等待可用 CDK' + lastError = '库存不足,等待可用库存凭据' } else { reservedInventoryItemId = reserved.id } @@ -624,29 +656,29 @@ export async function retryAdminTask(taskId) { return payload } -export async function releaseAdminTaskCdk(taskId) { +export async function releaseAdminTaskInventory(taskId) { const task = await getRequiredTask(taskId) const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task) if (!primaryInventoryItemId) { - throw createHttpError('当前任务没有预占 CDK', { + throw createHttpError('当前任务没有预占库存项', { statusCode: 409, - errorCode: 'admin_task_no_reserved_cdk', + errorCode: 'admin_task_no_reserved_inventory', }) } if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能释放 CDK', { + throw createHttpError('已兑换任务不能释放库存项', { statusCode: 409, errorCode: 'admin_task_release_not_allowed', }) } - await releaseReservedCdk(primaryInventoryItemId, nowIso()) + await releaseReservedInventoryItem(primaryInventoryItemId, nowIso()) const updatedTask = await updateTask(task.id, { task_status: 'waiting_inventory', inventory_status: 'pending', - last_error: '已手动释放预占 CDK', + last_error: '已手动释放预占库存项', updated_at: nowIso(), }) @@ -655,10 +687,84 @@ export async function releaseAdminTaskCdk(taskId) { } } +export async function releaseAdminTaskInventoryBinding(taskId, bindingId) { + const task = await getRequiredTask(taskId) + const binding = await getTaskInventoryBindingById(Number(bindingId)) + + if (!binding || Number(binding.task_id) !== Number(task.id)) { + throw createHttpError('任务库存绑定不存在', { + statusCode: 404, + errorCode: 'admin_task_inventory_binding_not_found', + }) + } + + if (String(binding.binding_status || '').trim() !== 'reserved') { + throw createHttpError('当前库存绑定不是预占状态,不能释放', { + statusCode: 409, + errorCode: 'admin_task_inventory_binding_release_not_allowed', + }) + } + + if (task.task_status === 'redeemed') { + throw createHttpError('已兑换任务不能释放库存绑定', { + statusCode: 409, + errorCode: 'admin_task_release_not_allowed', + }) + } + + const now = nowIso() + await releaseReservedInventoryItem(binding.inventory_item_id, now) + + const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id) + const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim())) + const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved') + const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed') + const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending') + const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim()) + ? 'waiting_inventory' + : task.task_status + + if (!hasReservedBindings) { + const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) + if (primaryClaimTokenId) { + await updateClaimToken(primaryClaimTokenId, { + status: 'revoked', + updated_at: now, + }) + } + } + + const updatedTask = await updateTask(task.id, { + task_status: nextTaskStatus, + inventory_status: nextInventoryStatus, + last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''), + updated_at: now, + }) + + await createTaskEvent(task.id, 'inventory_binding_released', { + bindingId: Number(binding.id), + inventoryItemId: Number(binding.inventory_item_id), + roleKey: String(binding.role_key || '').trim(), + }, now) + + return { + task: mapTaskActionPayload(updatedTask), + bindingId: Number(binding.id), + inventoryItemId: Number(binding.inventory_item_id), + } +} + export async function regenerateAdminTaskClaimLink(taskId) { const task = await getRequiredTask(taskId) const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) + if (isManualDispatchTask(task)) { + throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', { + statusCode: 409, + errorCode: 'admin_task_manual_dispatch_claim_not_allowed', + }) + } + if (['redeemed', 'closed'].includes(task.task_status)) { throw createHttpError('当前任务状态不允许重新生成领取链接', { statusCode: 409, @@ -715,6 +821,7 @@ export async function markAdminTaskManualReview(taskId) { const task = await getRequiredTask(taskId) const updatedTask = await updateTask(task.id, { task_status: 'manual_review', + delivery_status: task.delivery_status || 'pending', last_error: task.last_error || '已转人工处理', updated_at: nowIso(), }) @@ -724,7 +831,88 @@ export async function markAdminTaskManualReview(taskId) { } } -function mapAdminTaskSummary(task) { +export async function completeAdminTaskManualDispatch(taskId, payload = {}, session = null) { + const task = await getRequiredTask(taskId) + + if (!isManualDispatchTask(task)) { + throw createHttpError('当前任务不是人工履约任务', { + statusCode: 409, + errorCode: 'admin_task_not_manual_dispatch', + }) + } + + if (['redeemed', 'closed'].includes(task.task_status)) { + throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', { + statusCode: 409, + errorCode: 'admin_task_manual_dispatch_already_completed', + }) + } + + const now = nowIso() + const outcome = normalizeManualDispatchOutcome(payload.outcome) + const resultMessage = String(payload.resultMessage || '').trim() + const deliveryReference = String(payload.deliveryReference || '').trim() + const deliveredCredential = String(payload.deliveredCredential || '').trim() + const context = parseTaskContext(task) + const inventoryItemId = getTaskPrimaryInventoryItemId(task) + const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered' + const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成' + const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed' + const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered' + const nextInventoryStatus = outcome === 'delivered' && inventoryItemId + ? 'consumed' + : task.inventory_status || 'not_required' + const manualDispatch = { + outcome, + deliveryReference, + deliveredCredential, + resultMessage: resultMessage || fallbackMessage, + completedAt: now, + completedBy: session + ? { + userId: Number(session.userId || 0), + username: String(session.username || ''), + role: String(session.role || ''), + } + : null, + } + + if (outcome === 'delivered' && inventoryItemId) { + await markInventoryItemDelivered(inventoryItemId, now) + } + + const updatedTask = await updateTask(task.id, { + task_status: nextTaskStatus, + inventory_status: nextInventoryStatus, + delivery_status: nextDeliveryStatus, + result_code: resultCode, + result_message: resultMessage || fallbackMessage, + user_action_status: 'not_required', + last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '', + redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null, + context_json: JSON.stringify({ + ...context, + manualDispatch, + }), + updated_at: now, + }) + + await createTaskEvent(task.id, 'manual_dispatch_completed', { + outcome, + resultCode, + resultMessage: resultMessage || fallbackMessage, + deliveryReference, + deliveredCredentialMasked: maskCode(deliveredCredential), + completedBy: manualDispatch.completedBy, + }, now) + + return { + outcome, + task: mapTaskActionPayload(updatedTask), + } +} + +function mapAdminTaskSummary(task, bindingSummary = createEmptyTaskBindingSummary()) { const binding = buildTaskBindingState(task) return { @@ -740,6 +928,7 @@ function mapAdminTaskSummary(task) { redeemedAt: task.redeemed_at, lastError: task.last_error, retryCount: getTaskRetryCount(task), + bindingSummary, createdAt: task.created_at, updatedAt: task.updated_at, } @@ -883,17 +1072,17 @@ async function mapAdminWebhookEvent(item, { includeRaw = false } = {}) { } } -async function mapAdminCdkListItem(item) { +async function mapAdminInventoryListItem(item) { const task = item.reserved_by_task_id ? await getTaskById(item.reserved_by_task_id) : null const order = task?.order_id ? await getOrderById(task.order_id) : null const binding = buildTaskBindingState(task) return { - cdkId: item.id, + inventoryItemId: item.id, skuCode: item.sku_code, batchNo: item.batch_no, - cdkCode: item.cdk_code, credentialType: item.credential_type || 'tencent_code', + displayValue: item.display_value, status: item.status, reservedByTaskId: item.reserved_by_task_id, reservedByTaskNo: task?.task_no || '', @@ -909,15 +1098,17 @@ async function mapAdminCdkListItem(item) { function mapTaskActionPayload(task) { const inventoryItemId = getTaskPrimaryInventoryItemId(task) - const claimTokenId = getTaskPrimaryClaimTokenId(task) + const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) return { taskId: task.id, taskNo: task.task_no, status: task.task_status, + deliveryStatus: task.delivery_status, + resultCode: task.result_code, + resultMessage: task.result_message, inventoryItemId, - claimTokenId, - reservedCdkId: inventoryItemId, + primaryClaimTokenId, lastError: task.last_error, updatedAt: task.updated_at, } @@ -936,20 +1127,20 @@ async function getRequiredTask(taskId) { return task } -async function getRequiredCdk(cdkId) { - const cdk = await getCdkById(Number(cdkId)) +async function getRequiredInventoryItem(inventoryItemId) { + const inventoryItem = await getInventoryItemById(Number(inventoryItemId)) - if (!cdk) { - throw createHttpError('CDK 不存在', { + if (!inventoryItem) { + throw createHttpError('库存项不存在', { statusCode: 404, - errorCode: 'admin_cdk_not_found', + errorCode: 'admin_inventory_not_found', }) } - return cdk + return inventoryItem } -function mapAdminTaskListItem(task) { +function mapAdminTaskListItem(task, bindingSummary = createEmptyTaskBindingSummary()) { const binding = buildTaskBindingState(task) return { @@ -959,6 +1150,10 @@ function mapAdminTaskListItem(task) { skuCode: task.sku_code || '', skuName: task.sku_name || '', status: task.task_status, + executorKey: task.executor_key || '', + deliveryStatus: task.delivery_status || '', + resultCode: task.result_code || '', + resultMessage: task.result_message || '', systemBindingStatus: binding.systemBindingStatus, userBindingStatus: binding.userBindingStatus, loginType: task.login_type, @@ -969,21 +1164,120 @@ function mapAdminTaskListItem(task) { roleConfirmedAt: task.role_confirmed_at, redeemedAt: task.redeemed_at, retryCount: getTaskRetryCount(task), + bindingSummary, lastError: task.last_error, createdAt: task.created_at, updatedAt: task.updated_at, - reservedCdkCodeMasked: maskCode(task.primary_inventory_display_value), + inventoryDisplayMasked: maskCode(task.primary_inventory_display_value), + inventoryCredentialType: String(task.primary_inventory_credential_type || '').trim(), claimToken: task.primary_claim_token || task.claim_token || '', screenshotPath: task.screenshot_path || '', } } +function mapAdminTaskEvent(event) { + const payload = safeParseJson(event.payload_json) + + return { + eventId: event.id, + eventType: String(event.event_type || '').trim(), + payload: normalizeRecord(payload), + createdAt: event.created_at, + } +} + +function createEmptyTaskBindingSummary() { + return { + totalBindingCount: 0, + reservedBindingCount: 0, + consumedBindingCount: 0, + releasedBindingCount: 0, + roleKeys: [], + } +} + +async function getTaskBindingSummaryMap(taskIds = []) { + const rows = await listTaskInventoryBindingSummariesByTaskIds(taskIds) + const output = new Map() + + for (const row of rows) { + output.set(Number(row.task_id), { + totalBindingCount: Number(row.total_binding_count || 0), + reservedBindingCount: Number(row.reserved_binding_count || 0), + consumedBindingCount: Number(row.consumed_binding_count || 0), + releasedBindingCount: Number(row.released_binding_count || 0), + roleKeys: Array.isArray(row.role_keys) + ? row.role_keys.map((value) => String(value || '').trim()).filter(Boolean) + : [], + }) + } + + return output +} + +function getTaskBindingSummary(summaryMap, taskId) { + return summaryMap.get(Number(taskId)) || createEmptyTaskBindingSummary() +} + +function createTaskBindingSummaryFromBindings(bindings = []) { + const normalizedBindings = Array.isArray(bindings) ? bindings : [] + const roleKeys = Array.from(new Set(normalizedBindings + .map((binding) => String(binding?.role_key || '').trim()) + .filter(Boolean))) + + return { + totalBindingCount: normalizedBindings.length, + reservedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'reserved').length, + consumedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'consumed').length, + releasedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'released').length, + roleKeys, + } +} + +function mapAdminTaskInventoryBinding(binding, task) { + const metadata = safeParseJson(binding.metadata_json) + + return { + bindingId: binding.id, + inventoryItemId: binding.inventory_item_id, + roleKey: String(binding.role_key || '').trim(), + quantity: Math.max(1, Number(binding.quantity || 1)), + bindingStatus: String(binding.binding_status || '').trim(), + inventoryStatus: String(binding.inventory_item_status || '').trim(), + skuCode: String(binding.sku_code || '').trim(), + batchNo: String(binding.batch_no || '').trim(), + credentialType: String(binding.credential_type || 'tencent_code').trim() || 'tencent_code', + displayValue: String(binding.display_value || '').trim(), + invalidReason: String(binding.invalid_reason || '').trim(), + consumedAt: binding.consumed_at || null, + releasedAt: binding.released_at || null, + createdAt: binding.created_at, + updatedAt: binding.updated_at, + metadata: normalizeRecord(metadata), + isPrimary: Number(binding.inventory_item_id || 0) === getTaskPrimaryInventoryItemId(task), + canRelease: canReleaseTaskInventoryBinding(task, binding), + } +} + +function canReleaseTaskInventoryBinding(task, binding) { + if (!task || !binding) { + return false + } + + if (String(binding.binding_status || '').trim() !== 'reserved') { + return false + } + + return !['redeemed', 'expired'].includes(String(task.task_status || '').trim()) +} + async function mapAdminOrderListItem(item) { const [tasks, orderItems] = await Promise.all([ listTasksByOrderId(item.id), listOrderItemsByOrderId(item.id), ]) - const bindingSummary = buildOrderBindingSummary(tasks) + const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id)) + const bindingSummary = buildOrderBindingSummary(tasks, taskBindingSummaryMap) const itemSummary = summarizeOrderItems(orderItems) return { @@ -1012,6 +1306,10 @@ async function mapAdminOrderListItem(item) { userBindingStatus: bindingSummary.userBindingStatus, systemBoundTaskCount: bindingSummary.systemBoundTaskCount, completedBindingTaskCount: bindingSummary.completedBindingTaskCount, + totalBindingCount: bindingSummary.totalBindingCount, + reservedBindingCount: bindingSummary.reservedBindingCount, + consumedBindingCount: bindingSummary.consumedBindingCount, + releasedBindingCount: bindingSummary.releasedBindingCount, } } @@ -1070,12 +1368,17 @@ function resolveOrderItemDeliveryMode(tasks, orderItemId) { return String(task.executor_key || '').trim() } -function buildOrderBindingSummary(tasks) { +function buildOrderBindingSummary(tasks, taskBindingSummaryMap = new Map()) { const normalizedTasks = Array.isArray(tasks) ? tasks : [] const totalTaskCount = normalizedTasks.length const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task)) const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length + const bindingSummaries = normalizedTasks.map((task) => getTaskBindingSummary(taskBindingSummaryMap, task.id)) + const totalBindingCount = bindingSummaries.reduce((sum, item) => sum + item.totalBindingCount, 0) + const reservedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.reservedBindingCount, 0) + const consumedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.consumedBindingCount, 0) + const releasedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.releasedBindingCount, 0) let systemBindingStatus = 'pending_binding' let userBindingStatus = 'not_started' @@ -1085,6 +1388,10 @@ function buildOrderBindingSummary(tasks) { totalTaskCount, systemBoundTaskCount, completedBindingTaskCount, + totalBindingCount, + reservedBindingCount, + consumedBindingCount, + releasedBindingCount, systemBindingStatus, userBindingStatus, } @@ -1112,6 +1419,10 @@ function buildOrderBindingSummary(tasks) { totalTaskCount, systemBoundTaskCount, completedBindingTaskCount, + totalBindingCount, + reservedBindingCount, + consumedBindingCount, + releasedBindingCount, systemBindingStatus, userBindingStatus, } @@ -1187,13 +1498,58 @@ function isTaskSystemBound(task) { } function parseTaskContext(task) { + const value = task?.context_json + + if (!value) { + return {} + } + + if (typeof value === 'object') { + return value + } + try { - return JSON.parse(String(task?.context_json || '{}')) + return JSON.parse(String(value || '{}')) } catch { return {} } } +function mapManualDispatchContext(value) { + if (!value || typeof value !== 'object') { + return null + } + + return { + outcome: String(value.outcome || '').trim(), + deliveryReference: String(value.deliveryReference || '').trim(), + deliveredCredential: String(value.deliveredCredential || '').trim(), + resultMessage: String(value.resultMessage || '').trim(), + completedAt: value.completedAt || null, + completedBy: value.completedBy && typeof value.completedBy === 'object' + ? { + userId: Number(value.completedBy.userId || 0) || 0, + username: String(value.completedBy.username || '').trim(), + role: String(value.completedBy.role || '').trim(), + } + : null, + } +} + +function normalizeManualDispatchOutcome(value) { + const normalized = String(value || '').trim().toLowerCase() + + if (normalized === 'failed') { + return 'failed' + } + + return 'delivered' +} + +function isManualDispatchTask(task) { + return String(task?.executor_key || '').trim() === 'manual_dispatch' +} + function getTaskPrimaryInventoryItemId(task) { const value = Number(task?.primary_inventory_item_id || 0) return Number.isFinite(value) && value > 0 ? value : null @@ -1233,7 +1589,7 @@ function resolveDisplayShopName(provider, shopId, shopName) { return normalizedShopId } -function normalizeCdkImportRows(payload) { +function normalizeInventoryImportRows(payload) { const rows = [] const directRows = Array.isArray(payload.rows) ? payload.rows : [] const bulkCodes = Array.isArray(payload.codes) ? payload.codes : [] @@ -1241,15 +1597,15 @@ function normalizeCdkImportRows(payload) { if (directRows.length > 0) { for (const row of directRows) { const skuCode = String(row?.skuCode || '').trim() - const cdkCode = String(row?.cdkCode || '').trim() + const displayValue = String(row?.displayValue || '').trim() const batchNo = String(row?.batchNo || '').trim() const credentialType = String(row?.credentialType || payload.credentialType || 'tencent_code').trim() || 'tencent_code' - if (!skuCode || !cdkCode) { + if (!skuCode || !displayValue) { continue } - rows.push({ skuCode, cdkCode, batchNo, credentialType }) + rows.push({ skuCode, displayValue, batchNo, credentialType }) } } @@ -1261,16 +1617,16 @@ function normalizeCdkImportRows(payload) { if (!skuCode) { throw createHttpError('批量导入时缺少 skuCode', { statusCode: 400, - errorCode: 'admin_cdk_import_missing_sku', + errorCode: 'admin_inventory_import_missing_sku', }) } - for (const code of bulkCodes) { - const cdkCode = String(code || '').trim() - if (!cdkCode) { + for (const rawValue of bulkCodes) { + const displayValue = String(rawValue || '').trim() + if (!displayValue) { continue } - rows.push({ skuCode, cdkCode, batchNo, credentialType }) + rows.push({ skuCode, displayValue, batchNo, credentialType }) } } @@ -1282,7 +1638,7 @@ function dedupeRows(rows) { const output = [] for (const row of rows) { - const key = `${row.skuCode}::${row.credentialType || 'tencent_code'}::${row.cdkCode}` + const key = `${row.skuCode}::${row.credentialType || 'tencent_code'}::${row.displayValue}` if (seen.has(key)) { continue } diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js index aecae0e1..b5718d8c 100644 --- a/apps/backend/src/services/claim/claim-session-service.js +++ b/apps/backend/src/services/claim/claim-session-service.js @@ -9,7 +9,11 @@ import { findClaimTokenByToken, updateClaimToken } from '../../repositories/clai import { getOrderById } from '../../repositories/order-repo.js' import { getOrderItemById } from '../../repositories/order-item-repo.js' import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js' -import { getCdkById, markCdkDelivered, releaseReservedCdk } from '../../repositories/cdk-repo.js' +import { + getInventoryItemById, + markInventoryItemDelivered, + releaseReservedInventoryItem, +} from '../../repositories/inventory-repo.js' import { buildClaimUrl } from './claim-service.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' @@ -151,12 +155,14 @@ export async function redeemClaimTask(token) { }) } - const cdk = context.task.primary_inventory_item_id ? await getCdkById(context.task.primary_inventory_item_id) : null + const inventoryItem = context.task.primary_inventory_item_id + ? await getInventoryItemById(context.task.primary_inventory_item_id) + : null - if (!cdk || !String(cdk.cdk_code || '').trim()) { - throw createHttpError('当前任务没有可用的预占 CDK', { + if (!inventoryItem || !String(inventoryItem.display_value || '').trim()) { + throw createHttpError('当前任务没有可用的预占库存凭据', { statusCode: 409, - errorCode: 'claim_cdk_not_reserved', + errorCode: 'claim_inventory_not_reserved', }) } @@ -169,7 +175,7 @@ export async function redeemClaimTask(token) { try { const session = await redeemTencentBrowserSession(context.task.browser_session_id, { - code: cdk.cdk_code, + code: inventoryItem.display_value, }) const finalRedeem = session.redeem?.final?.redeem || null const updatedTask = await updateTask(context.task.id, { @@ -185,7 +191,7 @@ export async function redeemClaimTask(token) { last_error: '', }) - await markCdkDelivered(cdk.id, nowIso()) + await markInventoryItemDelivered(inventoryItem.id, nowIso()) return buildClaimDetailPayload({ claimToken: context.claimToken, @@ -312,14 +318,14 @@ async function expireClaimContext(claimToken, task) { if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') { if (task.primary_inventory_item_id) { - await releaseReservedCdk(task.primary_inventory_item_id, now) + await releaseReservedInventoryItem(task.primary_inventory_item_id, now) } nextTask = await updateTask(task.id, { task_status: 'expired', inventory_status: 'pending', user_action_status: 'expired', - last_error: '领取链接已过期,预占 CDK 已释放', + last_error: '领取链接已过期,预占库存项已释放', updated_at: now, }) } diff --git a/apps/backend/src/services/order/cdk-service.js b/apps/backend/src/services/order/cdk-service.js deleted file mode 100644 index fec2ba32..00000000 --- a/apps/backend/src/services/order/cdk-service.js +++ /dev/null @@ -1,33 +0,0 @@ -import { assignReservedCdk, findFirstAvailableCdkBySkuCode, releaseReservedCdk } from '../../repositories/cdk-repo.js' -import { nowIso } from '../../utils/time.js' - -export async function reserveCdkForTask({ skuCode, taskId, credentialType = 'tencent_code', roleKey = 'primary_code' }) { - if (!skuCode) { - return null - } - - const available = await findFirstAvailableCdkBySkuCode(skuCode, credentialType) - - if (!available) { - return null - } - - return assignReservedCdk(available.id, taskId, nowIso(), roleKey) -} - -export async function releaseReservedCdks(cdkIds = [], updatedAt = nowIso()) { - const released = [] - - for (const cdkId of cdkIds) { - if (!cdkId) { - continue - } - - const item = await releaseReservedCdk(cdkId, updatedAt) - if (item) { - released.push(item) - } - } - - return released -} diff --git a/apps/backend/src/services/order/delivery-task-service.js b/apps/backend/src/services/order/delivery-task-service.js index 2a3dae99..1fee9c5a 100644 --- a/apps/backend/src/services/order/delivery-task-service.js +++ b/apps/backend/src/services/order/delivery-task-service.js @@ -4,7 +4,7 @@ import { listFulfillmentProfileRequirements, resolveFulfillmentBinding, } from '../../repositories/fulfillment-profile-repo.js' -import { reserveCdkForTask } from './cdk-service.js' +import { reserveInventoryForTask } from './inventory-service.js' import { createTaskClaimToken } from '../claim/claim-service.js' import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' @@ -125,7 +125,7 @@ async function preparePaidTask(task) { if (!task.skuCode) { return updateTask(task.id, { task_status: 'manual_review', - last_error: '未匹配到 SKU,无法为任务分配 CDK', + last_error: '未匹配到 SKU,无法为任务分配库存凭据', updated_at: now, }) } @@ -146,7 +146,7 @@ async function preparePaidTask(task) { }) } - const reserved = await reserveCdkForTask({ + const reserved = await reserveInventoryForTask({ skuCode: task.skuCode, taskId: task.id, credentialType: primaryRequirement.credentialType, @@ -157,7 +157,7 @@ async function preparePaidTask(task) { return updateTask(task.id, { task_status: 'waiting_inventory', inventory_status: 'pending', - last_error: '库存不足,等待可用 CDK', + last_error: '库存不足,等待可用库存凭据', updated_at: now, }) } @@ -188,8 +188,18 @@ function resolvePaidTaskStatus(profile) { } function parseTaskContext(task) { + const value = task?.context_json + + if (!value) { + return {} + } + + if (typeof value === 'object') { + return value + } + try { - return JSON.parse(String(task?.context_json || '{}')) + return JSON.parse(String(value || '{}')) } catch { return {} } diff --git a/apps/backend/src/services/order/inventory-service.js b/apps/backend/src/services/order/inventory-service.js new file mode 100644 index 00000000..9839de30 --- /dev/null +++ b/apps/backend/src/services/order/inventory-service.js @@ -0,0 +1,37 @@ +import { + assignReservedInventoryItem, + findFirstAvailableInventoryItemBySkuCode, + releaseReservedInventoryItem, +} from '../../repositories/inventory-repo.js' +import { nowIso } from '../../utils/time.js' + +export async function reserveInventoryForTask({ skuCode, taskId, credentialType = 'tencent_code', roleKey = 'primary_code' }) { + if (!skuCode) { + return null + } + + const available = await findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType) + + if (!available) { + return null + } + + return assignReservedInventoryItem(available.id, taskId, nowIso(), roleKey) +} + +export async function releaseReservedInventoryItems(inventoryItemIds = [], updatedAt = nowIso()) { + const released = [] + + for (const inventoryItemId of inventoryItemIds) { + if (!inventoryItemId) { + continue + } + + const item = await releaseReservedInventoryItem(inventoryItemId, updatedAt) + if (item) { + released.push(item) + } + } + + return released +} diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index 09541a56..b672e623 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -62,7 +62,11 @@ const router = createRouter({ }, { path: 'cdks', - component: () => import('@/views/admin/AdminCdksView.vue'), + redirect: '/admin/inventory', + }, + { + path: 'inventory', + component: () => import('@/views/admin/AdminInventoryView.vue'), }, { path: 'webhook-events', diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index 1963a7f0..d0273225 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -3,8 +3,8 @@ import type { AdminAgisoObservedShopItem, AdminAgisoShopConfigItem, AdminAuditLogItem, + AdminInventoryItemListItem, AdminTaskActionResponse, - AdminCdkListItem, AdminDashboardSummary, AdminLoginResponse, AdminOrderDetail, @@ -98,8 +98,12 @@ export function retryAdminTask(taskId: number | string) { return apiPost(`/api/v1/admin/tasks/${taskId}/retry`, {}) } -export function releaseAdminTaskCdk(taskId: number | string) { - return apiPost(`/api/v1/admin/tasks/${taskId}/release-cdk`, {}) +export function releaseAdminTaskInventory(taskId: number | string) { + return apiPost(`/api/v1/admin/tasks/${taskId}/release-inventory`, {}) +} + +export function releaseAdminTaskInventoryBinding(taskId: number | string, bindingId: number | string) { + return apiPost(`/api/v1/admin/tasks/${taskId}/inventory-bindings/${bindingId}/release`, {}) } export function regenerateAdminTaskClaimLink(taskId: number | string) { @@ -117,24 +121,36 @@ export function markAdminTaskManualReview(taskId: number | string) { return apiPost(`/api/v1/admin/tasks/${taskId}/mark-manual-review`, {}) } -export function fetchAdminCdks(params?: Record) { - return apiGet<{ items: AdminCdkListItem[]; pagination: AdminPagination }>('/api/v1/admin/cdks', params) +export function completeAdminTaskManualDispatch( + taskId: number | string, + payload: { + outcome: 'delivered' | 'failed' + resultMessage?: string + deliveryReference?: string + deliveredCredential?: string + }, +) { + return apiPost(`/api/v1/admin/tasks/${taskId}/complete-manual-dispatch`, payload) } -export function importAdminCdks(payload: Record) { - return apiPost<{ total: number; created: number; duplicated: number }>('/api/v1/admin/cdks/import', payload) +export function fetchAdminInventoryItems(params?: Record) { + return apiGet<{ items: AdminInventoryItemListItem[]; pagination: AdminPagination }>('/api/v1/admin/inventory', params) } -export function createAdminCdk(payload: Record) { - return apiPost<{ cdk: AdminCdkListItem | null }>('/api/v1/admin/cdks', payload) +export function importAdminInventoryItems(payload: Record) { + return apiPost<{ total: number; created: number; duplicated: number }>('/api/v1/admin/inventory/import', payload) } -export function releaseAdminInventoryCdk(cdkId: number | string) { - return apiPost<{ cdk: AdminCdkListItem | null }>(`/api/v1/admin/cdks/${cdkId}/release`, {}) +export function createAdminInventoryItem(payload: Record) { + return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>('/api/v1/admin/inventory', payload) } -export function invalidateAdminCdk(cdkId: number | string, payload?: Record) { - return apiPost<{ cdk: AdminCdkListItem | null }>(`/api/v1/admin/cdks/${cdkId}/invalidate`, payload || {}) +export function releaseAdminInventoryItem(inventoryItemId: number | string) { + return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>(`/api/v1/admin/inventory/${inventoryItemId}/release`, {}) +} + +export function invalidateAdminInventoryItem(inventoryItemId: number | string, payload?: Record) { + return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>(`/api/v1/admin/inventory/${inventoryItemId}/invalidate`, payload || {}) } export function fetchAdminWebhookEvents(params?: Record) { diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index ec3f2f03..126e4b9e 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -78,6 +78,26 @@ export interface AdminDashboardSummary { skuWithInventory: number } +export interface AdminTaskBindingSummary { + totalBindingCount: number + reservedBindingCount: number + consumedBindingCount: number + releasedBindingCount: number + roleKeys: string[] +} + +export interface AdminOrderBindingSummary { + totalTaskCount: number + systemBoundTaskCount: number + completedBindingTaskCount: number + totalBindingCount: number + reservedBindingCount: number + consumedBindingCount: number + releasedBindingCount: number + systemBindingStatus: string + userBindingStatus: string +} + export interface AdminOrderListItem { orderId: number provider: string @@ -104,6 +124,10 @@ export interface AdminOrderListItem { userBindingStatus: string systemBoundTaskCount: number completedBindingTaskCount: number + totalBindingCount: number + reservedBindingCount: number + consumedBindingCount: number + releasedBindingCount: number } export interface AdminTaskListItem { @@ -113,6 +137,10 @@ export interface AdminTaskListItem { skuCode: string skuName: string status: string + executorKey: string + deliveryStatus: string + resultCode: string + resultMessage: string systemBindingStatus: string userBindingStatus: string loginType: string @@ -123,20 +151,23 @@ export interface AdminTaskListItem { roleConfirmedAt: string | null redeemedAt: string | null retryCount: number + bindingSummary: AdminTaskBindingSummary lastError: string createdAt: string updatedAt: string - reservedCdkCodeMasked: string + inventoryDisplayMasked: string + inventoryCredentialType: string claimToken: string screenshotPath: string } export interface AdminTaskOperations { canRetry: boolean - canReleaseCdk: boolean + canReleaseInventory: boolean canRegenerateClaimLink: boolean canClose: boolean canMarkManualReview: boolean + canCompleteManualDispatch: boolean } export interface AdminTaskActionResponse { @@ -144,9 +175,11 @@ export interface AdminTaskActionResponse { taskId: number taskNo: string status: string + deliveryStatus: string + resultCode: string + resultMessage: string inventoryItemId: number | null - claimTokenId: number | null - reservedCdkId?: number | null + primaryClaimTokenId: number | null lastError: string updatedAt: string } @@ -175,13 +208,7 @@ export interface AdminOrderDetail { updatedAt: string itemSummary: string rawPayload: Record - bindingSummary: { - totalTaskCount: number - systemBoundTaskCount: number - completedBindingTaskCount: number - systemBindingStatus: string - userBindingStatus: string - } + bindingSummary: AdminOrderBindingSummary } items: Array<{ orderItemId: number @@ -205,6 +232,7 @@ export interface AdminOrderDetail { redeemedAt: string | null lastError: string retryCount: number + bindingSummary: AdminTaskBindingSummary createdAt: string updatedAt: string }> @@ -238,28 +266,68 @@ export interface AdminTaskDetail { quantity: number } claimToken: null | { - claimTokenId: number + primaryClaimTokenId: number token: string status: string expiredAt: string } - cdk: null | { - cdkId: number + inventory: null | { + inventoryItemId: number skuCode: string batchNo: string - cdkCode: string + credentialType: string + displayValue: string status: string } + inventoryBindings: Array<{ + bindingId: number + inventoryItemId: number + roleKey: string + quantity: number + bindingStatus: string + inventoryStatus: string + skuCode: string + batchNo: string + credentialType: string + displayValue: string + invalidReason: string + consumedAt: string | null + releasedAt: string | null + createdAt: string + updatedAt: string + metadata: Record + isPrimary: boolean + canRelease: boolean + }> artifacts: Record screenshotUrl: string + manualDispatch: null | { + outcome: string + deliveryReference: string + deliveredCredential: string + resultMessage: string + completedAt: string | null + completedBy: null | { + userId: number + username: string + role: string + } + } + events: Array<{ + eventId: number + eventType: string + payload: Record + createdAt: string + }> operations: AdminTaskOperations } -export interface AdminCdkListItem { - cdkId: number +export interface AdminInventoryItemListItem { + inventoryItemId: number skuCode: string batchNo: string - cdkCode: string + credentialType: string + displayValue: string status: string reservedByTaskId: number | null reservedByTaskNo: string diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index e0627d11..44e2a855 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -21,12 +21,14 @@ export function formatAuditAction(action: string) { admin_user_role_updated: '修改用户角色', admin_user_status_updated: '修改用户状态', admin_user_password_reset: '重置用户密码', - task_release_cdk: '释放任务 CDK', + task_release_inventory: '释放任务库存', + task_release_inventory_binding: '释放任务库存绑定', task_regenerate_claim_link: '重发领取链接', task_closed: '关闭任务', task_mark_manual_review: '转人工处理', - inventory_cdk_released: '释放库存 CDK', - inventory_cdk_invalidated: '作废库存 CDK', + task_complete_manual_dispatch: '完成人工履约', + inventory_item_released: '释放库存项', + inventory_item_invalidated: '作废库存项', webhook_replayed: '重放 Webhook', platform_shop_config_updated: '更新店铺配置', } @@ -38,10 +40,20 @@ export function formatAuditTargetType(targetType: string) { const labelMap: Record = { admin_user: '后台用户', task: '交付任务', - cdk: 'CDK 库存', + task_inventory_binding: '任务库存绑定', + inventory_item: '库存项', webhook_event: 'Webhook 事件', platform_config: '平台配置', } return formatStatusWithRaw(targetType, labelMap) } + +export function formatTaskEventType(eventType: string) { + const labelMap: Record = { + manual_dispatch_completed: '人工履约已回写', + inventory_binding_released: '库存绑定已释放', + } + + return formatStatusWithRaw(eventType, labelMap) +} diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index f236d40c..0f709720 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -23,7 +23,7 @@ export const adminTaskStatusOptions = [ { label: '已关闭', value: 'closed' }, ] -export const adminCdkStatusOptions = [ +export const adminInventoryStatusOptions = [ { label: '全部', value: '' }, { label: '可用', value: 'available' }, { label: '已预占', value: 'reserved' }, @@ -55,12 +55,12 @@ export const adminAuditActionOptions = [ { label: '修改用户角色', value: 'admin_user_role_updated' }, { label: '修改用户状态', value: 'admin_user_status_updated' }, { label: '重置用户密码', value: 'admin_user_password_reset' }, - { label: '释放任务 CDK', value: 'task_release_cdk' }, + { label: '释放任务库存', value: 'task_release_inventory' }, { label: '重发领取链接', value: 'task_regenerate_claim_link' }, { label: '关闭任务', value: 'task_closed' }, { label: '转人工处理', value: 'task_mark_manual_review' }, - { label: '释放库存 CDK', value: 'inventory_cdk_released' }, - { label: '作废库存 CDK', value: 'inventory_cdk_invalidated' }, + { label: '释放库存项', value: 'inventory_item_released' }, + { label: '作废库存项', value: 'inventory_item_invalidated' }, { label: '重放 Webhook', value: 'webhook_replayed' }, ] @@ -68,6 +68,6 @@ export const adminAuditTargetTypeOptions = [ { label: '全部', value: '' }, { label: '后台用户', value: 'admin_user' }, { label: '交付任务', value: 'task' }, - { label: 'CDK 库存', value: 'cdk' }, + { label: '库存项', value: 'inventory_item' }, { label: 'Webhook 事件', value: 'webhook_event' }, ] diff --git a/apps/frontend/src/views/admin/AdminCdksView.vue b/apps/frontend/src/views/admin/AdminInventoryView.vue similarity index 70% rename from apps/frontend/src/views/admin/AdminCdksView.vue rename to apps/frontend/src/views/admin/AdminInventoryView.vue index 9912b920..07c29687 100644 --- a/apps/frontend/src/views/admin/AdminCdksView.vue +++ b/apps/frontend/src/views/admin/AdminInventoryView.vue @@ -4,10 +4,10 @@ import { ElMessage, ElMessageBox } from 'element-plus' import AdminPaginationBar from '@/components/admin/AdminPaginationBar.vue' import AdminStatusTag from '@/components/admin/AdminStatusTag.vue' -import { createAdminCdk, fetchAdminCdks, importAdminCdks, invalidateAdminCdk, releaseAdminInventoryCdk } from '@/services/admin' -import type { AdminCdkListItem, AdminPagination } from '@/types/admin' +import { createAdminInventoryItem, fetchAdminInventoryItems, importAdminInventoryItems, invalidateAdminInventoryItem, releaseAdminInventoryItem } from '@/services/admin' +import type { AdminInventoryItemListItem, AdminPagination } from '@/types/admin' import { hasAdminRole } from '@/utils/admin-auth' -import { adminCdkStatusOptions } from '@/utils/admin-options' +import { adminInventoryStatusOptions } from '@/utils/admin-options' const loading = ref(true) const importing = ref(false) @@ -18,20 +18,20 @@ const skuCode = ref('') const status = ref('') const batchNo = ref('') const bulkText = ref('') -const singleCdkCode = ref('') -const items = ref([]) +const singleDisplayValue = ref('') +const items = ref([]) const pagination = ref({ page: 1, pageSize: 20, total: 0, }) -async function loadCdks(page = pagination.value.page) { +async function loadInventoryItems(page = pagination.value.page) { loading.value = true errorMessage.value = '' try { - const response = await fetchAdminCdks({ + const response = await fetchAdminInventoryItems({ page, pageSize: pagination.value.pageSize, skuCode: skuCode.value.trim(), @@ -41,32 +41,32 @@ async function loadCdks(page = pagination.value.page) { items.value = response.data.items pagination.value = response.data.pagination } catch (error) { - errorMessage.value = error instanceof Error ? error.message : '读取 CDK 列表失败' + errorMessage.value = error instanceof Error ? error.message : '读取库存列表失败' } finally { loading.value = false } } async function submitCreate() { - if (!skuCode.value.trim() || !singleCdkCode.value.trim()) { - ElMessage.error('请填写 SKU 和单条 CDK') + if (!skuCode.value.trim() || !singleDisplayValue.value.trim()) { + ElMessage.error('请填写 SKU 和库存凭据') return } creating.value = true try { - await createAdminCdk({ + await createAdminInventoryItem({ skuCode: skuCode.value.trim(), batchNo: batchNo.value.trim(), - cdkCode: singleCdkCode.value.trim(), + displayValue: singleDisplayValue.value.trim(), }) - ElMessage.success('CDK 已新增') - singleCdkCode.value = '' - await loadCdks() + ElMessage.success('库存项已新增') + singleDisplayValue.value = '' + await loadInventoryItems() } catch (error) { - ElMessage.error(error instanceof Error ? error.message : '新增 CDK 失败') + ElMessage.error(error instanceof Error ? error.message : '新增库存项失败') } finally { creating.value = false } @@ -79,14 +79,14 @@ async function submitImport() { .filter(Boolean) if (!skuCode.value.trim() || codes.length === 0) { - ElMessage.error('请填写 SKU 并输入至少一条 CDK') + ElMessage.error('请填写 SKU 并输入至少一条库存凭据') return } importing.value = true try { - const response = await importAdminCdks({ + const response = await importAdminInventoryItems({ skuCode: skuCode.value.trim(), batchNo: batchNo.value.trim(), codes, @@ -94,7 +94,7 @@ async function submitImport() { ElMessage.success(`导入完成,新增 ${response.data.created} 条`) bulkText.value = '' - await loadCdks() + await loadInventoryItems() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '导入失败') } finally { @@ -102,7 +102,7 @@ async function submitImport() { } } -async function runRowAction(cdkId: number, action: () => Promise, successMessage: string, confirmText: string) { +async function runRowAction(inventoryItemId: number, action: () => Promise, successMessage: string, confirmText: string) { try { await ElMessageBox.confirm(confirmText, '确认操作', { type: 'warning', @@ -113,12 +113,12 @@ async function runRowAction(cdkId: number, action: () => Promise, succe return } - actionLoadingId.value = cdkId + actionLoadingId.value = inventoryItemId try { await action() ElMessage.success(successMessage) - await loadCdks() + await loadInventoryItems() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '操作失败') } finally { @@ -126,15 +126,15 @@ async function runRowAction(cdkId: number, action: () => Promise, succe } } -onMounted(loadCdks) +onMounted(loadInventoryItems)