增加分组库存与客服

This commit is contained in:
yml2213
2026-04-23 13:38:21 +08:00
parent 2a05a68959
commit f0b31121d7
31 changed files with 675 additions and 80 deletions
@@ -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);
@@ -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) { export async function getAdminUserById(userId) {
const result = await query( const result = await query(
'SELECT * FROM admin_users WHERE id = $1 LIMIT 1', `${ADMIN_USER_SELECT} WHERE au.id = $1 LIMIT 1`,
[Number(userId)], [Number(userId)],
) )
return result.rows[0] || null return result.rows[0] || null
@@ -10,7 +22,7 @@ export async function getAdminUserById(userId) {
export async function getAdminUserByUsername(username) { export async function getAdminUserByUsername(username) {
const result = await query( 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()], [String(username || '').trim().toLowerCase()],
) )
return result.rows[0] || null return result.rows[0] || null
@@ -27,7 +39,7 @@ export async function createAdminUser(input) {
created_at, created_at,
updated_at updated_at
) VALUES ($1, $2, $3, $4, $5, $6) ) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING * RETURNING id
`, `,
[ [
input.username, 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) { export async function updateAdminUser(userId, patch) {
@@ -59,7 +71,7 @@ export async function updateAdminUser(userId, patch) {
status = $4, status = $4,
updated_at = $5 updated_at = $5
WHERE id = $6 WHERE id = $6
RETURNING * RETURNING id
`, `,
[ [
next.username, 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 = '' } = {}) { 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) params.push(offset)
const itemsResult = await query( const itemsResult = await query(
` `
SELECT * ${ADMIN_USER_SELECT}
FROM admin_users
${whereClause} ${whereClause}
ORDER BY id DESC ORDER BY au.id DESC
LIMIT $${params.length - 1} OFFSET $${params.length} LIMIT $${params.length - 1} OFFSET $${params.length}
`, `,
params, params,
@@ -122,3 +133,35 @@ export async function countActiveAdminUsers() {
) )
return Number(result.rows[0]?.total || 0) 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)
}
+112 -6
View File
@@ -15,6 +15,7 @@ const INVENTORY_ITEM_SELECT = `
ii.sku_code, ii.sku_code,
ii.batch_no, ii.batch_no,
ii.credential_type, ii.credential_type,
ii.inventory_group_code,
ii.display_value, ii.display_value,
ii.status, ii.status,
ii.invalid_reason, ii.invalid_reason,
@@ -36,13 +37,34 @@ const INVENTORY_ITEM_SELECT = `
` `
/** @returns {Promise<InventoryItemRow | null>} */ /** @returns {Promise<InventoryItemRow | null>} */
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( const result = await query(
`${INVENTORY_ITEM_SELECT} `${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 ORDER BY ii.id ASC
LIMIT 1`, LIMIT 1`,
[skuCode, credentialType], params,
) )
return /** @type {InventoryItemRow | null} */ (result.rows[0] || null) return /** @type {InventoryItemRow | null} */ (result.rows[0] || null)
@@ -162,7 +184,17 @@ export async function listInventoryItems({
credentialType = '', credentialType = '',
status = '', status = '',
batchNo = '', batchNo = '',
inventoryGroupCode = '',
allowedInventoryGroupCodes = null,
} = /** @type {InventoryListQueryInput} */ ({})) { } = /** @type {InventoryListQueryInput} */ ({})) {
const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes)
if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) {
return {
items: [],
total: 0,
}
}
const offset = (page - 1) * pageSize const offset = (page - 1) * pageSize
const filters = [] const filters = []
const params = [] const params = []
@@ -187,6 +219,16 @@ export async function listInventoryItems({
filters.push(`ii.batch_no ILIKE $${params.length}`) 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 whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
const totalResult = await query( const totalResult = await query(
`SELECT COUNT(*)::int AS total FROM inventory_items ii ${whereClause}`, `SELECT COUNT(*)::int AS total FROM inventory_items ii ${whereClause}`,
@@ -215,7 +257,14 @@ export async function listInventorySkuSuggestions({
credentialType = '', credentialType = '',
keyword = '', keyword = '',
limit = 50, limit = 50,
inventoryGroupCode = '',
allowedInventoryGroupCodes = null,
} = /** @type {InventorySkuSuggestionQueryInput} */ ({})) { } = /** @type {InventorySkuSuggestionQueryInput} */ ({})) {
const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes)
if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) {
return []
}
const filters = [] const filters = []
const params = [] const params = []
@@ -229,6 +278,16 @@ export async function listInventorySkuSuggestions({
filters.push(`sku_code ILIKE $${params.length}`) 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)) const normalizedLimit = Number.isFinite(Number(limit))
? Math.max(1, Math.min(Number(limit), 100)) ? Math.max(1, Math.min(Number(limit), 100))
: 50 : 50
@@ -240,17 +299,19 @@ export async function listInventorySkuSuggestions({
SELECT SELECT
sku_code, sku_code,
credential_type, credential_type,
inventory_group_code,
COUNT(*)::int AS total_count, COUNT(*)::int AS total_count,
COUNT(*) FILTER (WHERE status = 'available')::int AS available_count, COUNT(*) FILTER (WHERE status = 'available')::int AS available_count,
MAX(updated_at) AS latest_updated_at MAX(updated_at) AS latest_updated_at
FROM inventory_items FROM inventory_items
${whereClause} ${whereClause}
GROUP BY sku_code, credential_type GROUP BY sku_code, credential_type, inventory_group_code
ORDER BY ORDER BY
COUNT(*) FILTER (WHERE status = 'available') DESC, COUNT(*) FILTER (WHERE status = 'available') DESC,
COUNT(*) DESC, COUNT(*) DESC,
MAX(updated_at) DESC, MAX(updated_at) DESC,
sku_code ASC sku_code ASC,
inventory_group_code ASC
LIMIT $${params.length} LIMIT $${params.length}
`, `,
params, params,
@@ -266,12 +327,14 @@ export async function createInventoryItems(rows) {
for (const row of rows) { for (const row of rows) {
const payloadJson = JSON.stringify(row.payload || { code: row.displayValue }) const payloadJson = JSON.stringify(row.payload || { code: row.displayValue })
const displayValue = String(row.displayValue || '').trim() const displayValue = String(row.displayValue || '').trim()
const inventoryGroupCode = String(row.inventoryGroupCode || '').trim()
const result = await query( const result = await query(
` `
INSERT INTO inventory_items ( INSERT INTO inventory_items (
batch_no, batch_no,
sku_code, sku_code,
credential_type, credential_type,
inventory_group_code,
display_value, display_value,
payload_json, payload_json,
source_type, source_type,
@@ -280,7 +343,7 @@ export async function createInventoryItems(rows) {
metadata_json, metadata_json,
created_at, created_at,
updated_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 ON CONFLICT DO NOTHING
RETURNING id RETURNING id
`, `,
@@ -288,6 +351,7 @@ export async function createInventoryItems(rows) {
row.batchNo || '', row.batchNo || '',
row.skuCode, row.skuCode,
row.credentialType || 'tencent_code', row.credentialType || 'tencent_code',
inventoryGroupCode,
displayValue, displayValue,
payloadJson, payloadJson,
row.createdAt, row.createdAt,
@@ -303,6 +367,48 @@ export async function createInventoryItems(rows) {
return created 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<InventoryItemRow | null>} */ /** @returns {Promise<InventoryItemRow | null>} */
export async function releaseReservedInventoryItem(inventoryItemId, updatedAt) { export async function releaseReservedInventoryItem(inventoryItemId, updatedAt) {
return withTransaction(async (client) => { return withTransaction(async (client) => {
@@ -24,6 +24,7 @@ export async function listTaskInventoryBindingsByTaskId(taskId) {
ii.sku_code, ii.sku_code,
ii.batch_no, ii.batch_no,
ii.credential_type, ii.credential_type,
ii.inventory_group_code,
ii.display_value, ii.display_value,
ii.status AS inventory_item_status, ii.status AS inventory_item_status,
ii.invalid_reason ii.invalid_reason
@@ -57,6 +58,7 @@ export async function getTaskInventoryBindingById(bindingId) {
ii.sku_code, ii.sku_code,
ii.batch_no, ii.batch_no,
ii.credential_type, ii.credential_type,
ii.inventory_group_code,
ii.display_value, ii.display_value,
ii.status AS inventory_item_status, ii.status AS inventory_item_status,
ii.invalid_reason ii.invalid_reason
+8 -2
View File
@@ -27,7 +27,10 @@ const router = Router()
router.use('/inventory', requireAdminRoles(['admin', 'operator'])) router.use('/inventory', requireAdminRoles(['admin', 'operator']))
router.get('/inventory', createJsonHandler( router.get('/inventory', createJsonHandler(
(req) => getAdminInventoryItems(/** @type {AdminInventoryRouteQuery} */ (req.query)), (req) => getAdminInventoryItems(
/** @type {AdminInventoryRouteQuery} */ (req.query),
req.adminSession || null,
),
{ {
successMessage: 'ok', successMessage: 'ok',
errorMessage: '读取库存列表失败', errorMessage: '读取库存列表失败',
@@ -36,7 +39,10 @@ router.get('/inventory', createJsonHandler(
)) ))
router.get('/inventory/sku-suggestions', createJsonHandler( router.get('/inventory/sku-suggestions', createJsonHandler(
(req) => getAdminInventorySkuSuggestions(/** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query)), (req) => getAdminInventorySkuSuggestions(
/** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query),
req.adminSession || null,
),
{ {
successMessage: 'ok', successMessage: 'ok',
errorMessage: '读取内部 SKU 建议失败', errorMessage: '读取内部 SKU 建议失败',
+25 -6
View File
@@ -25,7 +25,10 @@ const router = Router()
router.use('/manual-redeem', requireAdminRoles(['admin', 'operator', 'support'])) router.use('/manual-redeem', requireAdminRoles(['admin', 'operator', 'support']))
router.get('/manual-redeem/sku-suggestions', createJsonHandler( router.get('/manual-redeem/sku-suggestions', createJsonHandler(
(req) => getAdminInventorySkuSuggestions(/** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query)), (req) => getAdminInventorySkuSuggestions(
/** @type {AdminInventorySkuSuggestionRouteQuery} */ (req.query),
req.adminSession || null,
),
{ {
successMessage: 'ok', successMessage: 'ok',
errorMessage: '读取人工兑换 SKU 建议失败', errorMessage: '读取人工兑换 SKU 建议失败',
@@ -60,7 +63,10 @@ router.post('/manual-redeem', createJsonHandler(
)) ))
router.get('/manual-redeem/:taskId', 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', successMessage: 'ok',
errorMessage: '读取人工兑换详情失败', errorMessage: '读取人工兑换详情失败',
@@ -72,6 +78,7 @@ router.post('/manual-redeem/:taskId/session', createJsonHandler(
(req) => createAdminManualRedeemSession( (req) => createAdminManualRedeemSession(
(/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId, (/** @type {AdminManualRedeemRouteParams} */ (req.params)).taskId,
/** @type {{ loginType?: string, forceRecreate?: boolean }} */ (req.body), /** @type {{ loginType?: string, forceRecreate?: boolean }} */ (req.body),
req.adminSession || null,
), ),
{ {
successMessage: '人工兑换登录会话已创建', successMessage: '人工兑换登录会话已创建',
@@ -81,7 +88,10 @@ router.post('/manual-redeem/:taskId/session', createJsonHandler(
)) ))
router.get('/manual-redeem/:taskId/session/summary', 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', successMessage: 'ok',
errorMessage: '读取人工兑换会话摘要失败', errorMessage: '读取人工兑换会话摘要失败',
@@ -90,7 +100,10 @@ router.get('/manual-redeem/:taskId/session/summary', createJsonHandler(
)) ))
router.post('/manual-redeem/:taskId/session/refresh', 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: '人工兑换会话已刷新', successMessage: '人工兑换会话已刷新',
errorMessage: '刷新人工兑换会话失败', errorMessage: '刷新人工兑换会话失败',
@@ -99,7 +112,10 @@ router.post('/manual-redeem/:taskId/session/refresh', createJsonHandler(
)) ))
router.delete('/manual-redeem/:taskId/session', 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: '人工兑换会话已关闭', successMessage: '人工兑换会话已关闭',
errorMessage: '关闭人工兑换会话失败', errorMessage: '关闭人工兑换会话失败',
@@ -159,7 +175,10 @@ router.post('/manual-redeem/:taskId/redeem', createJsonHandler(
)) ))
router.post('/manual-redeem/:taskId/close', 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: '人工兑换任务已关闭', successMessage: '人工兑换任务已关闭',
errorMessage: '关闭人工兑换任务失败', errorMessage: '关闭人工兑换任务失败',
+19
View File
@@ -4,6 +4,7 @@ import {
createManagedAdminUser, createManagedAdminUser,
getAdminUserList, getAdminUserList,
resetManagedAdminUserPassword, resetManagedAdminUserPassword,
updateManagedAdminUserInventoryGroups,
updateManagedAdminUserRole, updateManagedAdminUserRole,
updateManagedAdminUserStatus, updateManagedAdminUserStatus,
} from '../../services/admin/admin-auth-service.js' } 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( router.post('/users/:userId/reset-password', createJsonHandler(
(req) => resetManagedAdminUserPassword(req.params.userId, req.body), (req) => resetManagedAdminUserPassword(req.params.userId, req.body),
{ {
@@ -7,6 +7,7 @@ import {
getAdminUserById, getAdminUserById,
getAdminUserByUsername, getAdminUserByUsername,
listAdminUsers, listAdminUsers,
replaceAdminUserInventoryGroupBindings,
updateAdminUser, updateAdminUser,
} from '../../repositories/admin-user-repo.js' } from '../../repositories/admin-user-repo.js'
import { addHours, nowIso } from '../../utils/time.js' import { addHours, nowIso } from '../../utils/time.js'
@@ -127,6 +128,7 @@ export async function verifyAdminSessionToken(token) {
userId: Number(user.id), userId: Number(user.id),
username: String(user.username || ''), username: String(user.username || ''),
role: normalizeAdminRole(user.role), role: normalizeAdminRole(user.role),
allowedInventoryGroups: normalizeInventoryGroupCodes(user.inventory_group_codes),
expiresAt, expiresAt,
} }
} }
@@ -141,6 +143,7 @@ export async function getAdminSessionSummary(token) {
userId: session.userId, userId: session.userId,
username: session.username, username: session.username,
role: session.role, 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) { export async function updateManagedAdminUserRole(userId, payload = {}, session) {
const user = await getRequiredAdminUser(userId) const user = await getRequiredAdminUser(userId)
const role = normalizeAdminRole(payload.role) const role = normalizeAdminRole(payload.role)
@@ -315,6 +330,7 @@ function createAdminSession(user) {
userId: Number(user.id), userId: Number(user.id),
username: String(user.username || ''), username: String(user.username || ''),
role: normalizeAdminRole(user.role), role: normalizeAdminRole(user.role),
inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes),
}, },
} }
} }
@@ -445,7 +461,14 @@ function mapAdminUser(user) {
username: String(user.username || ''), username: String(user.username || ''),
role: normalizeAdminRole(user.role), role: normalizeAdminRole(user.role),
status: normalizeAdminUserStatus(user.status), status: normalizeAdminUserStatus(user.status),
inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes),
createdAt: user.created_at, createdAt: user.created_at,
updatedAt: user.updated_at, updatedAt: user.updated_at,
} }
} }
function normalizeInventoryGroupCodes(values) {
return Array.from(new Set((Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim())
.filter(Boolean)))
}
@@ -23,6 +23,7 @@ export async function mapAdminInventoryListItem(item) {
skuCode: item.sku_code, skuCode: item.sku_code,
batchNo: item.batch_no, batchNo: item.batch_no,
credentialType: item.credential_type || 'tencent_code', credentialType: item.credential_type || 'tencent_code',
inventoryGroupCode: String(item.inventory_group_code || '').trim(),
displayValue: item.display_value, displayValue: item.display_value,
status: item.status, status: item.status,
reservedByTaskId: item.reserved_by_task_id, reservedByTaskId: item.reserved_by_task_id,
@@ -19,7 +19,7 @@ import { createTaskEvent } from '../../repositories/task-event-repo.js'
import { nowIso } from '../../utils/time.js' import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js' import { randomId } from '../../utils/random.js'
import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.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_PROVIDER = 'manual'
const MANUAL_PLATFORM = 'manual_redeem' const MANUAL_PLATFORM = 'manual_redeem'
@@ -36,6 +36,7 @@ export async function createAdminManualRedeemTask(
payload = /** @type {AdminManualRedeemCreateInput} */ ({}), payload = /** @type {AdminManualRedeemCreateInput} */ ({}),
session = null, session = null,
) { ) {
const viewerContext = createAdminViewerContext(session)
const proofValue = normalizeManualProofValue(payload.proofValue) const proofValue = normalizeManualProofValue(payload.proofValue)
const skuCode = String(payload.skuCode || '').trim() const skuCode = String(payload.skuCode || '').trim()
const skuName = String(payload.skuName || '').trim() || skuCode 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 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 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 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 (!availableInventory) {
if (viewerContext.restrictInventoryGroups && viewerContext.allowedInventoryGroupCodes?.length === 0) {
throw createHttpError('当前客服未绑定任何库存组,无法创建人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_unbound',
})
}
throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', { throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', {
statusCode: 409, statusCode: 409,
errorCode: 'admin_manual_redeem_inventory_unavailable', errorCode: 'admin_manual_redeem_inventory_unavailable',
@@ -208,6 +220,7 @@ export async function createAdminManualRedeemTask(
credentialType, credentialType,
} }
: null, : null,
inventoryGroupCode: String(availableInventory.inventory_group_code || '').trim(),
manualRedeem: manualContext, manualRedeem: manualContext,
}), }),
createdAt: now, createdAt: now,
@@ -226,6 +239,7 @@ export async function createAdminManualRedeemTask(
taskId: createdTask.id, taskId: createdTask.id,
credentialType, credentialType,
roleKey, roleKey,
inventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
}) })
if (!reservedInventory) { if (!reservedInventory) {
@@ -250,6 +264,10 @@ export async function createAdminManualRedeemTask(
claim_expires_at: claimToken.expired_at, claim_expires_at: claimToken.expired_at,
user_action_status: 'pending_claim', user_action_status: 'pending_claim',
last_error: '', last_error: '',
context_json: JSON.stringify({
...parseTaskContext(createdTask),
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
}),
updated_at: nowIso(), updated_at: nowIso(),
}) })
@@ -259,73 +277,80 @@ export async function createAdminManualRedeemTask(
skuCode, skuCode,
skuName, skuName,
inventoryItemId: Number(reservedInventory.id || 0) || null, inventoryItemId: Number(reservedInventory.id || 0) || null,
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
createdBy: manualContext.createdBy, createdBy: manualContext.createdBy,
remark, remark,
}, nowIso()) }, nowIso())
return getAdminManualRedeemDetail(createdTask.id) return getAdminManualRedeemDetail(createdTask.id, session)
} }
/** @param {AdminEntityIdInput} taskId */ /** @param {AdminEntityIdInput} taskId */
export async function getAdminManualRedeemDetail(taskId) { /** @param {AdminViewerSessionInput | null} [session] */
const task = await getRequiredManualRedeemTask(taskId) export async function getAdminManualRedeemDetail(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await getClaimDetailForAdminTask(task.id) const detail = await getClaimDetailForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail) return decorateManualRedeemDetail(task, detail)
} }
/** @param {AdminEntityIdInput} taskId */ /** @param {AdminEntityIdInput} taskId */
/** @param {{ loginType?: string, forceRecreate?: boolean }} [payload] */ /** @param {{ loginType?: string, forceRecreate?: boolean }} [payload] */
export async function createAdminManualRedeemSession(taskId, payload = {}) { /** @param {AdminViewerSessionInput | null} [session] */
const task = await getRequiredManualRedeemTask(taskId) export async function createAdminManualRedeemSession(taskId, payload = {}, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await createClaimSessionForAdminTask(task.id, payload) const detail = await createClaimSessionForAdminTask(task.id, payload)
return decorateManualRedeemDetail(task, detail) return decorateManualRedeemDetail(task, detail)
} }
/** @param {AdminEntityIdInput} taskId */ /** @param {AdminEntityIdInput} taskId */
export async function getAdminManualRedeemSessionSummary(taskId) { /** @param {AdminViewerSessionInput | null} [session] */
const task = await getRequiredManualRedeemTask(taskId) export async function getAdminManualRedeemSessionSummary(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await getClaimSessionSummaryForAdminTask(task.id) const detail = await getClaimSessionSummaryForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail) return decorateManualRedeemDetail(task, detail)
} }
/** @param {AdminEntityIdInput} taskId */ /** @param {AdminEntityIdInput} taskId */
export async function reloadAdminManualRedeemSession(taskId) { /** @param {AdminViewerSessionInput | null} [session] */
const task = await getRequiredManualRedeemTask(taskId) export async function reloadAdminManualRedeemSession(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await reloadClaimSessionForAdminTask(task.id) const detail = await reloadClaimSessionForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail) return decorateManualRedeemDetail(task, detail)
} }
/** @param {AdminEntityIdInput} taskId */ /** @param {AdminEntityIdInput} taskId */
export async function closeAdminManualRedeemSession(taskId) { /** @param {AdminViewerSessionInput | null} [session] */
const task = await getRequiredManualRedeemTask(taskId) export async function closeAdminManualRedeemSession(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await closeClaimSessionForAdminTask(task.id) const detail = await closeClaimSessionForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail) return decorateManualRedeemDetail(task, detail)
} }
/** @param {AdminEntityIdInput} taskId */ /** @param {AdminEntityIdInput} taskId */
export async function closeAdminManualRedeemTask(taskId) { /** @param {AdminViewerSessionInput | null} [session] */
const task = await getRequiredManualRedeemTask(taskId) export async function closeAdminManualRedeemTask(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId, session)
await closeAdminTask(task.id) await closeAdminTask(task.id)
return getAdminManualRedeemDetail(task.id) return getAdminManualRedeemDetail(task.id, session)
} }
/** @param {AdminEntityIdInput} taskId */ /** @param {AdminEntityIdInput} taskId */
/** @param {AdminViewerSessionInput | null} [session] */ /** @param {AdminViewerSessionInput | null} [session] */
export async function confirmAdminManualRedeemRole(taskId, session = null) { export async function confirmAdminManualRedeemRole(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId) const task = await getRequiredManualRedeemTask(taskId, session)
await confirmAdminTaskAssistedRole(task.id, session) await confirmAdminTaskAssistedRole(task.id, session)
return getAdminManualRedeemDetail(task.id) return getAdminManualRedeemDetail(task.id, session)
} }
/** @param {AdminEntityIdInput} taskId */ /** @param {AdminEntityIdInput} taskId */
/** @param {AdminViewerSessionInput | null} [session] */ /** @param {AdminViewerSessionInput | null} [session] */
export async function redeemAdminManualRedeemTask(taskId, session = null) { export async function redeemAdminManualRedeemTask(taskId, session = null) {
const task = await getRequiredManualRedeemTask(taskId) const task = await getRequiredManualRedeemTask(taskId, session)
await redeemAdminTaskAssisted(task.id, 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)) const task = await getTaskById(Number(taskId))
if (!task) { 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 return task
} }
@@ -2,7 +2,11 @@
import { buildClaimUrl } from '../claim/claim-service.js' import { buildClaimUrl } from '../claim/claim-service.js'
import { getClaimTokenById } from '../../repositories/claim-token-repo.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 { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { getOrderById, listOrders } from '../../repositories/order-repo.js' import { getOrderById, listOrders } from '../../repositories/order-repo.js'
import { getTaskById, listTasks, listTasksByOrderId } from '../../repositories/task-repo.js' import { getTaskById, listTasks, listTasksByOrderId } from '../../repositories/task-repo.js'
@@ -313,9 +317,11 @@ export async function getAdminTaskScreenshotPath(taskId, session = null) {
/** @returns {Promise<AdminInventoryListResponse>} */ /** @returns {Promise<AdminInventoryListResponse>} */
/** @param {AdminInventoryListQueryInput} [query] */ /** @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 page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize) const pageSize = normalizePageSize(query.pageSize)
const viewerContext = createAdminViewerContext(session)
const { items, total } = await listInventoryItems({ const { items, total } = await listInventoryItems({
page, page,
pageSize, pageSize,
@@ -323,6 +329,8 @@ export async function getAdminInventoryItems(query = /** @type {AdminInventoryLi
credentialType: String(query.credentialType || '').trim(), credentialType: String(query.credentialType || '').trim(),
status: String(query.status || '').trim(), status: String(query.status || '').trim(),
batchNo: String(query.batchNo || '').trim(), batchNo: String(query.batchNo || '').trim(),
inventoryGroupCode: String(query.inventoryGroupCode || '').trim(),
allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
}) })
return { return {
@@ -335,9 +343,12 @@ export async function getAdminInventoryItems(query = /** @type {AdminInventoryLi
/** @param {AdminInventorySkuSuggestionQueryInput} [query] */ /** @param {AdminInventorySkuSuggestionQueryInput} [query] */
export async function getAdminInventorySkuSuggestions( export async function getAdminInventorySkuSuggestions(
query = /** @type {AdminInventorySkuSuggestionQueryInput} */ ({}), query = /** @type {AdminInventorySkuSuggestionQueryInput} */ ({}),
session = null,
) { ) {
const viewerContext = createAdminViewerContext(session)
const credentialType = String(query.credentialType || '').trim() const credentialType = String(query.credentialType || '').trim()
const keyword = String(query.keyword || '').trim() const keyword = String(query.keyword || '').trim()
const inventoryGroupCode = String(query.inventoryGroupCode || '').trim()
const requestedLimit = Number(query.limit) const requestedLimit = Number(query.limit)
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
? Math.min(100, Math.floor(requestedLimit)) ? Math.min(100, Math.floor(requestedLimit))
@@ -346,12 +357,15 @@ export async function getAdminInventorySkuSuggestions(
credentialType, credentialType,
keyword, keyword,
limit, limit,
inventoryGroupCode,
allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
}) })
return { return {
items: items.map((item) => ({ items: items.map((item) => ({
skuCode: String(item.sku_code || '').trim(), skuCode: String(item.sku_code || '').trim(),
credentialType: String(item.credential_type || '').trim() || 'tencent_code', credentialType: String(item.credential_type || '').trim() || 'tencent_code',
inventoryGroupCode: String(item.inventory_group_code || '').trim(),
totalCount: Number(item.total_count || 0), totalCount: Number(item.total_count || 0),
availableCount: Number(item.available_count || 0), availableCount: Number(item.available_count || 0),
latestUpdatedAt: item.latest_updated_at || null, latestUpdatedAt: item.latest_updated_at || null,
@@ -57,9 +57,14 @@ export function getTaskPrimaryClaimTokenId(task) {
export function createAdminViewerContext(session = null) { export function createAdminViewerContext(session = null) {
const role = normalizeAdminRole(session?.role) const role = normalizeAdminRole(session?.role)
const allowedInventoryGroupCodes = role === 'support'
? normalizeInventoryGroupCodes(session?.allowedInventoryGroups)
: null
return { return {
role, role,
allowedInventoryGroupCodes,
restrictInventoryGroups: role === 'support',
canViewSensitiveTaskData: role === 'admin' || role === 'operator', canViewSensitiveTaskData: role === 'admin' || role === 'operator',
canManageSensitiveInventory: role === 'admin', canManageSensitiveInventory: role === 'admin',
canManageTaskLifecycle: role === 'admin' || role === 'operator', canManageTaskLifecycle: role === 'admin' || role === 'operator',
@@ -200,11 +205,13 @@ export function canRegenerateClaimLinkForViewer(task, viewerContext) {
return true return true
} }
return viewerContext.canOperateAssistedTask && isAssistedClaimTask(task) return viewerContext.canOperateAssistedTask
&& isAssistedClaimTask(task)
&& canViewerAccessTaskInventoryGroup(task, viewerContext)
} }
export function canViewerConfirmAssistedRole(task, viewerContext) { export function canViewerConfirmAssistedRole(task, viewerContext) {
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task) || !canViewerAccessTaskInventoryGroup(task, viewerContext)) {
return false return false
} }
@@ -212,13 +219,42 @@ export function canViewerConfirmAssistedRole(task, viewerContext) {
} }
export function canViewerRedeemAssistedTask(task, viewerContext) { export function canViewerRedeemAssistedTask(task, viewerContext) {
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task) || !canViewerAccessTaskInventoryGroup(task, viewerContext)) {
return false return false
} }
return ['role_confirmed', 'redeeming'].includes(String(task?.task_status || '').trim()) 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) { function mapRedeemResolutionAttempt(value) {
if (!value || typeof value !== 'object') { if (!value || typeof value !== 'object') {
return null return null
@@ -2,6 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { import {
canViewerConfirmAssistedRole,
canViewerCloseTask, canViewerCloseTask,
createAdminViewerContext, createAdminViewerContext,
resolveAdminTaskScreenshotUrl, 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: 'link_generated' }, viewerContext), true)
assert.equal(canViewerCloseTask({ task_status: 'redeemed' }, viewerContext), false) 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)
})
@@ -64,6 +64,7 @@ export async function createAdminInventoryItem(payload = /** @type {AdminInvento
const displayValue = String(payload.displayValue || '').trim() const displayValue = String(payload.displayValue || '').trim()
const batchNo = String(payload.batchNo || '').trim() const batchNo = String(payload.batchNo || '').trim()
const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code'
const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim()
if (!skuCode || !displayValue) { if (!skuCode || !displayValue) {
throw createHttpError('缺少 skuCode 或 displayValue', { throw createHttpError('缺少 skuCode 或 displayValue', {
@@ -79,6 +80,7 @@ export async function createAdminInventoryItem(payload = /** @type {AdminInvento
displayValue, displayValue,
batchNo, batchNo,
credentialType, credentialType,
inventoryGroupCode,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}, },
@@ -93,10 +95,11 @@ export async function createAdminInventoryItem(payload = /** @type {AdminInvento
const { items } = await listInventoryItems({ const { items } = await listInventoryItems({
page: 1, page: 1,
pageSize: 1, pageSize: 20,
skuCode, 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 { return {
inventoryItem: createdItem ? await mapAdminInventoryListItem(createdItem) : null, inventoryItem: createdItem ? await mapAdminInventoryListItem(createdItem) : null,
@@ -121,6 +124,7 @@ export async function importAdminInventoryItems(payload = /** @type {AdminInvent
batchNo: row.batchNo, batchNo: row.batchNo,
displayValue: row.displayValue, displayValue: row.displayValue,
credentialType: row.credentialType, credentialType: row.credentialType,
inventoryGroupCode: row.inventoryGroupCode,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})) }))
@@ -538,6 +542,7 @@ export async function retryAdminTask(taskId) {
taskId: task.id, taskId: task.id,
credentialType: primaryRequirement?.credentialType || 'tencent_code', credentialType: primaryRequirement?.credentialType || 'tencent_code',
roleKey: primaryRequirement?.roleKey || 'primary_code', roleKey: primaryRequirement?.roleKey || 'primary_code',
inventoryGroupCodes: resolveTaskInventoryGroupCodes(task),
}) })
if (!reserved) { if (!reserved) {
@@ -672,7 +677,7 @@ export async function completeAdminTaskManualDispatch(taskId, payload = {}, sess
/** /**
* @param {AdminInventoryImportInput} payload * @param {AdminInventoryImportInput} payload
* @returns {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType'>>>} * @returns {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType' | 'inventoryGroupCode'>>>}
*/ */
function normalizeInventoryImportRows(payload) { function normalizeInventoryImportRows(payload) {
const rows = [] const rows = []
@@ -685,12 +690,13 @@ function normalizeInventoryImportRows(payload) {
const displayValue = String(row?.displayValue || '').trim() const displayValue = String(row?.displayValue || '').trim()
const batchNo = String(row?.batchNo || '').trim() const batchNo = String(row?.batchNo || '').trim()
const credentialType = String(row?.credentialType || payload.credentialType || 'tencent_code').trim() || 'tencent_code' const credentialType = String(row?.credentialType || payload.credentialType || 'tencent_code').trim() || 'tencent_code'
const inventoryGroupCode = String(row?.inventoryGroupCode || payload.inventoryGroupCode || '').trim()
if (!skuCode || !displayValue) { if (!skuCode || !displayValue) {
continue 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 skuCode = String(payload.skuCode || '').trim()
const batchNo = String(payload.batchNo || '').trim() const batchNo = String(payload.batchNo || '').trim()
const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code'
const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim()
if (!skuCode) { if (!skuCode) {
throw createHttpError('批量导入时缺少 skuCode', { throw createHttpError('批量导入时缺少 skuCode', {
@@ -711,7 +718,7 @@ function normalizeInventoryImportRows(payload) {
if (!displayValue) { if (!displayValue) {
continue continue
} }
rows.push({ skuCode, displayValue, batchNo, credentialType }) rows.push({ skuCode, displayValue, batchNo, credentialType, inventoryGroupCode })
} }
} }
@@ -719,7 +726,7 @@ function normalizeInventoryImportRows(payload) {
} }
/** /**
* @param {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType'>>>} rows * @param {Array<Required<Pick<AdminInventoryImportRowInput, 'skuCode' | 'displayValue' | 'batchNo' | 'credentialType' | 'inventoryGroupCode'>>>} rows
*/ */
function dedupeRows(rows) { function dedupeRows(rows) {
const seen = new Set() const seen = new Set()
@@ -737,6 +744,12 @@ function dedupeRows(rows) {
return output return output
} }
function resolveTaskInventoryGroupCodes(task) {
const taskContext = parseTaskContext(task)
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
return inventoryGroupCode ? [inventoryGroupCode] : null
}
/** /**
* @param {Awaited<ReturnType<typeof getRequiredTask>>} task * @param {Awaited<ReturnType<typeof getRequiredTask>>} task
@@ -964,6 +964,9 @@ export async function redeemClaimTaskWithInventoryFallbackWithDeps(
taskId: context.task.id, taskId: context.task.id,
credentialType: currentInventoryItem.credential_type || 'tencent_code', credentialType: currentInventoryItem.credential_type || 'tencent_code',
roleKey: 'primary_code', roleKey: 'primary_code',
inventoryGroupCodes: currentInventoryItem.inventory_group_code
? [String(currentInventoryItem.inventory_group_code).trim()]
: null,
}) })
if (!replacement || !String(replacement.display_value || '').trim()) { if (!replacement || !String(replacement.display_value || '').trim()) {
@@ -989,6 +992,7 @@ export async function redeemClaimTaskWithInventoryFallbackWithDeps(
nextInventoryItemId: replacement.id, nextInventoryItemId: replacement.id,
nextCodeMasked: maskCode(replacement.display_value), nextCodeMasked: maskCode(replacement.display_value),
credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''), credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''),
inventoryGroupCode: String(replacement.inventory_group_code || currentInventoryItem.inventory_group_code || '').trim(),
}, replacedAt) }, replacedAt)
currentInventoryItem = replacement currentInventoryItem = replacement
shouldReloadBeforeNextAttempt = true shouldReloadBeforeNextAttempt = true
@@ -22,11 +22,13 @@ test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and
id: 1, id: 1,
display_value: 'DJQFf7HgONCL4XD4EH', display_value: 'DJQFf7HgONCL4XD4EH',
credential_type: 'tencent_code', credential_type: 'tencent_code',
inventory_group_code: 'A组',
} }
const replacementInventoryItem = { const replacementInventoryItem = {
id: 2, id: 2,
display_value: 'ABCD1234EFGH5678', display_value: 'ABCD1234EFGH5678',
credential_type: 'tencent_code', credential_type: 'tencent_code',
inventory_group_code: 'A组',
} }
let redeemCount = 0 let redeemCount = 0
@@ -127,6 +129,7 @@ test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and
taskId: 42, taskId: 42,
credentialType: 'tencent_code', credentialType: 'tencent_code',
roleKey: 'primary_code', roleKey: 'primary_code',
inventoryGroupCodes: ['A组'],
}, },
]) ])
assert.deepEqual(reloadCalls, ['txbs-test']) assert.deepEqual(reloadCalls, ['txbs-test'])
@@ -160,6 +163,7 @@ test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and
nextInventoryItemId: 2, nextInventoryItemId: 2,
nextCodeMasked: 'ABCD****5678', nextCodeMasked: 'ABCD****5678',
credentialType: 'tencent_code', credentialType: 'tencent_code',
inventoryGroupCode: 'A组',
}, },
}, },
], ],
@@ -5,9 +5,15 @@ import {
} from '../../repositories/inventory-repo.js' } from '../../repositories/inventory-repo.js'
import { nowIso } from '../../utils/time.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( return reserveInventoryForTaskWithDeps(
{ skuCode, taskId, credentialType, roleKey }, { skuCode, taskId, credentialType, roleKey, inventoryGroupCodes },
{ {
assignReservedInventoryItem, assignReservedInventoryItem,
findFirstAvailableInventoryItemBySkuCode, findFirstAvailableInventoryItemBySkuCode,
@@ -17,7 +23,13 @@ export async function reserveInventoryForTask({ skuCode, taskId, credentialType
} }
export async function reserveInventoryForTaskWithDeps( 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, assignReservedInventoryItem: assignReserved = assignReservedInventoryItem,
findFirstAvailableInventoryItemBySkuCode: findAvailable = findFirstAvailableInventoryItemBySkuCode, findFirstAvailableInventoryItemBySkuCode: findAvailable = findFirstAvailableInventoryItemBySkuCode,
@@ -28,7 +40,11 @@ export async function reserveInventoryForTaskWithDeps(
return null 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) { if (!available) {
return null return null
@@ -28,8 +28,8 @@ test('reserveInventoryForTaskWithDeps reserves first available inventory item wi
const result = await reserveInventoryForTaskWithDeps( const result = await reserveInventoryForTaskWithDeps(
{ skuCode: 'dnf-cdk-a', taskId: 88 }, { skuCode: 'dnf-cdk-a', taskId: 88 },
{ {
findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType) => { findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType, inventoryGroupCodes) => {
calls.push(['find', skuCode, credentialType]) calls.push(['find', skuCode, credentialType, inventoryGroupCodes])
return { id: 321 } return { id: 321 }
}, },
assignReservedInventoryItem: async (inventoryItemId, taskId, updatedAt, roleKey) => { assignReservedInventoryItem: async (inventoryItemId, taskId, updatedAt, roleKey) => {
@@ -41,13 +41,50 @@ test('reserveInventoryForTaskWithDeps reserves first available inventory item wi
) )
assert.deepEqual(calls, [ 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'], ['assign', 321, 88, '2026-04-14T12:00:00.000Z', 'primary_code'],
]) ])
assert.equal(result?.id, 321) assert.equal(result?.id, 321)
assert.equal(result?.reserved_by_task_id, 88) 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 () => { test('releaseReservedInventoryItemsWithDeps skips empty ids and keeps successful releases only', async () => {
const calls = [] const calls = []
const result = await releaseReservedInventoryItemsWithDeps( const result = await releaseReservedInventoryItemsWithDeps(
+5 -2
View File
@@ -7,6 +7,7 @@ export {}
* userId?: number | string * userId?: number | string
* username?: string * username?: string
* role?: string * role?: string
* allowedInventoryGroups?: string[]
* }} AdminViewerSessionInput * }} AdminViewerSessionInput
*/ */
@@ -41,9 +42,10 @@ export {}
* page?: number | string * page?: number | string
* pageSize?: number | string * pageSize?: number | string
* skuCode?: string * skuCode?: string
* credentialType?: string * credentialType?: string
* status?: string * status?: string
* batchNo?: string * batchNo?: string
* inventoryGroupCode?: string
* }} AdminInventoryListQueryInput * }} AdminInventoryListQueryInput
*/ */
@@ -51,7 +53,8 @@ export {}
* @typedef {{ * @typedef {{
* credentialType?: string * credentialType?: string
* keyword?: string * keyword?: string
* limit?: number | string * limit?: number | string
* inventoryGroupCode?: string
* }} AdminInventorySkuSuggestionQueryInput * }} AdminInventorySkuSuggestionQueryInput
*/ */
@@ -99,6 +99,7 @@ export {}
* skuCode: string * skuCode: string
* batchNo: string * batchNo: string
* credentialType: string * credentialType: string
* inventoryGroupCode: string
* displayValue: string * displayValue: string
* status: string * status: string
* reservedByTaskId: number | null * reservedByTaskId: number | null
@@ -155,6 +156,7 @@ export {}
* @typedef {{ * @typedef {{
* skuCode: string * skuCode: string
* credentialType: string * credentialType: string
* inventoryGroupCode: string
* totalCount: number * totalCount: number
* availableCount: number * availableCount: number
* latestUpdatedAt: string | null * latestUpdatedAt: string | null
@@ -54,6 +54,10 @@ export {}
* @typedef {import('./admin-write-inputs.js').AdminInventoryInvalidateInput} AdminInventoryInvalidateRouteBody * @typedef {import('./admin-write-inputs.js').AdminInventoryInvalidateInput} AdminInventoryInvalidateRouteBody
*/ */
/**
* @typedef {import('./admin-write-inputs.js').AdminUserInventoryGroupsInput} AdminUserInventoryGroupsRouteBody
*/
/** /**
* @typedef {import('./admin-write-inputs.js').AdminTaskManualDispatchInput} AdminTaskManualDispatchRouteBody * @typedef {import('./admin-write-inputs.js').AdminTaskManualDispatchInput} AdminTaskManualDispatchRouteBody
*/ */
@@ -8,6 +8,7 @@ export {}
* displayValue?: string * displayValue?: string
* batchNo?: string * batchNo?: string
* credentialType?: string * credentialType?: string
* inventoryGroupCode?: string
* }} AdminInventoryCreateInput * }} AdminInventoryCreateInput
*/ */
@@ -17,6 +18,7 @@ export {}
* displayValue?: string * displayValue?: string
* batchNo?: string * batchNo?: string
* credentialType?: string * credentialType?: string
* inventoryGroupCode?: string
* }} AdminInventoryImportRowInput * }} AdminInventoryImportRowInput
*/ */
@@ -27,9 +29,16 @@ export {}
* skuCode?: string * skuCode?: string
* batchNo?: string * batchNo?: string
* credentialType?: string * credentialType?: string
* inventoryGroupCode?: string
* }} AdminInventoryImportInput * }} AdminInventoryImportInput
*/ */
/**
* @typedef {{
* inventoryGroupCodes?: string[]
* }} AdminUserInventoryGroupsInput
*/
/** /**
* @typedef {{ * @typedef {{
* messageTemplate?: string * messageTemplate?: string
@@ -6,6 +6,10 @@ export {}
* @typedef {import('./admin-read-models.js').AdminInventoryItemListItem} AdminInventoryItemListItem * @typedef {import('./admin-read-models.js').AdminInventoryItemListItem} AdminInventoryItemListItem
*/ */
/**
* @typedef {import('./admin-read-models.js').AdminInventorySkuSuggestion} AdminInventorySkuSuggestion
*/
/** /**
* @typedef {{ * @typedef {{
* inventoryItem: AdminInventoryItemListItem | null * inventoryItem: AdminInventoryItemListItem | null
@@ -20,6 +24,12 @@ export {}
* }} AdminInventoryImportResponse * }} AdminInventoryImportResponse
*/ */
/**
* @typedef {{
* items: AdminInventorySkuSuggestion[]
* }} AdminInventoryGroupListResponse
*/
/** /**
* @typedef {{ * @typedef {{
* messageTemplate: string * messageTemplate: string
@@ -148,6 +148,8 @@ export {}
* credentialType?: string * credentialType?: string
* status?: string * status?: string
* batchNo?: string * batchNo?: string
* inventoryGroupCode?: string
* allowedInventoryGroupCodes?: string[] | null
* }} InventoryListQueryInput * }} InventoryListQueryInput
*/ */
@@ -156,6 +158,8 @@ export {}
* credentialType?: string * credentialType?: string
* keyword?: string * keyword?: string
* limit?: number * limit?: number
* inventoryGroupCode?: string
* allowedInventoryGroupCodes?: string[] | null
* }} InventorySkuSuggestionQueryInput * }} InventorySkuSuggestionQueryInput
*/ */
@@ -164,6 +168,7 @@ export {}
* batchNo?: string * batchNo?: string
* skuCode: string * skuCode: string
* credentialType?: string * credentialType?: string
* inventoryGroupCode?: string
* displayValue: string * displayValue: string
* payload?: Record<string, unknown> * payload?: Record<string, unknown>
* createdAt: string * createdAt: string
@@ -93,6 +93,7 @@ export {}
* sku_code: string * sku_code: string
* batch_no: string * batch_no: string
* credential_type: string * credential_type: string
* inventory_group_code: string
* display_value: string * display_value: string
* status: string * status: string
* invalid_reason: string * invalid_reason: string
@@ -109,6 +110,7 @@ export {}
* @typedef {{ * @typedef {{
* sku_code: string * sku_code: string
* credential_type: string * credential_type: string
* inventory_group_code: string
* total_count: number * total_count: number
* available_count: number * available_count: number
* latest_updated_at: string | null * latest_updated_at: string | null
@@ -152,6 +154,7 @@ export {}
* sku_code: string * sku_code: string
* batch_no: string * batch_no: string
* credential_type: string * credential_type: string
* inventory_group_code: string
* display_value: string * display_value: string
* inventory_status: string * inventory_status: string
* }} TaskInventoryBindingRow * }} TaskInventoryBindingRow
+4
View File
@@ -58,6 +58,10 @@ export function updateAdminUserStatus(userId: number | string, payload: { status
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/status`, payload) 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 }) { export function resetAdminUserPassword(userId: number | string, payload: { password: string }) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/reset-password`, payload) return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/reset-password`, payload)
} }
+5
View File
@@ -16,6 +16,7 @@ export interface AdminLoginResponse {
userId: number userId: number
username: string username: string
role: AdminRole role: AdminRole
inventoryGroupCodes?: string[]
} }
} }
@@ -26,6 +27,7 @@ export interface AdminSessionSummary {
userId: number userId: number
username: string username: string
role: AdminRole role: AdminRole
inventoryGroupCodes?: string[]
} }
} }
@@ -34,6 +36,7 @@ export interface AdminUserListItem {
username: string username: string
role: AdminRole role: AdminRole
status: AdminUserStatus status: AdminUserStatus
inventoryGroupCodes: string[]
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
@@ -436,6 +439,7 @@ export interface AdminTaskDetail {
export interface AdminInventorySkuSuggestion { export interface AdminInventorySkuSuggestion {
skuCode: string skuCode: string
credentialType: string credentialType: string
inventoryGroupCode: string
totalCount: number totalCount: number
availableCount: number availableCount: number
latestUpdatedAt: string | null latestUpdatedAt: string | null
@@ -446,6 +450,7 @@ export interface AdminInventoryItemListItem {
skuCode: string skuCode: string
batchNo: string batchNo: string
credentialType: string credentialType: string
inventoryGroupCode: string
displayValue: string displayValue: string
status: string status: string
reservedByTaskId: number | null reservedByTaskId: number | null
+1
View File
@@ -78,6 +78,7 @@ export const adminAuditActionOptions = [
{ label: '创建后台用户', value: 'admin_user_created' }, { label: '创建后台用户', value: 'admin_user_created' },
{ label: '修改用户角色', value: 'admin_user_role_updated' }, { label: '修改用户角色', value: 'admin_user_role_updated' },
{ label: '修改用户状态', value: 'admin_user_status_updated' }, { label: '修改用户状态', value: 'admin_user_status_updated' },
{ label: '修改用户库存组', value: 'admin_user_inventory_groups_updated' },
{ label: '重置用户密码', value: 'admin_user_password_reset' }, { label: '重置用户密码', value: 'admin_user_password_reset' },
{ label: '释放任务库存', value: 'task_release_inventory' }, { label: '释放任务库存', value: 'task_release_inventory' },
{ label: '重发领取链接', value: 'task_regenerate_claim_link' }, { label: '重发领取链接', value: 'task_regenerate_claim_link' },
@@ -24,6 +24,7 @@ type InventoryImportRow = {
skuCode: string skuCode: string
credentialType: string credentialType: string
batchNo: string batchNo: string
inventoryGroupCode: string
displayValue: string displayValue: string
} }
@@ -31,6 +32,7 @@ type InventoryPoolGroup = {
key: string key: string
skuCode: string skuCode: string
credentialType: string credentialType: string
inventoryGroupCode: string
totalCount: number totalCount: number
availableCount: number availableCount: number
reservedCount: number reservedCount: number
@@ -50,10 +52,12 @@ const filterSkuCode = ref('')
const filterCredentialType = ref('') const filterCredentialType = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const filterBatchNo = ref('') const filterBatchNo = ref('')
const filterInventoryGroupCode = ref('')
const entrySkuCode = ref('') const entrySkuCode = ref('')
const entryCredentialType = ref('tencent_code') const entryCredentialType = ref('tencent_code')
const entryBatchNo = ref('') const entryBatchNo = ref('')
const entryInventoryGroupCode = ref('')
const singleDisplayValue = ref('') const singleDisplayValue = ref('')
const bulkText = ref('') const bulkText = ref('')
const skuSuggestionsLoading = ref(false) const skuSuggestionsLoading = ref(false)
@@ -84,6 +88,7 @@ const inventoryGroups = computed<InventoryPoolGroup[]>(() => {
const groupMap = new Map<string, { const groupMap = new Map<string, {
skuCode: string skuCode: string
credentialType: string credentialType: string
inventoryGroupCode: string
totalCount: number totalCount: number
availableCount: number availableCount: number
reservedCount: number reservedCount: number
@@ -94,10 +99,11 @@ const inventoryGroups = computed<InventoryPoolGroup[]>(() => {
}>() }>()
for (const item of items.value) { 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) || { const current = groupMap.get(key) || {
skuCode: item.skuCode, skuCode: item.skuCode,
credentialType: item.credentialType || 'tencent_code', credentialType: item.credentialType || 'tencent_code',
inventoryGroupCode: item.inventoryGroupCode || '',
totalCount: 0, totalCount: 0,
availableCount: 0, availableCount: 0,
reservedCount: 0, reservedCount: 0,
@@ -135,6 +141,7 @@ const inventoryGroups = computed<InventoryPoolGroup[]>(() => {
key, key,
skuCode: value.skuCode, skuCode: value.skuCode,
credentialType: value.credentialType, credentialType: value.credentialType,
inventoryGroupCode: value.inventoryGroupCode,
totalCount: value.totalCount, totalCount: value.totalCount,
availableCount: value.availableCount, availableCount: value.availableCount,
reservedCount: value.reservedCount, reservedCount: value.reservedCount,
@@ -253,16 +260,18 @@ function getGroupSuggestion(group: InventoryPoolGroup) {
function useGroupAsFilter(group: InventoryPoolGroup) { function useGroupAsFilter(group: InventoryPoolGroup) {
filterSkuCode.value = group.skuCode filterSkuCode.value = group.skuCode
filterCredentialType.value = group.credentialType filterCredentialType.value = group.credentialType
filterInventoryGroupCode.value = group.inventoryGroupCode
void loadInventoryItems(1) void loadInventoryItems(1)
} }
function useGroupAsEntry(group: InventoryPoolGroup) { function useGroupAsEntry(group: InventoryPoolGroup) {
entrySkuCode.value = group.skuCode entrySkuCode.value = group.skuCode
entryCredentialType.value = group.credentialType entryCredentialType.value = group.credentialType
entryInventoryGroupCode.value = group.inventoryGroupCode
if (group.batchSummary !== '未分批' && !group.batchSummary.includes(' / ')) { if (group.batchSummary !== '未分批' && !group.batchSummary.includes(' / ')) {
entryBatchNo.value = group.batchSummary entryBatchNo.value = group.batchSummary
} }
showSuccess(`已把 ${group.skuCode} / ${group.credentialType} 填入入库表单`) showSuccess(`已把 ${group.skuCode} / ${group.credentialType} / ${group.inventoryGroupCode || '未分组'} 填入入库表单`)
} }
function applySkuSuggestion(skuCode: string) { function applySkuSuggestion(skuCode: string) {
@@ -285,11 +294,13 @@ function buildImportRows() {
if (line.includes('|')) { if (line.includes('|')) {
const parts = line.split('|').map((part) => part.trim()) const parts = line.split('|').map((part) => part.trim())
if (parts.length !== 4) { if (parts.length !== 4 && parts.length !== 5) {
throw new Error(`${index + 1} 行格式不正确,请使用 内部SKU|凭据类型|批次号|凭据内容`) 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) { if (!skuCode || !displayValue) {
throw new Error(`${index + 1} 行缺少内部 SKU 或凭据内容`) throw new Error(`${index + 1} 行缺少内部 SKU 或凭据内容`)
} }
@@ -298,6 +309,7 @@ function buildImportRows() {
skuCode, skuCode,
credentialType: credentialType || defaultCredentialType, credentialType: credentialType || defaultCredentialType,
batchNo, batchNo,
inventoryGroupCode: inventoryGroupCode || entryInventoryGroupCode.value.trim(),
displayValue, displayValue,
}) })
continue continue
@@ -311,6 +323,7 @@ function buildImportRows() {
skuCode: defaultSkuCode, skuCode: defaultSkuCode,
credentialType: defaultCredentialType, credentialType: defaultCredentialType,
batchNo: defaultBatchNo, batchNo: defaultBatchNo,
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
displayValue: line, displayValue: line,
}) })
} }
@@ -330,6 +343,7 @@ async function loadInventoryItems(page = pagination.value.page) {
credentialType: filterCredentialType.value.trim(), credentialType: filterCredentialType.value.trim(),
status: filterStatus.value.trim(), status: filterStatus.value.trim(),
batchNo: filterBatchNo.value.trim(), batchNo: filterBatchNo.value.trim(),
inventoryGroupCode: filterInventoryGroupCode.value.trim(),
}) })
items.value = response.data.items items.value = response.data.items
pagination.value = response.data.pagination pagination.value = response.data.pagination
@@ -346,6 +360,7 @@ async function loadInventorySkuSuggestions() {
try { try {
const response = await fetchAdminInventorySkuSuggestions({ const response = await fetchAdminInventorySkuSuggestions({
credentialType: entryCredentialType.value.trim() || 'tencent_code', credentialType: entryCredentialType.value.trim() || 'tencent_code',
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
limit: 50, limit: 50,
}) })
inventorySkuSuggestions.value = response.data.items inventorySkuSuggestions.value = response.data.items
@@ -361,6 +376,7 @@ function resetFilters() {
filterCredentialType.value = '' filterCredentialType.value = ''
filterStatus.value = '' filterStatus.value = ''
filterBatchNo.value = '' filterBatchNo.value = ''
filterInventoryGroupCode.value = ''
void loadInventoryItems(1) void loadInventoryItems(1)
} }
@@ -377,6 +393,7 @@ async function submitCreate() {
skuCode: entrySkuCode.value.trim(), skuCode: entrySkuCode.value.trim(),
credentialType: entryCredentialType.value.trim() || 'tencent_code', credentialType: entryCredentialType.value.trim() || 'tencent_code',
batchNo: entryBatchNo.value.trim(), batchNo: entryBatchNo.value.trim(),
inventoryGroupCode: entryInventoryGroupCode.value.trim(),
displayValue: singleDisplayValue.value.trim(), displayValue: singleDisplayValue.value.trim(),
}) })
@@ -450,6 +467,10 @@ watch(entryCredentialType, () => {
void loadInventorySkuSuggestions() void loadInventorySkuSuggestions()
}) })
watch(entryInventoryGroupCode, () => {
void loadInventorySkuSuggestions()
})
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
loadInventoryItems(), loadInventoryItems(),
@@ -492,6 +513,7 @@ onMounted(async () => {
</option> </option>
</select> </select>
<input v-model="filterBatchNo" class="text-input" placeholder="批次号" /> <input v-model="filterBatchNo" class="text-input" placeholder="批次号" />
<input v-model="filterInventoryGroupCode" class="text-input" placeholder="库存组,例如 A组" />
</div> </div>
<div class="action-row"> <div class="action-row">
<el-button round type="primary" @click="() => loadInventoryItems(1)">查询列表</el-button> <el-button round type="primary" @click="() => loadInventoryItems(1)">查询列表</el-button>
@@ -518,9 +540,9 @@ onMounted(async () => {
<datalist id="inventory-sku-suggestions"> <datalist id="inventory-sku-suggestions">
<option <option
v-for="suggestion in entrySkuSuggestions" v-for="suggestion in entrySkuSuggestions"
:key="`${suggestion.credentialType}-${suggestion.skuCode}`" :key="`${suggestion.credentialType}-${suggestion.inventoryGroupCode}-${suggestion.skuCode}`"
:value="suggestion.skuCode" :value="suggestion.skuCode"
:label="`${suggestion.skuCode} · 可用 ${suggestion.availableCount} / 共 ${suggestion.totalCount}`" :label="`${suggestion.skuCode} · ${suggestion.inventoryGroupCode || '未分组'} · 可用 ${suggestion.availableCount} / 共 ${suggestion.totalCount}`"
/> />
</datalist> </datalist>
</div> </div>
@@ -534,6 +556,7 @@ onMounted(async () => {
</option> </option>
</select> </select>
<input v-model="entryBatchNo" class="text-input" placeholder="批次号,可选" /> <input v-model="entryBatchNo" class="text-input" placeholder="批次号,可选" />
<input v-model="entryInventoryGroupCode" class="text-input" placeholder="库存组,例如 A组 / B组" />
</div> </div>
<div class="sku-helper-bar"> <div class="sku-helper-bar">
@@ -544,13 +567,13 @@ onMounted(async () => {
<div v-if="entrySkuSuggestions.length > 0" class="sku-chip-row"> <div v-if="entrySkuSuggestions.length > 0" class="sku-chip-row">
<button <button
v-for="suggestion in entrySkuSuggestions" v-for="suggestion in entrySkuSuggestions"
:key="`${suggestion.credentialType}-${suggestion.skuCode}-chip`" :key="`${suggestion.credentialType}-${suggestion.inventoryGroupCode}-${suggestion.skuCode}-chip`"
type="button" type="button"
class="sku-chip" class="sku-chip"
@click="applySkuSuggestion(suggestion.skuCode)" @click="applySkuSuggestion(suggestion.skuCode)"
> >
<span class="sku-chip-title">{{ suggestion.skuCode }}</span> <span class="sku-chip-title">{{ suggestion.skuCode }}</span>
<span class="sku-chip-meta">可用 {{ suggestion.availableCount }} / {{ suggestion.totalCount }}</span> <span class="sku-chip-meta">{{ suggestion.inventoryGroupCode || '未分组' }} · 可用 {{ suggestion.availableCount }} / {{ suggestion.totalCount }}</span>
</button> </button>
</div> </div>
@@ -559,6 +582,10 @@ onMounted(async () => {
<span class="helper-title">当前默认匹配</span> <span class="helper-title">当前默认匹配</span>
<span class="helper-copy">{{ getCredentialTypeLabel(entryCredentialType) }}</span> <span class="helper-copy">{{ getCredentialTypeLabel(entryCredentialType) }}</span>
</div> </div>
<div class="helper-item">
<span class="helper-title">库存组</span>
<span class="helper-copy">{{ entryInventoryGroupCode || '未分组,默认全局可用' }}</span>
</div>
<div class="helper-item helper-item-wide"> <div class="helper-item helper-item-wide">
<span class="helper-title">说明</span> <span class="helper-title">说明</span>
<span class="helper-copy">{{ getCredentialTypeHint(entryCredentialType) }}</span> <span class="helper-copy">{{ getCredentialTypeHint(entryCredentialType) }}</span>
@@ -573,9 +600,9 @@ onMounted(async () => {
<textarea <textarea
v-model="bulkText" v-model="bulkText"
class="text-area" class="text-area"
placeholder="批量导入支持两种格式:&#10;1. 每行一个凭据内容(使用上方默认内部 SKU / 凭据类型 / 批次)&#10;2. 每行一条:内部SKU|凭据类型|批次号|凭据内容" placeholder="批量导入支持两种格式:&#10;1. 每行一个凭据内容(使用上方默认内部 SKU / 凭据类型 / 批次 / 库存组&#10;2. 每行一条:内部SKU|凭据类型|批次号|库存组|凭据内容"
/> />
<p class="section-tip">示例`sjz_hdl_test01|tencent_code|sjz-20260412|DJQFf7HgXXXXXXXXXX`</p> <p class="section-tip">示例`sjz_hdl_test01|tencent_code|sjz-20260412|A组|DJQFf7HgXXXXXXXXXX`</p>
<div class="action-row"> <div class="action-row">
<el-button :loading="importing" round type="primary" @click="submitImport">批量导入</el-button> <el-button :loading="importing" round type="primary" @click="submitImport">批量导入</el-button>
@@ -625,7 +652,9 @@ onMounted(async () => {
<div class="pool-topline"> <div class="pool-topline">
<div class="pool-title-block"> <div class="pool-title-block">
<h3 class="pool-title">{{ group.skuCode }}</h3> <h3 class="pool-title">{{ group.skuCode }}</h3>
<span class="pool-subtitle">{{ getCredentialTypeLabel(group.credentialType) }} · {{ group.credentialType }}</span> <span class="pool-subtitle">
{{ getCredentialTypeLabel(group.credentialType) }} · {{ group.credentialType }} · {{ group.inventoryGroupCode || '未分组' }}
</span>
</div> </div>
<span class="pool-total"> {{ group.totalCount }} </span> <span class="pool-total"> {{ group.totalCount }} </span>
</div> </div>
@@ -684,12 +713,14 @@ onMounted(async () => {
<span class="item-title">#{{ item.inventoryItemId }}</span> <span class="item-title">#{{ item.inventoryItemId }}</span>
<code class="item-value">{{ item.displayValue }}</code> <code class="item-value">{{ item.displayValue }}</code>
<span class="item-subline">批次 {{ item.batchNo || '未分批' }}</span> <span class="item-subline">批次 {{ item.batchNo || '未分批' }}</span>
<span class="item-subline">库存组 {{ item.inventoryGroupCode || '未分组' }}</span>
</div> </div>
</td> </td>
<td> <td>
<div class="sku-cell"> <div class="sku-cell">
<span class="sku-title">{{ item.skuCode }}</span> <span class="sku-title">{{ item.skuCode }}</span>
<span class="sku-subline">{{ getCredentialTypeLabel(item.credentialType) }}</span> <span class="sku-subline">{{ getCredentialTypeLabel(item.credentialType) }}</span>
<span class="sku-subline">库存组 {{ item.inventoryGroupCode || '未分组' }}</span>
<span class="sku-subline sku-raw">{{ item.credentialType || '-' }}</span> <span class="sku-subline sku-raw">{{ item.credentialType || '-' }}</span>
</div> </div>
</td> </td>
@@ -828,7 +859,7 @@ onMounted(async () => {
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }
@@ -135,9 +135,9 @@ const taskDetailLink = computed(() => (task.value?.taskId ? `/admin/tasks/${task
<datalist id="manual-redeem-sku-suggestions"> <datalist id="manual-redeem-sku-suggestions">
<option <option
v-for="item in suggestions" v-for="item in suggestions"
:key="`${item.credentialType}-${item.skuCode}`" :key="`${item.credentialType}-${item.inventoryGroupCode}-${item.skuCode}`"
:value="item.skuCode" :value="item.skuCode"
:label="`${item.skuCode} · 可用 ${item.availableCount} / 共 ${item.totalCount}`" :label="`${item.skuCode} · ${item.inventoryGroupCode || '未分组'} · 可用 ${item.availableCount} / 共 ${item.totalCount}`"
/> />
</datalist> </datalist>
</div> </div>
@@ -8,6 +8,7 @@ import {
createAdminUser, createAdminUser,
fetchAdminUsers, fetchAdminUsers,
resetAdminUserPassword, resetAdminUserPassword,
updateAdminUserInventoryGroups,
updateAdminUserRole, updateAdminUserRole,
updateAdminUserStatus, updateAdminUserStatus,
} from '@/services/admin' } from '@/services/admin'
@@ -49,6 +50,18 @@ function formatRoleLabel(role: AdminRole) {
return '普通运营' return '普通运营'
} }
function formatInventoryGroups(item: AdminUserListItem) {
if (item.role !== 'support') {
return ['不限库存组']
}
if (item.inventoryGroupCodes.length === 0) {
return ['未绑定']
}
return item.inventoryGroupCodes
}
async function loadUsers(page = pagination.value.page) { async function loadUsers(page = pagination.value.page) {
if (!hasAdminRole('admin')) { if (!hasAdminRole('admin')) {
loading.value = false loading.value = false
@@ -180,6 +193,46 @@ async function promptResetPassword(item: AdminUserListItem) {
} }
} }
async function promptUpdateInventoryGroups(item: AdminUserListItem) {
if (item.role !== 'support') {
showError('仅客服账号需要绑定库存组')
return
}
try {
const result = await showPrompt(
`请输入 ${item.username} 可用的库存组,多个组用英文逗号分隔`,
'配置库存组',
{
inputType: 'text',
inputValue: item.inventoryGroupCodes.join(','),
inputPlaceholder: '例如 A组,B组,售后组',
confirmButtonText: '保存',
cancelButtonText: '取消',
},
)
const inventoryGroupCodes = String(result.value || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean)
actionLoadingId.value = item.userId
await updateAdminUserInventoryGroups(item.userId, {
inventoryGroupCodes,
})
showSuccess('用户库存组已更新')
await loadUsers()
} catch (error) {
if (isFeedbackDismissed(error)) {
return
}
showError(error instanceof Error ? error.message : '更新库存组失败')
} finally {
actionLoadingId.value = null
}
}
onMounted(loadUsers) onMounted(loadUsers)
</script> </script>
@@ -233,6 +286,7 @@ onMounted(loadUsers)
<th>ID</th> <th>ID</th>
<th>账号</th> <th>账号</th>
<th>角色</th> <th>角色</th>
<th>库存组</th>
<th>状态</th> <th>状态</th>
<th>创建时间</th> <th>创建时间</th>
<th>更新时间</th> <th>更新时间</th>
@@ -244,6 +298,17 @@ onMounted(loadUsers)
<td>{{ item.userId }}</td> <td>{{ item.userId }}</td>
<td>{{ item.username }}</td> <td>{{ item.username }}</td>
<td>{{ formatRoleLabel(item.role) }}</td> <td>{{ formatRoleLabel(item.role) }}</td>
<td>
<div class="group-chip-row">
<span
v-for="groupCode in formatInventoryGroups(item)"
:key="`${item.userId}-${groupCode}`"
:class="['group-chip', { 'group-chip--muted': groupCode === '未绑定' || groupCode === '不限库存组' }]"
>
{{ groupCode }}
</span>
</div>
</td>
<td><AdminStatusTag :status="item.status" /></td> <td><AdminStatusTag :status="item.status" /></td>
<td>{{ formatAdminDateTime(item.createdAt) }}</td> <td>{{ formatAdminDateTime(item.createdAt) }}</td>
<td>{{ formatAdminDateTime(item.updatedAt) }}</td> <td>{{ formatAdminDateTime(item.updatedAt) }}</td>
@@ -281,6 +346,14 @@ onMounted(loadUsers)
> >
{{ item.status === 'active' ? '停用' : '启用' }} {{ item.status === 'active' ? '停用' : '启用' }}
</el-button> </el-button>
<el-button
:loading="actionLoadingId === item.userId"
link
:disabled="item.role !== 'support'"
@click="promptUpdateInventoryGroups(item)"
>
配库存组
</el-button>
<el-button <el-button
:loading="actionLoadingId === item.userId" :loading="actionLoadingId === item.userId"
link link
@@ -385,6 +458,31 @@ onMounted(loadUsers)
gap: 6px; gap: 6px;
} }
.group-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.group-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(37, 89, 167, 0.08);
border: 1px solid rgba(37, 89, 167, 0.12);
color: #2559a7;
font-size: 12px;
font-weight: 700;
}
.group-chip--muted {
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.18);
color: #64748b;
}
.self-copy { .self-copy {
color: #64748b; color: #64748b;
font-size: 12px; font-size: 12px;