diff --git a/apps/backend/src/db/migrations/004_inventory_group_bindings.sql b/apps/backend/src/db/migrations/004_inventory_group_bindings.sql new file mode 100644 index 00000000..6a3a7a27 --- /dev/null +++ b/apps/backend/src/db/migrations/004_inventory_group_bindings.sql @@ -0,0 +1,17 @@ +ALTER TABLE inventory_items + ADD COLUMN IF NOT EXISTS inventory_group_code TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_inventory_items_group_lookup + ON inventory_items(sku_code, credential_type, inventory_group_code, status, id); + +CREATE TABLE IF NOT EXISTS admin_user_inventory_group_bindings ( + id BIGSERIAL PRIMARY KEY, + admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + inventory_group_code TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(admin_user_id, inventory_group_code) +); + +CREATE INDEX IF NOT EXISTS idx_admin_user_inventory_group_bindings_user_id + ON admin_user_inventory_group_bindings(admin_user_id, inventory_group_code); diff --git a/apps/backend/src/repositories/admin-user-repo.js b/apps/backend/src/repositories/admin-user-repo.js index b7afff20..763ad865 100644 --- a/apps/backend/src/repositories/admin-user-repo.js +++ b/apps/backend/src/repositories/admin-user-repo.js @@ -1,8 +1,20 @@ -import { query } from '../db/client.js' +import { query, withTransaction } from '../db/client.js' + +const ADMIN_USER_SELECT = ` + SELECT + au.*, + COALESCE(bindings.inventory_group_codes, ARRAY[]::text[]) AS inventory_group_codes + FROM admin_users au + LEFT JOIN LATERAL ( + SELECT ARRAY_AGG(auigb.inventory_group_code ORDER BY auigb.inventory_group_code) AS inventory_group_codes + FROM admin_user_inventory_group_bindings auigb + WHERE auigb.admin_user_id = au.id + ) bindings ON TRUE +` export async function getAdminUserById(userId) { const result = await query( - 'SELECT * FROM admin_users WHERE id = $1 LIMIT 1', + `${ADMIN_USER_SELECT} WHERE au.id = $1 LIMIT 1`, [Number(userId)], ) return result.rows[0] || null @@ -10,7 +22,7 @@ export async function getAdminUserById(userId) { export async function getAdminUserByUsername(username) { const result = await query( - 'SELECT * FROM admin_users WHERE username = $1 LIMIT 1', + `${ADMIN_USER_SELECT} WHERE au.username = $1 LIMIT 1`, [String(username || '').trim().toLowerCase()], ) return result.rows[0] || null @@ -27,7 +39,7 @@ export async function createAdminUser(input) { created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6) - RETURNING * + RETURNING id `, [ input.username, @@ -39,7 +51,7 @@ export async function createAdminUser(input) { ], ) - return result.rows[0] || null + return getAdminUserById(result.rows[0]?.id || 0) } export async function updateAdminUser(userId, patch) { @@ -59,7 +71,7 @@ export async function updateAdminUser(userId, patch) { status = $4, updated_at = $5 WHERE id = $6 - RETURNING * + RETURNING id `, [ next.username, @@ -71,7 +83,7 @@ export async function updateAdminUser(userId, patch) { ], ) - return result.rows[0] || null + return getAdminUserById(result.rows[0]?.id || 0) } export async function listAdminUsers({ page = 1, pageSize = 20, username = '', role = '', status = '' } = {}) { @@ -101,10 +113,9 @@ export async function listAdminUsers({ page = 1, pageSize = 20, username = '', r params.push(offset) const itemsResult = await query( ` - SELECT * - FROM admin_users + ${ADMIN_USER_SELECT} ${whereClause} - ORDER BY id DESC + ORDER BY au.id DESC LIMIT $${params.length - 1} OFFSET $${params.length} `, params, @@ -122,3 +133,35 @@ export async function countActiveAdminUsers() { ) return Number(result.rows[0]?.total || 0) } + +export async function replaceAdminUserInventoryGroupBindings(userId, inventoryGroupCodes = [], timestamp) { + const normalizedUserId = Number(userId) + const normalizedCodes = Array.from(new Set((Array.isArray(inventoryGroupCodes) ? inventoryGroupCodes : []) + .map((value) => String(value || '').trim()) + .filter(Boolean))) + + await withTransaction(async (client) => { + await client.query( + 'DELETE FROM admin_user_inventory_group_bindings WHERE admin_user_id = $1', + [normalizedUserId], + ) + + for (const inventoryGroupCode of normalizedCodes) { + await client.query( + ` + INSERT INTO admin_user_inventory_group_bindings ( + admin_user_id, + inventory_group_code, + created_at, + updated_at + ) VALUES ($1, $2, $3, $4) + ON CONFLICT (admin_user_id, inventory_group_code) DO UPDATE + SET updated_at = EXCLUDED.updated_at + `, + [normalizedUserId, inventoryGroupCode, timestamp, timestamp], + ) + } + }) + + return getAdminUserById(normalizedUserId) +} diff --git a/apps/backend/src/repositories/inventory-repo.js b/apps/backend/src/repositories/inventory-repo.js index 25b83f30..6e00781f 100644 --- a/apps/backend/src/repositories/inventory-repo.js +++ b/apps/backend/src/repositories/inventory-repo.js @@ -15,6 +15,7 @@ const INVENTORY_ITEM_SELECT = ` ii.sku_code, ii.batch_no, ii.credential_type, + ii.inventory_group_code, ii.display_value, ii.status, ii.invalid_reason, @@ -36,13 +37,34 @@ const INVENTORY_ITEM_SELECT = ` ` /** @returns {Promise} */ -export async function findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType = 'tencent_code') { +export async function findFirstAvailableInventoryItemBySkuCode( + skuCode, + credentialType = 'tencent_code', + inventoryGroupCodes = null, +) { + const normalizedInventoryGroupCodes = normalizeInventoryGroupCodes(inventoryGroupCodes) + if (normalizedInventoryGroupCodes && normalizedInventoryGroupCodes.length === 0) { + return null + } + + const params = [skuCode, credentialType] + const filters = [ + 'ii.sku_code = $1', + 'ii.credential_type = $2', + `ii.status = 'available'`, + ] + + if (normalizedInventoryGroupCodes) { + params.push(normalizedInventoryGroupCodes) + filters.push(`ii.inventory_group_code = ANY($${params.length}::text[])`) + } + const result = await query( `${INVENTORY_ITEM_SELECT} - WHERE ii.sku_code = $1 AND ii.credential_type = $2 AND ii.status = 'available' + WHERE ${filters.join(' AND ')} ORDER BY ii.id ASC LIMIT 1`, - [skuCode, credentialType], + params, ) return /** @type {InventoryItemRow | null} */ (result.rows[0] || null) @@ -162,7 +184,17 @@ export async function listInventoryItems({ credentialType = '', status = '', batchNo = '', + inventoryGroupCode = '', + allowedInventoryGroupCodes = null, } = /** @type {InventoryListQueryInput} */ ({})) { + const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes) + if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) { + return { + items: [], + total: 0, + } + } + const offset = (page - 1) * pageSize const filters = [] const params = [] @@ -187,6 +219,16 @@ export async function listInventoryItems({ filters.push(`ii.batch_no ILIKE $${params.length}`) } + if (inventoryGroupCode) { + params.push(inventoryGroupCode) + filters.push(`ii.inventory_group_code = $${params.length}`) + } + + if (normalizedAllowedInventoryGroupCodes) { + params.push(normalizedAllowedInventoryGroupCodes) + filters.push(`ii.inventory_group_code = ANY($${params.length}::text[])`) + } + const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' const totalResult = await query( `SELECT COUNT(*)::int AS total FROM inventory_items ii ${whereClause}`, @@ -215,7 +257,14 @@ export async function listInventorySkuSuggestions({ credentialType = '', keyword = '', limit = 50, + inventoryGroupCode = '', + allowedInventoryGroupCodes = null, } = /** @type {InventorySkuSuggestionQueryInput} */ ({})) { + const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes) + if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) { + return [] + } + const filters = [] const params = [] @@ -229,6 +278,16 @@ export async function listInventorySkuSuggestions({ filters.push(`sku_code ILIKE $${params.length}`) } + if (inventoryGroupCode) { + params.push(inventoryGroupCode) + filters.push(`inventory_group_code = $${params.length}`) + } + + if (normalizedAllowedInventoryGroupCodes) { + params.push(normalizedAllowedInventoryGroupCodes) + filters.push(`inventory_group_code = ANY($${params.length}::text[])`) + } + const normalizedLimit = Number.isFinite(Number(limit)) ? Math.max(1, Math.min(Number(limit), 100)) : 50 @@ -240,17 +299,19 @@ export async function listInventorySkuSuggestions({ SELECT sku_code, credential_type, + inventory_group_code, COUNT(*)::int AS total_count, COUNT(*) FILTER (WHERE status = 'available')::int AS available_count, MAX(updated_at) AS latest_updated_at FROM inventory_items ${whereClause} - GROUP BY sku_code, credential_type + GROUP BY sku_code, credential_type, inventory_group_code ORDER BY COUNT(*) FILTER (WHERE status = 'available') DESC, COUNT(*) DESC, MAX(updated_at) DESC, - sku_code ASC + sku_code ASC, + inventory_group_code ASC LIMIT $${params.length} `, params, @@ -266,12 +327,14 @@ export async function createInventoryItems(rows) { for (const row of rows) { const payloadJson = JSON.stringify(row.payload || { code: row.displayValue }) const displayValue = String(row.displayValue || '').trim() + const inventoryGroupCode = String(row.inventoryGroupCode || '').trim() const result = await query( ` INSERT INTO inventory_items ( batch_no, sku_code, credential_type, + inventory_group_code, display_value, payload_json, source_type, @@ -280,7 +343,7 @@ export async function createInventoryItems(rows) { metadata_json, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5::jsonb, 'static_import', 'available', '', '{}'::jsonb, $6, $7) + ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'static_import', 'available', '', '{}'::jsonb, $7, $8) ON CONFLICT DO NOTHING RETURNING id `, @@ -288,6 +351,7 @@ export async function createInventoryItems(rows) { row.batchNo || '', row.skuCode, row.credentialType || 'tencent_code', + inventoryGroupCode, displayValue, payloadJson, row.createdAt, @@ -303,6 +367,48 @@ export async function createInventoryItems(rows) { return created } +export async function listInventoryGroupCodes({ + keyword = '', + limit = 100, +} = {}) { + const params = [] + const filters = [`inventory_group_code <> ''`] + + if (keyword) { + params.push(`%${String(keyword || '').trim()}%`) + filters.push(`inventory_group_code ILIKE $${params.length}`) + } + + const normalizedLimit = Number.isFinite(Number(limit)) + ? Math.max(1, Math.min(Number(limit), 200)) + : 100 + + params.push(normalizedLimit) + const result = await query( + ` + SELECT inventory_group_code + FROM inventory_items + WHERE ${filters.join(' AND ')} + GROUP BY inventory_group_code + ORDER BY inventory_group_code ASC + LIMIT $${params.length} + `, + params, + ) + + return result.rows.map((row) => String(row.inventory_group_code || '').trim()).filter(Boolean) +} + +function normalizeInventoryGroupCodes(inventoryGroupCodes) { + if (inventoryGroupCodes == null) { + return null + } + + return Array.from(new Set((Array.isArray(inventoryGroupCodes) ? inventoryGroupCodes : [inventoryGroupCodes]) + .map((value) => String(value || '').trim()) + .filter(Boolean))) +} + /** @returns {Promise} */ export async function releaseReservedInventoryItem(inventoryItemId, updatedAt) { return withTransaction(async (client) => { diff --git a/apps/backend/src/repositories/task-inventory-binding-repo.js b/apps/backend/src/repositories/task-inventory-binding-repo.js index 3989b0a2..4089b510 100644 --- a/apps/backend/src/repositories/task-inventory-binding-repo.js +++ b/apps/backend/src/repositories/task-inventory-binding-repo.js @@ -24,6 +24,7 @@ export async function listTaskInventoryBindingsByTaskId(taskId) { ii.sku_code, ii.batch_no, ii.credential_type, + ii.inventory_group_code, ii.display_value, ii.status AS inventory_item_status, ii.invalid_reason @@ -57,6 +58,7 @@ export async function getTaskInventoryBindingById(bindingId) { ii.sku_code, ii.batch_no, ii.credential_type, + ii.inventory_group_code, ii.display_value, ii.status AS inventory_item_status, ii.invalid_reason diff --git a/apps/backend/src/routes/admin/inventory.js b/apps/backend/src/routes/admin/inventory.js index ffa9c3cc..1643447d 100644 --- a/apps/backend/src/routes/admin/inventory.js +++ b/apps/backend/src/routes/admin/inventory.js @@ -27,7 +27,10 @@ const router = Router() router.use('/inventory', requireAdminRoles(['admin', 'operator'])) router.get('/inventory', createJsonHandler( - (req) => getAdminInventoryItems(/** @type {AdminInventoryRouteQuery} */ (req.query)), + (req) => getAdminInventoryItems( + /** @type {AdminInventoryRouteQuery} */ (req.query), + req.adminSession || null, + ), { successMessage: 'ok', errorMessage: '读取库存列表失败', @@ -36,7 +39,10 @@ router.get('/inventory', createJsonHandler( )) router.get('/inventory/sku-suggestions', createJsonHandler( - (req) => getAdminInventorySkuSuggestions(/** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query)), + (req) => getAdminInventorySkuSuggestions( + /** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query), + req.adminSession || null, + ), { successMessage: 'ok', errorMessage: '读取内部 SKU 建议失败', diff --git a/apps/backend/src/routes/admin/manual-redeem.js b/apps/backend/src/routes/admin/manual-redeem.js index 659ac736..e9b50d80 100644 --- a/apps/backend/src/routes/admin/manual-redeem.js +++ b/apps/backend/src/routes/admin/manual-redeem.js @@ -25,7 +25,10 @@ const router = Router() router.use('/manual-redeem', requireAdminRoles(['admin', 'operator', 'support'])) router.get('/manual-redeem/sku-suggestions', createJsonHandler( - (req) => getAdminInventorySkuSuggestions(/** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query)), + (req) => getAdminInventorySkuSuggestions( + /** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query), + req.adminSession || null, + ), { successMessage: 'ok', errorMessage: '读取人工兑换 SKU 建议失败', @@ -60,7 +63,10 @@ router.post('/manual-redeem', createJsonHandler( )) router.get('/manual-redeem/:taskId', createJsonHandler( - (req) => getAdminManualRedeemDetail((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId), + (req) => getAdminManualRedeemDetail( + (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId, + req.adminSession || null, + ), { successMessage: 'ok', errorMessage: '读取人工兑换详情失败', @@ -72,6 +78,7 @@ router.post('/manual-redeem/:taskId/session', createJsonHandler( (req) => createAdminManualRedeemSession( (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId, /** @type {{ loginType?: string, forceRecreate?: boolean }} */ (req.body), + req.adminSession || null, ), { successMessage: '人工兑换登录会话已创建', @@ -81,7 +88,10 @@ router.post('/manual-redeem/:taskId/session', createJsonHandler( )) router.get('/manual-redeem/:taskId/session/summary', createJsonHandler( - (req) => getAdminManualRedeemSessionSummary((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId), + (req) => getAdminManualRedeemSessionSummary( + (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId, + req.adminSession || null, + ), { successMessage: 'ok', errorMessage: '读取人工兑换会话摘要失败', @@ -90,7 +100,10 @@ router.get('/manual-redeem/:taskId/session/summary', createJsonHandler( )) router.post('/manual-redeem/:taskId/session/refresh', createJsonHandler( - (req) => reloadAdminManualRedeemSession((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId), + (req) => reloadAdminManualRedeemSession( + (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId, + req.adminSession || null, + ), { successMessage: '人工兑换会话已刷新', errorMessage: '刷新人工兑换会话失败', @@ -99,7 +112,10 @@ router.post('/manual-redeem/:taskId/session/refresh', createJsonHandler( )) router.delete('/manual-redeem/:taskId/session', createJsonHandler( - (req) => closeAdminManualRedeemSession((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId), + (req) => closeAdminManualRedeemSession( + (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId, + req.adminSession || null, + ), { successMessage: '人工兑换会话已关闭', errorMessage: '关闭人工兑换会话失败', @@ -159,7 +175,10 @@ router.post('/manual-redeem/:taskId/redeem', createJsonHandler( )) router.post('/manual-redeem/:taskId/close', createJsonHandler( - (req) => closeAdminManualRedeemTask((/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId), + (req) => closeAdminManualRedeemTask( + (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId, + req.adminSession || null, + ), { successMessage: '人工兑换任务已关闭', errorMessage: '关闭人工兑换任务失败', diff --git a/apps/backend/src/routes/admin/users.js b/apps/backend/src/routes/admin/users.js index eddcc023..f4967d35 100644 --- a/apps/backend/src/routes/admin/users.js +++ b/apps/backend/src/routes/admin/users.js @@ -4,6 +4,7 @@ import { createManagedAdminUser, getAdminUserList, resetManagedAdminUserPassword, + updateManagedAdminUserInventoryGroups, updateManagedAdminUserRole, updateManagedAdminUserStatus, } from '../../services/admin/admin-auth-service.js' @@ -77,6 +78,24 @@ router.post('/users/:userId/status', createJsonHandler( }, )) +router.post('/users/:userId/inventory-groups', createJsonHandler( + (req) => updateManagedAdminUserInventoryGroups(req.params.userId, req.body, req.adminSession), + { + successMessage: '用户库存组已更新', + errorMessage: '更新用户库存组失败', + scope: '[admin/users/:userId/inventory-groups]', + audit: (_req, data) => ({ + action: 'admin_user_inventory_groups_updated', + targetType: 'admin_user', + targetId: String(data.user.userId), + data: { + username: data.user.username, + inventoryGroupCodes: data.user.inventoryGroupCodes, + }, + }), + }, +)) + router.post('/users/:userId/reset-password', createJsonHandler( (req) => resetManagedAdminUserPassword(req.params.userId, req.body), { diff --git a/apps/backend/src/services/admin/admin-auth-service.js b/apps/backend/src/services/admin/admin-auth-service.js index d3fe3754..6a4a63af 100644 --- a/apps/backend/src/services/admin/admin-auth-service.js +++ b/apps/backend/src/services/admin/admin-auth-service.js @@ -7,6 +7,7 @@ import { getAdminUserById, getAdminUserByUsername, listAdminUsers, + replaceAdminUserInventoryGroupBindings, updateAdminUser, } from '../../repositories/admin-user-repo.js' import { addHours, nowIso } from '../../utils/time.js' @@ -127,6 +128,7 @@ export async function verifyAdminSessionToken(token) { userId: Number(user.id), username: String(user.username || ''), role: normalizeAdminRole(user.role), + allowedInventoryGroups: normalizeInventoryGroupCodes(user.inventory_group_codes), expiresAt, } } @@ -141,6 +143,7 @@ export async function getAdminSessionSummary(token) { userId: session.userId, username: session.username, role: session.role, + inventoryGroupCodes: session.allowedInventoryGroups, }, } } @@ -216,6 +219,18 @@ export async function createManagedAdminUser(payload = {}) { } } +export async function updateManagedAdminUserInventoryGroups(userId, payload = {}, session) { + const user = await getRequiredAdminUser(userId) + const inventoryGroupCodes = normalizeInventoryGroupCodes(payload.inventoryGroupCodes) + + await ensureAdminUserChangeAllowed(user, {}, session) + const updated = await replaceAdminUserInventoryGroupBindings(user.id, inventoryGroupCodes, nowIso()) + + return { + user: mapAdminUser(updated), + } +} + export async function updateManagedAdminUserRole(userId, payload = {}, session) { const user = await getRequiredAdminUser(userId) const role = normalizeAdminRole(payload.role) @@ -315,6 +330,7 @@ function createAdminSession(user) { userId: Number(user.id), username: String(user.username || ''), role: normalizeAdminRole(user.role), + inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes), }, } } @@ -445,7 +461,14 @@ function mapAdminUser(user) { username: String(user.username || ''), role: normalizeAdminRole(user.role), status: normalizeAdminUserStatus(user.status), + inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes), createdAt: user.created_at, updatedAt: user.updated_at, } } + +function normalizeInventoryGroupCodes(values) { + return Array.from(new Set((Array.isArray(values) ? values : []) + .map((value) => String(value || '').trim()) + .filter(Boolean))) +} diff --git a/apps/backend/src/services/admin/admin-inventory-read-helpers.js b/apps/backend/src/services/admin/admin-inventory-read-helpers.js index 180ae3a7..f8bb6e29 100644 --- a/apps/backend/src/services/admin/admin-inventory-read-helpers.js +++ b/apps/backend/src/services/admin/admin-inventory-read-helpers.js @@ -23,6 +23,7 @@ export async function mapAdminInventoryListItem(item) { skuCode: item.sku_code, batchNo: item.batch_no, credentialType: item.credential_type || 'tencent_code', + inventoryGroupCode: String(item.inventory_group_code || '').trim(), displayValue: item.display_value, status: item.status, reservedByTaskId: item.reserved_by_task_id, diff --git a/apps/backend/src/services/admin/admin-manual-redeem-service.js b/apps/backend/src/services/admin/admin-manual-redeem-service.js index 87b428ce..58e03233 100644 --- a/apps/backend/src/services/admin/admin-manual-redeem-service.js +++ b/apps/backend/src/services/admin/admin-manual-redeem-service.js @@ -19,7 +19,7 @@ import { createTaskEvent } from '../../repositories/task-event-repo.js' import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.js' -import { isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js' +import { createAdminViewerContext, isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js' const MANUAL_PROVIDER = 'manual' const MANUAL_PLATFORM = 'manual_redeem' @@ -36,6 +36,7 @@ export async function createAdminManualRedeemTask( payload = /** @type {AdminManualRedeemCreateInput} */ ({}), session = null, ) { + const viewerContext = createAdminViewerContext(session) const proofValue = normalizeManualProofValue(payload.proofValue) const skuCode = String(payload.skuCode || '').trim() const skuName = String(payload.skuName || '').trim() || skuCode @@ -81,9 +82,20 @@ export async function createAdminManualRedeemTask( const primaryRequirement = requirements.find((item) => item.is_required !== false) || requirements[0] || null const credentialType = String(primaryRequirement?.credential_type || primaryRequirement?.credentialType || 'tencent_code').trim() || 'tencent_code' const roleKey = String(primaryRequirement?.role_key || primaryRequirement?.roleKey || 'primary_code').trim() || 'primary_code' - const availableInventory = await findFirstAvailableInventoryItemBySkuCode(skuCode, credentialType) + const availableInventory = await findFirstAvailableInventoryItemBySkuCode( + skuCode, + credentialType, + viewerContext.allowedInventoryGroupCodes, + ) if (!availableInventory) { + if (viewerContext.restrictInventoryGroups && viewerContext.allowedInventoryGroupCodes?.length === 0) { + throw createHttpError('当前客服未绑定任何库存组,无法创建人工兑换任务', { + statusCode: 403, + errorCode: 'admin_manual_redeem_inventory_group_unbound', + }) + } + throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', { statusCode: 409, errorCode: 'admin_manual_redeem_inventory_unavailable', @@ -208,6 +220,7 @@ export async function createAdminManualRedeemTask( credentialType, } : null, + inventoryGroupCode: String(availableInventory.inventory_group_code || '').trim(), manualRedeem: manualContext, }), createdAt: now, @@ -226,6 +239,7 @@ export async function createAdminManualRedeemTask( taskId: createdTask.id, credentialType, roleKey, + inventoryGroupCodes: viewerContext.allowedInventoryGroupCodes, }) if (!reservedInventory) { @@ -250,6 +264,10 @@ export async function createAdminManualRedeemTask( claim_expires_at: claimToken.expired_at, user_action_status: 'pending_claim', last_error: '', + context_json: JSON.stringify({ + ...parseTaskContext(createdTask), + inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(), + }), updated_at: nowIso(), }) @@ -259,73 +277,80 @@ export async function createAdminManualRedeemTask( skuCode, skuName, inventoryItemId: Number(reservedInventory.id || 0) || null, + inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(), createdBy: manualContext.createdBy, remark, }, nowIso()) - return getAdminManualRedeemDetail(createdTask.id) + return getAdminManualRedeemDetail(createdTask.id, session) } /** @param {AdminEntityIdInput} taskId */ -export async function getAdminManualRedeemDetail(taskId) { - const task = await getRequiredManualRedeemTask(taskId) +/** @param {AdminViewerSessionInput | null} [session] */ +export async function getAdminManualRedeemDetail(taskId, session = null) { + const task = await getRequiredManualRedeemTask(taskId, session) const detail = await getClaimDetailForAdminTask(task.id) return decorateManualRedeemDetail(task, detail) } /** @param {AdminEntityIdInput} taskId */ /** @param {{ loginType?: string, forceRecreate?: boolean }} [payload] */ -export async function createAdminManualRedeemSession(taskId, payload = {}) { - const task = await getRequiredManualRedeemTask(taskId) +/** @param {AdminViewerSessionInput | null} [session] */ +export async function createAdminManualRedeemSession(taskId, payload = {}, session = null) { + const task = await getRequiredManualRedeemTask(taskId, session) const detail = await createClaimSessionForAdminTask(task.id, payload) return decorateManualRedeemDetail(task, detail) } /** @param {AdminEntityIdInput} taskId */ -export async function getAdminManualRedeemSessionSummary(taskId) { - const task = await getRequiredManualRedeemTask(taskId) +/** @param {AdminViewerSessionInput | null} [session] */ +export async function getAdminManualRedeemSessionSummary(taskId, session = null) { + const task = await getRequiredManualRedeemTask(taskId, session) const detail = await getClaimSessionSummaryForAdminTask(task.id) return decorateManualRedeemDetail(task, detail) } /** @param {AdminEntityIdInput} taskId */ -export async function reloadAdminManualRedeemSession(taskId) { - const task = await getRequiredManualRedeemTask(taskId) +/** @param {AdminViewerSessionInput | null} [session] */ +export async function reloadAdminManualRedeemSession(taskId, session = null) { + const task = await getRequiredManualRedeemTask(taskId, session) const detail = await reloadClaimSessionForAdminTask(task.id) return decorateManualRedeemDetail(task, detail) } /** @param {AdminEntityIdInput} taskId */ -export async function closeAdminManualRedeemSession(taskId) { - const task = await getRequiredManualRedeemTask(taskId) +/** @param {AdminViewerSessionInput | null} [session] */ +export async function closeAdminManualRedeemSession(taskId, session = null) { + const task = await getRequiredManualRedeemTask(taskId, session) const detail = await closeClaimSessionForAdminTask(task.id) return decorateManualRedeemDetail(task, detail) } /** @param {AdminEntityIdInput} taskId */ -export async function closeAdminManualRedeemTask(taskId) { - const task = await getRequiredManualRedeemTask(taskId) +/** @param {AdminViewerSessionInput | null} [session] */ +export async function closeAdminManualRedeemTask(taskId, session = null) { + const task = await getRequiredManualRedeemTask(taskId, session) await closeAdminTask(task.id) - return getAdminManualRedeemDetail(task.id) + return getAdminManualRedeemDetail(task.id, session) } /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function confirmAdminManualRedeemRole(taskId, session = null) { - const task = await getRequiredManualRedeemTask(taskId) + const task = await getRequiredManualRedeemTask(taskId, session) await confirmAdminTaskAssistedRole(task.id, session) - return getAdminManualRedeemDetail(task.id) + return getAdminManualRedeemDetail(task.id, session) } /** @param {AdminEntityIdInput} taskId */ /** @param {AdminViewerSessionInput | null} [session] */ export async function redeemAdminManualRedeemTask(taskId, session = null) { - const task = await getRequiredManualRedeemTask(taskId) + const task = await getRequiredManualRedeemTask(taskId, session) await redeemAdminTaskAssisted(task.id, session) - return getAdminManualRedeemDetail(task.id) + return getAdminManualRedeemDetail(task.id, session) } -async function getRequiredManualRedeemTask(taskId) { +async function getRequiredManualRedeemTask(taskId, session = null) { const task = await getTaskById(Number(taskId)) if (!task) { @@ -345,6 +370,25 @@ async function getRequiredManualRedeemTask(taskId) { }) } + const viewerContext = createAdminViewerContext(session) + if (viewerContext.restrictInventoryGroups) { + if (!Array.isArray(viewerContext.allowedInventoryGroupCodes) || viewerContext.allowedInventoryGroupCodes.length === 0) { + throw createHttpError('当前客服未绑定任何库存组,无法操作人工兑换任务', { + statusCode: 403, + errorCode: 'admin_manual_redeem_inventory_group_unbound', + }) + } + + const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim() + + if (inventoryGroupCode && !viewerContext.allowedInventoryGroupCodes.includes(inventoryGroupCode)) { + throw createHttpError('当前客服无权操作该库存组下的人工兑换任务', { + statusCode: 403, + errorCode: 'admin_manual_redeem_inventory_group_denied', + }) + } + } + return task } diff --git a/apps/backend/src/services/admin/admin-read-service.js b/apps/backend/src/services/admin/admin-read-service.js index 25678d96..0f76390e 100644 --- a/apps/backend/src/services/admin/admin-read-service.js +++ b/apps/backend/src/services/admin/admin-read-service.js @@ -2,7 +2,11 @@ import { buildClaimUrl } from '../claim/claim-service.js' import { getClaimTokenById } from '../../repositories/claim-token-repo.js' -import { getInventoryItemById, listInventoryItems, listInventorySkuSuggestions } from '../../repositories/inventory-repo.js' +import { + getInventoryItemById, + listInventoryItems, + listInventorySkuSuggestions, +} 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 } from '../../repositories/task-repo.js' @@ -313,9 +317,11 @@ export async function getAdminTaskScreenshotPath(taskId, session = null) { /** @returns {Promise} */ /** @param {AdminInventoryListQueryInput} [query] */ -export async function getAdminInventoryItems(query = /** @type {AdminInventoryListQueryInput} */ ({})) { +/** @param {AdminViewerSessionInput | null} [session] */ +export async function getAdminInventoryItems(query = /** @type {AdminInventoryListQueryInput} */ ({}), session = null) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) + const viewerContext = createAdminViewerContext(session) const { items, total } = await listInventoryItems({ page, pageSize, @@ -323,6 +329,8 @@ export async function getAdminInventoryItems(query = /** @type {AdminInventoryLi credentialType: String(query.credentialType || '').trim(), status: String(query.status || '').trim(), batchNo: String(query.batchNo || '').trim(), + inventoryGroupCode: String(query.inventoryGroupCode || '').trim(), + allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes, }) return { @@ -335,9 +343,12 @@ export async function getAdminInventoryItems(query = /** @type {AdminInventoryLi /** @param {AdminInventorySkuSuggestionQueryInput} [query] */ export async function getAdminInventorySkuSuggestions( query = /** @type {AdminInventorySkuSuggestionQueryInput} */ ({}), + session = null, ) { + const viewerContext = createAdminViewerContext(session) const credentialType = String(query.credentialType || '').trim() const keyword = String(query.keyword || '').trim() + const inventoryGroupCode = String(query.inventoryGroupCode || '').trim() const requestedLimit = Number(query.limit) const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(100, Math.floor(requestedLimit)) @@ -346,12 +357,15 @@ export async function getAdminInventorySkuSuggestions( credentialType, keyword, limit, + inventoryGroupCode, + allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes, }) return { items: items.map((item) => ({ skuCode: String(item.sku_code || '').trim(), credentialType: String(item.credential_type || '').trim() || 'tencent_code', + inventoryGroupCode: String(item.inventory_group_code || '').trim(), totalCount: Number(item.total_count || 0), availableCount: Number(item.available_count || 0), latestUpdatedAt: item.latest_updated_at || null, diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.js b/apps/backend/src/services/admin/admin-read-shared-helpers.js index 7d2be6f1..ca217893 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.js +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.js @@ -57,9 +57,14 @@ export function getTaskPrimaryClaimTokenId(task) { export function createAdminViewerContext(session = null) { const role = normalizeAdminRole(session?.role) + const allowedInventoryGroupCodes = role === 'support' + ? normalizeInventoryGroupCodes(session?.allowedInventoryGroups) + : null return { role, + allowedInventoryGroupCodes, + restrictInventoryGroups: role === 'support', canViewSensitiveTaskData: role === 'admin' || role === 'operator', canManageSensitiveInventory: role === 'admin', canManageTaskLifecycle: role === 'admin' || role === 'operator', @@ -200,11 +205,13 @@ export function canRegenerateClaimLinkForViewer(task, viewerContext) { return true } - return viewerContext.canOperateAssistedTask && isAssistedClaimTask(task) + return viewerContext.canOperateAssistedTask + && isAssistedClaimTask(task) + && canViewerAccessTaskInventoryGroup(task, viewerContext) } export function canViewerConfirmAssistedRole(task, viewerContext) { - if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { + if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task) || !canViewerAccessTaskInventoryGroup(task, viewerContext)) { return false } @@ -212,13 +219,42 @@ export function canViewerConfirmAssistedRole(task, viewerContext) { } export function canViewerRedeemAssistedTask(task, viewerContext) { - if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { + if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task) || !canViewerAccessTaskInventoryGroup(task, viewerContext)) { return false } return ['role_confirmed', 'redeeming'].includes(String(task?.task_status || '').trim()) } +export function canViewerAccessTaskInventoryGroup(task, viewerContext = createAdminViewerContext()) { + if (!viewerContext.restrictInventoryGroups) { + return true + } + + const allowedInventoryGroupCodes = Array.isArray(viewerContext.allowedInventoryGroupCodes) + ? viewerContext.allowedInventoryGroupCodes + : [] + + if (allowedInventoryGroupCodes.length === 0) { + return false + } + + const taskContext = parseTaskContext(task) + const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim() + + if (!inventoryGroupCode) { + return true + } + + return allowedInventoryGroupCodes.includes(inventoryGroupCode) +} + +export function normalizeInventoryGroupCodes(values) { + return Array.from(new Set((Array.isArray(values) ? values : []) + .map((value) => String(value || '').trim()) + .filter(Boolean))) +} + function mapRedeemResolutionAttempt(value) { if (!value || typeof value !== 'object') { return null diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.test.js b/apps/backend/src/services/admin/admin-read-shared-helpers.test.js index 94a82325..1f3047e8 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.test.js +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.test.js @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { + canViewerConfirmAssistedRole, canViewerCloseTask, createAdminViewerContext, resolveAdminTaskScreenshotUrl, @@ -37,3 +38,18 @@ test('canViewerCloseTask allows support to close active task but not redeemed ta assert.equal(canViewerCloseTask({ task_status: 'link_generated' }, viewerContext), true) assert.equal(canViewerCloseTask({ task_status: 'redeemed' }, viewerContext), false) }) + +test('canViewerConfirmAssistedRole blocks support when task inventory group is not bound', () => { + const viewerContext = createAdminViewerContext({ + role: 'support', + allowedInventoryGroups: ['A组'], + }) + + assert.equal(canViewerConfirmAssistedRole({ + task_status: 'claimed', + executor_key: 'tencent_claim_assisted', + context_json: { + inventoryGroupCode: 'B组', + }, + }, viewerContext), false) +}) diff --git a/apps/backend/src/services/admin/admin-write-service.js b/apps/backend/src/services/admin/admin-write-service.js index ca0ebb8b..80040d9c 100644 --- a/apps/backend/src/services/admin/admin-write-service.js +++ b/apps/backend/src/services/admin/admin-write-service.js @@ -64,6 +64,7 @@ export async function createAdminInventoryItem(payload = /** @type {AdminInvento const displayValue = String(payload.displayValue || '').trim() const batchNo = String(payload.batchNo || '').trim() const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' + const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim() if (!skuCode || !displayValue) { throw createHttpError('缺少 skuCode 或 displayValue', { @@ -79,6 +80,7 @@ export async function createAdminInventoryItem(payload = /** @type {AdminInvento displayValue, batchNo, credentialType, + inventoryGroupCode, createdAt: now, updatedAt: now, }, @@ -93,10 +95,11 @@ export async function createAdminInventoryItem(payload = /** @type {AdminInvento const { items } = await listInventoryItems({ page: 1, - pageSize: 1, + pageSize: 20, skuCode, + inventoryGroupCode, }) - const createdItem = items.find((item) => item.display_value === displayValue) || null + const createdItem = items.find((item) => item.display_value === displayValue && item.inventory_group_code === inventoryGroupCode) || null return { inventoryItem: createdItem ? await mapAdminInventoryListItem(createdItem) : null, @@ -121,6 +124,7 @@ export async function importAdminInventoryItems(payload = /** @type {AdminInvent batchNo: row.batchNo, displayValue: row.displayValue, credentialType: row.credentialType, + inventoryGroupCode: row.inventoryGroupCode, createdAt: now, updatedAt: now, })) @@ -538,6 +542,7 @@ export async function retryAdminTask(taskId) { taskId: task.id, credentialType: primaryRequirement?.credentialType || 'tencent_code', roleKey: primaryRequirement?.roleKey || 'primary_code', + inventoryGroupCodes: resolveTaskInventoryGroupCodes(task), }) if (!reserved) { @@ -672,7 +677,7 @@ export async function completeAdminTaskManualDispatch(taskId, payload = {}, sess /** * @param {AdminInventoryImportInput} payload - * @returns {Array>>} + * @returns {Array>>} */ function normalizeInventoryImportRows(payload) { const rows = [] @@ -685,12 +690,13 @@ function normalizeInventoryImportRows(payload) { const displayValue = String(row?.displayValue || '').trim() const batchNo = String(row?.batchNo || '').trim() const credentialType = String(row?.credentialType || payload.credentialType || 'tencent_code').trim() || 'tencent_code' + const inventoryGroupCode = String(row?.inventoryGroupCode || payload.inventoryGroupCode || '').trim() if (!skuCode || !displayValue) { continue } - rows.push({ skuCode, displayValue, batchNo, credentialType }) + rows.push({ skuCode, displayValue, batchNo, credentialType, inventoryGroupCode }) } } @@ -698,6 +704,7 @@ function normalizeInventoryImportRows(payload) { const skuCode = String(payload.skuCode || '').trim() const batchNo = String(payload.batchNo || '').trim() const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' + const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim() if (!skuCode) { throw createHttpError('批量导入时缺少 skuCode', { @@ -711,7 +718,7 @@ function normalizeInventoryImportRows(payload) { if (!displayValue) { continue } - rows.push({ skuCode, displayValue, batchNo, credentialType }) + rows.push({ skuCode, displayValue, batchNo, credentialType, inventoryGroupCode }) } } @@ -719,7 +726,7 @@ function normalizeInventoryImportRows(payload) { } /** - * @param {Array>>} rows + * @param {Array>>} rows */ function dedupeRows(rows) { const seen = new Set() @@ -737,6 +744,12 @@ function dedupeRows(rows) { return output } +function resolveTaskInventoryGroupCodes(task) { + const taskContext = parseTaskContext(task) + const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim() + return inventoryGroupCode ? [inventoryGroupCode] : null +} + /** * @param {Awaited>} task diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js index 77a1e1a5..744e0668 100644 --- a/apps/backend/src/services/claim/claim-session-service.js +++ b/apps/backend/src/services/claim/claim-session-service.js @@ -964,6 +964,9 @@ export async function redeemClaimTaskWithInventoryFallbackWithDeps( taskId: context.task.id, credentialType: currentInventoryItem.credential_type || 'tencent_code', roleKey: 'primary_code', + inventoryGroupCodes: currentInventoryItem.inventory_group_code + ? [String(currentInventoryItem.inventory_group_code).trim()] + : null, }) if (!replacement || !String(replacement.display_value || '').trim()) { @@ -989,6 +992,7 @@ export async function redeemClaimTaskWithInventoryFallbackWithDeps( nextInventoryItemId: replacement.id, nextCodeMasked: maskCode(replacement.display_value), credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''), + inventoryGroupCode: String(replacement.inventory_group_code || currentInventoryItem.inventory_group_code || '').trim(), }, replacedAt) currentInventoryItem = replacement shouldReloadBeforeNextAttempt = true diff --git a/apps/backend/src/services/claim/claim-session-service.test.js b/apps/backend/src/services/claim/claim-session-service.test.js index c83e25bc..ef49410e 100644 --- a/apps/backend/src/services/claim/claim-session-service.test.js +++ b/apps/backend/src/services/claim/claim-session-service.test.js @@ -22,11 +22,13 @@ test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and id: 1, display_value: 'DJQFf7HgONCL4XD4EH', credential_type: 'tencent_code', + inventory_group_code: 'A组', } const replacementInventoryItem = { id: 2, display_value: 'ABCD1234EFGH5678', credential_type: 'tencent_code', + inventory_group_code: 'A组', } let redeemCount = 0 @@ -127,6 +129,7 @@ test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and taskId: 42, credentialType: 'tencent_code', roleKey: 'primary_code', + inventoryGroupCodes: ['A组'], }, ]) assert.deepEqual(reloadCalls, ['txbs-test']) @@ -160,6 +163,7 @@ test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and nextInventoryItemId: 2, nextCodeMasked: 'ABCD****5678', credentialType: 'tencent_code', + inventoryGroupCode: 'A组', }, }, ], diff --git a/apps/backend/src/services/order/inventory-service.js b/apps/backend/src/services/order/inventory-service.js index 8d270d5e..e9533fa0 100644 --- a/apps/backend/src/services/order/inventory-service.js +++ b/apps/backend/src/services/order/inventory-service.js @@ -5,9 +5,15 @@ import { } from '../../repositories/inventory-repo.js' import { nowIso } from '../../utils/time.js' -export async function reserveInventoryForTask({ skuCode, taskId, credentialType = 'tencent_code', roleKey = 'primary_code' }) { +export async function reserveInventoryForTask({ + skuCode, + taskId, + credentialType = 'tencent_code', + roleKey = 'primary_code', + inventoryGroupCodes = null, +}) { return reserveInventoryForTaskWithDeps( - { skuCode, taskId, credentialType, roleKey }, + { skuCode, taskId, credentialType, roleKey, inventoryGroupCodes }, { assignReservedInventoryItem, findFirstAvailableInventoryItemBySkuCode, @@ -17,7 +23,13 @@ export async function reserveInventoryForTask({ skuCode, taskId, credentialType } export async function reserveInventoryForTaskWithDeps( - { skuCode, taskId, credentialType = 'tencent_code', roleKey = 'primary_code' }, + { + skuCode, + taskId, + credentialType = 'tencent_code', + roleKey = 'primary_code', + inventoryGroupCodes = null, + }, { assignReservedInventoryItem: assignReserved = assignReservedInventoryItem, findFirstAvailableInventoryItemBySkuCode: findAvailable = findFirstAvailableInventoryItemBySkuCode, @@ -28,7 +40,11 @@ export async function reserveInventoryForTaskWithDeps( return null } - const available = await findAvailable(skuCode, credentialType) + if (Array.isArray(inventoryGroupCodes) && inventoryGroupCodes.length === 0) { + return null + } + + const available = await findAvailable(skuCode, credentialType, inventoryGroupCodes) if (!available) { return null diff --git a/apps/backend/src/services/order/inventory-service.test.js b/apps/backend/src/services/order/inventory-service.test.js index 80c62f10..0fa76a6c 100644 --- a/apps/backend/src/services/order/inventory-service.test.js +++ b/apps/backend/src/services/order/inventory-service.test.js @@ -28,8 +28,8 @@ test('reserveInventoryForTaskWithDeps reserves first available inventory item wi const result = await reserveInventoryForTaskWithDeps( { skuCode: 'dnf-cdk-a', taskId: 88 }, { - findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType) => { - calls.push(['find', skuCode, credentialType]) + findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType, inventoryGroupCodes) => { + calls.push(['find', skuCode, credentialType, inventoryGroupCodes]) return { id: 321 } }, assignReservedInventoryItem: async (inventoryItemId, taskId, updatedAt, roleKey) => { @@ -41,13 +41,50 @@ test('reserveInventoryForTaskWithDeps reserves first available inventory item wi ) assert.deepEqual(calls, [ - ['find', 'dnf-cdk-a', 'tencent_code'], + ['find', 'dnf-cdk-a', 'tencent_code', null], ['assign', 321, 88, '2026-04-14T12:00:00.000Z', 'primary_code'], ]) assert.equal(result?.id, 321) assert.equal(result?.reserved_by_task_id, 88) }) +test('reserveInventoryForTaskWithDeps forwards inventory group filters', async () => { + const calls = [] + + await reserveInventoryForTaskWithDeps( + { skuCode: 'dnf-cdk-a', taskId: 88, inventoryGroupCodes: ['A组', 'B组'] }, + { + findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType, inventoryGroupCodes) => { + calls.push(['find', skuCode, credentialType, inventoryGroupCodes]) + return { id: 321 } + }, + assignReservedInventoryItem: async () => ({ id: 321 }), + nowIso: () => '2026-04-14T12:00:00.000Z', + }, + ) + + assert.deepEqual(calls, [ + ['find', 'dnf-cdk-a', 'tencent_code', ['A组', 'B组']], + ]) +}) + +test('reserveInventoryForTaskWithDeps returns null when restricted inventory groups are empty', async () => { + let findCalled = false + + const result = await reserveInventoryForTaskWithDeps( + { skuCode: 'dnf-cdk-a', taskId: 12, inventoryGroupCodes: [] }, + { + findFirstAvailableInventoryItemBySkuCode: async () => { + findCalled = true + return null + }, + }, + ) + + assert.equal(result, null) + assert.equal(findCalled, false) +}) + test('releaseReservedInventoryItemsWithDeps skips empty ids and keeps successful releases only', async () => { const calls = [] const result = await releaseReservedInventoryItemsWithDeps( diff --git a/apps/backend/src/types/admin-read-inputs.js b/apps/backend/src/types/admin-read-inputs.js index ce466a04..b0531e3f 100644 --- a/apps/backend/src/types/admin-read-inputs.js +++ b/apps/backend/src/types/admin-read-inputs.js @@ -7,6 +7,7 @@ export {} * userId?: number | string * username?: string * role?: string + * allowedInventoryGroups?: string[] * }} AdminViewerSessionInput */ @@ -41,9 +42,10 @@ export {} * page?: number | string * pageSize?: number | string * skuCode?: string - * credentialType?: string + * credentialType?: string * status?: string * batchNo?: string + * inventoryGroupCode?: string * }} AdminInventoryListQueryInput */ @@ -51,7 +53,8 @@ export {} * @typedef {{ * credentialType?: string * keyword?: string - * limit?: number | string + * limit?: number | string + * inventoryGroupCode?: string * }} AdminInventorySkuSuggestionQueryInput */ diff --git a/apps/backend/src/types/admin-read-models.js b/apps/backend/src/types/admin-read-models.js index 77ba465f..eee36ebe 100644 --- a/apps/backend/src/types/admin-read-models.js +++ b/apps/backend/src/types/admin-read-models.js @@ -99,6 +99,7 @@ export {} * skuCode: string * batchNo: string * credentialType: string + * inventoryGroupCode: string * displayValue: string * status: string * reservedByTaskId: number | null @@ -155,6 +156,7 @@ export {} * @typedef {{ * skuCode: string * credentialType: string + * inventoryGroupCode: string * totalCount: number * availableCount: number * latestUpdatedAt: string | null diff --git a/apps/backend/src/types/admin-route-inputs.js b/apps/backend/src/types/admin-route-inputs.js index e7494e47..736346cf 100644 --- a/apps/backend/src/types/admin-route-inputs.js +++ b/apps/backend/src/types/admin-route-inputs.js @@ -54,6 +54,10 @@ export {} * @typedef {import('./admin-write-inputs.js').AdminInventoryInvalidateInput} AdminInventoryInvalidateRouteBody */ +/** + * @typedef {import('./admin-write-inputs.js').AdminUserInventoryGroupsInput} AdminUserInventoryGroupsRouteBody + */ + /** * @typedef {import('./admin-write-inputs.js').AdminTaskManualDispatchInput} AdminTaskManualDispatchRouteBody */ diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js index 9e8a7f7c..55a3d67b 100644 --- a/apps/backend/src/types/admin-write-inputs.js +++ b/apps/backend/src/types/admin-write-inputs.js @@ -8,6 +8,7 @@ export {} * displayValue?: string * batchNo?: string * credentialType?: string + * inventoryGroupCode?: string * }} AdminInventoryCreateInput */ @@ -17,6 +18,7 @@ export {} * displayValue?: string * batchNo?: string * credentialType?: string + * inventoryGroupCode?: string * }} AdminInventoryImportRowInput */ @@ -27,9 +29,16 @@ export {} * skuCode?: string * batchNo?: string * credentialType?: string + * inventoryGroupCode?: string * }} AdminInventoryImportInput */ +/** + * @typedef {{ + * inventoryGroupCodes?: string[] + * }} AdminUserInventoryGroupsInput + */ + /** * @typedef {{ * messageTemplate?: string diff --git a/apps/backend/src/types/admin-write-models.js b/apps/backend/src/types/admin-write-models.js index 4bf83600..be10859f 100644 --- a/apps/backend/src/types/admin-write-models.js +++ b/apps/backend/src/types/admin-write-models.js @@ -6,6 +6,10 @@ export {} * @typedef {import('./admin-read-models.js').AdminInventoryItemListItem} AdminInventoryItemListItem */ +/** + * @typedef {import('./admin-read-models.js').AdminInventorySkuSuggestion} AdminInventorySkuSuggestion + */ + /** * @typedef {{ * inventoryItem: AdminInventoryItemListItem | null @@ -20,6 +24,12 @@ export {} * }} AdminInventoryImportResponse */ +/** + * @typedef {{ + * items: AdminInventorySkuSuggestion[] + * }} AdminInventoryGroupListResponse + */ + /** * @typedef {{ * messageTemplate: string diff --git a/apps/backend/src/types/repository-inputs.js b/apps/backend/src/types/repository-inputs.js index d707a26f..f1c13513 100644 --- a/apps/backend/src/types/repository-inputs.js +++ b/apps/backend/src/types/repository-inputs.js @@ -148,6 +148,8 @@ export {} * credentialType?: string * status?: string * batchNo?: string + * inventoryGroupCode?: string + * allowedInventoryGroupCodes?: string[] | null * }} InventoryListQueryInput */ @@ -156,6 +158,8 @@ export {} * credentialType?: string * keyword?: string * limit?: number + * inventoryGroupCode?: string + * allowedInventoryGroupCodes?: string[] | null * }} InventorySkuSuggestionQueryInput */ @@ -164,6 +168,7 @@ export {} * batchNo?: string * skuCode: string * credentialType?: string + * inventoryGroupCode?: string * displayValue: string * payload?: Record * createdAt: string diff --git a/apps/backend/src/types/repository-rows.js b/apps/backend/src/types/repository-rows.js index 3176906e..f7502e08 100644 --- a/apps/backend/src/types/repository-rows.js +++ b/apps/backend/src/types/repository-rows.js @@ -93,6 +93,7 @@ export {} * sku_code: string * batch_no: string * credential_type: string + * inventory_group_code: string * display_value: string * status: string * invalid_reason: string @@ -109,6 +110,7 @@ export {} * @typedef {{ * sku_code: string * credential_type: string + * inventory_group_code: string * total_count: number * available_count: number * latest_updated_at: string | null @@ -152,6 +154,7 @@ export {} * sku_code: string * batch_no: string * credential_type: string + * inventory_group_code: string * display_value: string * inventory_status: string * }} TaskInventoryBindingRow diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index e5e5bf55..c9435c5f 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -58,6 +58,10 @@ export function updateAdminUserStatus(userId: number | string, payload: { status return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/status`, payload) } +export function updateAdminUserInventoryGroups(userId: number | string, payload: { inventoryGroupCodes: string[] }) { + return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/inventory-groups`, payload) +} + export function resetAdminUserPassword(userId: number | string, payload: { password: string }) { return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/reset-password`, payload) } diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index 95721605..67072bf6 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -16,6 +16,7 @@ export interface AdminLoginResponse { userId: number username: string role: AdminRole + inventoryGroupCodes?: string[] } } @@ -26,6 +27,7 @@ export interface AdminSessionSummary { userId: number username: string role: AdminRole + inventoryGroupCodes?: string[] } } @@ -34,6 +36,7 @@ export interface AdminUserListItem { username: string role: AdminRole status: AdminUserStatus + inventoryGroupCodes: string[] createdAt: string updatedAt: string } @@ -436,6 +439,7 @@ export interface AdminTaskDetail { export interface AdminInventorySkuSuggestion { skuCode: string credentialType: string + inventoryGroupCode: string totalCount: number availableCount: number latestUpdatedAt: string | null @@ -446,6 +450,7 @@ export interface AdminInventoryItemListItem { skuCode: string batchNo: string credentialType: string + inventoryGroupCode: string displayValue: string status: string reservedByTaskId: number | null diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 1295b3d1..0225b131 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -78,6 +78,7 @@ export const adminAuditActionOptions = [ { label: '创建后台用户', value: 'admin_user_created' }, { label: '修改用户角色', value: 'admin_user_role_updated' }, { label: '修改用户状态', value: 'admin_user_status_updated' }, + { label: '修改用户库存组', value: 'admin_user_inventory_groups_updated' }, { label: '重置用户密码', value: 'admin_user_password_reset' }, { label: '释放任务库存', value: 'task_release_inventory' }, { label: '重发领取链接', value: 'task_regenerate_claim_link' }, diff --git a/apps/frontend/src/views/admin/AdminInventoryView.vue b/apps/frontend/src/views/admin/AdminInventoryView.vue index 01aa4f6d..5bac0d07 100644 --- a/apps/frontend/src/views/admin/AdminInventoryView.vue +++ b/apps/frontend/src/views/admin/AdminInventoryView.vue @@ -24,6 +24,7 @@ type InventoryImportRow = { skuCode: string credentialType: string batchNo: string + inventoryGroupCode: string displayValue: string } @@ -31,6 +32,7 @@ type InventoryPoolGroup = { key: string skuCode: string credentialType: string + inventoryGroupCode: string totalCount: number availableCount: number reservedCount: number @@ -50,10 +52,12 @@ const filterSkuCode = ref('') const filterCredentialType = ref('') const filterStatus = ref('') const filterBatchNo = ref('') +const filterInventoryGroupCode = ref('') const entrySkuCode = ref('') const entryCredentialType = ref('tencent_code') const entryBatchNo = ref('') +const entryInventoryGroupCode = ref('') const singleDisplayValue = ref('') const bulkText = ref('') const skuSuggestionsLoading = ref(false) @@ -84,6 +88,7 @@ const inventoryGroups = computed(() => { const groupMap = new Map(() => { }>() for (const item of items.value) { - const key = `${item.skuCode}::${item.credentialType || 'tencent_code'}` + const key = `${item.skuCode}::${item.credentialType || 'tencent_code'}::${item.inventoryGroupCode || ''}` const current = groupMap.get(key) || { skuCode: item.skuCode, credentialType: item.credentialType || 'tencent_code', + inventoryGroupCode: item.inventoryGroupCode || '', totalCount: 0, availableCount: 0, reservedCount: 0, @@ -135,6 +141,7 @@ const inventoryGroups = computed(() => { key, skuCode: value.skuCode, credentialType: value.credentialType, + inventoryGroupCode: value.inventoryGroupCode, totalCount: value.totalCount, availableCount: value.availableCount, reservedCount: value.reservedCount, @@ -253,16 +260,18 @@ function getGroupSuggestion(group: InventoryPoolGroup) { function useGroupAsFilter(group: InventoryPoolGroup) { filterSkuCode.value = group.skuCode filterCredentialType.value = group.credentialType + filterInventoryGroupCode.value = group.inventoryGroupCode void loadInventoryItems(1) } function useGroupAsEntry(group: InventoryPoolGroup) { entrySkuCode.value = group.skuCode entryCredentialType.value = group.credentialType + entryInventoryGroupCode.value = group.inventoryGroupCode if (group.batchSummary !== '未分批' && !group.batchSummary.includes(' / ')) { entryBatchNo.value = group.batchSummary } - showSuccess(`已把 ${group.skuCode} / ${group.credentialType} 填入入库表单`) + showSuccess(`已把 ${group.skuCode} / ${group.credentialType} / ${group.inventoryGroupCode || '未分组'} 填入入库表单`) } function applySkuSuggestion(skuCode: string) { @@ -285,11 +294,13 @@ function buildImportRows() { if (line.includes('|')) { const parts = line.split('|').map((part) => part.trim()) - if (parts.length !== 4) { - throw new Error(`第 ${index + 1} 行格式不正确,请使用 内部SKU|凭据类型|批次号|凭据内容`) + if (parts.length !== 4 && parts.length !== 5) { + throw new Error(`第 ${index + 1} 行格式不正确,请使用 内部SKU|凭据类型|批次号|库存组|凭据内容`) } - const [skuCode, credentialType, batchNo, displayValue] = parts + const [skuCode, credentialType, batchNo, inventoryGroupCode, displayValue] = parts.length === 5 + ? parts + : [parts[0], parts[1], parts[2], '', parts[3]] if (!skuCode || !displayValue) { throw new Error(`第 ${index + 1} 行缺少内部 SKU 或凭据内容`) } @@ -298,6 +309,7 @@ function buildImportRows() { skuCode, credentialType: credentialType || defaultCredentialType, batchNo, + inventoryGroupCode: inventoryGroupCode || entryInventoryGroupCode.value.trim(), displayValue, }) continue @@ -311,6 +323,7 @@ function buildImportRows() { skuCode: defaultSkuCode, credentialType: defaultCredentialType, batchNo: defaultBatchNo, + inventoryGroupCode: entryInventoryGroupCode.value.trim(), displayValue: line, }) } @@ -330,6 +343,7 @@ async function loadInventoryItems(page = pagination.value.page) { credentialType: filterCredentialType.value.trim(), status: filterStatus.value.trim(), batchNo: filterBatchNo.value.trim(), + inventoryGroupCode: filterInventoryGroupCode.value.trim(), }) items.value = response.data.items pagination.value = response.data.pagination @@ -346,6 +360,7 @@ async function loadInventorySkuSuggestions() { try { const response = await fetchAdminInventorySkuSuggestions({ credentialType: entryCredentialType.value.trim() || 'tencent_code', + inventoryGroupCode: entryInventoryGroupCode.value.trim(), limit: 50, }) inventorySkuSuggestions.value = response.data.items @@ -361,6 +376,7 @@ function resetFilters() { filterCredentialType.value = '' filterStatus.value = '' filterBatchNo.value = '' + filterInventoryGroupCode.value = '' void loadInventoryItems(1) } @@ -377,6 +393,7 @@ async function submitCreate() { skuCode: entrySkuCode.value.trim(), credentialType: entryCredentialType.value.trim() || 'tencent_code', batchNo: entryBatchNo.value.trim(), + inventoryGroupCode: entryInventoryGroupCode.value.trim(), displayValue: singleDisplayValue.value.trim(), }) @@ -450,6 +467,10 @@ watch(entryCredentialType, () => { void loadInventorySkuSuggestions() }) +watch(entryInventoryGroupCode, () => { + void loadInventorySkuSuggestions() +}) + onMounted(async () => { await Promise.all([ loadInventoryItems(), @@ -492,6 +513,7 @@ onMounted(async () => { +
查询列表 @@ -518,9 +540,9 @@ onMounted(async () => {
@@ -534,6 +556,7 @@ onMounted(async () => { +
@@ -544,13 +567,13 @@ onMounted(async () => {
@@ -559,6 +582,10 @@ onMounted(async () => { 当前默认匹配 {{ getCredentialTypeLabel(entryCredentialType) }}
+
+ 库存组 + {{ entryInventoryGroupCode || '未分组,默认全局可用' }} +
说明 {{ getCredentialTypeHint(entryCredentialType) }} @@ -573,9 +600,9 @@ onMounted(async () => {